悅耳的類型系統

更新 發佈閱讀 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!):我們不需要付出任何代價,只需要寫下函式的類型宣告,就能免費得到承諾。只要稍微有點違規,這些承諾就無法兌現,這顯示了紀律的重要性。函數式程式語言更注重這種紀律,因此大部分的方法只要看過類型,甚至連名稱都不用看,就能知道他在幹嘛。


留言
avatar-img
留言分享你的想法!
avatar-img
have bear的沙龍
4會員
28內容數
這不是教你如何從物件導向到函數式編程的入門教程。我會深入探討物件導向與函數式編程的差異,並討論為什麼你應該使用函數式編程並徹底放棄物件導向。
have bear的沙龍的其他內容
2024/02/15
這個系列的文章主要專注於物件導向到函數式編程的差異與分析,並針對概念與機制上的不同進行比較。很多人說物件導向和函數式編程沒有哪個比較好的問題,只有哪個比較適合的問題,然而我並不這麼認為,我透過這一系列的文章從各個角度討論它們之間的優缺點就是為了闡述我的觀點。物件導向錯在沒有理論基礎,但它贏在熟悉性,
2024/02/15
這個系列的文章主要專注於物件導向到函數式編程的差異與分析,並針對概念與機制上的不同進行比較。很多人說物件導向和函數式編程沒有哪個比較好的問題,只有哪個比較適合的問題,然而我並不這麼認為,我透過這一系列的文章從各個角度討論它們之間的優缺點就是為了闡述我的觀點。物件導向錯在沒有理論基礎,但它贏在熟悉性,
2024/02/08
前一篇文章中所提的函數式的三個機制明確說明了它關注的規則與能力具體是什麼。然而這套對於函數式編程的定義主要基於特定的類型系統,作為一個編程範式來說過於狹隘(物件導向的定義也是這樣)。更廣義的,我認為函數式編程主要依循三個原則,它們可以應用於任何程式語言,就算沒有靜態類型系統的支援也可以。例如在不管類
2024/02/08
前一篇文章中所提的函數式的三個機制明確說明了它關注的規則與能力具體是什麼。然而這套對於函數式編程的定義主要基於特定的類型系統,作為一個編程範式來說過於狹隘(物件導向的定義也是這樣)。更廣義的,我認為函數式編程主要依循三個原則,它們可以應用於任何程式語言,就算沒有靜態類型系統的支援也可以。例如在不管類
2024/02/04
前面談了那麽多函數式編程與物件導向的差異,但我們還沒定義函數式編程。就像物件導向,函數式編程沒有明確的定義,每個人對於什麼是函數式編程都有不同的看法。在這裡我會總結前面的討論,給出我對於函數式編程的觀點。 物件導向注重封裝與延展性,因此一般基於三個機制:繼承、多型、封裝,它們代表了物件導向所重
2024/02/04
前面談了那麽多函數式編程與物件導向的差異,但我們還沒定義函數式編程。就像物件導向,函數式編程沒有明確的定義,每個人對於什麼是函數式編程都有不同的看法。在這裡我會總結前面的討論,給出我對於函數式編程的觀點。 物件導向注重封裝與延展性,因此一般基於三個機制:繼承、多型、封裝,它們代表了物件導向所重
看更多
你可能也想看
Thumbnail
想在蝦皮雙11買到最划算?這篇文章將分享作者精選的蝦皮高CP值商品,包含HERAN禾聯冷氣、HITACHI日立冰箱、DJI無線麥克風、FUJIFILM拍立得,並提供蝦皮雙11優惠券領取教學、省錢技巧,以及蝦皮分潤計畫介紹,讓你買得開心、省得多!
Thumbnail
想在蝦皮雙11買到最划算?這篇文章將分享作者精選的蝦皮高CP值商品,包含HERAN禾聯冷氣、HITACHI日立冰箱、DJI無線麥克風、FUJIFILM拍立得,並提供蝦皮雙11優惠券領取教學、省錢技巧,以及蝦皮分潤計畫介紹,讓你買得開心、省得多!
Thumbnail
2025 蝦皮 1111 購物節又來了!分享三大必買原因:全站 $0 起免運、多重優惠疊加、便利取貨。 此外,推薦兩款高 CP 值的即食拉麵(無印良品即食迷你拉麵、維力迷你麵野菜拉麵),並分享如何透過「蝦皮分潤計畫」放大效益,開心購物之餘還能獲得額外收益!
Thumbnail
2025 蝦皮 1111 購物節又來了!分享三大必買原因:全站 $0 起免運、多重優惠疊加、便利取貨。 此外,推薦兩款高 CP 值的即食拉麵(無印良品即食迷你拉麵、維力迷你麵野菜拉麵),並分享如何透過「蝦皮分潤計畫」放大效益,開心購物之餘還能獲得額外收益!
Thumbnail
  程式中很常會看到千奇百怪的運算式,這些運算式都隱藏著各種運算元和運算子,這些是什麼呢?讓我們來一探究竟。   運算元是指變數、常數這類(如:A、B、C、Data、123等),運算子是指運算符號(如:+、-、*、/、%、==、<、&&等這類型),這邊就要介紹C#的運算子以及怎麼使用。
Thumbnail
  程式中很常會看到千奇百怪的運算式,這些運算式都隱藏著各種運算元和運算子,這些是什麼呢?讓我們來一探究竟。   運算元是指變數、常數這類(如:A、B、C、Data、123等),運算子是指運算符號(如:+、-、*、/、%、==、<、&&等這類型),這邊就要介紹C#的運算子以及怎麼使用。
Thumbnail
這次分享的是常數、變數、宣告與初始化。 [常數]就是固定不變的數,如:PI=3.14 [變數]顧名思義就是會改變的數,如:y=2x (在數學中x確定後y才會確定,因此x為自變數,y為應變數,x、y都屬於變數) 一、常數   常數在定義的時候,一開始就必須指定好資料型別並且給予值,因為它在整個程式在執
Thumbnail
這次分享的是常數、變數、宣告與初始化。 [常數]就是固定不變的數,如:PI=3.14 [變數]顧名思義就是會改變的數,如:y=2x (在數學中x確定後y才會確定,因此x為自變數,y為應變數,x、y都屬於變數) 一、常數   常數在定義的時候,一開始就必須指定好資料型別並且給予值,因為它在整個程式在執
Thumbnail
這篇文章將會介紹函式(Function)及其回傳值(retrun)的定義及介紹。
Thumbnail
這篇文章將會介紹函式(Function)及其回傳值(retrun)的定義及介紹。
Thumbnail
這篇文章為介紹C#基礎知識的一部分,如果你是直接開始寫程式的C#程式員,可以看看這篇文章補足一些基礎知識。
Thumbnail
這篇文章為介紹C#基礎知識的一部分,如果你是直接開始寫程式的C#程式員,可以看看這篇文章補足一些基礎知識。
Thumbnail
函式(Function)、傳值法、傳位址法、傳參考法
Thumbnail
函式(Function)、傳值法、傳位址法、傳參考法
Thumbnail
指標(Pointer)、參考(reference)
Thumbnail
指標(Pointer)、參考(reference)
Thumbnail
變數(variable)、型別(type)、初始化(initialize)、宣告
Thumbnail
變數(variable)、型別(type)、初始化(initialize)、宣告
Thumbnail
《Course in General Linguistics》 Ferdinand de Saussure published in 1916          語言是一種社會制度,是一種表達觀念的符號系統,我們可以設想有一門研究社會生活中符號生命的科學,它將構成社會心理學的一部分,因而也是普通心理
Thumbnail
《Course in General Linguistics》 Ferdinand de Saussure published in 1916          語言是一種社會制度,是一種表達觀念的符號系統,我們可以設想有一門研究社會生活中符號生命的科學,它將構成社會心理學的一部分,因而也是普通心理
Thumbnail
  眾人一提到數學,的確會想像它是嚴密嵌合的邏輯代碼,是不可移動的判準依據,然實際上,它與現實生活是相互影響的,在更廣袤框架下,自教育、文學、藝術、歷史裡,都可以從中析分出數學意義,這便是所謂文化;從出現、發展到集大成者,當中亦存在著先後與否的因果關係,將現象置放到正確位置給予適當評價,會稱之脈絡。
Thumbnail
  眾人一提到數學,的確會想像它是嚴密嵌合的邏輯代碼,是不可移動的判準依據,然實際上,它與現實生活是相互影響的,在更廣袤框架下,自教育、文學、藝術、歷史裡,都可以從中析分出數學意義,這便是所謂文化;從出現、發展到集大成者,當中亦存在著先後與否的因果關係,將現象置放到正確位置給予適當評價,會稱之脈絡。
追蹤感興趣的內容從 Google News 追蹤更多 vocus 的最新精選內容追蹤 Google News