書寫高質量程式碼之狀態維護

mrpeak發表於2016-09-16

維護程式狀態的一些小心得。

狀態之始

我們第一眼接觸新事物所觸發的思考方式,決定了以後我們看待這樣事物的角度,進而影響更深層次的理解和行為。

程式設計相對於人類歷史的程式而言,不過是個六七歲孩童偶然撿到的新玩具,因為新鮮好玩到現在都還愛不釋手。這個玩具於我們的大腦會產生怎麼樣的化學反應是個未知數,每個個體都不同。你第一眼見到色彩或形狀直接關係到你的興趣點或是以後會怎樣去把玩這個玩具。

小朋友拿到新玩具往往急不可耐去動手試玩,成年人面對程式設計的時候應該理智的去建立自己的知識觀。程式設計到底是件什麼樣的玩具,它的本質是什麼?

程式設計是和我們平行的宇宙,有自己的世界和規則。它的基礎元素是名詞和動詞。名詞即資料(data),動詞即行為(action)。理解這兩個基礎元素是建立程式設計世界觀的基石。

我們經常會談論data,只不過形式各異。data是個寬泛的概念集,它可以是:變數,狀態,model,資料,屬性等等。同行在談論app架構如何設計model layer的時候,我反應:哦,是在說怎麼維護app的狀態。

所以你看,將概念歸類很重要,理解狀態的本質和表現形式很重要,怎麼去維護狀態很重要。

狀態生命週期

每一個變數的誕生,無論身份如何都有一個生命週期。

函式內部變數:

int i = 0

生於函式內部,存在記憶體棧上。一旦函式結束,i也隨著結束生命。

類的屬性:

@property (nonatomic, assign) int count;

生於某個類的例項,存在例項的記憶體堆上。一旦物件被銷燬,count也被回收。

全域性變數:

int index = 0;

生於程式初始化之刻,存於記憶體data區。程式被退出,index才會隨之消失。

Model例項:

User* user = [User new];

model例項的誕生一般散落在各個模組,注重架構的程式設計師會把model的建立都放在同一個layer或者module。model一般依附於cache或者某個業務物件,生命週期較之一般狀態更難把控確定。

狀態還有更多的表現形式,無論其形式如何,明確我們所創造每一個狀態的生命週期,對於書寫高質量程式碼至關重要。生命週期越短,能夠訪問狀態的物件越少,我們的程式碼就越可控,越安全。你所寫app當中的每一個狀態是否安全?

安全的狀態

狀態是否安全十分重要,如果條件允許,我們總是應該嘗試儘可能創造“無害”的狀態。

狀態的安全性可以從兩個角度去理解。

訪問許可權安全:

一個狀態生命週期越短,能夠訪問(read和write)它的物件越少,我們可以認為這個狀態越安全。

在類當中建立新的property的時候,將property定義在.m當中是個好習慣。放在.h當中意味著任何物件都可以訪問。

確實需要被其他物件訪問(read)之時,我們應該吝嗇的只提供get方法。

當你覺得實在需要被外部物件修改(write)狀態的時候,這很有可能是一個程式碼開始降級的消極訊號,我們需要反覆審視這個“需求”的合理性,在找不到其他設計來規避之時,可以惶恐的提供一個set方法。但必須記住,這個set方法被呼叫的越頻繁,這個狀態越危險。

if else或者switch,是bug最容易生長的土壤。當我們嘗試在if語句中判斷狀態的時候,不穩定的狀態會讓我們原本以為清晰簡單的判斷,變得不可控而且難以除錯。

每次書寫if else之時,謙卑謹慎的去審視我們所依賴狀態的安全性,會讓我們的程式碼更健康,更容易發現問題癥結所在。

近幾年炙手可熱的函數語言程式設計強調“無狀態”,無狀態並不是禁止我們去定義變數,宣告狀態。狀態是程式設計宇宙中的基礎元素,沒有狀態談何邏輯。無狀態是指將狀態“鎖在”函式的內部,使其生命週期僅存於某個函式個體之內。所以函式成了函數語言程式設計當中的第一公民,函式可以返回狀態,不過這個狀態更像一個結果,一個數學公式運算的結果,每次公式運算都是一份新的結果,一個結果決不被多個物件共同持有。無狀態其實是在強化狀態的安全性。

多執行緒安全:

多執行緒訪問較之於多物件訪問是另一個維度,一個和人腦執行方式迥異的維度。

多執行緒問題複雜度在於執行的時序不確定性,結合狀態被write的場景,如果不仔細設計,很容易讓你的程式碼變得一團糟。甚至有時候debug多執行緒狀態問題,所費時間不亞於開發投入的時間。

多執行緒read狀態不需要過於擔心,read操作幾乎沒有副作用。需要謹慎對待的是write操作。write和read在多執行緒的場景下,同時發生在集合類(比如陣列)物件之時,程式碼會變得十分脆弱。陣列類物件是我們程式碼當中常用的狀態,也是很多疑難雜症bug產生的源頭。比如如下程式碼:

- (void)initTableArray
{
    if (_tableArr == nil) {
        _tableArr = @[].mutableCopy;
    }
}
- (void)renderTableArray
{
    for (NSObject* item in _tableArr) {
        //render
    }
}
- (void)insertTableItem:(NSObject*)item
{
    [_tableArr addObject:item];
}

上面三段程式碼分別對應陣列狀態的三種操作:建立狀態,讀取狀態,修改狀態。看似簡單的程式碼,如果放在多執行緒的場景之下問題很容易變得複雜起來。

多執行緒併發下,_tableArr可能會被建立多次。

多執行緒併發下,_tableArr在遍歷之時可能被修改,直接導致crash。

多執行緒併發下,_tableArr中item插入的順序變得不確定。

此時我們需要“鎖技”來應對陣列類狀態,鎖可以讓多執行緒場景下,我們的狀態得以“原子”的粒度被訪問或被修改。OC這類高階語言使得加鎖變得輕而易舉:

- (void)initTableArray
{
    @synchronized (self) {
        if (_tableArr == nil) {
            _tableArr = @[].mutableCopy;
        }
    }
}
- (void)renderTableArray
{
    @synchronized (self) {
        for (NSObject* item in _tableArr) {
            //render
        }
    }
}
- (void)insertTableItem:(NSObject*)item
{
    @synchronized (self) {
        [_tableArr addObject:item];
    }
}

如果覺得synchronized效能不夠好,可以換成dispatch_semaphore_t,但絕大部分業務場景下,這點效能的損耗是無法被感知的。

我們還可以採用“縮短狀態生命週期”的方式,來規避多執行緒帶來的風險。比如:

- (void)renderTableArray
{
    NSMutableArray* arr = [self createNewRanderArr];
    @synchronized (self) {
        for (NSObject* item in arr) {
            //render
        }
    }
}

- (NSMutableArray*)createNewRanderArr
{
    return @[].mutableCopy;
}

每次渲染的array都是重新生成的,不會被其他物件訪問修改,render之後array就可以被廢棄。通過這種方式我們也可以儘量避免多個執行緒同時修改狀態,所引入的不穩定性。

清理狀態

對於函式內部的臨時變數,函式退出之時,狀態也就隨著被清理。更多的場景下,狀態由我們自己生成,並存放於heap上。如果不手動清理,狀態就會一直存在,並帶來可能的風險。

如果可以,我們應該總是儘可能縮短一個狀態的生命週期,減少狀態暴露給其他物件的機會。適時的清理狀態會讓我們的程式碼更加健壯。

狀態皆有其所依賴的業務場景。購物車裡的商品在完成購買之後就失去了依附的業務環境,使用者的購買記錄在使用者退出登入之後也應該被清除,更不應該影響到下一個登入的新使用者。

所以在使用新狀態描述業務的時候,我們總是需要考慮以下收尾工作:

- (void)onUserLogout
{
    //clear state
}

結束語

每一個新的狀態就像程式王國裡的新子民,其所扮演的角色,影響範圍,生命週期都需要被程式設計師以上帝視角反覆的推敲設計。

相關文章