为什么不变性如此重要

哈Ha!



今天,我们想谈谈不变性的话题,看看这个问题是否值得更认真的考虑。



不变对象是编程中不可估量的强大现象。不变性可以帮助您避免各种并发问题和许多错误,但是了解不变结构可能很棘手。让我们看看它们是什么以及如何使用它们。



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



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


如您所见,对象Person在其构造函数中采用一个参数,然后将其放入公共变量name因此,我们可以执行以下操作:



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


简单吧?您随时可以根据需要阅读或修改数据。但是这种方法有两个问题。其中的第一个也是最重要的一点是,我们在类中使用了变量name,因此,将类的内部存储不可撤销地引入了公共API。换句话说,除非我们重写应用程序的重要部分,否则无法更改名称在类内部的存储方式。



某些语言(例如C#)提供了插入getter函数来解决此问题的能力,但是在大多数面向对象的语言中,您必须明确地采取行动:



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;
    }
}


如果您不研究与名称表示有关严重问题,那么很明显,API并getName()没有在外部进行更改。



设置名称呢?您不仅需要添加名称,还需要设置什么呢?



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


乍看起来,它看起来很棒,因为现在我们可以再次更改名称。但是,这种修改数据的方式存在根本缺陷。它有两个方面:哲学的和实践的。



让我们从一个哲学问题开始。该对象Person旨在代表一个人。确实,一个人的姓氏可以更改,但为此目的命名一个函数会更好changeName,因为这样的名字意味着我们正在更改同一人的姓氏。它还应包括更改个人姓氏的业务逻辑,而不仅仅是像塞特犬那样。该名称setName得出一个完全合乎逻辑的结论,即我们可以自愿和强制更改存储在person对象中的名称,而为此我们一无所获。



第二个原因与实践有关:可变状态(可以更改的存储数据)容易出错。让我们以这个对象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
    ) {
        this.name = 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")) {

    //     ,     John
    
    p.setName(p.getName() + "Doe");
}


这是比赛条件。第一个线程检查名称是否相等“John”,但是第二个线程更改名称。同时,第一个线程继续工作,仍然假设名称等于John



当然,可以使用锁定来确保在任何给定时间只有一个线程进入代码的关键部分,但是,这可能是瓶颈。但是,如果对象是不可变的,则不会出现这种情况,因为同一对象始终存储在p中。如果另一个线程想要影响更改,它将创建一个不在第一个线程中的新副本。



结果



基本上,我的建议是始终确保在您的应用程序中将可变状态最小化。如果确实要使用它,请使用设计良好的API对其进行严格限制,不要让它泄漏到应用程序的其他区域。包含状态的代码越少,捕获状态错误的可能性就越小。



当然,如果根本不使用状态,大多数编程问题是无法解决的。但是,如果我们认为所有数据结构默认情况下都是不可变的,那么代码中的随机错误会少得多。如果您真的被迫在代码中引入了可变性,那么您将必须谨慎地进行操作并仔细考虑后果,而不是从头开始编写所有代码。



All Articles