君子博学而日参省乎己 则知明而行无过矣

博客园 首页 新随笔 联系 订阅 管理

面向对象的编程通过封装可变动的部分来构造能够让人读懂的代码,函数式编程则是通过最大程度地减少可变动的部分来构造出可让人读懂的代码。 
— Michael Feathers,Working with Legacy Code一文的作者,这篇文章是通过微博发表的

关于本系列

本系列的目标是重新塑造您对函数式编程思想的看法,帮助您从新的角度看待常见问题,找到改​​进日常编码的途径。它探讨了函数式编程的概念,支持在Java 语言中进行函数式编程的框架,运行在JVM 上的函数式编程语言,以及语言设计的一些未来发展方向。本系列面向想了解Java 及其抽象工作、但在使用功能性语言方面经验很少或根本没有经验的开发人员。

在这一部分中,我讨论的是函数式编程的基石之一:不变性。一个不可变对象的状态在其构造完成之后就不可改变,换句话说,构造函数是唯一一个您可以改变对象的状态的地方。如果您想要改变一个不可变对象的话,您不会改变它,而是使用修改后的值来创建一个新的对象,并把您的引用指向它。(String就是构建在Java语言内核中的不可变类的一个典型例子。)不变性是函数式编程的关键,因为它与尽量减少变化部分的这一目标相一致,这使得对这些部分的推断更为容易一些。

在Java 中实现不可变类

诸如Java、Ruby、Perl、Groovy 和C# 一类的现代面向对象语言都拥有一些内置的便利机制,这些机制使得以可控方式来修改状态变得很容易。然而,状态对于计算来说是如此基础的信息,因此您永远也无法预料它会在哪个地方出纰漏。例如,由于大量可变化机制的存在,因此用面向对象的语言编写高性能的、正确的多线程代码会很困难。因为Java 已针对操纵状态进行了优化,因此您不得不绕过这样的一些机制来获得的不变性的一些好处。不过一旦您了解了要避免的一些陷阱之后,在Java 中构建不可变类这件事情就会变得非常容易。

定义不可变类

要将一个Java 类构造成不可变的类,您必须执行以下操作:

  • 把所有的域声明成final

    在Java中将域定义成final之后,您必须在声明的时候初始化它们,或是在构造函数中初始化它们。如果您的IDE抱怨您没有在声明的时候初始化它们,别紧张;当您在构造函数中写入适当的代码后,他们就会意识到您知道自己在做什么。

  • 将类声明为final,这样就不会重写它。

    如果可以重写类的话,则可以重写它的方法的行为,因此您最安全的选择就是不允许将类子类化。注意,这就是Java的String类使用的策略。

  • 不要提供一个无参数的构造函数。

    如果您有一个不可变对象,则必须要在构造函数中设置该对象将包含的任何状态。如果没有状态要设置,那么要一个对象来干什么?无状态类的静态方法一样可以起到很好的作用;因此,您永远都不该为一个不可变类提供一个无参数的构造函数。如果您正在使用的框架因为某些原因需要使用这样的构造函数的话,那么您可以了解以下能否通过提供一个私有的无参数构造函数(这是经由反射可见的)来满足这一要求。

    需要注意的一点是,无参数构造函数的缺失违反了JavaBeans的标准,该标准坚持要有一个默认的构造函数。不过JavaBeans无论如何都不可能是不可变的,这是set XXX方法的工作方式所决定的。

  • 至少提供一个构造函数。

    如果您没有提供一个无参数构造函数的话,那么这是您给对象添加一些状态的最后机会!

  • 除构造函数之外,不再提供任何的可变方法。

    您不仅要避免典型的受JavaBeans启发的set XXX方法,还必须注意不要返回可变的对象引用。对象引用被声明为final,这是实情,但这并不意味这您无法更改它所指向的内容。因此,您需要确保您已经防御性地复制了从get XXX方法中返回的任何对象引用。

“传统的” 不可变类

清单1 中列出了一个满足上述要求的不可变类:


清单1. Java中的不可变的Address

public final class Address {
    private final String name;
    private final List<String> streets;
    private final String city;
    private final String state;
    private final String zip;

    public Address(String name, List<String> 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<String> 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 class 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();
    }

在您尝试着在从getAddress()方法调用中返回的克隆数组上进行任何操作的时候,清单2中的代码问题就暴露出来了,如清单3所示:


清单3. 展示了正确但非直观结果的测试

public static List<String> streets(String... streets) {
    return asList(streets);
}

public static Address address(List<String> streets, 
                              String city, String state, String zip) {
    return new Address(streets, city, state, zip);
}

@Test public void 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");
    // doesn't work, but fails invisibly
    c.getAddress()[0] = newAddress;

    // illustration that the above unable to change to Customer's 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

public final class Address {
    private final List<String> streets;
    public final String city;
    public final String state;
    public final String zip;

    public Address(List<String> streets, String city, String state, String zip) {
        this.streets = streets;
        this.city = city;
        this.state = state;
        this.zip = zip;
    }

    public final List<String> getStreets() {
        return Collections.unmodifiableList(streets);
    }

在您想要隐藏底层表示形式的时候,只有为不可变域声明公共的get XXX ()方法才会带一些好处,但在重构期间会有一些显而易见的好处,比如可以很容易地发现细微的改变。通过将域声明成公共的或是不可变的,就能够直接在代码中访问它们,无需担心不小心更改它们的情况发生。

一开始的时候,使用不可变域似乎有些不自然,如果您听过愤怒的猴子这个故事的话,就会知道这种不同其实是有好处的:您还不习惯于处理Java 中的不可变类,这看起来像是一种新的类型,如清单5 中所示:


清单5. Address类的单元测试

@Test (expected = UnsupportedOperationException.class)
public void 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));
    // compiler disallows
    
//a.city = "New York";
    a.getStreets().clear();

愤怒的猴子

我第一次是从Dave Thomas那里听说了这个故事,并随后在我的著作The Productive Programmer(参见参考资料)中引用了它。我不知道它的真实性如何(也没有做过多少调查),但谁会在意呢?它只是完美地阐释了一个观点。

早在20 世纪60 年代,行为科学家进行了一项实验。他们把五只猴子和一架活梯放在一间屋子里,并在天花板上挂了一串香蕉。这些猴子很快就想到它们可以爬上梯子去吃香蕉,但每当它们靠近活梯的时候,科学家们就用冰水浸满整个屋子。然后就养成了一群愤怒的猴子。很快,再没有一只猴子会去靠近那个梯子了。之后,科学家们将其中一只猴子替换成另一只没有忍受过冰水折磨的新猴子。这只新猴子所做的第一件事就是直奔那架梯子,但当它这么做时其他所有猴子都痛打它。它不明白为什么,但很快就学乖了:不要去靠近那架梯子。科学家们逐渐将最初的那些猴子都替换成新猴子,直到这群猴子中谁都没有被冰水浸泡过,然而它们还是会去攻击任何靠近梯子的猴子。

这个故事要说明的观点是什么?那就是,软件项目中许多惯例之所以存在,就因为“我们一直是那样做的”。

对公有不可变域的访问避免了一系列get XXX ()调用所带来的可见开销,还要注意的是,编译器不会允许您给这些原始类型中的任一个赋值,如果您试着调用street集合上的可变方法的话,您就会收到一个UnsupportedOperationException(方式是在测试的顶部捕获)。这种代码风格的使用从视觉上给出了一种强烈的指示:该类是一个不可变类。

不利的方面

这种更清晰的语法的一个可能缺点是需要花一些精力来学习这种新的编程技法,不过我觉得这样做是值得的:这一过程会促进您在创建类的时候想着不变性,因为类的风格是如此明显不同,并且删除了不必要的样板代码。不过Java 中的这种代码风格也有着一些缺点(说句公道话,Java 的直接目的从来都不是为了迎合不变性):

  • 正如Glenn Vanderburg向我指出的那样,最大的缺点是这一风格违反了Bertrand Meyer(Eiffel编程语言的创建者)所说的统一访问原则(Uniform Access Principle):模块提供的所有服务应该是通过一种统一的标记法来使用的,无论服务是通过存储还是通过计算来实现的,都不能违背这种标记法。换句话说,对域的访问不应该暴露出该域是一个域还是一个返回值的方法。Address类的getStreets()方法与其他域没有保持统一。这一问题在Java中不可能得到真正的解决;但在其他的一些JVM语言中已经通过实现不变性解决了这个问题。
  • 一些重度依赖反射的框架无法使用这种编程技法来工作,因为他们需要一个默认的构造函数。
  • 因为您是创建新的对象而不是改变原有的对象,因此有着大量更新的系统可能就会导致以为垃圾收集而带来的效率低下。Clojure 一类的语言内置了一些工具,通过使用不可变引用将这种情况变得更高效一些,这是这些语言中的默认做法。

回页首

Groovy 中的不可变性

可以使用Groovy来构建公共的不可变域版本的Address类,这带来的是一种非常清晰的实现,如清单6所示:


清单6.使用Groovy编写的不可变的Address

class Address {
    def public final List<String> 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 中的统一访问

class AddressTest {
    @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

@Immutable
class Client {
    String name, city, state, zip
    String[] streets

因为用到了@Immutable注解,该类具有以下一些特点:

  • 它是最终的。
  • 属性自动拥有了私有的、合成了get 方法的域。
  • 任何更新属性的企图都会导致抛出ReadOnlyPropertyException异常。
  • Groovy 既创建了有序的构造函数,又创建了基于映射的构造函数。
  • 集合类被封装在适当的包装器中,数组(及其他可克隆的对象)被克隆。
  • 自动生成默认的equalshashcodetoString方法。

一句注解提供了这么多的作用!它的行为也正如您所期望的那样,如清单9 所示:


清单9. @Immutable注解正确地处理了预期的情况

@Test (expected = ReadOnlyPropertyException)
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异常。该注解还创建了适当的equalshashcode方法,如最后一个测试中所示,对象内容是相同的,但它们没有指向同一个引用。

当然,Scala 和Clojure 都支持并促进了不变性,且都有着清晰的不变性语法,接下来的文章会不时地谈到它们所带来的影响。


回页首

不变性的好处

在像函数式编程者那样思考的方法列表中,维护不变性处于列表的较高位置。尽管用Java 来构建不可变对象前期带来了更多的复杂性,但由这种抽象带来的后期简易性很容易补偿前面所做的工作。

不可变类摈弃了Java中许多一些典型的令人烦心的缺陷。转向函数式编程的好处之一是让人们意识到,测试的存在是为了检查代码中成功发生的转变。换句话说,测试的真正目的是验证改变,改变越多,就需要越多的测试来确保您的做法是正确的。如果您通过严格限制改变来隔离变化的发生,那么您为错误的发生制造了更小的空间,需要测试的地方也就更少。因为变化只会发生构造函数中,因此不可变类会将编写单元测试变成了一件微不足道的事情。您不需要使用复制构造函数,并且永远也不需要大汗淋漓地去实现clone()方法的那些令人惨不忍睹的细节。将不可变对象用作Map或是Set中的键值是也一种很不错的选择;因为Java的字典集合中的键不能更改值,因此,在将不可变对象用作键时,它是非常好用的键。

不可变对象也是自动线程安全的,不存在同步问题。它们也不可能因为异常的发生而处于一种未知的或是无法预期的状态中。因为所有的初始化都发生在构造阶段,这在Java中是一个原子过程,所有异常都发生在拥有对象实例之前。Joshua Bloch将这称作失败的原子性:在已经构建对象后,这种基于不可变性的成功或是失败就是一锤定音的了(请参阅参考资料)。

最后要说一点,不可变类最酷的一个地方是,它们融合到复合(composition)抽象中的能力是如此之强。在下一篇文章中,我会开始研究复合及其在函数式编程思想领域中的重要性。

posted on 2012-10-14 20:54  刺猬的温驯  阅读(278)  评论(0编辑  收藏  举报