为什么不变性至关重要 - Janos Pasztor

19-01-09 banq
              

我以前在干净的代码中谈到了不可变对象,但究竟是什么呢?我们为什么要使用它们?

不可变对象是一个非常强大的编程概念,可以避免各种并发问题和一大堆错误,但它们不一定容易理解。我们来看看它们是什么以及我们如何使用它们。

首先,让我们看一个简单的对象:

class Person {
    public String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
}

正如您所看到的,Person对象在其构造函数中接受一个参数,然后将其放入公共name 变量中。这意味着我们可以做这样的事情:

Person p = new Person("John");
p.name = "Jane";

简单吧?我们可以随时阅读或修改数据。但是,这种方法存在一些问题。首先,我们name在我们的类中使用变量,这意味着我们不可逆转地使公共API的类部分的内部存储。换句话说,我们永远不能改变名称在类中的存储方式,否则需要重写应用程序的大部分内容。

有些语言提供了安装“getter”函数来解决此问题的能力(例如C#),但在大多数OOP语言中,您必须明确地执行此操作:

class Person {
    private String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
}

到现在为止还挺好。如果您现在想要将名称的内部存储更改为,例如,名字和姓氏,您可以这样做:

class Person {
    private String firstName;
    private String lastName;
    
    public Person(
        String firstName,
        String lastName
    ) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    
    public String getName() {
        return firstName + " " + lastName;
    }
}

抛开名称带来的大量问题,你可以看到getName()API没有改变。

现在,如何设置名称?我们添加一些东西不仅得到了名字,还设置了这样的名字?

class Person {
    private String name;
    
    //...
    
    public void setName(String name) {
        this.name = name;
    }
    
    //...
}

从表面上看,这看起来很棒,因为我们现在可以再次更改名称。但是,像这样更改数据存在根本性的错误。有两个原因:一个是哲学的,一个是实践的。

让我们首先看一下哲学。该Person对象旨在代表一个人。人们确实改变了名称,但是这个函数应该被命名changeName为暗示我们实际上正在改变同一个人的名字,它还应该包括改变人名的业务流程,而不仅仅是作为一个人。setName很容易让某人得出的结论是,他们可以毫不犹豫地改变存储在人物对象中的名字而不受惩罚。(banq注:他们有点像上帝或别人父母,可以随便改变别人的名称)

第二个原因本质上是实用的:可变状态(可以更改的存储数据)会导致潜在的错误。让我们拿这个Person对象,让我们定义一个PersonStorage接口,如下所示:

interface PersonStorage {
    public void store(Person person);
    public Person getByName(String name);
}

请注意,这PersonStorage并不说明如何该对象存储:在内存中,在磁盘上,或者在数据库中。该接口也不会强制实现创建它存储的对象的副本。因此可能会发生有趣的错误:

Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");
myPersonStorage.store(p);

现在,这里存储了有多少人?一个人或两个人?此外,如果您现在使用该getByName方法,它将返回哪一个人?

你看,有两种情况:要么PersonStorage制作一个Person对象的副本,在这种情况下会Person存储两个记录,或者它没有,只是存储对传递的对象的引用,在这种情况下只有一个对象存储的名称为“Jane”。后者的实现可能如下所示:

class InMemoryPersonStorage implements PersonStorage {
    private Set<Person> persons = new HashSet<>();

    public void store(Person person) {
        this.persons.add(person);
    }
}

更糟糕的是,我们甚至可以在不调用store函数的情况下更改存储的数据。由于存储仅存储对原始对象的引用,因此更改名称也将更改存储的版本:

Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");

所以从本质上讲,我们有可变状态的事实会导致我们程序中的错误。当然,您可以通过在存储上明确地创建副本的工作来解决这些问题,但有一种更简单的方法:不可变对象 。我们来看一个例子:

class Person {
    private String name;
    
    public Person(
        String name
    ) 
    
    public String getName() {
        return name;
    }
    
    public Person withName(String name) {
        return new Person(name);
    }
}

如您所见,这里没有使用setName,现在使用的withName方法替代,这个方法来创建Person对象的新副本。始终创建新副本解决了具有可变状态的问题。果然,这可能会导致一些开销,但现代编译器可以解决这个问题,如果遇到性能问题,可以稍后修复。

记住: 过早优化是万恶之源(Donald Knuth)

现在,您可能会争辩说,一个持久层保持对工作对象的引用是一个破碎的持久层,但这是一个真实的场景。破碎的代码确实存在,不变性是防止此类错误发生的有效工具。

在更复杂的场景中,当对象通过应用程序中的多个层传递时,错误可以很容易地进入,并且不可变性可以防止那些与状态相关的错误。这些场景可能包括内存缓存或无序函数调用等示例。

不可变性如何帮助并行处理

不变性有助于的另一个重要领域是并行处理。更具体地说,多线程。在多线程应用程序中,多个代码路径并行运行,但访问相同的内存区域。让我们看一段非常简单的代码:

if (p.getName().equals("John")) {
    p.setName(p.getName() + "Doe");
}

从表面上来看,这段代码没有错误,但是如果你并行运行,抢占就会让事情变得混乱。让我们看一下上面的代码示例,

if (p.getName().equals("John")) {

    //The other thread changes the name here, so it is no longer John
    
    p.setName(p.getName() + "Doe");
}

这是竞争条件。第一个线程检查名称是否为“John”,但第二个线程更改名称。第一个线程仍在假设名称为John的情况下继续进行。

当然,您可以采用锁定来确保在任何给定时间只有一根线程进入临界区,但这可能是瓶颈。但是,如果对象是不可变的,则由于存储的对象p始终相同,因此不会发生此情况 。如果另一个线程想要影响更改,它会创建一个新副本,该副本将不会出现在第一个线程中。

总结

一般来说,我建议您确保在应用程序中尽可能少的地方具有可变状态,即使在您执行操作时,也要使用正确设计的API严格控制它,不要让它泄漏到其他部分。代码部分越少,状态就越差,与状态相关的错误就越少。

显然,在大多数编程任务中你不能完全没有状态编程,但是默认情况下将数据结构视为不可变会使你的程序对随机错误更具弹性。当你真的需要引入可变性时,你将被迫考虑其含义,而不是在整个地方都有可变性。