您的位置:知识库 » 编程基础

好对象的7大美德

来源: 伯乐在线  发布时间: 2015-04-19 21:56  阅读: 3640 次  推荐: 8   原文链接   [收藏]  

  英文原文:Seven Virtues of a Good Object

  Marin Folwer 说过

“库本质上是一组可以调用的函数,这些函数现在经常被组织到类中。”

  函数组织到类中?恕我冒昧,这个观点是错误的。而且这是对面向对象编程中类的非常普遍的误解。类不是函数的组织者,对象也不是数据结构。

  那么什么是“合理的”对象呢?哪些不合理呢?区别又是什么?虽然这是个争论比较激烈的主题,但同时也是非常重要的。如果我们不了解对象到底是什么,我们怎么才能编写出面向对象的软件呢?好吧,幸亏Java、Ruby,还有其他语言,我们可以。但是它到底有多好呢?很不幸,这不是精确的科学,而且有很多不同的观点。下面是我认为一个良好对象应该具有的品质。

  类与对象

  在我们谈论对象之前,我们先来看看类是什么。类是对象出生(也叫实例化)的地方。类的主要职责是根据需要创建新对象,以及当它们不再被使用时销毁它们。类知道它的孩子长什么样、如何表现。换言之,类知道它们遵循的合约(contract)。

  有时我听到类被称作“对象模板”(比如,Wikipedia就这样说)。这个定义是不对的,因为它把类放到了被动的境地。这个定义假设有人先取得一个模板,然后使用这个模板创建一个对象。技术上这可能是对的,但是在概念上是错误的。其他人不应该牵涉进来 —— 应该只有类和它的孩子。一个对象请求类创建另一个对象,然后类创建了一个对象;就是这样。Ruby表达这个概念要比Java或C++好多了:

photo = File.new('/tmp/photo.png')

  photo对象被类File创建(new是类的入口点)。一旦被创建后,对象可以自我支配。它不应该知道是谁创建了它,以及类中它的兄弟姐妹有多少。是的,我的意思是反射(reflection) 是个可怕的观点,我将会在接下来用一篇博客来详细阐述:) 现在,我们来谈谈对象以及它们最好和最糟的方面。

  1. 他存在于现实生活中

  首先,对象是一个活着的有机体。而且,对象应该被人格化,即,被当做人一样对待(或者宠物,如果你更喜欢宠物的话)。根本上说,我的意思是对象不是一个数据结构或者一组函数的集合。相反,它是一个独立的实体,有自己的生命周期,自己的行为,自己的习惯。

  一名雇员,一个部门,一个HTTP请求,MySQL中的一张表,文件的一行,或者文件本身都是合理的对象 —— 因为它们存在于现实生活,即使当软件被关闭时。更准确来说,一个对象是现实生活中一个生物的表示(representative)。与其他对象来一样,它作为现实生活中生物的代理。如果没有这样的生物,显然不存在这样的对象。

photo = File.new('/tmp/photo.png')
puts photo.width()

  这个例子中,我请求File创建一个新对象photo,它将是磁盘上一个真实文件的表示。你也许会说文件也是虚拟的东西,只有电脑开机时才会存在。我同意,那么我把“现实生活”的重新定义为:它是对象所处的程序范围之外的一切事物。磁盘上的文件在我们的程序范围之外;这就是为何在程序内创建它的表示是完全正确的。

  一个控制器,一个解析器,一个过滤器,一个验证器,一个服务定位器,一个单例,或者一个工厂都不是良好对象(是的,多数GoF模式都是反模式(anti-patterns)!)。脱离了软件,它们并不存在于现实生活中。它们被创建完全是为了将其他对象联系在一起。它们是人造的、仿冒的生物。它们并不表示任何人。严格上说,一个XML解析器到底表示谁呢?没有人。

  它们中的一些如果改变名字可能变成良好的;其余对象的存在则是毫无理由的。比如,XML解析器可以更名为“可解析的XML”,然后可以表示我们程序范围外的XML文档。

  始终问问自己,“我的对象所对应现实生活中的实体是什么?”如果你不能找到答案,考虑下重构吧。

  2. 他根据合约办事

  一个良好对象总是根据合约(constract)办事。他期望被雇佣是因为他遵循合约而不是他的个人优点。另一方面,当我们雇佣一个对象,我们不应该歧视它,并期望一个特定类的特定对象来为我们工作。我们应该期望任何对象做我们间的合约所约定的事情。只要这个对象做我们所需要的事,我们就不应该关心他的出身,他的性别,或者他的信仰。

  比如,我想要在屏幕上展示一张图片。我希望图片从一个PNG格式的文件读取。我其实是在雇佣一个来自DataFile类的对象,要求他给我那幅图片的二进制内容。

  但是等会,我关心内容到底来自哪里吗 —— 磁盘上的文件,或者HTTP请求,或者可能Dropbox中的一个文档?事实上,我不关心。我所关心的是有对象给我PNG内容的字节数组。所以,我的合约是这样的:

interface Binary {
  byte[] read();
}

  现在,任何类的任何对象(不仅仅是DataFile)都可以为我工作。如果他是合格的,那么他所应该做的,就是遵循合约 —— 通过实现Binary接口。

  规则很简单:良好对象的每个公共方法都应该实现接口中对应的方法。如果你的对象有公共方法没有实现任何接口,那么他被设计得很糟糕。

  这里有两个实际原因。首先,一个没有合约的对象不能在单元测试中进行模拟(mock)。另外,无合约的对象不能通过装饰(decoration)来扩展。

  3. 他是独特的

  一个良好对象应当总是封装一些东西以保持独特性。如果没有可以封装的东西,这个对象可能有完全一样的复制品(克隆),我认为这是糟糕的。下面是一个可能有克隆的糟糕对象的例子:

class HTTPStatus implements Status {
  private URL page = new URL("http://www.google.com");
  @Override
  public int read() throws IOException {
    return HttpURLConnection.class.cast(
      this.page.openConnection()
    ).getResponseCode();
  }
}

  我可以创建很多HTTPStatus类的实例,它们都是相等的:

first = new HTTPStatus();
second = new HTTPStatus();
assert first.equals(second);

  很显然,实用类(utility classes),可能只包含静态方法,不能实例化良好对象。更一般地说,实用类没有本文提到的任何优点,甚至不能称作”类”。它们仅仅滥用了对象范式(object paradign),它们能存在于面向对象中仅仅由于它们的创造者启用了静态方法。

  4. 他是不可变的

  一个良好对象应该永远不改变他封装的状态。记住,对象是现实生活中实体的表示,而这个实体应该在对象的整个生命周期中保持不变。换句话说,对象不应该背叛他所表示的实体。他永远不应该换主人。:)

  注意,不可变性(immutability)并不意味着所有方法都应该返回相同的值。相反,一个良好的不可变对象是非常动态的。然而,他不应该改变他的内部状态。比如:

@Immutable
final class HTTPStatus implements Status {
  private URL page;
  public HTTPStatus(URL url) {
    this.page = url;
  }

  @Override
  public int read() throws IOException {
    return HttpURLConnection.class.cast(
      this.page.openConnection()
    ).getResponseCode();
  }
}

  尽管read()方法返回不同的值,这个对象仍然是不可变的。他指向一个特定的Web页面,并且永远不会指向其他地方。他永远不会改变他的内部状态,也不会背叛他所表示的URL。

  为什么不可变性是一个美德呢?这篇文章进行了详细的解释:对象应该是不可变的。简而言之,不可变对象更好,因为:

  • 不可变对象创建、测试和使用更加简单。
  • 真正的不可变对象总是线程安全的。
  • 他们可以帮助避免时间耦合(temporal coupling,[译者注]指系统中组件的依赖关系与时间有关,如,两行代码,后一行需要前一行代码先执行,这种依赖关系就是与时间有关的,对应的还有空间耦合/spatial coupling)。
  • 他们的用法没有副作用(没有防御性拷贝,[译者注]由于对象是可变的,为了保存对象在执行代码前的状态,需要对该对象做一份拷贝)。
  • 他们总是具有失败原子性(failure atomicity, [译者注]如果方法失败,那么对象状态应该与方法调用前一致)。
  • 他们更容易缓存。
  • 他们可以防止空引用

  当然,一个良好的对象不应该有setter方法,因为这些方法可以改变他的状态,强迫他背叛URL。换言之,在HTTPStatus类中加入一个setURL()方法是个可怕的错误。

  除了这些,不可变对象将督促你进行更加内聚(cohesive)、健壮(solid)、容易理解(understandable)的设计,如这篇文件阐述的:不可变性如何有用

  5. 他的类不应该包含任何静态(static)的东西

  一个静态方法实现了类的行为,而不是对象的。假如我们有个类File,他的孩子都拥有size()方法:

final class File implements Measurable {
  @Override
  public int size() {
    // calculate the size of the file and return
  }
}

  目前为止,一切都还好;size()方法的存在是因为合约Measurable,每个File类的对象都可以测量自身的大小。一个可怕的错误可能是将类的这个方法设计为静态方法(这种类被称作实用类,在Java,Ruby,几乎每一个OOP语言中都很流行):

// 糟糕的设计,请勿使用!
class File {
  public static int size(String file) {
    // 计算文件大小并返回
  }
}

  这种设计完全违背了面向对象范式(object-oriented paradigm)。为什么?因为静态方法将面向对象编程变成“面向类”编程(class-oriented programming)了。size()方法将类的行为暴露出去,而不是他的对象。这有什么错呢,你可能会问?为什么我们不能在代码中将对象和类都当做第一类公民(first-class citizens,[译者注]可以参与其他实体所有操作的实体,这些操作可能是赋值给变量,作为参数传递给方法,可以从方法返回等,比如int就是大多数语言的第一类公民,函数是函数式语言的第一类公民等)呢?为什么他们不能同时有方法和属性呢?

  问题是在面向类编程中,分解(decomposition)不适用。我们不能拆分一个复杂的问题,因为整个程序中只有一个类的实例存在。而OOP的强大是允许我们将对象作为一种作用域分解(scope decomposition)的工具来用。当我在方法中实例化一个对象,他将专注于我的特定任务。他与这个方法中的其他对象是完全隔离的。这个对象在此方法的作用域中是个局部变量。含有静态方法的类,总是一个全局变量,不管我在哪里使用他。因此,我不能把与这个变量的交互与其他变量隔离开来。

  除了概念上与面向对象的原则相悖,公共静态方法有一些实际的缺点:

  首先,不可能模拟他们(好吧,你可以使用PowerMock,这将成为你在一个Java项目所能做出的最可怕决定…几年前,我犯过一次)。

  再者,概念上他们不是线程安全的,因为他们总是根据静态变量交互,而静态变量可以被所有线程访问。你可以使他们线程安全,但是这总是需要显式地同步(explicit synchronization)。

  每次你遇到一个静态方法,马上重写!我不想再说静态(或全局)变量有多可怕了。我认为这是很明显的。

  6. 他的名字不是一个工作头衔

  一个对象的名字应该告诉我们这个对象什么,而不是它什么,就像我们在现实生活中给物体起名字一样:书而不是页面聚合器,杯子而不是装水器,T恤而不是身体服装师(body dresser)。当然也有例外,比如打印机和计算机,但是他们都是最近才被发明出来,而且这些人没有读过这篇文章。:)

  比如,这些名字告诉我们他们的主人是谁:苹果,文件,一组HTTP请求,一个socket,一个XML文档,一个用户列表,一个正则表达式,一个整数,一个PostgreSQL表,或者Jeffrey Lebowski。一个命名合理的对象总是可以用一个小的示意图就能画出来。即使正则表达式也可以画出来。

  相反,下面例子中的命名,是在告诉我们他们的主人做什么:一个文件阅读器,一个文本解析器,一个URL验证器,一个XML打印机,一个服务定位器,一个单例,一个脚本运行器,或者一个Java程序员。你能画出来他们吗?不,你不能。这些名字对良好对象来说是不合适的。他们是糟糕的名字,会导致糟糕的设计。

  一般来说,避免以“-er”结尾的命名 —— 他们中的大多数都是糟糕的。

  “FileReader的替代名字是什么呢?”我听到你问了。什么将会是个好命名呢?我们想想。我们已经有File了,他是真实世界中磁盘上文件的表示。这个表示并不足够强大,因为他不知道怎么读取文件内容。我们希望创建更强大的,并且具有此能力的一个。我们怎么称呼他呢?记住,名字应该说明他是什么,而不是他做什么。那他是什么呢?他是个拥有数据的文件;但是不仅仅是类似File的文件,而是一个更复杂的拥有数据的文件。那么FileWithData或者更简单DataFile怎么样?

  相同的逻辑也适用于其他名字。始终思考下他是什么而不是他做什么。给你的对象一个真实的、有意义的名字而不是一个工作头衔。

  7. 他的类要么是Final,要么是Abstract

  一个良好对象要么来自一个最终类,要么来自一个抽象类。一个final类不能通过继承被扩展。一个abstract类不能拥有孩子。简单上说,一个类应该要么声称,“你不能破坏我,我对你来说是个黑盒”,要么“我已经被破坏了;先修复我然后再使用我”。

  它们中间不会有其他选项。最终类是个黑盒,你不能通过任何方式进行修改。当他工作他就工作,你要么用他,要么丢弃他。你不能创建另外一个类继承他的属性。这是不允许的,因为final修饰符的存在。唯一可以扩展最终类的方法是对他的孩子进行包装。假如有个类HTTPStatus(见上),我不喜欢他。好吧,我喜欢他,但是他对我来说不是足够强大。我希望如果HTTP状态码大于400时能抛出一个异常。我希望他的方法read()可以做得更多。一个传统的方式是扩展这个类,并重写他的方法:

class OnlyValidStatus extends HTTPStatus {
  public OnlyValidStatus(URL url) {
    super(url);
  }
  @Override
  public int read() throws IOException {
    int code = super.read();
    if (code > 400) {
      throw new RuntimException("unsuccessful HTTP code");
    }
    return code;
  }
}

  为什么这是错的?我们冒险破坏了整个父类的逻辑,因为重写了他的一个方法。记住,一旦我在子类重写了read()方法,所有来自父类的方法都会使用新版本的read()方法。字面上讲,我们其实是在将一份新的“实现片段”插入到类中。理论上讲,这是种冒犯。

  另外,扩展一个最终类,你需要把他当做一个黑盒,然后使用自己的实现来包装他(也叫装饰器模式):

final class OnlyValidStatus implements Status {
  private final Status origin;
  public OnlyValidStatus(Status status) {
   this.origin = status;
  }
  @Override
  public int read() throws IOException {
    int code = this.origin.read();
    if (code > 400) {
      throw new RuntimException("unsuccessful HTTP code");
    }
    return code;
  }
}

  确保该类实现了与原始类相同的接口:StatusHTTPStatus的实例将会通过构造函数被传递和封装给他。然后所有的调用将会被拦截,如果需要,可以通过其他方式来实现。这个设计中,我们把原始对象当做黑盒,而没有触及他的内部逻辑。

  如果你不使用final关键字,任何人(包括你自己)都可以扩展这个类并且…冒犯他:( 所以没有final的类是个糟糕的设计。

  抽象类则完全相反 —— 他告诉我们他是不完整的,我们不能”原封不动(as is)”直接使用他。我们需要将我们自己的实现逻辑插入到其中,但是只插入到他开放给我们的位置。这些位置被显式地标记为abstract。比如,我们的HTTPStatus可能看起来像这样:

abstract class ValidatedHTTPStatus implements Status {
  @Override
  public final int read() throws IOException {
    int code = this.origin.read();
    if (!this.isValid()) {
      throw new RuntimException("unsuccessful HTTP code");
    }
   return code;
  }
  protected abstract boolean isValid();
}

  你也看到了,这个类不能够准确地知道如何去验证HTTP状态码,他期望我们通过继承或者重载isValid()方法来插入那一部分逻辑。我们将不会通过继承来冒犯他,因为他通过final来保护其他方法(注意他的方法的修饰符)。因此,这个类预料到我们的冒犯,并完美地保护了这些方法。

  总结一下,你的类应该要么是final要么是abstract的 —— 而不是其他任何类型。

8
6
标签:面向对象

编程基础热门文章

    编程基础最新文章

      最新新闻

        热门新闻