函数式编程思想:不变性

标签: 函数 编程 思想 | 发表时间:2011-07-28 17:31 | 作者:Elaine.Ye pippo
出处:http://www.yeeyan.org

原作者:
来源Functional thinking: Immutability
译者Elaine.Ye

面向对象的编程通过封装可变动的部分来构造出可让人读懂的代码,函数式编程则是通过最小化可变动的部分来构造出可让人读懂的代码。
——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类

public final class 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 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();
    }
}
如清单3所示,在你尝试着在从getAddress()方法调用中返回的克隆数组上进行任何操作的时候,清单2中的代码的问题就暴露出来了:

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

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

public static Address address(List 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");
    // 不起作用,但这种失败没有显现出来
    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类

public final class 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类的单元测试

@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));
    // 编译器不允许
    //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类

class 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中的统一式的访问

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这一注解,该类有着如下的一些特点:

1. 它是最终的(final)。

2. 属性自动拥有了私下的、合成了get方法的域。

3. 任何更新属性的企图都会导致一个ReadOnlyPropertyException异常。

4. Groovy既创建了有序的构造函数,又创建了基于映射的构造函数。

5. 集合类被封装在适当的包装器中,数组(及其他可克隆的对象)被克隆。

6. 缺省的equals、hashcode和toString方法会自动生成。

一句注解提供了这么多的作用!它的行为也正如你所期望的那样,如清单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异常。注解还创建了适当的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用户建立联系。

关于作者


Neal Ford是一家全球性的IT咨询公司ThoughtWorks的软件架构师和Meme Wrangler。他还设计并编写了一些应用、教材、杂志文章、课件和视频/DVD演示,他是一些跨多种技术的书籍的作者或是编辑,其中包括了最近出版的这本The Productive Programmer。他的工作重点是设计和构建大型的企业级应用,他还是全球范围的开发者大会上的一位国际知名的演讲者。你可看看他的网站。           

译者注:标题配图来自developerWorks网站

                    

添加新评论

相关文章:

  乌兹别克的风味茶舍

  不要在优秀中走向平庸

  中国动车事故引发愤怒和争议

  中国动车事故:归责于“信号灯设计的瑕疵”

  热爱音乐?揭示背后的六大心理因素

相关 [函数 编程 思想] 推荐:

函数式编程思想:不变性

- pippo - 译言-每日精品译文推荐
来源Functional thinking: Immutability. 面向对象的编程通过封装可变动的部分来构造出可让人读懂的代码,函数式编程则是通过最小化可变动的部分来构造出可让人读懂的代码. ——Michael Feathers,Working with Legacy Code一书的作者,经由Twitter在这部分内容中,我讨论的是函数式编程的基石之一:不变性(immutability ).

函数式编程思想:耦合和组合,第2部分

- TONY - 译言-电脑/网络/数码科技
在上一部分内容中,我说明了代码重用的不同做法. 在面向对象的版本中,我提取出了重复的方法,把他们和一个受保护(protected)域一起移到一个超类中. 在函数式版本中,我把纯函数(不会带来边际效应的那些函数)提取出来放到了它们自己的类中,通过提供参数值来调用它们. 我改变了重用机制,把经由继承的受保护域改成方法参数.

学会像函数式编程者那样思考——函数式编程思想:以函数的方式来思考,第1部分

- xf - 译言-每日精品译文推荐
来源Learning to think like a functional programmer——Functional thinking: Thinking functionally, Part 1. 让我们先来扯一下这样的一个话题,你是一个伐木工,在这森林中,你有着一把最好的斧头,这使得你成为了营地中最具生产效率的樵夫.

函数式编程初探

- - 博客 - 伯乐在线
诞生50多年之后, 函数式编程(functional programming)开始获得越来越多的关注. 不仅最古老的函数式语言Lisp重获青春,而且新的函数式语言 层出不穷,比如Erlang、clojure、Scala、F#等等. 目前最当红的Python、Ruby、Javascript,对函数式编程的支持都 很强,就连老牌的面向对象的Java、面向过程的PHP,都忙不迭地加入对匿名函数的支持.

Dart中的函数式编程

- - 技术改变世界 创新驱动中国 - 《程序员》官网
JavaScript的一个特点是它支持函数式编程. 因为Dart的目标是让人感觉熟悉,现在让我们看看在Dart语言中函数式编程是什么样的. 我们先从一个传统的例子开始,计算斐波纳契数列. 在JavaScript中,大概像下面这样写:. 探索一个语言的函数式编程特性,斐波纳契数列是个很好的例子. 不仅因为它是一个函数,也因为它的递归性质可以展示函数的调用.

【外刊IT评论网】如何学会函数式编程

- iBeyond - 外刊IT评论网
本文是从 How to get started with functional programming 这篇文章翻译而来. 上周末,有人问我,如何学会函数式编程. 我的回答是:用你现在使用的编程语言写纯正函数. 纯函数唯一的输入是它的参数,唯一的输出是它的返回值. 如果你以前从未接触过这个概念,你会以为所有的函数都是纯正的.

Clojure 1.3发布,基于JVM的函数式编程语言

- bamerl - ITeye资讯频道
Clojure日前发布了 1.3 版本. Clojure是一个在JVM平台运行的动态函数式编程语言,在JVM平台运行的时候,会被编译为JVM的字节码进行运算,能调用Java的类库,支持并发,与Scala类似. Leinigen或是Maven用户现在可以设置依赖:.    该版本中包含了许多重大的特性和性能改进,比如增强了原生支持、改进了defrecord和deftype、改进了异常报告、可以通过Maven进行编辑和部署,以及绑定Conveyance等.

浅谈编程解决实际问题的常见思想

- - 博客园_知识库
  现实生活中有很多问题,人为不好解决,但利用计算机速度快,不出错的特性,可以很方便的解决这些问题,下面简单说说我在程序设计中解决实际问题的一些常见思想,高手可以忽略掉,我也是无聊了随便写写而已.    1、枚举最优解时的情况.   有很多问题初看很棘手,但经过仔细的分析,可以得出一些显然的结论.

【外刊IT评论网】函数式编程很难,这正是你要学习它的原因

- ZB - 外刊IT评论
本文是从 Functional Programming Is Hard,That's Why It's Good 这篇文章翻译而来. 很奇怪不是,很少有人每天都使用函数式编程语言. 如果你用Scala,Haskell,Erlang,F#或某个Lisp方言来编程,很可能没有公司会花钱聘你. 这个行业里的绝大部分人都是使用像Python,Ruby,Java或C#等面向对象的编程语言——它们用起来很顺手.

剧情函数库

- SourBell - 学而时嘻之
(《新知客》,2010年10月). 著名物理学家徐一鸿先生在《可怕的对称》这本书中谈到对称性群的时候提到一个很有意思的笑话. 有一个客人随他的朋友参加一个笑话俱乐部的聚会. 另一个站起来叫道,“S—5”,引得所有的人都笑了起来. 这个迷惑不解的客人问道,这是怎么回事. 他的朋友解释道:“所有可能的笑话,当然不能计细小的差别,都已经被归类编上号了,我们心里都知道这些编号指的是什么.