更新於 2024/12/19閱讀時間約 11 分鐘

悅耳的類型系統

物件導向程式語言的類型系統總是不合理的,這些程式語言承襲至舊的語言不好的特性,而人們並沒有意識到它的問題,或者比起健全(soundness)它們更注重熟悉(familiarity)。多數的物件導向程式語言都源自c/c++,而c++有太多糟糕的設計,然而同一時期出來的Haskell卻很少有類似的問題。Haskell是基於數學理論的程式語言,而且它是高階程式語言,因此自然在抽象概念的合理性上有更多的要求。然而這種合理性不只侷限於他背後的數學理論,更可應用於其他程式設計的概念。事實上Haskell包含很多相關但相互獨立的特性,每個特性都可以單獨抽離出來使用,其中上一篇文章討論的代數資料類型就是一個例子。因此我們不應該將這種合理性片面地視作是學術且數學的,它對於我們理解程式語言以及如何寫好程式也有巨大的幫助。前幾篇文章已經提過一些不合理的設計,它們大多都藏在難以發現的地方或是因為習慣而被我們忽視。這篇文章將會討論合理性的重要性。


在這裡「不合理」並不代表違反規則,而是在實際使用上我們會如何看這些特性。例如即使我們知道被宣告成指標的變數可能為空,但我們仍然會不小心觸發空指標的錯誤,就算c++有提供非空指標給你使用,這種錯誤仍層出不窮。這有兩個原因。第一,「指標」的規則設計問題,指標被設計成存取成員變數或方法時會在空指標時出錯,這個規則使得我們必須透過上下文自己判斷是否合法。然而這仰賴於開發者的細心程度,這是軟體工程裡最不能相信的一件事。站在開發者的角度,這種莫名奇妙的規則有很糟的開發體驗。第二,「指標」(pointer)概念的語義模糊,指標代表的應該是指向某個特定類型物件的間接參考,但有空指標這一個特例。因此c++發明了的「非空指標/參考」(reference)以排除這個特例。反過來說,指標這個詞指的其實是參考和空指標的聯集。然而我們對指標這個詞的認識仍著重在指向物件這件事,常常忽略空指標的特例,這在Java這種刻意消除指標概念的程式語言尤其嚴重。rust的解決辦法是將聯集的部分抽取出來,因此c++的指標int*在rust就變成兩部分Option<&int>,如此設計更清楚地描述了指標和可能為空這兩個特性。指標在c++的定義很明確,規則雖然有點複雜但還不到難以理解的地步,但這種指標的概念在實際使用上容易出錯,甚至會造成理解上的差異。它的規則不是不合理,找到bug時原因也都能理解,但就是「不符合預期」,也就是跟我們心中的模型有所誤差。不合理的是程式語言的設計,這種設計對於機器的實際運作方式來說非常合理,但程式語言是給人類寫的,應該根據人類的思考方式設計。好的程式語言使用起來會感到很合理(指標就是指標,使用它底下的方法不會有任何問題),很和諧(若指標可能為空,就用Option,它並不是指標特有的;例如Iterator::find可能找不到東西,所以也會回傳Option),很賞心悅目(編碼時會跟著音樂一起嗨)。因此這篇的文章標題取為「悅耳的類型系統」(A sound type system, 故意翻錯的)。


物件導向相對於函數式編程概念都稍微有點「歪斜」:如果我們把程式語言的特性與概念一一列出,並從函數式編程的角度去分類,再依據物件導向的角度分類,會發現被圈在一起的部分幾乎跟函數式編程一樣,但是就是有一些地方不同,因此說它有一點歪斜。物件導向跟函數式編程其實很相似,抽象,封裝、多型、介面等概念函數式編程也都有,但就是那微小的差異使得物件導向變得難用不合理。有人認為如果用正確的方式使用物件導向,結果會很像函數式編程。但問題是物件導向程式語言的設計使得開發者容易出錯,例如明明最重要的概念就是繼承,但正確的做法卻是少用繼承多用介面;提倡盡量以組合代替擴展,但卻又允許以繼承改寫方法;建議把帶有狀態邏輯最小化,但物件本身就是狀態機。當有多種方法可以實現功能時,開發者往往會選擇最明顯的方法實作,而物件導向的問題就是最明顯的常常是不好的方法。為了避免開發者持續產出糟糕的程式碼,跑出了各種物件導向的開發原則和設計模式,這些主要都是為了「修復」物件導向的缺點,很多概念放到函數式編程就會變得稀鬆平常、廢話連篇。因此並不只是物件導向相對歪斜,而是它本來就是歪的。


這種歪斜不一定是壞的,在某些情況物件導向可以給出不同的觀點。舉一個實際的例子:我公司的同事最近在c#實作了Result class,但實作方式很有趣。他把Result<Value,Error>實作成Result<Error>的子類,為了方便起見我把前者重命名為ResultWithValue<Value,Error>。ResultWithValue實際上就是rust的Result,而Result則對應到rust的Option(但是它包含的值是Error)。用前一篇文章的概念來實作應該是實作成抽象類別Result和兩個子類SuccessResult和ErrorResult,但這種實作方式必須限制Result只能有這兩個子類。因為c#沒有像是java sealed的語法,因此在這裡只能用internal constructor的方式取巧地限制繼承。把ResultWithValue當作Result的延伸從物件導向的角度來說的確很合理,這種設計大概是啟發自c#的Task和Task<Result>,這能有效重用兩者的程式碼。一般繼承是使乘法類別橫向發展,例如A*B的類別延伸後可以變成A*B*C,這對應到函數式編程的row polymorphism,但這裡把加法類別也用這種方法做延伸,雖然使用row polymorphism也可以做到類似的事(purescript-variant),但一般不建議這麽做。這種繼承的用法我覺得非常有趣,也顯示出物件導向與函數式編程不同的思考方式。


合理穩健的型別系統可以提供開發者巨大的救贖,上篇文章討論的代數資料類型就是其中的精髓。在缺少加法類型的程式語言裡我們只能利用null pointer表示失敗,或是使用多個變數但只有一個為非空指標來表示不同狀態下的資料。這種基於習慣的用法依賴於開發者的細心,並不斷消耗有限的專注力。透過將狀態綁定到類型上,我們可以用更精確的方式描述程式邏輯,並把背後的結構看得更清楚。提供Tuple類型可以讓開發者更習慣使用回傳值傳回結果,而非使用參數參考傳回,或是暫存在內部並提供方法取得各個數值;提供Result類型可以讓開發者更容易傳回執行狀態,而非使用特殊值表示狀況,或是乾脆忽略例外狀況。合理的類型系統在簡單的情況下不需要任何說明,只靠名稱和結構就足以描述其意義與使用方法。比起寫一堆說明文檔講解應該怎麼使用這個方法,不如使用類型系統讓你只能這麽使用。這種依靠類型系統「說話」的方式很常出現在類型驅動開發,而只有當類型系統足夠穩健才有辦法這樣玩。在合理的類型系統下開發更容易除錯,因為不對的狀態變得不可表示,因而把執行時期的錯誤提昇到編譯時期。


並不是類型越豐富,能力越強就比較好。過多過於細緻的類型系統會增加學習難度,rust就是這樣的語言,然而對於關注記憶體安全的程式語言來說,這麽做是有必要的。但是rust的語法就有點複雜且多餘,例如rust提供非常多的方法處理加法類型,而其中包含了match, if let, let else, while let等語法,而且人們似乎還覺得不夠用(if let chains)。rust因為關於生命週期的類型使得操作一些結構變得複雜繁瑣,因而引入了很多語法與一些特殊的語義以簡化寫法,卻讓規則變得特別複雜。但對於函數式程式語言,一般只有case of和pattern matching兩種語法,而這些也足以描述所有可能的狀況。我認為有限的語法更能把邏輯理清,它能讓你意識到所有流程控制都是基於加法類型的pattern matching,這是理解函數式編程的重要關鍵。有時候功能過於強大也會造成不好的結果。Java這類語言的物件可以動態地檢閱實體類別並使用其中的方法,這稱為反射機制。這種機制使得我們可以直接把Java當作動態類型的程式語言來操作。這在一些地方很方便,但也使得一些糟糕的設計更容易出現(函式的參數都是Object,但事實上只能傳入特定類別,因為它會藉由反射機制操作)。問題不在於Java能做到這點,而是任何類別的變數都能這樣做,以致於我們沒辦法判斷他何時會使用反射機制。就算拿掉反射機制,instanceof就已經過於強大,然而在rust必須使用Any trait才能做到類似的事情。另外c++的const cast也是,利用const cast可以騙過編譯器常數的約定,使得const變得沒意義。rust也有類似的東西,但它把const cast做的很複雜並包在unsafe裡,明確說明了這個方法的風險。


有紀律的類型系統不會輕易開後門,因此不可變的還是不可變,不可能知道的就是不知道,無法存取的就不應該有偷渡的方法。類型系統的紀律關係到對類型系統的信賴,沒有紀律我們就必須相信其他開發者的素養。如果為了遵守紀律而把後門管的很嚴,在處理一些特殊狀況時就會不方便或者不可能,然而通常這時要想的是這種方法是否真的合理,如果真的合理的話你應該要把它抽取出來重用,而非抱怨它很難用。rust就是刻意把功能較強、容易出錯、不安全的方法設計得比較複雜,如此一來工程師就會為了方便使用比較安全的方法實作,若是想要用容易出錯的方法實作,也會因為寫起來比較複雜的關係而傾向包裝起來重用。這種人因工程的設計是rust的一大特點。而Python和TypeScript這種在動態類型程式語言上附加的靜態類型系統就沒有紀律可言,而嚴謹也不是它們的目的。TypeScript能提升JavaScript的表達力,增加帶有語義的類型標註能讓程式邏輯更清晰,並減少出錯的機會。然而不能指望TypeScript編譯器的檢查,除非你能掌握所有程式碼,並正確地使用類型與確實避開各種陷阱。紀律的好處不只是能獲得信賴,還能免費得到一些承諾。例如看到rust函式fn what<A>(a:A) -> Vec<A>,我們有99%的信心相信他總是會回傳一個只帶有一個元素(即參數a)的列表,或總是回傳沒有元素的列表,而參數就直接丟掉了,只是一般不會這麼寫。但它不可能回傳多個元素的列表,因為它不知道如何複製參數,更不可能憑空捏出類型為A的值,除非使用unsafe。然而如果是Java的話,它不只有可能會回傳多個元素的列表(任何物件參考都可以複製),還有可能會根據參數的實體類別回傳不同的長度,而這些都是使用普通的語言特性就能實現的。這種藉由泛型推導出的承諾稱為free theorem,顧名思義就是免費的理論(theorem for free!):我們不需要付出任何代價,只需要寫下函式的類型宣告,就能免費得到承諾。只要稍微有點違規,這些承諾就無法兌現,這顯示了紀律的重要性。函數式程式語言更注重這種紀律,因此大部分的方法只要看過類型,甚至連名稱都不用看,就能知道他在幹嘛。


分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.