LINE Corporation 於2023年10月1日成爲 LY Corporation。LY Corporation 的新部落格在這裏。LY Corporation Tech Blog

Blog


程式碼可讀性之簡報介紹vol. 3:「狀態與程序」篇

前言

大家好,我是通訊 App「LINE」Android 團隊的石川,本文是「程式碼可讀性介紹」系列連載的第三篇,介紹本系列的第四章與第五章:「狀態」與「程序」原則。

本系列的上一篇文章為:程式碼可讀性介紹 vol. 2 : “命名與註釋” 篇

第四章:狀態

開發者有時可藉由減少程式中不同狀態的數量,以及狀態改變的次數,讓程式整體變得更容易理解,特別是針對「不正確的狀態」與「不正確的狀態改變」。藉由將其排除,能讓可讀性大幅提升,也讓程式更為可靠。但須注意,減少狀態只是提升可讀性與強健性的方法,不可將其本身作為目標。

本文將針對「減少不正確狀態」與「使狀態改變單純化」這兩點進行解說,以提升程式的可讀性與可靠度。

1. 減少不正確狀態

在此,先介紹「orthogonal / non-orthogonal 關係」的概念,作為兩個變數間的關係:兩個變數中,其中一個變數的數值範圍,不因另一變數的改變而被影響時,兩個變數間的關係為 orthogonal。相反地,當其中一個變數的數值範圍,會受到另一個變數影響時,則兩個變數間的關係為 non-orthogonal。例如:userId 與 layoutVisibility 各自擁有的數值不會相互影響時,這兩個數值間的關係即為orthogonal。相對地,以 userId 與 userName 為例,由於改變 userId 時,userName 也必須同時更新,則兩者間的關係則為 non-orthogonal。

可藉由排除 non-orthogonal 的關係,減少錯誤數值的組合。其方法大致分為兩種:使用 getter property 函數,以及使用代數資料型態。

1-A: getter property 函數

若其中一方的數值,能透過另一方的數值被計算出時,可將該數值代換為 getter property 函數。例如:當 userName 的數值為 ”Alice”,且 welcomeMessage 的數值為 ”Hello Alice”,welcomeMessage 可代換為 ”Hello $userName” (= ”Hello ” + userName)。藉此方式,可將能變更的數值,限制為僅有 userName,且可避免 userName 雖然為 ”Alice”,但 welcomeMessage 卻變為 `”Hello Bob” 的錯誤數值組合。

1-B: 代數資料型態

當兩個數值間存在相關性,且無法由其中一方的數值計算另一方的數值時,代數資料型態可能會有效。例如:假設查詢的結果存在 resultMessage 與 errorCode 這兩個 nullable 數值,若此處查詢成功的話,resultMessage 應會具備數值,而 errorCode 則會變為 null。相反地,當查詢失敗時,errorCode 應會具備數值,而 resultMessage 則會變為 null。在此類雙變數的情況下,雙方都變為 null,或雙方都具備數值時,皆會發生錯誤。對於這類的錯誤情況,在 Kotlin 中可使用下方的 sealed class 來避免:

sealed class Result {
  class Success(message: String): Result()
  class Error(errorType: ErrorType): Result()
}

此處的 `Result`,保證為 `Success` 與 `Error` 的其中一方。結果,保證在 `message` 與 `errorType` 之中,只有其中一方會存在。

若以較不直觀的方式比喻,可解釋為:當某個抽象型被保證一定是 「列舉出之型態中的其中一方」時,該抽象型就稱為代數資料型態。使用代數資料型態時,在 Scala 上可使用 sealed class 實作 ; 在 Swift 上可使用 associated value 實作 ; 在 C++ 上則可使用 variant 實作。部分語言不支援代數資料型態 (例如:Java),此時為了隱藏錯誤數值的組合,可藉由建立較小的資料型態,再限制建構子與 Setter,製作出近似代數資料型態的內容。

此外,排除錯誤數值時,代數資料型態的效果有時會超出預期。此時,請試著嘗試是否能使用列舉型。例如:有兩個真假值,若不可能出現雙方都變為 `true` 的狀態,應改用三狀態的列舉型。

2. 使狀態改變單純化

除了減少狀態數量外,有時也可透過使狀態改變單純化,提升可讀性與穩定度。在此,以狀態改變的迴圈,尤其是數值的重複使用性為例,進行說明。將數值變為可重複使用時,雖然有助於提升性能,但相對地,也可能造成可讀性與穩定度下降。例如:當有一個名為 `VideoPlayer` 的類別時,此時可採取的選項共有兩個:

  1. 只建立一次名為 `VideoPlayer` 的實例,當希望改變播放的影片時,將影片的路徑遞送給 `play` 的方法。
  2. 在建立 `VideoPlayer`的實例時,指定影片的路徑,並將其數值設為固定不變。希望改變播放的動畫時,則建立新的實例。

前者會造成呼叫 play 前的狀態,管理將變得相當複雜,因此只要性能不是問題,就應該選擇後者。假如,設計成由所有狀態 (載入中、完成、錯誤等) 皆能呼叫 play 的話,play 的動作將會變複雜。相對地,限制可呼叫 play 的狀態 (禁止在載入中呼叫 play 等) 時,VideoPlayer 的使用方法本身將變得相當複雜,甚至可能成為程式錯誤 (Bug) 的原因。此外,在這兩種實作中,狀態數量增加時,程式碼的變動也跟著增加。

問題不只如此,在非同步處理的管理上,也會發生問題。例如:在處理「影片能暫停一段時間」的功能時,必須確認該工作想暫停的影片與目前播放中影片是否一致,或是在呼叫 play 的時間點,正確取消工作。尤其是在多執行緒  (multi-thread) 的環境下,競爭狀態的驗證難度也會提高。

但在某些狀況下,必須使用狀態轉換的迴圈。以 VideoPlayer 為例,有時會希望執行影片的暫停與播放操作。若此時盡可能縮小狀態轉換的迴圈,讓程式彷彿在宏觀狀態下,無迴圈地執行動作時,可減少可讀性與可靠度下降的情況。

第五章:程序

在確認程序可讀性的方法中,有一種方法是試著撰寫 Documentation 摘要。如果第三章介紹的摘要不易撰寫,可改為確認程序的責任範圍與流程是否明確。

1. 程序的責任範圍

一個程序的責任範圍,最好控制在能以一句話說明動作的程度。而實現此目標的原則之一,即為 command-query separation。此原則的內容,將程序分類為變更數值的「指令」,以及回傳某個數值的「查詢」,並規定不可建立能同時執行這兩種動作的程序。

例如:在 List 中,有一個名為 append(List) 的方法。若其回傳值為 void 或 Unit 時,可預測此方法是修改接收者本身的「指令」。相對地,若回傳值為 List 時,可預測此方法不會修改接收者,而是會建立新結合的清單並回傳,亦即屬於「查詢」的方法。若在此處忽視回傳值為 List 的情況,而依然修改接收者時,將違背大多數開發者的認知,成為程式錯誤 (Bug) 的原因。

但過度使用 command-query separation 時,反而會使可讀性與可靠度惡化。例如:存在一個變更某數值的指令,要確認該數值的變更是否成功時,只須回傳代表該指令本身是否成功的真假值。但要將該真假值切割為其他查詢時,必須事先將該真假值儲存在某處,結果將造成指令碼變得非常複雜。由此可看出,對於代表變更是否成功的真假值,以及錯誤代碼與元資料等附屬性的值,即使作為伴隨著指令的回傳值,應該也會被接受。但這類程序必須附有 Documentation。

此外,如果程序的格式是遵照已通用的標準,那麼,建立帶有回傳值的指令是被允許的。例如:大多數堆疊 (stack) 的實作中,pop 方法除了是移除最新值的指令外,同時也會回傳被移除值,此介面已廣為人知。

2. 程序的流程

為了讓程序流程更加明確,需要特別注意以下三點:

  1. Definition based programming
  2. Early return
  3. 不以情況區分邏輯,而是以對象區分邏輯。

在此,要特別針對本簡報中新引進的「definition based programming」概念進行解說。

Definition based programming 是使用有名稱的局部變數與私有函數,取代匿名函數、實際參數、接收者、呼叫鏈等對象的程式風格。以下將使用實際參數巢套的範例,說明此概念如何影響可讀性。

showImage(convertImage(cropImage(loadImage(imageUri), Shape.CIRCLE), ImageFormat.PNG))

此程式碼是將圖片切割為圓形,再轉換為PNG顯示。但此程式碼存在兩個問題,第一個,為了知道最終顯示何種內容,必須閱讀程式碼的細節。第二個,希望知道或更改「轉換為何種格式」與「切割為何種形狀」等特定值時,必須掌握整體內容。

此時若如下方所示,使用局部變數,應能變得更容易閱讀:

val originalBitmap = loadImage(imageUri)
val croppedBitmap = cropImage(originalBitmap, Shape.CIRCLE)
val croppedPng = convertImage(croppedBitmap, ImageFormat.PNG)
showImage(croppedPng)

以此類方式撰寫時,只須閱讀左側程式碼,便能重點式地瞭解整體內容 ; 若希望了解細節時,再閱讀右側即可。此外,不須閱讀程式碼的細節,也可大致掌握最終顯示的內容。

採用此 definition based programming 時,必須深入思考要代換哪個範圍的程式碼。例如:建立私有函數時,副作用是會殘留在呼叫端,新的函數若設為具有參照透明性時,可讀性應該就會上升。

結語

本次「狀態與程序」篇,說明在某個封閉型範圍中的構造,尤其「orthogonal / non-orthogonal」與「definition based programming」,是在本文中引進的概念,因而稍加詳細說明。

下次將介紹第六章與第七章的內容:「依賴關係」篇。