定義資料類型而非類別

閱讀時間約 10 分鐘

寫上一篇文章時我意識到,類型,類別,型別這幾個詞在物件導向當道的現代變得有些模糊,常常會不小心當成是物件導向的類,但我指的其實是資料類型。在英文中,我常常這樣區分它們:物件導向的類是class,代表的是抽象的物件模型,而類型是type/data type,代表的是實際的資料結構。正如上一篇文章所說,在物件導向裡變數的類型基本上都是抽象的,或者說type在這裡也指抽象的物件模型。class跟type的區別很重要,例如rust的trait system來自Haskell的type class,wiki把它翻成類型類,這聽起來有夠奇怪,但如果帶入上面的解釋就能說得通:它是描述資料結構具有哪些抽象特性的模型。類型本身不應該用來描述抽象的概念,這應該是trait/interface的職責,而類型則應該關注於資料的組成。本篇文章將討論資料類型的概念。


資料類型描述的是資料的組成結構,例如範圍Range是由兩個浮點數組成的資料,在c++裡面可以定義成struct Range { float min; float max; }。在物件導向裡,min跟max就是類別的成員變數。但有些物件導向程式語言可以使用property getter/setter的方式定義成員變數,這種定義使得我們連存取成員變數都不能相信,例如ran.min = 1.0可能會呼叫其他方法並改寫其他屬性。這種方法的好處是可以為存取提供額外的功能,例如驗證合法性,或是為可推導出來的屬性提供直覺的存取方法,例如提供ran.width的存取方法。然而定義資料類型時不應該為了方便使用這種打破界線的方法,長遠來看這樣會使得語義變得更複雜。


除去這種語法後,資料類型的屬性就代表了真實的資料,若不考慮他們之間的限制,事實上就只是兩個湊在一起的獨立變數而已。與獨立變數不同的是,我們定義資料類型一般是為了把具有關聯意義的獨立變數組合起來,例如Range的min跟max代表的是範圍的上下界,並不是任意的兩個數字。有些程式語言有tuple類型,它能提供相同的功能,但仍然建議使用自己定義的類型,因為它能賦予資料意義。甚至當成員變數只有一個時也應該定義獨立的類型,例如弳度struct Radian { double value; }。類型的定義不只是把結構定義出來,還可以賦予資料意義,並與其他相同結構的類型做出區別。原本常見的做法都是使用已有的類型並在變數上註記它代表的意義,而使用自定義類型時我們可以把說明和相關的方法加註在這個類型的定義上。然而它的缺點是程式碼的重用性降低,如果我們自己定義了Quaternion而非使用已有的Vector4類型,那麼我們就不能直接利用Vector4相關的函式。


對於物件導向的程式語言,物件是藉由建構子建構的。建構子可以用來檢查合法性,例如前面定義的Range必須符合min<=max的約束,這可以在建構子裡進行檢查。然而一般建構子不能回傳任何值(或者說只能回傳建構的物件),若建構失敗就只能拋出例外,因此不應該在建構子裡使用太複雜的操作,例如開啟連線或是進行大型運算。建構子基本上就只是在物件一開始執行的函式,因此若是要處理更複雜的建構狀態,可以直接改用靜態方法實作建構方法。如果不管成員變數之間的約束,建構子就只是將所有參數設定給所有成員變數而已,對於rust就是如此。rust定義的struct自帶建構子與特殊的建構語法,若要檢查合法性或是處理更複雜的情況,只要另外實作關聯方法並透過封裝限制建構子的存取就行了。事實上,在函數式程式語言裡定義建構子就等價於定義資料類型(GADT),它們只是在解釋上有所差異而已。


再除去資料代表的意義後,struct定義的就真的只是資料結構而已了。不管是struct Range { double min; double max; } 或是 struct Vector2 { double x; double y; } 還是tuple<double, double>都是相同的,它們都只是兩個獨立的浮點數而已。這種由兩個類型組成的類型稱做product type,這個名字代表他有類似乘法的代數結構。例如u8有256個可能的值, 而tuple<u8,u8>有256*256個可能的值;tuple<u8,u8>可以看作是運算式u8*u8。乘法類型就只是多個獨立的變數,相反地,只要有多個獨立變數同時存在都可以組成乘法類型,例如函式的參數列表在概念上就是一種乘法類型,而函式在做的就是對這個資料結構作轉換與處理,也就是一種演算法。


前面提到開發者應該定義自己的類型,其中列舉就是已被廣泛使用的類型。列舉實際上就只是整數類型而已,通常用於描述不同的狀況。例如,比起回傳錯誤代碼,回傳列舉可以更明白地表達錯誤類型。但仍然有許多函式是藉由某些特殊值解釋狀況,例如compare回傳整數值並可根據其正負號判斷順序,但數值大小本身沒有意義;findIndex函式一般都以回傳-1代表找不到;如果一個函式回傳的是指標,當他回傳空指標時一般代表某種失敗。但如果有多種失敗狀態,在c++常常改用傳入的參考回傳結果,並以回傳值傳回執行狀態。在golang則是使用comma-ok pattern,以最後一個回傳值作為例外,如果它是nil就代表成功。這些方法雖然有用但並不好用。空指標一般會被當作正常的物件,編譯器並不會阻止你使用類別底下的方法,因此常常需要在使用前做null check;透過參考傳回的值的意義根據函式回傳的狀態有所不同,常常需要仔細看文檔才能理解要怎麼用。而代表正確的錯誤代碼往往是0,如果把它當作boolean就會出錯。golang的comma-ok雖然好用,但仍然避免不了誤用未指定回傳值的問題。


會有這些問題是因為缺少一個能同時表達狀態和資料的結構,不合法的狀態仍然能被表達出來,使得我們必須花費額外的專注力從上下文判斷是否合法。如果有辦法讓函式回傳錯誤代碼的同時,自動使計算結果變成不可取得,就能在編譯時期檢查出問題所在。在rust裡可以為列舉綁定變數:enum Result { Ok { value: f32; }, Error { message: String; } },藉此同時回傳狀態與資料,因此在回傳失敗訊息時是不可能取得計算結果的。這種透過列舉控制資料類型的方式稱作sum type。就像是列舉,這種類型的基本用法就是透過switch跳轉到不同的分支執行不同的程序,但它還會另外送你相關的資料。更關鍵的是這些資料只能透過這種方法取得,使得我們不可能搞混狀態與資料的關係。這種類型強調「狀態」不應該只是一個列舉,還應該包含相關的資料。狀態的差異會使程式進入不同的流程,每個分支再根據取得的相關資料做出不同的行為。因此可以說狀態是流程控制的開關(switch)。


sum type有時又稱作tagged union。c語言的union是將多個類型聯合起來的類型,它底下的變數可以是其中一個類型,但沒辦法知道實際是哪個。指標可以看作是空指標與非空指標的union,空指標什麼都不能做,因此當你試圖存取指標的內容時,如果它是空指標就會出錯。而tagged union則帶有標記說明實際類型,因此可以在執行時期確定變數的實際類型。這有點像抽象類別的繼承,父類別是所有子類別的聯集。我們也不知道變數的實際類型,但可透過 isinstanceof 等方法判斷。然而不同的是tagged union只允許有限且定義時包含的類型,但繼承基本上可以延伸出任意多的類別。物件導向常常建議開發者使用多型的方式達到行為的差異化,甚至建議比起使用if/switch不如使用多型(clean code principle)。例如要計算各種形狀的面積時,應該把函式實作成抽象方法,而非利用switch去判斷形狀再個別計算面積。他們的差別在於多型多了一個抽象的階層,當你看到程式碼裡使用了抽象方法getArea(),並不能馬上知道他是做什麼的(額⋯在更複雜的例子應該是這樣的),如果裡面有bug也沒辦法馬上知道。藉由switch的方法則會暴露實作細節,因此若是過程過於複雜,那麼就會使程式難以閱讀。然而我們應該基於抽象化與否區分何時使用多型何時使用判斷式,並不是像clean code所說應盡量使用多型,過多的抽象化也會消耗開發者的專注力。還有基於多型的流程控制一般只允許做特定任務,而基於判斷式則能完成任意操作。tagged union的switch statement其實就是一種特殊的函式,可以用它實作出任何物件導向的多型,當然前提是只能有特定的類別繼承自它。sum type沒辦法達到如物件導向的非特定子類別的多型,但就如前一篇文章所說,這種情況應該使用trait,因為這種情況必須使用抽象化。


跟product type一樣,除去定義類型時所賦予的意義,它代表的是類似於列舉的資料結構。它被稱作sum type是因為具有加法的代數結構。例如在rust裡有一種加法類型Result<A,B>,它就像是A+B。例如u8有256個可能的值, 而Result<u8,u8>有256+256個可能的值。加法類型就是多個不能同時存在的獨立變數,例如多個分支計算出來的結果就算類型不一樣,也可以藉由加法類型合併成同一個類型。如果同樣以演算法的角度來看加法類型,它本質上和流程控制是相對應的,加法類型的結構轉換就是流程控制上的跳轉。product type和sum type被命名成如此自然是因為它們之間的代數關係,例如Tuple<A,Result<B,C>>可以被轉換成Result<Tuple<A,B>,Tuple<A,C>>,就像是A*(B+C) = A*B+A*C。從流程控制上來理解就是:類型為A的變數獨立於兩個分支,其中兩個分支裡分別定義了B類型的變數和C類型的變數;這個狀況和兩個分支分別定義Tuple<A,B>的變數和Tuple<A,C>的變數一樣,因為在上一個狀況中A的變數可以同時被兩個分支存取。在理解了乘法類型與加法類型後,我們可以說:資料結構本質上就是演算法的橫切面,演算法就是資料結構的轉換。在這個意義上加法類型與乘法類型是同等重要的存在,然而直到現在加法類型仍然並非主流。在缺少語言級別的支援下,很難利用加法類型所提供的優勢,因此若是人們不去嘗試擁有加法類型的程式語言,是很難理解其必要性。

3會員
23內容數
這不是教你如何從物件導向到函數式編程的入門教程。我會深入探討物件導向與函數式編程的差異,並討論為什麼你應該使用函數式編程並徹底放棄物件導向。
留言0
查看全部
發表第一個留言支持創作者!