如何在 Java 中实现不可变类
前言
面向对象的编程通过封装可变动的部分来构造能够让人读懂的代码,函数式编程则是通过最大程度地减少可变动的部分来构造出可让人读懂的代码。
—MichaelFeathers,WorkingwithLegacyCode一文的作者
在这一部分中,我讨论的是函数式编程的基石之一:不变性。一个不可变对象的状态在其构造完成之后就不可改变,换句话说,构造函数是唯一一个您可以改变对象的状态的地方。如果您想要改变一个不可变对象的话,您不会改变它,而是使用修改后的值来创建一个新的对象,并把您的引用指向它。(String就是构建在Java语言内核中的不可变类的一个典型例子。)不变性是函数式编程的关键,因为它与尽量减少变化部分的这一目标相一致,这使得对这些部分的推断更为容易一些。
在Java中实现不可变类
诸如Java、Ruby、Perl、Groovy和C#一类的现代面向对象语言都拥有一些内置的便利机制,这些机制使得以可控方式来修改状态变得很容易。然而,状态对于计算来说是如此基础的信息,因此您永远也无法预料它会在哪个地方出纰漏。例如,由于大量可变化机制的存在,因此用面向对象的语言编写高性能的、正确的多线程代码会很困难。因为Java已针对操纵状态进行了优化,因此您不得不绕过这样的一些机制来获得的不变性的一些好处。不过一旦您了解了要避免的一些陷阱之后,在Java中构建不可变类这件事情就会变得非常容易。
定义不可变类
要将一个Java类构造成不可变的类,您必须执行以下操作:
- 把所有的域声明成final。
在Java中将域定义成final之后,您必须在声明的时候初始化它们,或是在构造函数中初始化它们。如果您的IDE抱怨您没有在声明的时候初始化它们,别紧张;当您在构造函数中写入适当的代码后,他们就会意识到您知道自己在做什么。
- 将类声明为final,这样就不会重写它。
如果可以重写类的话,则可以重写它的方法的行为,因此您最安全的选择就是不允许将类子类化。注意,这就是Java的String类使用的策略。
- 不要提供一个无参数的构造函数。
如果您有一个不可变对象,则必须要在构造函数中设置该对象将包含的任何状态。如果没有状态要设置,那么要一个对象来干什么?无状态类的静态方法一样可以起到很好的作用;因此,您永远都不该为一个不可变类提供一个无参数的构造函数。如果您正在使用的框架因为某些原因需要使用这样的构造函数的话,那么您可以了解以下能否通过提供一个私有的无参数构造函数(这是经由反射可见的)来满足这一要求。
需要注意的一点是,无参数构造函数的缺失违反了JavaBeans的标准,该标准坚持要有一个默认的构造函数。不过JavaBeans无论如何都不可能是不可变的,这是setXXX方法的工作方式所决定的。
- 至少提供一个构造函数。
如果您没有提供一个无参数构造函数的话,那么这是您给对象添加一些状态的最后机会!
- 除构造函数之外,不再提供任何的可变方法。
您不仅要避免典型的受JavaBeans启发的setXXX方法,还必须注意不要返回可变的对象引用。对象引用被声明为final,这是实情,但这并不意味这您无法更改它所指向的内容。因此,您需要确保您已经防御性地复制了从getXXX方法中返回的任何对象引用。
“传统的”不可变类
清单1中列出了一个满足上述要求的不可变类:
清单1.Java中的不可变的Address类
publicfinalclassAddress{ privatefinalStringname; privatefinalListstreets; privatefinalStringcity; privatefinalStringstate; privatefinalStringzip; publicAddress(Stringname,List streets, Stringcity,Stringstate,Stringzip){ this.name=name; this.streets=streets; this.city=city; this.state=state; this.zip=zip; } publicStringgetName(){ returnname; } publicList getStreets(){ returnCollections.unmodifiableList(streets); } publicStringgetCity(){ returncity; } publicStringgetState(){ returnstate; } publicStringgetZip(){ returnzip; } }
需要注意的一点是,可以使用清单1中的Collections.unmodifiableList()方法对streets列表进行防御性复制。您应该始终使用集合而不是数组来创建不可变列表,尽管防御性的数组复制也是可行的,但这会带来一些不希望见到的副作用。考虑一下清单2中的代码:
清单2.使用数组而非集合的Customer类
publicclassCustomer{ publicfinalStringname; privatefinalAddress[]address; publicCustomer(Stringname,Address[]address){ this.name=name; this.address=address; } publicAddress[]getAddress(){ returnaddress.clone(); } }
在您尝试着在从getAddress()方法调用中返回的克隆数组上进行任何操作的时候,清单2中的代码问题就暴露出来了,如清单3所示:
清单3.展示了正确但非直观结果的测试
publicstaticListstreets(String...streets){ returnasList(streets); } publicstaticAddressaddress(List streets, Stringcity,Stringstate,Stringzip){ returnnewAddress(streets,city,state,zip); } @Testpublicvoidimmutability_of_array_references_issue(){ Address[]addresses=newAddress[]{ address(streets("201EWashingtonAve","Ste600"),"Chicago","IL","60601")}; Customerc=newCustomer("ACME",addresses); assertEquals(c.getAddress()[0].city,addresses[0].city); AddressnewAddress=newAddress( streets("HackerzRulzLn"),"Hackerville","LA","00000"); //doesn'twork,butfailsinvisibly c.getAddress()[0]=newAddress; //illustrationthattheaboveunabletochangetoCustomer'saddress 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中用于其他类型的一系列方法)时,您会收到一个对象引用,它没有改变方法的可用性。
更清晰的不可变类
您可能经常听到这样的说法:您还应该将不可变域声明为私有域。在听到有人以一种不同的、但明确的看法来澄清一些根深蒂固的臆断之后,我不再同意这样的观点了。在MichaelFogus对Clojure的创建者RichHickey所做的访谈中),Hickey谈到了Clojure的许多核心部分都缺少数据隐藏式的封装。Clojure在这一方面一直困扰着我,因为我是如此沉迷基于状态的思考方式。
但在那之后,我意识到了,如果域是不可变的话,则无需担心它们被暴露出来。许多我们用在封装中的保障措施实际上就是为了防止发生改变,一旦我们梳理清楚了这两个概念,一种更清晰的Java实现就浮现了出来。
请考虑清单4中的Address类版本:
清单4.使用了公共不可变域的Address类
publicfinalclassAddress{ privatefinalListstreets; publicfinalStringcity; publicfinalStringstate; publicfinalStringzip; publicAddress(List streets,Stringcity,Stringstate,Stringzip){ this.streets=streets; this.city=city; this.state=state; this.zip=zip; } publicfinalList getStreets(){ returnCollections.unmodifiableList(streets); } }
在您想要隐藏底层表示形式的时候,只有为不可变域声明公共的getXXX()方法才会带一些好处,但在重构期间会有一些显而易见的好处,比如可以很容易地发现细微的改变。通过将域声明成公共的或是不可变的,就能够直接在代码中访问它们,无需担心不小心更改它们的情况发生。
一开始的时候,使用不可变域似乎有些不自然,如果您听过愤怒的猴子这个故事的话,就会知道这种不同其实是有好处的:您还不习惯于处理Java中的不可变类,这看起来像是一种新的类型,如清单5中所示:
清单5.Address类的单元测试
@Test(expected=UnsupportedOperationException.class) publicvoidaddress_access_to_fields_but_enforces_immutability(){ Addressa=newAddress( streets("201ERandolphSt","Ste25"),"Chicago","IL","60601"); assertEquals("Chicago",a.city); assertEquals("IL",a.state); assertEquals("60601",a.zip); assertEquals("201ERandolphSt",a.getStreets().get(0)); assertEquals("Ste25",a.getStreets().get(1)); //compilerdisallows //a.city="NewYork"; a.getStreets().clear(); }
对公有不可变域的访问避免了一系列getXXX()调用所带来的可见开销,还要注意的是,编译器不会允许您给这些原始类型中的任一个赋值,如果您试着调用street集合上的可变方法的话,您就会收到一个UnsupportedOperationException(方式是在测试的顶部捕获)。这种代码风格的使用从视觉上给出了一种强烈的指示:该类是一个不可变类。
不利的方面
这种更清晰的语法的一个可能缺点是需要花一些精力来学习这种新的编程技法,不过我觉得这样做是值得的:这一过程会促进您在创建类的时候想着不变性,因为类的风格是如此明显不同,并且删除了不必要的样板代码。不过Java中的这种代码风格也有着一些缺点(说句公道话,Java的直接目的从来都不是为了迎合不变性):
1.正如GlennVanderburg向我指出的那样,最大的缺点是这一风格违反了BertrandMeyer(Eiffel编程语言的创建者)所说的统一访问原则(UniformAccessPrinciple):模块提供的所有服务应该是通过一种统一的标记法来使用的,无论服务是通过存储还是通过计算来实现的,都不能违背这种标记法。换句话说,对域的访问不应该暴露出该域是一个域还是一个返回值的方法。Address类的getStreets()方法与其他域没有保持统一。这一问题在Java中不可能得到真正的解决;但在其他的一些JVM语言中已经通过实现不变性解决了这个问题。
2.一些重度依赖反射的框架无法使用这种编程技法来工作,因为他们需要一个默认的构造函数。
3.因为您是创建新的对象而不是改变原有的对象,因此有着大量更新的系统可能就会导致以为垃圾收集而带来的效率低下。Clojure一类的语言内置了一些工具,通过使用不可变引用将这种情况变得更高效一些,这是这些语言中的默认做法。
Groovy中的不可变性
可以使用Groovy来构建公共的不可变域版本的Address类,这带来的是一种非常清晰的实现,如清单6所示:
清单6.使用Groovy编写的不可变的Address类
classAddress{ defpublicfinalListstreets; defpublicfinalcity; defpublicfinalstate; defpublicfinalzip; defAddress(streets,city,state,zip){ this.streets=streets; this.city=city; this.state=state; this.zip=zip; } defgetStreets(){ Collections.unmodifiableList(streets); } }
一如既往,Groovy需要的样板代码要比Java的少,并且还提供了其他方面的一些好处。因为Groovy允许您使用熟悉的get/set语法来创建属性,因此您可以为对象引用创建真正受保护的属性。考虑一下清单7中给出的单元测试:
清单7.单元测试展示了Groovy中的统一访问
classAddressTest{ @Test(expected=ReadOnlyPropertyException.class) voidaddress_primitives_immutability(){ Addressa=newAddress( ["201ERandolphSt","25thFloor"],"Chicago","IL","60601") assertEquals"Chicago",a.city a.city="NewYork" } @Test(expected=UnsupportedOperationException.class) voidaddress_list_references(){ Addressa=newAddress( ["201ERandolphSt","25thFloor"],"Chicago","IL","60601") assertEquals"201ERandolphSt",a.streets[0] assertEquals"25thFloor",a.streets[1] a.streets[0]="404WRandophSt" } }
可以注意到,在这两个用例中,测试会在抛出异常时终止,这是因为有语句违反了不可变性合约。不过在清单7中,streets属性看起来就像是原始类型,但实际上它是用自己的getStreets()方法来保护其自身。
Groovy的@Immutable注释
本文章系列所持的一个基本宗旨就是,函数式语言应该为您处理更多低层面的细节。一个很好的例子就是Groovy的1.7版本增加了@Immutable注解,该注解使得清单6中的编码方式变得不再重要了。清单8给出了一个使用了该注解的Client类:
清单8.不可变的Client类
@Immutable classClient{ Stringname,city,state,zip String[]streets }
因为用到了@Immutable注解,该类具有以下一些特点:
- 它是最终的。
- 属性自动拥有了私有的、合成了get方法的域。
- 任何更新属性的企图都会导致抛出ReadOnlyPropertyException异常。
- Groovy既创建了有序的构造函数,又创建了基于映射的构造函数。
- 集合类被封装在适当的包装器中,数组(及其他可克隆的对象)被克隆。
- 自动生成默认的equals、hashcode和toString方法。
一句注解提供了这么多的作用!它的行为也正如您所期望的那样,如清单9所示:
清单9.@Immutable注解正确地处理了预期的情况
@Test(expected=ReadOnlyPropertyException) voidclient_object_references_protected(){ defc=newClient([streets:["201ERandolphSt","Ste25"]]) c.streets=newArrayList(); } @Test(expected=UnsupportedOperationException) voidclient_reference_contents_protected(){ defc=newClient([streets:["201ERandolphSt","Ste25"]]) c.streets[0]="525BroadwaySt" } @Test voidequality(){ defd=newClient( [name:"ACME",city:"Chicago",state:"IL", zip:"60601", streets:["201ERandolphSt","Ste25"]]) defc=newClient( [name:"ACME",city:"Chicago",state:"IL", zip:"60601", streets:["201ERandolphSt","Ste25"]]) 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中是一个原子过程,所有异常都发生在拥有对象实例之前。JoshuaBloch将这称作失败的原子性:在已经构建对象后,这种基于不可变性的成功或是失败就是一锤定音的了
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持毛票票。