当前位置 博文首页 > Java 中的不可变数据结构_weixin_39809140的博客:修改list中对

    Java 中的不可变数据结构_weixin_39809140的博客:修改list中对

    作者:[db:作者] 时间:2021-08-10 13:17

    (给ImportNew加星标,提高Java技能)

    编译:ImportNew/唐尤华

    最近,在我主导的几场代码面试中,经常出现不可变数据结构(Immutable Data Structure)相关内容。关于这个主题我个人并不过分教条,不变性通常体现在数据结构中,"除非必要"否则不会要求代码一定具备不变性。然而,我发现大家对不变性(Immutability)这个概念似乎有一些误解。开发者通常认为加上 `final`,或者在 Kotlin、Scala 中加上 `val` 就足以实现不可变对象。这篇文章会深入讨论不可变引用和不可变数据结构。

    1. 不可变数据结构的优点

    不可变数据结构有下列显著优点:

    • 没有无效状态(Invalid State)

    • 线程安全

    • 代码易于理解

    • 易于测试

    • 可用作值类型

    译注:在计算机编程中包含两种类型,值类型 value type 与引用类型 reference type。值类型表示实际值,引用类型表示对其他值或对象的引用。

    2. 没有无效状态

    不可变对象只能通过构造函数初始化,并且通过参数限制了输入的有效性,从而确保对象不会包含无效值。例如下面这段代码示例:

    ```java
    Address address = new Address();
    address.setCity("Sydney");
    // 由于没有设置 country,address 现在处于无效状态.

    Address address = new Address("Sydney", "Australia");
    // Address 对象有效并且不提供 setter 方法,因此 address 对象会一直保持有效.
    ```

    3. 线程安全

    由于对象值不可修改,在线程间共享时不会产生竞态条件或者数据突变问题。

    4. 代码易于理解

    在上面的示例代码中,使用构造函数比初始化方法更易于理解。构造函数会强制检查输入参数,而 setter 或初始化方法不会在编译时进行检查。

    5. 易于测试

    使用初始化方法,必须测试调用顺序对对象的影响。而使用构造函数,对象的值要么有效要么无效,无需进行排列组合测试。代码执行结果的可靠性更强,出现 `NullPointerExceptions` 的机率更小。下面是一个传递对象过程中改变了对象状态的示例:

    ```javapublic boolean isOverseas(Address address) {
    if(address.getCountry().equals("Australia") == false) {
    address.setOverseas(true); // address 的值发生了改变!
    return true;
    } else {
    return false;
    }
    }
    ```

    上面的代码是一种错误示范,在返回 `boolean` 结果的同时改变了对象状态。这样的代码可读性和可测性都很差。一种更好的方法是从 `Address` 类中移除 setter 方法,为 `country` 属性提供 `boolean` 类型的测试方法;更进一步,可以把 `address.isOverseas()` 的逻辑移到 `Address` 类中。需要设置状态时,拷贝原来的对象而非修改输入对象的值。

    6. 可作为值类型使用

    如何做到使用 `Money` 对象表示10美金,使用的时候一直是10美金?比如这段代码,`public Money(final BigInteger amount, final Currency currency)` 确保了一旦声明10美金后接下来不会改变。这样对象的值可以安全地作为值类型使用。

    7. final 并不能让对象变成不可变对象

    文章开头提到过,我经常遇到开发者不能完全理解 `final` 引用和不可变对象的区别。最常见误区是,只要在变量前加上 `final` 就会成为不可变数据结构。不幸的是,实际并没有这么简单。接下来会为大家消除这个误解:

    在变量前加 `final` 不会产生不可变对象。

    换句话说,下面这段代码生成的对象是可变对象:

    ```java
    final Person person = new Person("John");
    ```

    尽管 `person` 是一个 final 字段不能重新赋值,但 `Person` 类可能提供了 setter 方法或者其他修改方法,比如像下面这个方法:

    ```java
    person.setName("Cindy");
    ```

    无论是否加 `final` 修饰符,轻易就可以修改对象。不仅如此,`Person` 类可能还提供了许多修改 address 属性的类似方法,调用它们可以向对象添加地址,同样会修改 `person` 对象。

    ```java
    person.getAddresses().add(new Address("Sydney"));
    ```

    `final` 引用并没能阻止修改对象。

    现在我们已经澄清了这个误解,接下来讨论如何让类具有不可变的特性。在设计时需要考虑以下事项:

    • 不要把内部状态暴露出来

    • 不要在内部修改状态

    • 确保子类不会破坏上面的行为

    按照上面这些建议,让我们重新设计 `Person` 类:

    ```java
    public final class Person { // final 类, 不支持重载
    private final String name; // 加 final 修饰, 支持多线程
    private final List
    addresses;public Person(String name, List addresses) {this.name = name;this.addresses = List.copyOf(addresses); // 拷贝列表, 避免从外面修改对象 (Java 10+). // 也可以使用 Collections.unmodifiableList(new ArrayList<>(addresses));
    }public String getName() {return this.name; // String 是不可变对象, 可以暴露
    }public List
    getAddresses() {return addresses; // Address list 可以修改
    }
    }public final class Address { // final 类, 不支持重载private final String city; // 只使用不可变类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;
    }
    }
    ```

    现在,代码变成下面这样:

    ```java
    import java.util.List;
    final Person person = new Person("John", List.of(new Address(“Sydney”, "Australia"));
    ```

    更新后的 `Person` 和 `Address` 让上面的代码成为不可变代码。不仅如此,`final` 引用让 `person` 变量无法再次赋值。

    更新:正如评论中[指出的][1],上面的代码还是可以修改的,因为并没有在构造函数中执行列表拷贝。如果不在构造函数中调用 `new ArrayList()` 还可以像下面这样做:

    ```java
    final List
    addresses = new ArrayList<>();
    addresses.add(new Address("Sydney", "Australia"));
    final Person person = new Person("John", addressList);
    addresses.clear();
    ```

    [1]:https://www.reddit.com/r/java/comments/azryu6/final_vs_immutable_data_structures_in_java/?st=jt74o32w&sh=40d418d3

    由于不在构造函数中执行 `copy`,上面的代码无法修改 `Person` 类中拷贝后的 address list,这样代码就安全了。感谢指正!

    希望本文能够有助于理解 `final` 与代码不可变之间的区别,如果有任何疑问,欢迎在评论区留言。

    推荐阅读

    (点击标题可跳转阅读)

    一步一脚印,了解多线程

    Java 进程中有哪些组件会占用内存?

    内联意味着简化?(1) 逃逸分析

    看完本文有收获?请转发分享给更多人

    关注「ImportNew」,提升Java技能

    098b7abb81202e646fc6e204cd02d959.png

    好文章,我在看??

    cs