优雅的对象
08 Jan 2018最近一口气读完了二百多页的《Elegant Objects》。可能因为整理自博客所以排版一般,而且才二百多页定价却40多刀。但读过之后发现超值,甚至还想去买第二卷。作者观点大多比较激进,对自己的理念异常坚定,所以经常使用诸如“绝对不要使用XXX”、“记住XXX,就这样,句号”。但作者绝不故弄玄虚,在批判之后,一定会给出自己的建议和代码示例。除去个别章节个人觉得很有争议,大部分内容读过之后都是很震撼的。另外原书观点较为零散,为了方便感兴趣的同学继续阅读学习,本文重点部分都标注了与原书章节的对应。
1.面向对象思想
面向对象思想与此前“远古”时期编程思想的不同就是:我们站在更高的抽象上思考问题,从问题领域出发定义概念和概念间的关系,而不再是以机器指令为中心,想尽各种办法指挥机器去做事。作者极力想要扭转我们的思想,不要被机器同化,总是从它的角度来思考问题,而是忘记具体的底层架构、指令、编程语言,让以我们要解决的问题为中心。
此外书中作者反复强调的一点就是:一切都是为了可维护性。这可能是工程里的编程与科学里的计算的主要区别之一。作为软件工程的一环,除了基本的正确性外,最重要的一点就是可维护性了。因为现实世界的不确定,需求的反复变化,代码量巨大等问题,没有良好的可维护性的话。以可维护性为中心出发,作者指出了面向对象的三大癌症:可变性、静态方法、NULL。就像《黑客帝国》里说的人类是地球的癌症,这三者就是我们日常开发的混乱之源。而且某种程度上,三者背后的思想都根植于面向过程编程。
2.概念的封装
对象不是一堆由我们调用者决定调用顺序的函数集合,依次调用操作其内部封装的数据。作者反复用拟人的方式强调,这种指手画脚地方式是对“别人”的不尊重。我们去餐厅点菜,不会跟厨师说你得先做这个再做那个,这样做出来才好吃。现实中我们直接说,请给我来一份这个菜,厨师会自己决定如何烹饪。跟一个对象打交道也是如此,对象是一个自洽的、能自己做决定并自己行动的实体,就像一个能自给自足(self-sufficient)的生物一样。所以说,对象与对象之间相互“尊重信任”的关系是面向对象思想的核心。
2.1 设计者:隐藏好你的抽象
从对象设计者角度,要自重,不要泄漏你的实现细节,不要泄漏任何内部抽象。从这个角度来说,setter/getter也是邪恶的(原书3.5节)。你可能会说,setter/getter不是远好于直接暴露成员变量吗?而且它还是Java Bean的标准,我们还可以在里面添加数据验证、甚至改变存储内容方式等逻辑。但这都不重要,重要的是对于调用者来说这与直接的数据访问没什么区别。比如下面Cash
类中如果加上一个getDollars()
方法,那就好像让调用者对自己说:“去成员数据里找,看看有没有一个叫dollars
的,把它返回给我”。而dollars()
则会好一些,仿佛在说:“请告诉我你有多少美元?”(另可参考原书的2.4节 Choose method names carefully,作者对方法起名的见解)。
此外也不要返回NULL
造成空指针异常,失去调用者对你的信任(原书4.1节 Never return NULL)。返回NULL
的最常见情况就是找不到调用者想要的对象,那么可选的其他方式有返回空列表/集合,如果只是要返回一个单一对象的话则可采用空对象设计模式,或者在必要时抛出异常。更深层的原因是NULL
不应该出现在面向对象的世界里(原书3.3节 Never accept NULL arguments)。
也不要因为方便而对外提供成员常量,比如public static final String CRLF = "\r\n"
,看似很常见却破坏了面向对象的理念(原书2.5节 Don’t use public constants)。下面将这部分逻辑封装到CRLFString
类中,我们再也不用担心明天要为不同平台添加不同的换行符了。但这会导致很多类不是么,参见第三部分关于短小而简单的类设计理念的讨论。
class CRLFString {
private final String src;
CRLFString(String src) {
this.src = src;
}
String toString() {
if (/* this is windows */) {
//...
}
return String.format("%s\r\n", src);
}
}
2.2 使用者:Show Your Respect
从使用者角度,不要去偷窥,比如利用反射技术在运行时获取对象内部的信息,比如最常用的instanceof
判断对象是否是某个具体的子类等等(原书3.7节 Avoid type introspection and casting)。可能你会说你很少用反射,也可以尽量不用,但单元测试里的mock技术是无人不知、无人不晓的。mock难道不是很好的技术吗?现在已经有了很多非常棒的mock框架。
class Cash {
private final Exchange exchange;
private final int cents;
public Cash(Exchange exchange, int cents) {
this.exchange = exchange;
this.cents = cents;
}
public Cash in(String currency) {
return new Cash(
exchange,
cents * exchange.rate("USD", currency)
);
}
}
Exchange exchange = Mockito.mock(Exchange.class);
Mockito.doReturn(1.15).when(exchange).rate("USD", "EUR");
Cash dollar = new Cash(exchange, 500);
Cash euro = dollar.in("EUR");
assert "5.75".equals(euro.toString());
经过本文前面的论述,你应该一眼就能看出问题了。我们读了被测试类Cash
的源代码,然后去控制它依赖的Exchange的行为。这是严重的冒犯啊!我们没有任何权利去假定别人的内部抉择,我们唯一能做的就是确定如何与Cash
交互,而不是干涉Cash
与别人的交互。于是作者提出不要mock,而是提供Fake
类给所有测试类共享使用(原书2.8节 Don’t mock; use fakes)。所以Fake
类可能比本尊更加复杂和强大。
interface Exchange {
float rate(String origin, String target);
final class Fake implements Exchange {
@Override
float rate(String origin, String target) {
return 1.2345;
}
}
}
2.3 观念转变:对象为王
在面向过程的时代,代码为王,代码指挥一切,数据只是被动地等待被处理。而面向对象将其反转,对象现在是王,代码变成了二等公民。之前起名一直是个难题,不管是给类、方法还是变量,可作者却不这样认为。作者说道:看看对象封装了什么数据,然后为这个(概念)起个名字,就是这样简单。所以如果你的类名中含有动作,或者动作-er,那就说明你潜意识中还是在按照面向过程的方式思考(原书1.1节 Never use -er names)。
class CashFormatter {
private int dollars;
CashFormatter(int dollars) {
this.dollars = dollars;
}
public String format() {
return String.format("$ %d", this.dollars);
}
}
CashFormatter
这种类我们见得太多了,Manager、Controller、Helper、Handler、Writer、Reader、Converter、Validator、Router、Dispatcher、Observer、Listener、Encoder、Decoder。如果谁说自己从来没起过这样的名字,那还真难以置信。因为不只我们,各种优秀的开源框架、设计模式里也在用这样的名字。但《Elegant Objects》一书的作者指出:Cash
类就是它封装的dollars
,而不是format()
方法,它就是它所封装的数据!
class Cash {
//...
public String usd() {
return String.format("$ %d", this.dollars);
}
}
3.短小简洁的力量
3.1 英语和汉语
更多更小的类,意味着职责、概念、含义的分散,作者举了一个形象的比喻(原书2.5节 Don’t use public constants):英语里的单词越多(并且不是同义词),也就意味着你的文字可读性更高。相反则说明一些词有很多含义,并且被反复使用着,造成可读性很低。比如下面两句话,可读性孰高孰低一目了然。
My cat likes to eat fish and drink milk.
My thing likes to eat that thing and drink another thing.
就好比公认难学的汉语,有很多的一词多义、一字多音,同样一个字或词。在不同的语境下有不同的含义甚至发音,于是让想学汉语的老外们望而却步。面向对象的语言世界也是如此,一个对象太过灵活、太过强大(一词多义),也就意味着使用的难度更大。一个对象能够出现在各种地方(用在各种语境有不同含义),就需要使用者对其有非常深刻的了解(像母语一样)。
3.2 规约越少越好
规约,即接口中我们要求实现者实现的公有方法。规约少则有以下几个好处(原书3.1节 Expose fewer than five public methods):
- 出问题时更容易定位,因为对象只有这几个入口
- 更可能达到高内聚,因为我们有更大机会让这几个方法都围绕数据
- 用例场景更少,意味着更容易测试,测试类也更小
但有时我们想提供多个重载方法,像多个构造器一样让使用者更加方便,那应该怎么办呢?类似供测试类共享的Fake
类一样,我们还可以提供一个Smart
类(原书2.9节 Keep interface short; use smarts)。
interface Exchange {
float rate(String target);
float rate(String source, String target);
}
interface Exchange {
float rate(String source, String target);
final class Smart {
private final Exchange origin;
public float toUsd(String source) {
return origin.rate(source, "USD");
}
public float eurToUsd() {
return toUsd("EUR");
}
}
}
float rate = new Exchange.Smart(new NYSE()).toUsd("EUR");
float rate = new Exchange.Smart(new NYSE()).eurToUsd();
3.3 构造器与私有方法,多些没问题
与规约不同,构造器和私有方法多一些则没有任何问题。不能说越多越好,但绝对没有公有方法的“危害”那样大。一个有20个公共接口的类绝对是有问题的,但一个只有两三个公共方法外加5个构造器和10个私有方法的对象,可能刚刚好。为什么会这样呢?因为你提供的构造器越多,你就提供了越多的灵活性给使用者(原书1.2节 Make one constructor primary)。同理,越多私有方法,意味着你的逻辑比较复杂,所以拆分成不同的私有方法来让整体更为清晰。而如前所述,公有方法却完全不是这个样子,它意味着对使用者更多的约束、更多更繁杂的使用场景、很可能更低的内聚性等。
所以说,不用担心简洁的类设计导致类数量的爆炸,也不用担心每个类都没什么内容。多提供一些构造方法,将公有方法的逻辑写仔细了,这样的内容就已经足够一个类文件去承载了。比如下面的Cash
类的各种构造器。
new Cash(30);
new Cash("$29.95");
new Cash(29.95d);
new Cash(29.95f);
new Cash(29.95, "USD");
4.可变世界中的不可变
4.1 狭义不可变
所谓狭义可变性就是我们常说的常量类(原书2.6节 Be immutable),《Effective Java》中用了一章来说明如何让你的类不可变。不可变的优点很明显,比如对象的Identity不可变、没有副作用、线程安全等,作者甚至说可变对象没有生存的权力,它们应该像Haskell那样被处置而不应该出现在OOP的世界里。可是我们知道,现实世界就是变化的,我们不可能让程序没有任何副作用地运行。于是,作者提出了广义上的不可变性。
4.2 广义不可变
广义上的不可变就是指对象的Identity不变(原书3.4节 Be loyal and immutable, or constant)。比如下面的例子,WebPage
就是不可变类,尽管它返回的网页内容可能不同,但是它的Identity即URI永远不会变。我们传入相同的URI,得到的永远是两个“相同的”对象。甚至假如它保存的不是URI而是一个链表,作者称它也是不可变的,因为本质上网络、磁盘、内存都是代码的外部资源。
class WebPage {
private final URI uri;
WebPage(URI path) {
this.uri = path;
}
public String content() {
// Make HTTP request and load web content
}
}
听起来有点极端,但是大概明白作者要表达的意思就是:只要对象的Identity(其他对象的引用、间接的指针等)没变,那它就是不可变的。至于代表它Identity的对象内部是如何变化的,我们不管。换一种角度来看,也许我们更容易接受作者的观点。比如下面的不可变链表的例子,假设Java里有个Memory类表示内存,这样看的话你还会觉得ImmutableList
是可变的吗?当然,我们失去了狭义不可变(常量)的种种优点,就当广义不可变是我们编程时的底线吧,即不要改变对象的Identity。
class ImmutableList<Integer> {
private final Memory total = new Memory(2);
private final Memory items = new Memory(100);
void add(Integer number) {
int pos = this.total.read();
this.items.store(pos, number);
this.total.store(pos + 1);
}
}
5.声明式编程
作者理想中的软件,除了最底层的细节,中上层是没有操作符、语句,只有对象的初始化和构造器。
5.1 顺序化思考
我们每天都在进行着顺序化的思考,比如你要去ATM取钱,第一步是查询最近的银行ATM在哪,第二步去输入密码取钱等等。下面则是一个典型的顺序化代码,为了求两数之中的最大值,先比较它们,如果A大则返回A,否则返回B。从这段代码,我们能清晰地在脑袋里想象出整个代码流程。如果你对汇编很熟悉的话,还是看出CPU是如何执行每条指令的(排除编译器优化的复杂性)。
int max(int a, int b) {
if (a > b) {
return a;
}
return b;
}
下面是一段LISP中的最大值函数代码,你还能看出它是如何开始执行、如何结束的吗?也许这个简单的例子难不倒你,也许你对解释器在现代计算机上的实现也了如指掌。但这并不重要,重要的是:从代码字面到实际执行流程不再是简单的映射。也就是说,我们隐藏了具体的“过程式”细节,达到了“声明式”的抽象层次。
(defun max (a b)
(if (> a b) a b))
5.2 面向对象思考
在面向对象的世界里,我们同样可以做到LISP能做的事,关键就是面向对象的思考方式。我们定义谁是谁,让它们根据需要自己交互。我们定义概念和概念之间的关系。比如下面的Max
类,在使用者的层次上,我们没有说如何求最大值,我们没有写任何的语句、指令或操作符,我们只是定义了5、9和x三者间的关系,即x是5和9之间的最大值。
class Max implements Number {
private final Number a;
private final Number b;
public Max(Number left, Number right) {
this.a = left;
this.b = right;
}
//...
}
Number x = new Max(5, 9);
这不是面向对象特有的东西,而是一种高抽象层次的思考方式——声明式。我们关注概念间的关系定义,关注What,关注我们想要的结果,而命令式实现则是How,只是实现方式的一种。这种思想根植于纯数学,就好比西格玛和开根号都是定义声明,而具体是从前往后还是从后往前循环求和,是牛顿法开根号还是其他方法,都只是实现方式的一种。在更高的层次上,我们不关心计算,而关心概念间关系的组织。
可是不管怎样隐藏,最终在某个角落,我们不是还要去用if语句、大于小于比较符,写“过程式”的代码吗?这也正是我的疑问。的确,因为我们所用的编程语言还没有达到很高的抽象,本质上还是对底层指令的抽象。所以我们要将低层次命令式的计算控制在大量微对象中,通过聚合和装饰器模式等,让代码整体上都处于更容易理解的高抽象层次。作者说这是我们的代码库中大部分代码应有的样子。他还举了个有些极端的例子,也许不容易接受,但其要表达的意思的确发人深省。
float rate;
if (client.age() > 65) {
rate = 2.5;
} else {
rate = 3.0;
}
float rate = new If(new GreaterThan(new AgeOf(client), 65)), 2.5, 3.0);
5.3 函数式编程
除了最明显的优点外,声明式的另一个直接好处就是语句间的时间解耦。因为避免了顺序化思考,除了最底层的积木,我们不再显示地控制程序的运行,所以自然我们也没有了调整两个语句的顺序结果导致Bug的烦恼。比如下面这个简单的例子,我们看不到控制语句了。也许你会说这个例子太简单,可当你改一个上百行的函数或者类时,哪个语句可以放在前面哪个在后面就不那么明显了。
Collection<Integer> evens = new LinkedList<>();
for (int number : numbers) {
if (number % 2 == 0) {
evens.add(number);
}
}
List<Integer> evens = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
这种风格不是函数式编程风格吗?的确,从这个小例子可以管中窥豹,看出函数式编程的优点:声明式风格、语句间时间解耦、相关语句“粘合”在一起达到高内聚。在之前的几节里我们也提到对象要小、不可变、没有静态方法,那我们为什么不直接用函数式语言呢?作者自信地说:FP是很好的范式,但OOP更好!Java 8中引入Lambda不是个好主意,这让开发者分散注意力,而不是思考如何在面向对象中实现同样的思想。
后记:面向对象的理想国
通过前面的讲解分析,相信大家心中对“什么样的对象才是优雅的?”的问题有了自己的答案。一个优雅对象具备了诸多好品质,比如本文中提到的自给自足的封装、短小简洁的类和方法、不可变的状态、声明式编程风格等等。以下是本人在读完此书之后,脑海中一个优雅对象应该有的样子:
- 封装:每个对象都是一个概念的完美封装,能够独立地“思考”、完成任务。
- 简短:每个对象都简洁明了,恰到好处,再多一点少一点内容都不合适。
- 不变:每个对象又是不可变的,对象初始化就创建好,之后也不会改变,不会再有神秘的并发问题。
- 声明:对象间通过声明式方式组合到一起,达到比较高的抽象层次。
- 纯粹:最底层的类方法里的代码也非常整洁,像函数式编程里的纯函数一样。
当然,也许你并不需要完全遵循本文或者原书的观点,但如果通过文本能激发你的思考,那也就算是一大收获。另外注意,我们也不是在一味地批判面向过程思考甚至其他编程范式,而盲目地鼓吹面向对象编程思想。不少伟大的软件都是面向过程语言写出来的,其他范式也自有可取之处。对计算和工程有了深刻理解后,不限于任何平台、语言、范式,伟大的思想最终应该是殊途同归的。希望有朝一日能悟出门道,拿一片树叶也能吹出美妙的声音,而不是拿着锤子看什么都像钉子。
趁热打铁,接下来的学习计划就是根据本书的读后感来的,尤其有两点非常感兴趣:
- OO思想:作者推荐了《Object Thinking》,偏“哲学”方面对不只软件开发而是整个世界的思考。
- 封装等技术:了解了本书的中心思想,再去看一些面向对象的书也许对OO本身以及SOLID原则会有更深的体会。推荐《Practical Object-Oriented Design in Ruby》、《Design Patterns Explained》和《Python 3 Object-oriented Programming》。
- 单元测试:本书批判了Mock技术对对象的“不尊重”,于是想看看如何能更好地单元测试。目前在读《Growing Object-Oriented Software, Guided by Tests》。
- 声明式编程:本书提及的另一个很惊艳的技术就是声明式编程,以前也有所接触,但本书作者应该是讲的比较深刻的一个。除了函数式编程语言外,貌似领域驱动设计DDD里也有对应的概念(领域特定语言DSL),可以继续深入学习《Patterns, Principles and Practices of Domain-Driven Design》。
- 不可变性:对函数式语言像Haskell如何利用Lazy Evaluation等技术抽离对外部世界的依赖依然不是很清楚,需要深入学习,看面向对象中如何借鉴。