為什麼不變性至關重要 - Janos Pasztor

banq發表於2019-01-09

我以前在乾淨的程式碼中談到了不可變物件,但究竟是什麼呢?我們為什麼要使用它們?
不可變物件是一個非常強大的程式設計概念,可以避免各種併發問題和一大堆錯誤,但它們不一定容易理解。我們來看看它們是什麼以及我們如何使用它們。
首先,讓我們看一個簡單的物件:

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嚴格控制它,不要讓它洩漏到其他部分。程式碼部分越少,狀態就越差,與狀態相關的錯誤就越少。
顯然,在大多數程式設計任務中你不能完全沒有狀態程式設計,但是預設情況下將資料結構視為不可變會使你的程式對隨機錯誤更具彈性。當你真的需要引入可變性時,你將被迫考慮其含義,而不是在整個地方都有可變性。

相關文章