Seven Virtues of a Good Object

The following text is a partial translation of the original English article, performed by ChatGPT (gpt-3.5-turbo) and this Jekyll plugin:

马丁·福勒

函数组织成类?恕我直言,这是错误的。这是对面向对象编程中类的一个非常常见的误解。类不是函数的组织者。对象也不是数据结构。

那么什么是“适当”的对象?哪一个不是适当的对象?它们之间有什么区别?尽管这是一个非常争议的话题,但它非常重要。除非我们理解了什么是对象,否则我们怎么能编写面向对象的软件呢?好在我们可以借助Java、Ruby和其他语言来实现。但是它会有多好呢?不幸的是,这不是一门确切的科学,存在很多观点。以下是我对一个好对象的特质的列表。

在我们开始讨论对象之前,让我们先定义一下什么是。类是对象诞生的地方(也叫做实例化)。类的主要责任是根据需求创建新的对象,并在不再使用时销毁它们。类知道它的子类应该如何看起来和如何行为。换句话说,它知道它们应该遵守的约定

有时我听到类被称为“对象模板”(例如,维基百科这样说)。这个定义是不正确的,因为它将类放在了被动的位置。这个定义假设有人会获取一个模板并使用它来构建一个对象。从技术上讲,这可能是正确的,但从概念上讲是错误的。除了类和它的子类,没有其他人应该参与其中。一个对象请求类创建另一个对象,而类则构造它;就是这样。Ruby比Java或C++更好地表达了这个概念。

对象photo是由类File构造的(new是类的入口点)。一旦构造完成,对象就可以自行操作。它不应该知道由谁构造它,以及在类中还有多少兄弟姐妹。是的,我的意思是反射是一个糟糕的想法,但我将在接下来的帖子中更多地讨论它。现在,让我们谈谈对象及其最好和最差的一面。

首先,一个对象是一个生物。此外,一个对象应该被拟人化,即被视为人类(或宠物,如果你更喜欢它们)。这基本上意味着一个对象不是一个数据结构或者一组函数的集合。相反,它是一个独立的实体,有其自己的生命周期、行为和习惯。

雇员、部门、HTTP请求、MySQL表、文件中的一行或者文件本身都是合适的对象—因为它们在现实生活中存在,即使我们的软件关闭。更准确地说,一个对象是真实生物的代表。它是在所有其他对象面前那个真实生物的代理人。没有这样的生物,显然就没有对象。

在这个例子中,我要求File构建一个新的对象photo,它将是磁盘上真实文件的代表。你可能会说,文件也是一种虚拟的存在,只有在计算机开机时才存在。我同意并对“现实生活”这个定义进行了修正:除了对象所在程序的范围之外的所有东西都属于现实生活。磁盘文件位于我们程序的范围之外,所以在程序内部创建它的代表是完全正确的。

控制器、解析器、过滤器、验证器、服务定位器、单例或工厂都不是好对象(是的,大多数GoF模式都是反模式!)。它们在现实生活中不存在,只是为了把其他对象联系在一起而被发明出来的。它们是人为的和虚假的存在。它们不代表任何人。说真的,XML解析器——它代表谁?没人。

如果它们改变了名称,其中一些可能会变得合适;而另一些则永远无法为它们的存在找到借口。例如,那个XML解析器可以更名为“可解析的XML”,并开始代表我们范围之外存在的XML文档。

始终问自己,“我的对象背后是什么实体?”如果找不到答案,开始考虑重构。

一个好的对象总是遵守合同。他希望被雇佣不是因为个人的优点,而是因为他遵守合同。另一方面,当我们雇佣一个对象时,我们不应该歧视并期望某个特定类的对象为我们工作。我们应该期望任何对象按照合同要求去做。只要对象能够满足我们的需求,我们就不关心他的原始类别、性别或宗教。

例如,我需要在屏幕上显示一张照片。我希望这张照片从一个以PNG格式存储的文件中读取。我正在从DataFile类中实例化一个对象,并要求它给我提供该图像的二进制内容。

但是,等等,我关心内容到底来自哪里——磁盘上的文件,还是一个HTTP请求,或者可能是Dropbox中的一个文档?实际上,我并不关心。我只关心某个对象能够给我提供一个包含PNG内容的字节数组。所以我的合同会是这样的:

现在,任何来自任何类(不仅仅是DataFile)的对象都可以适用于我。他只需要遵守合同,即实现接口Binary,就可以符合资格。

这里的规则很简单:一个好的对象的每个公共方法都应该实现接口中对应的方法。如果你的对象有公共方法没有从任何接口继承而来,那么它的设计是不好的。

这样做有两个实际的原因。首先,一个没有合同的对象无法在单元测试中进行模拟。其次,一个没有合同的对象无法通过装饰器模式进行扩展。

一个好的对象应该始终进行封装,以确保其独特性。如果没有什么可以进行封装,那么该对象可能会有相同的克隆对象,我认为这是不好的。以下是一个可能具有克隆对象的不好的对象的示例:

我可以创建几个 HTTPStatus 类的实例,而且它们之间都是相等的。

显然,仅具有静态方法的实用类无法实例化好的对象。更一般地说,实用类没有本文提到的任何优点,甚至不能被称为“类”。它们只是对象范式的可怕滥用者,在现代面向对象的语言中存在只因为它们的发明者启用了静态方法。

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

请注意,不可变性并不意味着所有的方法总是返回相同的值。相反,一个好的不可变对象非常动态,但他从不改变内部状态。例如:

尽管方法read()可能返回不同的值,但对象是不可变的。他指向特定的网页,永远不会指向其他地方。他永远不会改变他的封装状态,也永远不会背叛他所代表的URL。

为什么不可变性是一种美德?本文详细解释了这个问题:对象应该是不可变的。简而言之,不可变的对象更好,因为:

  • 真正的不可变对象始终是线程安全的。

  • 它们有助于避免时间上的耦合。

  • 它们的使用是无副作用的(无防御性副本)。

  • 他们总是存在失败的原子性。

  • 它们更容易进行缓存。

  • 它们防止空引用。

当然,一个好的对象不应该有setter方法,因为这可能会改变它的状态并迫使它泄露URL。换句话说,在HTTPStatus类中引入setURL()方法将是一个可怕的错误。

此外,不可变对象将迫使您设计更加内聚、稳定和易于理解的结构,正如这篇文章《如何通过不可变性提升设计》所解释的那样。

静态方法实现了一个类的行为,而不是一个对象的行为。假设我们有一个类File,它的子类有方法size()

到目前为止,一切都很顺利;方法size()是因为Measurable接口的约定而存在的,而且File类的每个对象都能够测量自己的大小。一个可怕的错误是将这个类设计为一个静态方法(这种设计也被称为实用类,在Java、Ruby和几乎所有面向对象的编程语言中非常流行)。

这个设计完全违背面向对象的范式。为什么呢?因为静态方法将面向对象编程变成了“类导向”编程。这个方法 size() 暴露了类的行为,而不是它的对象的行为。你可能会问,这有什么问题呢?为什么我们不能在代码中同时拥有对象和类作为一等公民?为什么它们不能都有方法和属性呢?

问题在于,使用类导向编程时,分解问题就不再起作用了。我们无法将复杂的问题分解为部分,因为整个程序中只存在一个类的实例。面向对象编程的优势在于它允许我们将对象作为范围分解的工具。当我在方法内实例化一个对象时,它专门用于我的特定任务。它与方法周围的所有其他对象完全隔离。这个对象在方法的作用域中是一个局部变量。而一个带有静态方法的类,无论我在哪里使用它,都是一个全局变量。因此,我无法将与这个变量的交互与其他变量隔离开。

除了在概念上违背面向对象原则之外,公共静态方法还有一些实际的缺点:

首先,它们无法进行模拟测试(好吧,你可以使用PowerMock,但这将是你在Java项目中做出的最糟糕的决定… 我几年前做过一次)。

其次,它们不是线程安全的,因为它们总是与静态变量一起工作,而静态变量可以从所有线程访问。你可以使它们线程安全,但这总是需要显式同步。

每当你看到一个公共静态方法时,立即开始重写。我甚至不想提及静态(或全局)变量有多糟糕。我认为这是显而易见的。

一个对象的名称应该告诉我们这个对象是什么,而不是它做什么,就像我们在现实生活中给对象命名一样:书而不是页面聚合器,杯子而不是水杯,T恤而不是身体梳理器。当然也有例外,像打印机或计算机,但它们是最近发明的,而且是由那些没有读过这篇文章的人发明的。

例如,这些名称告诉我们它们的所有者是谁:一个苹果,一个文件,一系列HTTP请求,一个套接字,一个XML文档,一个用户列表,一个正则表达式,一个整数,一个PostgreSQL表,或者Jeffrey Lebowski。一个命名得当的对象总是可以画成一幅小图片。甚至一个正则表达式也可以被画出来。

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

一般来说,避免以”-er”结尾的名称,它们中的大部分都不好。

FileReader的替代品是什么?”我听到你在问。有一个更好的名称吗?让我们看看。我们已经有了File,它代表了磁盘上的一个现实世界的文件。这个代表对我们来说不够强大,因为它不知道如何读取文件的内容。我们想创建一个更强大的对象,它具备这个能力。我们应该如何称呼它?记住,名称应该表示它是什么,而不是它做什么。它是什么?它是一个具有数据的文件;不仅仅是一个文件,像File那样简单,而是一个更复杂的,具有数据的文件。那么FileWithData或者简单地说DataFile如何?

同样的逻辑应该适用于所有其他名称。始终考虑它是什么,而不是它做什么。给你的对象起一个真实、有意义的名称,而不是职位名称。

更多关于这个主题的内容,请参阅《不要创建以-ER结尾的对象》。

一个好的对象可以来自最终类或抽象类。final 类是一种无法通过继承进行扩展的类。abstract 类是一种无法创建实例的类。简单来说,一个类要么说:“你永远无法打破我;对你来说我是一个黑匣子”,要么说:“我已经坏了;先修复我然后再使用”。

中间没有其他选择。最终类是一个无法通过任何方式修改的黑匣子。他按照他的方式工作,你只能使用他或者抛弃他。你无法创建另一个类来继承他的属性。这是因为 final 修饰符的原因。扩展这样的最终类的唯一方式是通过装饰他的子类。假设我有一个类 HTTPStatus(见上文),而且我不喜欢他。好吧,我喜欢他,但他对我来说不够强大。如果 HTTP 状态超过 400,我希望他抛出异常。我希望他的方法 read() 做更多的事情。传统的方式是扩展该类并重写他的方法:

为什么这是错误的?这是非常错误的,因为我们通过覆盖其中一个方法来冒着破坏整个父类逻辑的风险。请记住,一旦我们在子类中覆盖了read()方法,父类的所有方法都会开始使用它的新版本。我们实际上是将一个新的”实现部分”直接注入到类中。从哲学角度来说,这是一种冒犯。

另一方面,要扩展一个final类,你必须将其视为一个黑盒,并使用你自己的实现方式进行装饰(即装饰器模式)。

确保这个类实现了与原始类相同的接口:StatusHTTPStatus的实例将通过构造函数传递给他并封装起来。然后,如果需要,每个调用都将被拦截并以不同的方式实现。在这个设计中,我们将原始对象视为黑盒,从不触及它的内部逻辑。

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

抽象类正好相反—它告诉我们它是不完整的,我们不能直接使用它。我们必须将我们的自定义实现逻辑注入到它之中,但只能注入到它允许我们触及的地方。这些地方被明确标记为abstract方法。例如,我们的HTTPStatus可能看起来像这样:

正如你所见,这个类不知道如何准确地验证HTTP代码,他希望我们通过继承和重写isValid()方法来注入这个逻辑。我们不会因为这个继承而冒犯他,因为他用final来保护了所有其他方法(注意他方法的修饰符)。因此,这个类已经准备好接受我们的攻击,并且完全防范了它。

总之,你的类应该是finalabstract,不能两者之间。

更新(2017年4月):如果你也认为实现继承是邪恶的,那么你的所有类都必须是final的。

Translated by ChatGPT gpt-3.5-turbo/42 on 2023-12-27 at 04:54

sixnines availability badge   GitHub stars