DrillAI 筆記:Overengineering vs. Scaffolding

最近我把以前的一個 personal project 挖出來翻新,從中得到了許多奇奇怪怪的開發經驗。趁印象仍深,做點記錄。

對軟體工程師來說,這是個老掉牙的問題:實作一個功能的時候,要先保持簡單,避免 overengineer — 過度設計?還是預先拆設出架構、抽象化,來讓以後擴充功能的時候更順暢省力?

我想,雖然對於什麼是 “clean code” 普遍有各種原則,適當的平衡應該還是要視個人自身的經驗和能力和 project 的特性來拿捏。畢竟什麼東西是容易的,或是能判斷之後一定會用上的,並沒有標準答案。我其實也沒有什麼高見可以教別人,只是遇到了兩個相似的情況,產生了點想法,把它整理出來。

DrillAI

先描述一下這個 project 吧。

兩三年前,我用 (後來死掉了的) Swift for TensorFlow (S4TF) 在 Google Colab notebook 裡寫了個俄羅斯方塊的 AI。那時的想法,是透過實作去熟悉 AlphaGo 的核心原理,應用在一個我有興趣的問題上,順便玩玩新技術。寫著寫著它成了個還蠻巨大的 notebook,而隨著 S4TF 關門大吉,想延續這個 project 勢必要從 Colab 把 code 轉移出來。

最近終於付諸行動,而同時我也有了幾個新的學習目標:Swift protocols & generics,testing,Swift 5.5 async/await,SwiftUI,以及 app architecture 等。原本的舊程式成了拿來 refactor & 反思的好材料。從 git 看來實際開工大約是三週前吧,目前它長這個樣子:

由簡入繁:Protocol-Oriented Programming

Protocol / generics 的使用,就相當值得參考 KISS (keep it simple, stupid)、YAGNI (you’re not gonna need it) 的原則。Swift 問世不久時,在 WWDC 2015 有個著名的 “Crusty” talk (Protocol-Oriented Programming in Swift),奠定了 Swift protocol-oriented programming 的基礎。但講者 Dave Abrahams 後來澄清,並不是想做什麼都先丟一個 protocol 下去。建議的做法其實是先從單純的 value types 開始 (structs/enums),如果需要 polymorphism 的時候才用 protocol 而避免用 classes 來做。可見 Rob Napier 更詳細的敘述(Protocols I: “Start With a Protocol,” He Said)。

…話是這麼說,但我就是刻意想寫些 generics,所以在改寫 AI 時,就把主要的幾個類別弄成了有 protocol constraints 的 generic types,雖然老實說,它們未來應該也不會需要是 polymorphic。

寫俄羅斯方塊 AI 時參考了 AlphaGo 也有使用的 Monte Carlo tree search (MCTS) + UCT 概念。這樣的 AI 我會形容是由兩大元件組成:一個 game tree,和一個評估遊戲狀態好壞的方法。

會寫程式的人應該多對 game tree 的概念不陌生。這棵樹裡的每個節點 (node) 都代表了遊戲的一種狀態 (state)。在這個狀態下,玩家有幾個可以執行的行動 (action),而每一個行動都會把遊戲變成另一個未來的狀態。

譬如說下面這個圈圈叉叉遊戲,目前是在畫了三個 O、兩個 X 的狀態,而 X 有四個可能的行動,所以這棵「樹」的 root node 有四個 child nodes,各代表一種可能的未來狀態。

Tic-Tac-Toe Game Tree

A Tic-Tac-Toe Game Tree

MCTS 就是有這麼一棵樹,但它有些多加上去的邏輯,來判斷接下來「哪一個未來狀態最值得評估」。

評估一個狀態的好壞的方法,是這種 AI 的第二個大元件。對於圍棋這種雙方論輸驘的遊戲,「好」的定義基本上就是你輸我驘。也就是說,一個棋盤狀態對我的價值,就是我的勝利的可能性大小。AlphaGo 用 deep reinforcement learning 大量運算來培養出能精確評估棋盤的 neural network,造就超強 AI。

至於我的程式嘛,它其實只針對俄羅斯方塊這一種遊戲,但既然 tree search 應該是種泛用的策略,於是它成了

protocol MCTSState {
    associatedtype Action
    // ...
}

protocol MCTSEvaluator {
    associatedtype State: MCTSState

    func evaluate(state: State) async -> EvaluationResult // just a tuple
}

actor MCTSTree<State: MCTSState> {
    // ...
}

然後我就開始了與 generic 相關的 compiler warning 的奮戰。開玩笑的。其實因為是逐步 refactor 舊程式碼一邊追加 unit tests,我發現這樣改並不困難。畢竟程式也沒有很龐大,原本也有把物件職責切分好。

這是 overengineering 嗎?雖然說了不會很難,我想仍然是的。我的 Tree 可能永遠沒有支援另一種 game state 的需求,所以這些 protocols 和 generics,I ain’t gonna need ’em。使用這些 type 的地方都要把它們用同一種 state 來 specialize,不過是徒增複雜度而已。如果這是個商業產品,那過早帶進 polymorphism 的代價,大概就是浪費人力資源與維護成本吧。

不過從學習的角度而言,這是個不錯的經驗。除了比較習慣自己設計和使用 protocols with associated types,也感受到它的確對 testing 及 separation of concerns (SoC) 帶來好處。

雖說我的 Tree 沒有要支援另一種遊戲,但它的確支援 fake State。MCTS 的邏輯還是有些複雜的,所以為了確保它的行為如我預期,我可以在測試時做一個相對單純、容易調控的假遊戲 State 餵給它。要是它並非 generic,而我只能餵俄羅斯方塊的 State 來做測試,想必會變得十分困難。

在 SoC 方面,因為 protocol 的分隔,我必需重新思考這個是 Tree 的工作?還是這個遊戲本身的屬性?等等問題。

這裡有個意外的收穫,是在實作一個傳統方法的 Evaluator 的時候。我參考了一些 Tetris AI 的研究,複製其中對遊戲場地做評估的計算方法,作為在能做出 neural network evaluator 之前讓 AI 能動的替代方案 (前面的影片就是用傳統 evaluator)。但在實作時有個困擾:這個傳統計算的方法除了用到目前的狀態,還用到上一個狀態,和把上個狀態變成目前狀態的 action。

我先是想到兩種做法:最簡單的方法是在 Tree 裡補一個 method 來回傳這種「加強版」的資訊以供評估,麻煩的做法是把上一步的歷史加入到 State 的實作裡。前者好做,但後者概念上比較正確。此時,懶惰帶來創新,這讓我想通這個傳統評估法其實可以視為一個 action 的價值,加上 resulting state 的價值,也許我可以拆開使用。結果,因為 Tree/State/Evaluator 之間隔了 protocols 的 decoupling,逼我回思三者的 separation of concerns,反讓我想到我也許不用改 Tree 或 State,而是可以從 Evaluator 來下手。

好吧,既然有好處,那回到前一個問題:如果這是個商業產品,這樣做會利大於弊嗎?我還不知道。

未雨綢繆:Swift Packages

在剛開始整理舊 AI 的時候,我就希望之後同時能在 iOS 上執行,又能在電腦上生訓練用的資料。既然會這樣給不同的 app target 使用,很自然的是要做進一個 Swift Package。

另開 app project 並寫了一陣子 SwiftUI 界面後,在思考 SwiftUI app architecture 時讀到了 Matt Gallagher 的文章,大力推薦把 model layer 切出到一個 inline Swift Package (App architecture basics in SwiftUI Part 3: Module-separated layers)。於是我的 app 就又多了一個 package,在 iOS target 專屬的 folder 底下剩下幾乎全都是 View,各個 View 把兩個 modules 給 import 進來使用 (View import AI module 都只是為了它的 type 定義)。

在 project 裡多切一個 module 出來,無疑是提高了架構的複雜度。寫 code 時,也十分有感 — 一開始當然是要處理 access level,哪些東西要從 internal 升級成 public。再來偶爾會做蠢事,寫 model code 時,咦,怎麼沒有辦法用這個 type,一直說找不到?原來是還在 app 那頭,忘了搬進 module。

而原來這些都是 Matt 推薦這個做法的好處:它不僅在視覺上造成 model 和 view 的切分 (兩組檔案在兩個不同地方),更重要的是因為 access level 多一層,我必須去思考 view 到底需要對 model 知道得多詳細,而 model 呢,則是根本沒有辦法知道 view 任何事情 (因為我們只會從 view 那邊來 import model 所在的 module)。這真的有對維護 loose coupling 產生了正面的影響。

嗯,不過… 「我」同時知道 model 和 view 兩邊長什麼樣子,所以還是一定會為了 view 的需要去設計 model,反之亦然。兩邊增加的隔閡,其實也讓我更明確的感覺到我是怎麼樣忍不住不斷增加 model 和 view 在邏輯上的 coupling。

這方面最有感覺的,是在做「放下方塊 – 震動 – 填滿一行消失 – 重新組合」這一連串的動態呈現的時候。我想透過一連串的計時改變 view model 來達成這個視覺效果,而每個 view model 的改變,在 view 這一頭都有一個相對應的 animation 或 transition,但這每一步都要兩頭刻意串通好,才能做到現在 trigger 這個 animation,接下來 trigger 另一個 animation。這個現象到什麼程度是可允許的,以及針對這個例子可能的改善方式 (尤其是怎麼把某些動畫邏輯轉移到 view 層面),都是我還在思考的問題。

異中求同

一個 deja vu 的感覺,讓我開始思考這兩件事的相似之處。

向上追溯,protocol 和 modules 各自的目標裡,一個明顯的共通點是 decoupling。兩者在做下去以後,就產生種種限制,促使、甚至是限制開發者去將兩個物件,或是兩個 architectural layers 之間的關係給釐清開來。

在一個 project 還小還單純的時候,引進某些 protocol 或 module 比較可能令人疑惑:有必要搞這麼複雜嗎?但它們帶來的限制,對開發者的思考而言有時就像一種 scaffolding,有點像是 Swift 的 strong typing 引導我們寫更安全的 code 一樣。

但在優劣權衡上,我想最終兩者還是有些差異。我這次這種 protocol 和 generic 的使用,實作下來的感覺是比較不值得的。它讓 code 到處冒出許多的<尖角角>和很長的型別名字或 typealias,也讓我感覺程式變複雜了。反之切 module 雖然會多出一些 public 和 import,在思考架構時反而會感覺變得容易、單純了點。

我好奇的是,隨著經驗增長,和遇到不同的 app 需求,自己抉擇的天平又會怎麼改變呢?總之在那之前,DrillAI 還會繼續挖下去。

Leave a Reply

Your email address will not be published.