泛谈Java中的不可变数据结构
作为我最近一直在进行的一些编码访谈的一部分,有时会出现不变性问题。我自己并不过分教条,但每当不需要可变状态时,我会试图摆脱导致可变性的代码,这在数据结构中通常是最明显的。然而,似乎对不可变性的概念存在一些误解,开发人员通常认为拥有final引用,或者val在Kotlin或Scala中,足以使对象不可变。这篇博客文章深入研究了不可变引用和不可变数据结构。
不可变数据结构的好处
不可变数据结构具有显着优势,例如:
- 没有无效的状态
- 线程安全
- 易于理解的代码
- 更容易测试代码
- 可用于值类型
没有无效的状态
当一个对象是不可变的时,很难让对象处于无效状态。该对象只能通过其构造函数实例化,这将强制对象的有效性。这样,可以强制执行有效状态所需的参数。一个例子:
Address address = new Address(); address.setCity("Sydney"); // address is in invalid state now, since the country hasn't been set. Address address = new Address("Sydney", "Australia"); // Address is valid and doesn't have setters, so the address object is always valid.
线程安全
由于无法更改对象,因此可以在线程之间共享它,而不会出现竞争条件或数据突变问题。
易于理解的代码
与无效状态的代码示例类似,使用构造函数通常比初始化方法更容易。这是因为构造函数强制执行必需的参数,而setter或initializer方法在编译时不会强制执行。
更易于测试的代码
由于对象更具可预测性,因此不必测试初始化方法的所有排列,即在调用类的构造函数时,该对象有效或无效。使用这些类的代码的其他部分变得更可预测,具有更少的NullPointerException机会。有时,当传递对象时,有些方法可能会改变对象的状态。例如:
public boolean isOverseas(Address address) { if(address.getCountry().equals("Australia") == false) { address.setOverseas(true); // address has now been mutated! return true; } else { return false; } }
一般来说,上面的代码是不好的做法。它返回一个布尔值,并可能改变对象的状态。这使得代码更难理解和测试。更好的解决方案是从Address 类中删除setter ,并通过测试国家名称返回一个布尔值。更好的方法是将此逻辑移动到 Address 类本身(address.isOverseas())。当确实需要设置状态时,在不改变输入的情况下制作原始对象的副本。
可用于值类型
想象一下金额,比如10美元。10美元将永远是10美元。在代码中,这可能看起来像 public Money(final BigInteger amount, final Currency currency)。正如您在此代码中看到的那样,不可能将10美元的值更改为除此之外的任何值,因此,上述内容可以安全地用于值类型。
最终引用不要使对象不可变
如前所述,我经常遇到的问题之一是这些开发人员中的很大一部分并不完全理解最终引用和不可变对象之间的区别。似乎这些开发人员的共同理解是,变量成为最终的那一刻,数据结构变得不可变。不幸的是,这并不是那么简单,我想一劳永逸地把这种误解带出世界:
A final reference does not make your objects immutable!
换句话说,下面的代码并没有使对象不变:
final Person person = new Person("John");
为什么不?好吧,虽然person是最后一个字段而且无法重新分配,但是 Person类可能有一个setter方法或其他mutator方法,可以执行如下操作:
person.setName("Cindy");
无论最终修饰符如何,这都是一件非常容易的事情。或者, Person类可能会公开这样的地址列表。访问此列表允许您向其添加地址,因此,如下所示改变 person对象:
person.getAddresses().add(new Address("Sydney"));
好了,既然我们已经解决了这个问题,那么让我们深入了解一下我们如何使类不可变。在设计我们的类时,我们需要记住几件事:
- 不要以可变的方式暴露内部状态
- 要在内部改变状态
- 确保子类不会覆盖上述行为
根据以下准则,让我们设计一个更好的Person class 版本 。
public final class Person {// final class, can't be overridden by subclasses private final String name; // final for safe publication in multithreaded applications private final List<Address> addresses; public Person(String name, List<Address> addresses) { this.name = name; this.addresses = List.copyOf(addresses); // makes a copy of the list to protect from outside mutations (Java 10+). // Otherwise, use Collections.unmodifiableList(new ArrayList<>(addresses)); } public String getName() { return this.name; // String is immutable, okay to expose } public List<Address> getAddresses() { return addresses; // Address list is immutable } } public final class Address { // final class, can't be overridden by subclasses private final String city; // only immutable classes private final String country; public Address(String city, String country) { this.city = city; this.country = country; } public String getCity() { return city; } public String getCountry() { return country; } }
现在,可以使用以下代码:
import java.util.List; final Person person = new Person("John", List.of(new Address(“Sydney”, "Australia"));
现在,上面的代码是不可变的,但是由于Person 和 Address 类的设计 ,同时还有最终引用,因此无法将person变量重新分配给其他任何东西。
更新:正如有些人提到的,上面的代码仍然是可变的,因为我没有在构造函数中复制地址列表。因此,如果不在ArrayList() 构造函数中调用new ,仍然可以执行以下操作:
final List<Address> addresses = new ArrayList<>(); addresses.add(new Address("Sydney", "Australia")); final Person person = new Person("John", addressList); addresses.clear();
但是,由于在构造函数中创建了一个新副本,上面的代码将不再影响类中复制的地址列表引用Person ,从而使代码安全。
我希望上述内容有助于理解最终和不变性之间的差异。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。