英文原文:Functional thinking: Immutability
面向对象的编程通过封装可变动的部分来构造出可让人读懂的代码,函数式编程则是通过最小化可变动的部分来构造出可让人读懂的代码。
——Michael Feathers,Working with Legacy Code一书的作者,经由Twitter
在这部分内容中,我讨论的是函数式编程的基石之一:不变性(immutability )。一个不可变对象的状态在其构造完成之后就不可改变,换句话说,构造函数是唯一一个你可以改变对象的状态的地方。如果你想要改变一个不可变对象的话,你 不会改变它——而是使用修改后的值来创建一个新的对象,并把你的引用指向它。(String就是构建在Java语言内核中的不可变类的一个典型例子。)不 变性是函数式编程的关键,因为它与尽量减少变化部分的这一目标相一致,这使得对这些部分的推断更为容易一些。
在Java中实现不可变类
诸如 Java、Ruby、Perl、Groovy和C#一类的现代面向对象语言都拥有一些内置的便利机制,这些机制使得以受控的方式来修改状态变得很容易。然 而,状态对于计算来说是如此的基础,因此你永远也无法预料它会在哪个地方有泄漏。例如,由于大量变化性机制的存在,因此用面向对象的语言连编写高性能的、 正确的多线程代码就会很困难。因为Java是针对操纵状态做了优化的,因此你不得不绕过这样的一些机制来获得的不变性的好处。不过一旦你了解了要避免的一 些陷阱之后,在Java中构建不可变类这一事情就会变得较为容易起来。
定义不可变类
要把一个Java类构造成不可变的,你必须要:
1. 把所有的域声明成final的。
在Java中把域定义成final的时候,你必须或是在声明的时候或是在构造函数中初始化它们。如果你的IDE抱怨你没有在声明场合初始化它们的话,别紧张;当你在构造函数中写入适当的代码后,它就会意识到你知道你在做什么。
2. 把类声明成final的,这样它就不会被重写。
如果类可以被重写的话,那它的方法的行为也可以被重写,因此你最安全的选择就是不允许子类化。这里提一下,这就是Java的String类使用的策略。
3. 不要提供一个无参数的构造函数。
如果你有一个不可变对象的话,你就必须要在构造函数中设置其将会包含的任何状态。如果你没有状态要设置的话,那要一个对象来干什么?无状态类的 静态方法一样会起到很好的作用;因此,你永远也不应该为一个不可变类提供一个无参数的构造函数。如果你正在使用的框架基于某些原因需要这样的构造函数的 话,看看你能不能通过提供一个私有的无参数构造函数(这是经由反射可见的)来满足这一要求。
需要注意的一点是,无参数构造函数的缺失违反了JavaBeans的标准,该标准坚持要有一个默认的构造函数。不过JavaBeans无论如何都不可能是不可变的,这是由setXXX方法的工作方式决定了的。
4. 至少提供一个构造函数
如果你没有提供一个无参数构造函数的话,那么这就是你给对象添加一些状态的最后机会了!
5. 除了构造函数之外,不再提供任何的可变方法。
你不仅要避免典型的受JavaBeans启发的setXXX方法,还必须注意不要返回可变的对象引用。对象引用被声明成final的,这是实情,但这并不意味这你不能改变它所指向的内容。因此,你需要确保你是防御性地拷贝了从getXXX方法中返回的任何对象引用。
“传统的”不可变类
一个满足以上需求的不可变类如清单1所示:
清单1. Java中的一个不可变的Address类
private final String name;
private final List streets;
private final String city;
private final String state;
private final String zip;
public Address(String name, List streets,String city, String state, String zip) {
this.name = name;
this.streets = streets;
this.city = city;
this.state = state;
this.zip = zip;
}
public String getName() {
return name;
}
public List getStreets() {
return Collections.unmodifiableList(streets);
}
public String getCity() {
return city;
}
public String getState() {
return state;
}
public String getZip() {
return zip;
}
}
需要注意的一点是,清单1中的Collections.unmodifiableList()方法被用来对streets列表进行一个防御性的 拷贝。你应该始终使用集合而不是数组来创建不可变列表,尽管防御性的数组拷贝也是可能的,但这会带来一些不希望见到的副作用。考虑一下清单2中的代码:
清单2. 使用了数组而不是集合的Customer类
public final String name;
private final Address[] address;
public Customer(String name, Address[] address) {
this.name = name;
this.address = address;
}
public Address[] getAddress() {
return address.clone();
}
}
如清单3所示,在你尝试着在从getAddress()方法调用中返回的克隆数组上进行任何操作的时候,清单2中的代码的问题就暴露出来了:
清单3. 测试展示了正确的但却是非直观的结果
return asList(streets);
}
publicstatic Address address(List streets,String city, String state, String zip) {
returnnew Address(streets, city, state, zip);
}
@Test publicvoid immutability_of_array_references_issue() {
Address [] addresses =new Address[] {address(streets("201 E Washington Ave", "Ste 600"), "Chicago", "IL", "60601")};
Customer c =new Customer("ACME", addresses);
assertEquals(c.getAddress()[0].city, addresses[0].city);
Address newAddress =new Address(streets("HackerzRulz Ln"), "Hackerville", "LA", "00000");
// 不起作用,但这种失败没有显现出来
c.getAddress()[0] = newAddress;
// 说明上面的做法没有改变Customer的address
assertNotSame(c.getAddress()[0].city, newAddress.city);
assertSame(c.getAddress()[0].city, addresses[0].city);
assertEquals(c.getAddress()[0].city, addresses[0].city);
}
在返回一个克隆数组的时候,你保护了底层的数组——但你交还的数组看起来就像是一个普通的数组,即你可以修改这一数组的内容。(即使持有这一数 组的变量是final的,因为这只作用在数组引用自身上,而非数组的内容上。)在使用Collections.unmodifiableList() (以及Collections中的用在其他类型上的这一系列方法)时,你接收到的对象引用是没有做改变的方法可用的。
更清晰的不可变类
你经常会听到这样的说法,即你还应该要把不可变域声明成私有的。在听过有人以一种不同的但却是清晰的看法来澄清了一些根深蒂固的臆断之后,我不 再同意这样的观点了。在Michael Fogus对Clojure的创建者Rich Hickey所做的访谈中(参见参考资料),Hickey谈到了Clojure的许多核心部分都缺少数据隐藏式的封装。Clojure的这一方面一直都在 困扰着我,因为我是如此沉迷在基于状态的思考方式中。但在那之后我意识到了,如果域是不可变的话,那么就不需要担心它们被暴露出来。许多我们用在封装中的 保障措施实际上就是要防止改变的发生,一旦我们梳理清楚了这两个概念,一种更清晰的Java实现就浮现出来了。
考虑一下清单4中的Address类版本:
清单4. 使用了公有不可变域的Address类
private final List streets;
public final String city;
public final String state;
public final String zip;
public Address(List streets, String city, String state, String zip) {
this.streets = streets;
this.city = city;
this.state = state;
this.zip = zip;
}
public final List getStreets() {
return Collections.unmodifiableList(streets);
}
}
只有在你想要隐藏底层表示的时候,为不可变域声明公有的getXXX()方法才会带来唯一的好处,但是在这种支持重构的IDE很容易能够发现这 种改变的年代,这种好处也不算是什么好处了。通过把域声明成公有的并且是不可变的,你就能够直接在代码中访问它们,而又无需担心在不小心的情况下改变了它 们。一开始的时候,使用不可变域似乎有些不自然,如果你有听过愤怒的猴子这个故事(译者注:参见译文结尾处的补充内容)的话,但它们的这种不同是有好处的 的:你还不习惯于处理Java中的不可变类,这看起来像是一种新的类型,如果清单5中的用例说明:
清单5. Address类的单元测试
publicvoid address_access_to_fields_but_enforces_immutability() {
Address a =new Address(streets("201 E Randolph St", "Ste 25"), "Chicago", "IL", "60601");
assertEquals("Chicago", a.city);
assertEquals("IL", a.state);
assertEquals("60601", a.zip);
assertEquals("201 E Randolph St", a.getStreets().get(0));
assertEquals("Ste 25", a.getStreets().get(1));
// 编译器不允许
//a.city = "New York";
a.getStreets().clear();
}
对公有不可变域的访问避免了一系列getXXX()调用所带来的可见开销,还要注意的是,编译器不会允许你给这些原始类型中的任一个赋值,如果 你试着调用street集合上的可变方法的话,你就会收到一个UnsupportedOperationException(方式是在测试的顶部捕获)。 这种代码风格的使用从视觉上给出了一种强烈的指示:该类是一个不可变类。
不利的一面
这种更清晰的语法的一个可能缺点是需要花一些精力来学习这种新的编程技法,不过我觉得这是值得的:这一过程会促进你在创建类的时候思考不变性, 因为类的风格是如此明显不同,且其删去了不必要的样板代码。不过Java中的这种代码风格也有着一些缺点(说句公道话,Java的直接目的从来就不是为了 迎合不变性):
1. 正如Glenn Vanderburg向我指出的那样,最大的缺点是这一风格违反了Bertrand Meyer(Eiffel这一编程语言的创建者)所说的统一访问原则(Uniform Access Principle):模块提供的所有服务应该是通过一种统一的标记法来使用的,无论服务是通过存储还是通过计算来实现的,都不能违背这种标记法。换句话 说,对域的访问不应该暴露出其是一个域还是一个返回值的方法。Address类的getStreets()方法与其他域没有保持统一,这一问题在Java 中不可能得到真正的解决;但在其他的一些JVM语言中已解决了,方法是它们实现了不变性。
2. 一些重度依赖反射的框架无法使用这种编程技法来工作,因为他们需要一个默认的构造函数。
3. 因为你是创建了新的对象而不是改变旧有的那些,因此有着大量更新的系统可能就会导致由垃圾收集带来的效率低下。Clojure一类的语言内置了一些设施,通过使用不可变引用来把这种情况变得更有效率一些,这在这些语言中是默认的做法。
Groovy中的不可变性
可用Groovy来构建公有不可变域版本的Address类,其带来的是一种非常清晰的实现,如清单6所示:
清单6. 使用Groovy编写的不可变的Address类
def public final List streets;
def public final city;
def public final state;
def public final zip;
def Address(streets, city, state, zip) {
this.streets = streets;
this.city = city;
this.state = state;
this.zip = zip;
}
def getStreets() {
Collections.unmodifiableList(streets);
}
}
一如既往,Groovy需要的样板代码要比Java的少——还有其他方面的一些好处。因为Groovy允许你使用熟悉的get/set语法来创建属性,因此你可以为对象引用创建真正被保护起来的属性。考虑一下清单7中给出的单元测试:
清单7: 单元测试展示了Groovy中的统一式的访问
@Test (expected = ReadOnlyPropertyException.class)
void address_primitives_immutability() {
Address a =new Address(["201 E Randolph St", "25th Floor"], "Chicago", "IL", "60601")
assertEquals "Chicago", a.city
a.city ="New York"
}
@Test (expected=UnsupportedOperationException.class)
void address_list_references() {
Address a =new Address(["201 E Randolph St", "25th Floor"], "Chicago", "IL", "60601")
assertEquals "201 E Randolph St", a.streets[0]
assertEquals "25th Floor", a.streets[1]
a.streets[0] ="404 W Randoph St"
}
}
可以注意到,在这两个用例中,测试会在异常被抛出时终止,这是因为有语句违反了不可变性合约。不过在清单7中,streets属性看起来就像是原始类型,但实际上它是经由自己的getStreets()方法而受到保护的。
Groovy的@Immutable注解
这一文章系列所持有的一个基本宗旨就是,函数式语言应该为你处理更多低层面的细节。一个很好的例子就是Groovy的1.7版本增加了@Immutable这一注解,这一注解使得清单6中的编码方式变得不再重要了。清单8给出了一个使用了这一注解的Client类。
清单8. 不可变的Client类
class Client {
String name, city, state, zip
String[] streets
}
因为用到了@Immutable这一注解,该类有着如下的一些特点:
1. 它是最终的(final)。
2. 属性自动拥有了私下的、合成了get方法的域。
3. 任何更新属性的企图都会导致一个ReadOnlyPropertyException异常。
4. Groovy既创建了有序的构造函数,又创建了基于映射的构造函数。
5. 集合类被封装在适当的包装器中,数组(及其他可克隆的对象)被克隆。
6. 缺省的equals、hashcode和toString方法会自动生成。
一句注解提供了这么多的作用!它的行为也正如你所期望的那样,如清单9所示:
清单9. @Immutable注解正确地处理了预期的情况
void client_object_references_protected() {
def c =new Client([streets: ["201 E Randolph St", "Ste 25"]])
c.streets =new ArrayList();
}
@Test (expected = UnsupportedOperationException)
void client_reference_contents_protected() {
def c =new Client ([streets: ["201 E Randolph St", "Ste 25"]])
c.streets[0] ="525 Broadway St"
}
@Test
void equality() {
def d =new Client(
[name: "ACME", city:"Chicago", state:"IL",
zip:"60601",
streets: ["201 E Randolph St", "Ste 25"]])
def c =new Client(
[name: "ACME", city:"Chicago", state:"IL",
zip:"60601",
streets: ["201 E Randolph St", "Ste 25"]])
assertEquals(c, d)
assertEquals(c.hashCode(), d.hashCode())
assertFalse(c.is(d))
}
试图重置对象引用的操作带来了一个ReadOnlyPropertyException异常,试图改变其中的一个被封装起来的对象引用所指向的 内容,这一操作则是产生了一个UnsupportedOperationException异常。注解还创建了适当的equals和hashcode方 法,如最后一个测试中所做的展示——对象内容是相同的,但它们没有指向同一个引用。
当然,Scala和Clojure都支持并促进了不变性,且都有着清晰的不变性语法,接下来的文章会不时地谈到它们所带来的影响。
不变性的好处
在像函数式编程者那样思考的方法列表中,拥抱不变性处于列表的较高位置上。尽管用Java来构建不可变对象带来了更多的前期复杂性,但由这一抽象促成的后期简易性很容易就补偿了这种努力。
不可变类驱散了Java中许多典型的令人烦心的事情。转向函数式编程的好处之一是这样的一种情况得以实现,即测试的存在是为了检查代码中成功发 生了的转变。换句话说,测试的真正目的是验证改变——改变越多,就需要越多的测试来确保你的做法是正确的。如果你通过严格限制改变来隔离变化发生的地方的 话,那么你就为错误的发生创建了更小的空间,需要测试的地方就更少。因为变化只会发生构造函数中,因此不变类把编写单元测试变成了一件微不足道的事情。你 不需要一个拷贝构造函数,你永远也不需要大汗淋漓地去实现一个clone()方法的那些惨不忍睹的细节。把不可变对象用作Map或是Set中的键值是一种 很不错的选择;在被当成键来使用时,Java的集合字典中的键是不能改变值的,因此,不可变类是非常好用的键。
不可变对象也是自动线程安全的,不存在同步问题。它们也不可能因为异常的发生而处于一种未知的或是不期望的状态中。因为所有的初始化都发生在构 造阶段,这在Java中是一个原子过程,在拥有对象实例之前发生了异常,Joshua Bloch把这称作失败的原子性(failure atomicity:):一旦对象已经构造,这种基于不可变性的成功或是失败就是一锤定音的了(参见参考资料)。
最后要说一点,不可变类最棒的一个地方是,它们融合到复合(compositon)抽象中的能力是如此之强。在下一篇文章中,我会开始研究复合及其在函数式编程思想领域中的重要性。
补充内容
愤怒的猴子
这个故事我是从Dave Thomas那里听来的,后来它出现在了我的书The Productive Programmer(参见参考资料)中。我不知这是不是真的(尽管对此很是研究了一番),但管他呢,这个故事很完美地说明了一个观点。
话说早在1960年代,科学家们就进行了一项实验,他们把五只猴子关在一个屋子里,屋子里有一把梯子,还有一串挂在屋顶上的香蕉。猴子们很快就 发现,它们可以爬上梯子,然后就可以吃到香蕉。接下来,每次只要有猴子靠近梯子的话,科学家们就把整个房间置于冰冷的水中。不久之后,就没有猴子会走进梯 子了。接着,科学家就用一只新的猴子来替换掉其中一只浸过水的猴子,这只新猴子还从未被用到这一实验中。当该猴子直奔梯子而去时,所有其他的猴子把它揍了 一顿,它不明白它们为什么要打它,但它很快就学会了一件事情:不要靠近梯子。科学家逐个地用新猴子替换掉了最初的猴子,直到最后得到这样一群猴子,其中任 何一只都不曾被冷水浸泡过,但都会攻击任何靠近梯子的其他猴子。
要说明的观点?那就是,在软件项目中,许多做法存在的理由是,因为“我们一直就是这样做的”。
学习资料
1. The Productive Programmer(Neal Ford,O'Reilly Media,2008):Neal Ford的最新著作进一步阐述了这一系列中的许多主题。
2. Clojure:Clojure是一种现代的、运行在JVM上的函数式Lisp语言。
3. Rich Hickey Q&A:Michael Fogus对Clojure的创建者Rich Hickey所做的访谈。
4. Stuart Halloway on Clojure:从这developerWorks播客视频中了解更多关于Clojure的内容。
5. Scala:Scala是一种现代的、位于JVM之上的函数式语言。
6. The busy Java developer's guide to Scala:在这一developerWorks系列中,Ted Neward深入分析了Scala。
7. Effective Java, 2d ed. (Joshua Bloch,Addison Wesley,2008):阅读本书了解更多关于失败的原子性的内容。
8. 浏览technology bookstore来查找一些关于这些和另外一些技术主题的书籍。
9. developerWorks Java technology zone:可以找到几百篇关于Java编程的各个方面的文章。
获得产品和技术
1. 下载IBM产品评估版本或是浏览 IBM SOA Sandbox中的在线使用,动手操作DB2®, Lotus®, Rational®, Tivoli®, and WebSphere®方面的应用开发工具和中间件产品。
讨论
1. 加入developerWorks社区,浏览开发者驱动的博客、论坛、讨论组和wiki,与其他developerWorks用户建立联系。