字符串的不变性

    技术2024-01-29  107

    面向对象的编程通过封装运动部件使代码易于理解。 函数式编程通过最大程度地减少运动部件来 使代码易于理解 。 — Michael Feathers,《 使用旧版代码》的作者 ,通过Twitter

    关于本系列

    本系列旨在将您的观点重新定位为实用的心态,帮助您以新的方式看待常见问题并找到改善日常编码的方式。 它探讨了函数式编程的概念,允许在Java语言中进行函数式编程的框架,在JVM上运行的函数式编程语言以及语言设计的一些未来方向。 该系列面向那些了解Java及其抽象如何工作,但很少或没有使用功能语言经验的开发人员。

    在本期中,我将讨论函数式编程的组成部分之一: 不可变性 。 构造后,不变对象的状态无法更改。 换句话说,构造函数是您可以改变对象状态的唯一方法。 如果要更改不可变对象,则不需要-创建具有更改后的值的新对象,然后将其指向该对象。 ( String是Java语言核心中内置的不可变类的经典示例。)不变性是函数式编程的关键,因为它符合将更改部分最小化的目标,从而使人们更容易推理出这些部分。

    用Java实现不可变的类

    诸如Java,Ruby,Perl,Groovy和C#之类的现代面向对象语言已建立了便捷的机制,以使其易于以受控方式修改状态。 但是,状态对于计算至关重要,因此您永远无法预测状态将泄漏到哪里。 例如,由于无数的可变性机制,在面向对象的语言中很难编写高性能,正确的多线程代码。 因为Java针对操作状态进行了优化,所以您必须解决其中一些机制才能获得不变性的好处。 但是一旦您知道避免一些陷阱,就可以使用Java构建不可变的类变得更加容易。

    定义不可变的类

    要使Java类不可变,您必须:

    将所有字段定为final 。

    在Java中将字段定义为final时,必须在声明时或在构造函数中对其进行初始化。 如果您的IDE抱怨您未在声明站点初始化它们,请不要惊慌。 当您在构造函数中编写适当的代码时,它将意识到您已经恢复了理智。

    将班级定为final班,以使其无法被覆盖。

    如果可以重写该类,则也可以重写其方法的行为,因此,最安全的选择是禁止子类化。 注意,这是Java的String类使用的策略。

    不提供无参数构造函数。

    如果您有一个不可变的对象,则必须设置它在构造函数中将包含的任何状态。 如果没有要设置的状态,为什么要有一个对象? 无状态类上的静态方法也可以正常工作。 因此,您永远不应该为不可变的类提供无参数的构造函数。 如果您使用的框架出于某种原因需要此功能,请查看是否可以通过提供一个私有的无参数构造函数(通过反射可见)来满足要求。

    请注意,缺少无参数构造函数会违反JavaBeans标准,该标准坚持使用默认构造函数。 但是,由于set XXX方法的工作方式,JavaBeans仍然不能一成不变。

    提供至少一个构造函数。

    如果您没有提供无参数的方法,那么这是您向对象添加一些状态的最后机会!

    除了构造函数外,不要提供任何其他方法。

    您不仅必须避免使用典型的受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()方法来制作街道列表的防御性副本。 您应该始终使用集合来创建不可变列表,而不是数组。 尽管可以防御性地复制阵列,但这会导致一些不良的副作用。 考虑清单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的方法族),您会收到一个对象没有可用的变异方法的参考。

    更清洁的不变类

    您经常听到您还应该将不可变字段设为私有。 我不同意这种看法,因为我听到的人有不同但清晰的愿景,可以澄清根深蒂固的假设。 在迈克尔Fogus采访Clojure的创作者丰富的希基(见相关信息 ),对缺乏很多的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); } }

    声明不可变字段的public get XXX ()方法仅在您要隐藏基础表示形式时才有好处,但这在重构IDE的时代具有可疑的好处,因为IDE可以轻松地找到这种变化。 通过将字段设为公共字段和不可变字段,您可以直接在代码中访问它们,而不必担心会意外更改它们。

    如果你从来没有需要在内部发生变异的集合,可以投嵌入列表unmodifiableList在构造函数,它允许你做streets现场市民和消除对需要getStreets()方法。 正如我将在下一个示例中显示的那样,Groovy允许您创建诸如getStreets()类的警卫访问方法,但仍允许其显示为字段。

    首先,如果您听愤怒的猴子 ,那么使用不可变的公共字段似乎是不自然的,但是它们的区别是有好处的:您不习惯使用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(); }
    愤怒的猴子

    我最初是从戴夫·托马斯那里听到这个故事的。 它随后出现在我的著作The Productive Programmer中 (请参阅参考资料 )。 我不知道这是否是真的(尽管做了很多研究),但是谁在乎呢? 它很好地说明了一个观点。

    早在1960年代,行为科学家就进行了一项实验,将五只猴子放在一个装有梯凳和一堆香蕉从天花板上悬挂的房间里。 猴子们很快就发现他们可以爬上梯子吃香蕉。 然后,每当猴子靠近梯子时,科学家就将整个房间倒入冰冷的水中。 很快,所有的猴子都不会靠近梯子。 然后,科学家用一只尚未经过实验的新鲜猴子替换了一只被淹没的猴子。 当他为梯子做一条直线时,其他所有的猴子都把他殴打了。 他不知道他们为什么要殴打他,但他很快了解到:不要靠近梯子。 渐渐地,科学家们用新鲜的猴子代替了原来的猴子,直到它们的一群从未被冷水浸泡过的猴子攻击到梯子上的任何猴子。

    重点呢? 在软件项目中,存在许多实践,因为“这就是我们一直这样做的方式”。

    访问公共的不可变字段避免了一系列get XXX ()调用的视觉开销。 还要注意,编译器不允许您分配一个原语,并且,如果您尝试在street集合上调用mutating方法,则会收到UnsupportedOperationException (如测试顶部所示)。 使用这种风格的代码是一个很强的视觉指示器,表明它是一个不变的类。

    缺点

    更简洁的语法的一个可能的缺点是,学习这种新习语需要付出很多努力。 但是我认为这是值得的:它鼓励您在创建类时考虑到不变性,因为其明显的风格差异,并且减少了不必要的样板代码。 但是Java中的这种编码风格有一些缺点(公平地说,它从来没有被设计为直接适应不变性):

    正如Glenn Vanderburg向我指出的那样,最大的缺点是该样式违反了Bertrand Meyer(埃菲尔编程语言的创建者)所说的统一访问原则:“模块提供的所有服务都应该通过统一的符号来使用,不管是通过存储还是通过计算实现它们。” 换句话说,访问字段不应公开它是字段还是返回值的方法。 Address类的getStreets()方法与其他字段getStreets() 。 这个问题不能用Java真正解决。 在其他一些JVM语言中,通过启用不变性的方式可以解决该问题。 一些严重依赖反射的框架无法使用该惯用语,因为它们需要默认的构造函数。 因为您创建新对象而不是突变旧对象,所以具有大量更新的系统可能会导致垃圾回收效率低下。 诸如Clojure之类的语言具有内置的功能,可以通过不可变的引用来提高效率,这是这些语言的默认设置。

    Groovy中的不变性

    在Groovy中构建Address类的public-immutable-field版本会产生一个很好的干净实现,如清单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中的所有代码@Immutable 。 清单8显示了使用此批注的Client类:

    清单8.不可变的Client类
    @Immutable class Client { String name, city, state, zip String[] streets }

    通过使用@Immutable批注,此类具有以下特征:

    这是最终的。 属性自动具有私有后备字段,并且具有合成的get方法。 任何尝试更新属性的尝试都会导致ReadOnlyPropertyException 。 Groovy创建序数和基于地图的构造函数。 集合类被包装在适当的包装器中,并且阵列(和其他可克隆对象)被克隆。 默认的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将这种失败称为原子性 :一旦构建了对象,就永远可以解决基于可变性的成功或失败(请参阅参考资料 )。

    最后,不可变类的最佳功能之一是它们如何适合组成抽象。 在下一部分中,我将开始研究组成以及为什么它在功能思考领域如此重要。


    翻译自: https://www.ibm.com/developerworks/java/library/j-ft4/index.html

    Processed: 0.011, SQL: 9