Day18 談的是 state 與 side effect 會不會藏在 signature 外面。但即使都攤在明面上,boundary 裡的 state 本身,也不是任何值都合理。
今天要談的,就是這些 state 必須始終成立的條件:representation invariant。
以一個正方形為例。對 client 來說,它的 contract 很簡單:用邊長建立,可以查詢面積與周長。
class Square:
def __init__(self, side: float) -> None:
self._width = side
self._height = side
def area(self) -> float:
return self._width * self._height
def perimeter(self) -> float:
return 4 * self._width
至於邊長在裡面怎麼存,是 implementer 的選擇。這裡選了 _width 與 _height 兩個欄位,也可以只存一個 _side;client 看不到,也不需要知道。
這份 implementer 選來表示資料的內部結構,稱為 representation,常簡稱 rep。
但 rep 能表達的狀態,往往比合理的狀態多。_width = 3, _height = 5 是兩個合法的 float,卻不是正方形;_width = -1 也一樣。
Representation invariant 就是 rep 必須始終成立的條件,用來劃出哪些狀態是合理的。Square 的 invariant 是:
_width == _height,且兩者都大於 0
「始終」指的是 client 看得到的每一個時間點:物件建立之後,以及每個 method 回傳之後。Method 執行到一半時,invariant 可以暫時不成立,只要回傳前恢復即可。
因此,invariant 對每個 method 來說,同時是前提與義務:進入時可以假設它成立,離開時必須讓它依然成立。perimeter() 只用了 _width,正是因為 invariant 保證 _height 與它相同;寫 perimeter() 時,不必回頭讀其他 method,確認 _height 會不會被改成別的值。
這也讓 invariant 與 contract 有所不同。Contract 寫在 spec 裡,是給 client 看的;invariant 則只存在於 boundary 裡面,client 不需要知道,rep 換掉時也會跟著換掉。
Contract 是 implementer 對 client 的承諾;invariant 是 implementer 對自己的承諾。
假設要讓正方形可以調整大小。既然 rep 裡有 _width 與 _height,最直覺的做法,是各提供一個 setter:
def set_width(self, width: float) -> None:
self._width = width
def set_height(self, height: float) -> None:
self._height = height
問題是,呼叫 set_width(5) 之後,_width 與 _height 就不再相等,invariant 被打破,perimeter() 的假設也跟著落空。
如果讓 set_width() 順手把 _height 一起改掉,invariant 是守住了,client 卻會被嚇一跳:只想改寬,高也跟著變了,而這件事從 signature 上完全看不出來。
不論哪一種,問題都不在實作,而在 contract 提供了正方形本來就沒有的操作。正方形只有一個能調整的東西,contract 也就只該提供一個 set_side()。這也是 Square 不該繼承 Rectangle 的原因:Rectangle 承諾寬與高可以分開設定,Square 守不住這個承諾。
Contract 只該提供這個抽象真正擁有的操作;多出來的,遲早會打破 invariant。
即使 contract 設計得當,implementer 自己仍然可能寫錯。寫在 comment 裡的 invariant 只能提醒,要讓它真的被檢查,可以寫成 _check_rep(),在 constructor 與每個修改 rep 的 method 結尾呼叫:
def _check_rep(self) -> None:
assert self._width == self._height, "寬與高必須相等"
assert self._width > 0, "邊長必須大於 0"
這樣一來,invariant 一被打破就會當場失敗,錯誤直接指向打破它的 method,而不是等到 perimeter() 算錯了,才回頭追查。
檢查能抓到錯誤,但更好的做法,是讓錯誤無從發生。
_width == _height 之所以需要守,是因為 rep 裡同時存了兩個本該相同的值。如果 rep 只存一個 _side,這條 invariant 就不存在了:不是因為守得更好,而是不合理的狀態根本寫不出來。
剩下的 _side > 0,在邊長進來的地方檢查即可:
class Square:
def __init__(self, side: float) -> None:
# Rep invariant: _side > 0
self.set_side(side)
def set_side(self, side: float) -> None:
if side <= 0:
raise ValueError("邊長必須大於 0")
self._side = side
而這次換 rep,client 一行都不用改:Square(side)、area()、perimeter() 與 set_side() 的 contract 完全沒變,變的只有 boundary 裡面。
能用 rep 排除的不合理狀態,就不必再靠 invariant 守住。
回頭看,invariant 做的事其實只有一件:把「什麼樣的資料才算合理」,從每個 method 心照不宣的假設,變成一條寫下來的規則。
它背後的理念是:boundary 裡的資料,由 implementer 全權負責。 只要所有修改都經過 boundary 上的 method,而每個 method 都守住 invariant,不論 client 怎麼呼叫、呼叫幾次,物件都不會落入不合理的狀態。
這件事有一個前提:rep 只有 implementer 碰得到。如果把內部的 list 直接交給 client,client 不必經過任何 method 就能改動它,這種情況稱為 rep exposure;invariant 一旦不再只由 implementer 決定,也就守不住了,這時就得改成回傳複本,或使用 immutable 的型別。
對 coding agent 而言,invariant 讓它在 boundary 裡面,同樣只需要理解一小部分。Agent 每個 session 都從零開始,往往也只讀進部分程式;當 invariant 寫在 rep 旁邊,它要修改或新增一個 method 時,只需要知道這條規則,不必讀完其他 method,才推敲得出哪些狀態可能出現。
而 invariant 寫成檢查之後,agent 守錯時也能立刻知道。請 agent「讓正方形可以調整大小」,它很可能照著常見的 Rectangle 寫出 set_width();type checker 不會發現,只測這個 method 的 test 也可能通過。只要它照著其他 method 在結尾呼叫 _check_rep(),invariant 一被打破就當場失敗,agent 可以依據錯誤自行修正。若 rep 一開始就只存 _side,agent 連寫錯的機會都沒有。
Invariant 讓人與 agent 只需要記住一條規則,就能安全地修改 boundary 裡的任何一處。