函数式思维: 不变性

通过较少的改动来提供功能更强大的 Java 代码

不变性 (immutability) 是函数式编程的基石之一。“函数式思维” 系列的这一部分主要讨论了 Java 语言中的不变性的许多方面,并介绍了如何以传统的和新式的风格来创建不可改变的 Java 类。本文还展示了使用 Groovy 创建不可变类的两种方法,这两种方法摈弃了 Java 实现中的许多令人头疼的缺陷。最后您还要了解的是,何时适合使用这种抽象。

Neal Ford, 软件架构师, ThoughtWorks Inc.

Neal FordNeal Ford 是一家全球性 IT 咨询公司 ThoughtWorks 的软件架构师和 Meme Wrangler。他的工作还包括设计和开发应用程序、教材、杂志文章、课件和视频/DVD 演示,而且他是各种技术书籍的作者或编辑,包括最近的新书 The Productive Programmer。他主要的工作重心是设计和构建大型企业应用程序。他还是全球开发人员会议上的国际知名演说家。请访问他的 Web 站点



2011 年 10 月 17 日

面向对象的编程通过封装可变动的部分来构造能够让人读懂的代码,函数式编程则是通过最大程度地减少 可变动的部分来构造出可让人读懂的代码。
— 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 无论如何都不可能是不可变的,这是 setXXX 方法的工作方式所决定的。

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

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

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

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

“传统的” 不可变类

清单 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);
    }
}

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

一开始的时候,使用不可变域似乎有些不自然,如果您听过 愤怒的猴子 这个故事的话,就会知道这种不同其实是有好处的:您还不习惯于处理 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 年代,行为科学家进行了一项实验。他们把五只猴子和一架活梯放在一间屋子里,并在天花板上挂了一串香蕉。这些猴子很快就想到它们可以爬上梯子去吃香蕉,但每当它们靠近活梯的时候,科学家们就用冰水浸满整个屋子。然后就养成了一群愤怒的猴子。很快,再没有一只猴子会去靠近那个梯子了。之后,科学家们将其中一只猴子替换成另一只没有忍受过冰水折磨的新猴子。这只新猴子所做的第一件事就是直奔那架梯子,但当它这么做时其他所有猴子都痛打它。它不明白为什么,但很快就学乖了:不要去靠近那架梯子。科学家们逐渐将最初的那些猴子都替换成新猴子,直到这群猴子中谁都没有被冰水浸泡过,然而它们还是会去攻击任何靠近梯子的猴子。

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

对公有不可变域的访问避免了一系列 getXXX() 调用所带来的可见开销,还要注意的是,编译器不会允许您给这些原始类型中的任一个赋值,如果您试着调用 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) 抽象中的能力是如此之强。在下一篇文章中,我会开始研究复合及其在函数式编程思想领域中的重要性。

参考资料

学习

获得产品和技术

讨论

  • 加入 developerWorks 中文社区。查看开发人员推动的博客、论坛、组和维基,并与其他 developerWorks 用户交流。

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=765849
ArticleTitle=函数式思维: 不变性
publish-date=10172011