程式語言:型別系統的本質

陳光劍 發表於 2022-07-03
程式語言

程式語言:型別系統的本質

0. 引子

我一直對編寫更好的程式碼有濃厚的興趣。如果你能真正理解什麼是抽象,什麼是具象,就能理解為什麼現代程式語言中,介面和函式型別為什麼那麼普遍存在了。在使用函式式語言進行程式設計後,就能夠很清晰地理解為什麼隨著時間的推移,更主流的語言開始採用函式式語言中的一些被認為理所當然的特性。

我將多年間學習型別系統和程式語言開發的經驗匯聚起來,加以提煉,並輔以現實世界的應用,撰寫了這篇文章。本文脈絡如下:

  1. 概述:什麼是型別?為什麼要引入型別的概念?
  2. 程式語言中的基本型別
  3. 型別組合
  4. OOP與介面型別
  5. 函式型別
  6. 函子(Functor)和單子(Monad)

1. 概述:什麼是型別?為什麼要引入型別的概念?

型別系統設計的理論與日常生產軟體之間存在直接的聯絡。這並不是一個革命性的發現:複雜的型別系統特性之所以存在,就是為了解決現實世界的問題。

本節介紹型別和型別系統,討論它們為什麼存在以及為什麼有用。我們將討論型別系統的型別,並解釋型別強度、靜態型別和動態型別。

兩個術語:型別、型別系統

型別

型別是對資料做的一種分類,定義了能夠對資料執行的操作、資料的意義,以及允許資料接受的值的集合。編譯器和執行時會檢查型別,以確保資料的完整性,實施訪問限制,以及按照開發人員的意圖來解釋資料。

型別系統

型別系統是一組規則,為程式語言的元素分配和實施型別。這些元素可以是變數、函式和其他高階結構。型別系統通過兩種方式分配型別:程式設計師在程式碼中指定型別,或者型別系統根據上下文,隱式推斷出某個元素的型別。型別系統允許在型別之間進行某些轉換,而阻止其他型別的轉換。

從複雜系統的約束開始

“系統”一詞由來已久,在古希臘是指複雜事物的總體。到近代,一些科學家和哲學家常用系統一詞來表示複雜的具有一定結構的整體。在巨集觀世界和微觀世界,從基本粒子到宇宙,從細胞到人類社會,從動植物到社會組織,無一不是系統的存在方式。

控制論(維納,1948,《控制論(或關於在動物和機器中控制和通訊的科學)》)告訴我們,負反饋就是系統穩定的機制,一個組織系統之所以能夠受到干擾後能迅速排除偏差恢復恆定的能力,關鍵在於存在著“負反饋調節”機制:系統必須有一種裝置,來測量受干擾的變數和維持有機體生存所必需的恆值之間的差別。 例如,一個實時系統複雜性任務的約束,包括時間約束、資源約束、執行順序約束和效能約束。

程式語言:型別系統的本質

型別檢查:型別檢查確保程式遵守型別系統的規則。編譯器在轉換程式碼時進行型別檢查,而執行時在執行程式碼時進行型別檢查。編譯器中負責實施型別規則的元件叫作型別檢查器。如果型別檢查失敗,則意味著程式沒有遵守型別系統的規則,此時程式將會編譯失敗,或者發生執行時錯誤。“遵守型別系統規則的程式相當於一個邏輯證明。”

型別系統,就是複雜軟體系統的“負反饋調節器”。通過一套型別規範,加上編譯監控和測試機制,來實現軟體系統的資料抽象和執行時資料處理的安全。

隨著軟體變得越來越複雜,我們越來越需要保證軟體能夠正確執行。通過監控和測試,能夠說明在給定特定輸入時,軟體在特定時刻的行為是符合規定的。但型別為我們提供了更加一般性的證明,說明無論給定什麼輸入,程式碼都將按照規定執行。

例如,將一個值標記為 const,或者將一個成員變數標記為 private,型別檢查將強制限制實施其他許多安全屬性。

從 01 到現實世界物件模型

型別為資料賦予了意義。型別還限制了一個變數可以接受的有效值的集合。

在低層的硬體和機器程式碼級別,程式邏輯(程式碼)及其操作的資料是用位來表示的。在這個級別,程式碼和資料沒有區別,所以當系統誤將程式碼當成資料,或者將資料當成程式碼時,就很容易發生錯誤。這些錯誤可能導致系統崩潰,也可能導致嚴重的安全漏洞,攻擊者利用這些漏洞,讓系統把他們的輸入資料作為程式碼執行。

程式語言:型別系統的本質

通過對程式語言的研究,人們正在設計出越來越強大的型別系統(例如,Elm或Idris語言的型別系統)。Haskell正變得越來越受歡迎。同時,在動態型別語言中新增編譯時型別檢查的工作也在推進中:Python新增了對型別提示的支援,而TypeScript這種語言純粹是為了在JavaScript中新增編譯時型別檢查而建立的。

顯然,為程式碼新增型別是很有價值的,利用程式語言提供的型別系統的特性,可以編寫出更好、更安全的程式碼。

程式語言中的資料型別

型別系統是每個程式語言都會有的基本概念。

  1. Lisp 資料型別可分類為:
  2. 標量型別 - 例如,數字型別,字元,符號等。
    -資料結構 - 例如,列表,向量,位元向量和字串。
  3. C 語言的型別系統分為:基本型別和複合型別。基本型別又可以細分為:整型數值型別和浮點數數值型別,不同型別所佔用的記憶體長度不相同:

整型數值基本型別

char 佔用一個位元組
short 佔用兩個位元組
int 目前基本都是4位元組
long int (可以簡寫為 long) (32位系統是4位元組,64位系統是8位元組)
long long int ( 可以簡寫為long long) 佔用8節字

浮點數數值基本型別

float 佔用4位元組 (單精度)
double 佔用8節字 (雙精度浮點數)

複合型別包含如下幾種

struct 結構體
union 聯合體
enum 列舉 (長度等同 int )
陣列
指標
  1. Go語言中有豐富的資料型別,除了基本的整型、浮點型、布林型、字串外,還有陣列,切片(slice),結構體(struct),介面(interface),函式(func),map , 通道(channel)等。
  2. 整型:int8 int6 int32 int64;對應的無符號整型:uint8 uint16 uint32 uint64。uint8 就是我們熟知的 byte 型,int16對應C語言中的short型,int64 對應C語言中 long 型。
  3. 浮點型別:float32和 float64, 浮點這兩種浮點型資料格式遵循 IEEE 754標準。
  4. 切片:可變陣列,是對陣列的一種抽象。切片是引用型別。
  5. 介面: 實現多型,面向介面程式設計。定義一個介面 I , 然後使用不同的結構體對介面 I 進行實現,然後利用介面物件作為形式引數,將不同型別的物件傳入並呼叫相關的函式,實現多型。介面可以進行巢狀實現,通過大介面包含小介面。

型別強度

強型別和弱型別的區別沒有權威的定義。大多數早期關於強型別和弱型別的討論可以概括為靜態型別和動態型別之間的區別。

但流行的說法是強型別傾向於不容忍隱式型別轉換,而弱型別傾向於容忍隱式型別轉換。這樣,強型別語言通常是型別安全的,也就是說,它只能以允許的方式訪問它被授權訪問的記憶體。

程式語言:型別系統的本質

通常,動態型別語言傾向於與 Python、Ruby、Perl 或 Javascript 等解釋型語言相關聯,而靜態型別語言傾向於編譯型語言,例如 Golang、Java 或 C。

我總結了一個常見程式語言型別的分類圖,注意拆分的四個區域是分割槽,比如PHP和JS都是動態弱型別。

程式語言:型別系統的本質

靜態型別與動態型別

我們經常聽到“靜態與動態型別”這個問題,其實,兩者的區別在於型別檢查發生的時間。

程式語言:型別系統的本質

  1. 靜態型別系統在編譯時確定所有變數的型別,並在使用不正確的情況下丟擲異常。靜態型別系統,將執行時錯誤轉換成編譯時錯誤,能夠使程式碼更容易維護、適應性更強,對於大型應用程式,尤其如此。
  2. 而在動態型別中,型別繫結到值。檢查是在執行時進行的。動態型別系統在執行時確定變數型別,如果有錯誤則丟擲異常,如果沒有適當的處理,可能會導致程式崩潰。動態型別不會在編譯時施加任何型別約束。日常交流中有時會將動態型別叫作“鴨子型別”(duck typing),這個名稱來自俗語:“如果一種動物走起來像鴨子,叫起來像鴨子,那麼它就是一隻鴨子。”程式碼可按照需要自由使用一個變數,執行時將對變數應用型別。

靜態型別系統的早期型別錯誤報告保證了大規模應用程式開發的安全性,而動態型別系統的缺點是編譯時沒有型別檢查,程式不夠安全。只有大量的單元測試才能保證程式碼的健壯性。但是使用動態型別系統的程式,很容易編寫並且不需要花費很多時間來確保型別正確。所謂“魚和熊掌不可兼得”,這就是關於“效率”與“質量”的哲學問題了。

不過,現代型別檢查器具有強大的型別推斷演算法,使它們能夠確定變數或者函式的型別,而不需要我們顯式地寫出型別。

小結

  • 型別是一種資料分類,定義了可以對這類資料執行的操作、這類資料的意義以及允許取值的集合。
  • 型別系統是一組規則,為程式語言的元素分配並實施型別。
  • 型別限制了變數的取值範圍,所以在一些情況中,執行時錯誤就被轉換成了編譯時錯誤。
  • 不可變性是型別施加的一種資料屬性,保證了值在不應該發生變化時不會發生變化。
  • 可見性是另外一種型別級別的屬性,決定了哪些元件能訪問哪些資料。
  • 型別識別符號使得閱讀程式碼的人更容易理解程式碼。
  • 動態型別(或叫“鴨子型別”)在執行時決定型別。
  • 靜態型別在編譯時檢查型別,捕獲到原本有可能成為執行時錯誤的型別錯誤。
  • 型別系統的強度衡量的是該系統允許在型別之間進行多少隱式轉換。
  • 現代型別檢查器具有強大的型別推斷演算法,使它們能夠確定變數或者函式的型別,而不需要我們顯式地寫出型別。

2. 程式語言中的基本型別

本節介紹程式語言型別系統的特性,從基本型別開始,到函式型別、OOP、泛型程式設計和高階型別(如函子和單子)。

基本型別

常用的基本型別包括空型別、單元型別、布林型別、數值型別、字串型別、陣列型別和引用型別。

函式型別

“函式型別是型別系統在基本型別及其組合的基礎上發展的又一個階段。”

大部分現代程式語言都支援匿名函式,也稱為lambda。lambda與普通的函式類似,但是沒有名稱。每當我們需要使用一次性函式時,就會使用lambda。所謂一次性函式,是指我們只會引用這種函式一次,所以為其命名就成了多餘的工作。

lambda或匿名函式:lambda,也稱為匿名函式,是沒有名稱的函式定義。lambda通常用於一次性的、短期存在的處理,並像資料一樣被傳來傳去。

函式能夠接受其他函式作為實參,或者返回其他函式。接受一個或多個非函式實參並返回一個非函式型別的“標準”函式也稱為一階函式,或普通函式。接受一個一階函式作為實參或者返回一個一階函式的函式稱為二階函式。

我們可以繼續往後推,稱接受二階函式作為實參或者返回二階函式的函式為三階函式,但是在實際運用中,我們只是簡單地把所有接受或返回其他函式的函式稱為高階函式。

我們可以使用“函式型別”簡化策略模式。如果一個變數是函式型別(命名函式型別),並在使用其他型別的值的地方能夠使用函式,就可以簡化一些常用結構的實現,並把常用演算法抽象為庫函式。

泛型程式設計

泛型程式設計支援強大的解耦合以及程式碼重用。
泛型資料結構把資料的佈局與資料本身分隔開。迭代器支援遍歷這些資料結構。泛型演算法(例如,最經典的 sort 排序演算法 )是能夠在不同資料型別上重用的演算法。迭代器(Iterator)用作資料結構和演算法之間的介面,並且能夠根據迭代器的能力啟用不同的演算法。

例如, 一個泛型函式 :

(value:T) => T

它的型別引數是T。當為T指定了實際型別時,就建立了具體函式。具體類圖示例如下:

程式語言:型別系統的本質

再例如,一個泛型二叉樹。

程式語言:型別系統的本質

泛型高階函式 map() , filter() , reduce() 程式碼和示意圖如下。

  • map()
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
    return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}

程式語言:型別系統的本質

  • filter()

    public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
      return filterTo(ArrayList<T>(), predicate)
    }
  • reduce()

    public inline fun <S, T : S> Iterable<T>.reduce(operation: (acc: S, T) -> S): S {
      val iterator = this.iterator()
      if (!iterator.hasNext()) throw UnsupportedOperationException("Empty collection can't be reduced.")
      var accumulator: S = iterator.next()
      while (iterator.hasNext()) {
          accumulator = operation(accumulator, iterator.next())
      }
      return accumulator
    }

高階型別

高階型別與高階函式類似,代表具有另外一個型別引數的型別引數。例如,T<U>或Box<T<U>>有一個型別引數T,後者又有一個型別引數U。

正如高階函式是接受其他函式作為實參的函式,高階型別是接受其他種類作為實參的種類(引數化的型別建構函式)。

型別建構函式

在型別系統中,我們可以認為型別建構函式是返回型別的一個函式。我們不需要自己實現型別建構函式,因為這是型別系統在內部看待型別的方式。

每個型別都有一個建構函式。一些建構函式很簡單。例如,可以把型別number的建構函式看作不接受實參、返回number型別的一個函式,也就是() -> [number type]。

對於泛型,情況則有了變化。泛型型別,如T[],需要一個實際的型別引數來生成一個具體型別。其型別建構函式為(T) -> [T[] type]。例如,當T是number時,我們得到的型別是一個數值陣列number[],而當T是string時,得到的型別是一個字串陣列string[]。這種建構函式也稱為“種類”,即型別T[]的種類。

高階型別與高階函式一樣,將抽象程度提高了一個級別。在這裡,我們的型別建構函式可以接受另外一個型別建構函式作為實參。

空型別(nil / null pointer)

null vs 億萬美元的錯誤

著名的電腦科學家、圖靈獎獲得者託尼·霍爾爵士稱null引用是他犯下的“億萬美元錯誤”。他說過:
“1965年我發明了null引用。現在我把它叫作我犯下的億萬美元錯誤。當時,我在一種面嚮物件語言中為引用設計第一個全面的型別系統。我的目標是讓編譯器來自動執行檢查,確保所有使用引用的地方都是絕對安全的。但是,我沒能抗拒誘惑,在型別系統中新增了null引用,這只是因為實現null引用太簡單了。這導致了難以計數的錯誤、漏洞和系統崩潰,在過去四十年中可能造成了數億美元的損失。”
幾十年來發生了非常多的null解引用錯誤,所以現在很明顯,最好不要讓null(即沒有值)自身成為某個型別的一個有效的值。

接下來,我們介紹通過組合現有型別來建立新型別的多種方式。

3. 型別組合

本節介紹型別組合,即如何把型別組合起來,從而定義新型別的各種方式。
組合型別,是將型別放到一起,使結果型別的值由每個成員型別的值組成。

代數資料型別(Algebraic Data Type,ADT)

ADT是在型別系統中組合型別的方式。ADT提供了兩種組合型別的方式:

  1. 乘積型別
  2. 和型別

乘積型別

乘積型別就是本章所稱的複合型別。元組和記錄是乘積型別,因為它們的值是各構成型別的乘積。型別A = {a1, a2}(型別A的可能值為a1和a2)和B = {b1, b2}(型別B的可能值為b1和b2)組合成為元素型別<A, B>時,結果為A×B = {(a1, b1), (a1, b2), (a2, b1), (a2, b2)}。

元組和記錄型別都是乘積型別的例子。另外,記錄允許我們為每個成員分配有意義的名稱。

和型別

和型別,是將多個其他型別組合成為一個新型別,它儲存任何一個構成型別的值。型別A、B和C的和型別可以寫作A + B + C,它包含A的一個值,或者B的一個值,或者C的一個值。

可選型別和變體型別是“和型別”的例子。

4. OOP 與介面型別

本節介紹物件導向程式設計的關鍵元素,以及什麼時候使用每種元素,並討論介面、繼承、組合和混入。

OOP: 物件導向程式設計

物件導向程式設計(Object-Oriented Programming,OOP):OOP是基於物件的概念的一種程式設計正規化,物件可以包含資料和程式碼。資料是物件的狀態,程式碼是一個或多個方法,也叫作“訊息”。在物件導向系統中,通過使用其他物件的方法,物件之間可以“對話”或者傳送訊息。

OOP的兩個關鍵特徵是封裝和繼承。封裝允許隱藏資料和方法,而繼承則使用額外的資料和程式碼擴充套件一個型別。

封裝出現在多個層次,例如,服務將其API公開為介面,模組匯出其介面並隱藏實現細節,類只公開公有成員,等等。與巢狀娃娃一樣,程式碼兩部分之間的關係越弱,共享的資訊就越少。這樣一來,元件對其內部管理的資料能夠做出的保證就得到了強化,因為如果不經過該元件的介面,外部程式碼將無法修改這些資料。

一個“引數化表示式”的物件導向繼承體系的例子。類圖如下。

程式語言:型別系統的本質

這裡的表示式,可以通過eval() 方法,計算得到一個數字,二元表示式有兩個運算元,加法和乘法表示式通過把運算元相加或相乘來計算結果。

我們可以把表示式建模為具有eval()方法的IExpression介面。之所以能將其建模為介面,是因為它不儲存任何狀態。

接下來,我們實現一個BinaryExpression抽象類,在其中儲存兩個運算元。但是,我們讓eval()是抽象方法,從而要求派生類實現該方法。SumExpression和MulExpression都從BinaryExpression繼承兩個運算元,並提供它們自己的eval()實現。程式碼如下。

程式語言:型別系統的本質

介面型別: 抽象類和介面

我們使用介面來指定契約。介面可被擴充套件和組合。

介面或契約:介面(或契約)描述了實現該介面的任何物件都理解的一組訊息。訊息是方法,包括名稱、實參和返回型別。介面沒有任何狀態。與現實世界的契約(它們是書面協議)一樣,介面也相當於書面協議,規定了實現者將提供什麼。

介面又稱為動態資料型別,在進行介面使用的的時候,會將介面對位置的動態型別改為所指向的型別
會將動態值改成所指向型別的結構體。

5. 函式型別

本節介紹函式型別,以及當我們獲得了建立函式變數的能力後能夠做些什麼,還展示實現策略模式和狀態機的不同方式,並介紹基本的map()、filter()和reduce()演算法。

什麼是函式型別?

函式型別或簽名

函式的實參集合加上返回型別稱為函式型別(或函式簽名)。

程式語言:型別系統的本質

函式型別本質上跟介面型別的範疇相同,都是一組對映規則(介面協議),不繫結具體的實現(class,struct)。

函式的實參型別和返回型別決定了函式的型別。如果兩個函式接受相同的實參,並返回相同的型別,那麼它們具有相同的型別。實參集合加上返回型別也稱為函式的簽名。

一等函式

將函式賦值給變數,並像處理型別系統中的其他值一樣處理它們,就得到了所謂的一等函式。這意味著語言將函式視為“一等公民”,賦予它們與其他值相同的權利:它們有型別,可被賦值給變數,可作為實參傳遞,可被檢查是否有效,以及在相容的情況下可被轉換為其他型別。

“一等函式”程式語言,可以把函式賦值給變數、作為實參傳遞以及像使用其他值一樣使用,這使得程式碼的表現力更強。

一個簡單的策略模式

策略設計模式

策略模式是最常用的設計模式之一。策略設計模式是一種行為軟體設計模式,允許在執行時從一組演算法中選擇某個演算法。它把演算法與使用演算法的元件解耦,從而提高了整個系統的靈活性。下圖展示了這種模式。

程式語言:型別系統的本質

策略模式由IStrategy介面、ConcreteStrategy1和ConcreteStrategy2實現以及通過IStrategy介面使用演算法的Context構成。程式碼如下:

程式語言:型別系統的本質

函式式策略

我們可以把WashingStrategy定義為一個型別,代表接受Car作為實參並返回void的一個函式。然後,我們可以把兩種洗車服務實現為兩個函式,standardWash()和premiumWash(),它們都接受Car作為實參,並返回void。CarWash可以選擇其中一個函式應用到一輛給定的汽車,如下圖。

程式語言:型別系統的本質

策略模式由Context構成,它使用兩個函式之一:concreteStrategy1()或concreteStrategy2() 。程式碼如下:

程式語言:型別系統的本質

一個簡單的裝飾器模式

裝飾器模式是一個簡單的行為軟體設計模式,可擴充套件物件的行為,而不必修改物件的類。裝飾的物件可以執行其原始實現沒有提供的功能。裝飾器模式如圖所示。
程式語言:型別系統的本質

圖說明:裝飾器模式,一個IComponent介面,一個具體實現,即ConcreteComponent,以及使用額外行為來增強IComponent的Decorator。

一個單例邏輯的裝飾器

一個單例邏輯的裝飾器程式碼例項如下。

程式語言:型別系統的本質

用函式裝飾器來實現

下面我們來使用函式型別實現裝飾器模式。
首先,刪除IWidgetFactory介面,改為使用一個函式型別。該型別的函式不接受實參,返回一個Widget:() => Widget。

在之前使用IWidgetFactory並傳入WidgetFactor例項的地方,現在需要使用() => Widget型別的函式,並傳入makeWidget(),程式碼如下。

程式語言:型別系統的本質

我們使用了一種類似於上面的策略模式的技術:將函式作為實參,在需要的時候進行呼叫。但是,上面的 use10Widgets() 每次呼叫都會構造生成一個新的 Widget 例項。

接下來看如何新增單例行為。我們提供一個新函式singletonDecorator(),它接受一個WidgetFactory型別的函式,並返回另外一個WidgetFactory型別的函式。程式碼如下。

程式語言:型別系統的本質

現在,use10Widgets()不會構造10個Widget物件,而是會呼叫lambda,為所有呼叫重用相同的Widget例項。

小結

與策略模式一樣,物件導向方法和函式式方法實現了相同的裝飾器模式。

物件導向版本需要宣告一個介面(IWidgetFactory),該介面的至少一個實現(WidgetFactory),以及處理附加行為的一個裝飾器類。

與之相對,函式式實現只是宣告瞭工廠函式的型別(() => Widget),並使用兩個函式:一個工廠函式(makeWidget())和一個裝飾器函式(singletonDecorator())。

6. 函子和單子(Functor and Monad)

概述

函子和單子的概念來自範疇論。範疇論是數學的一個分支,研究的是由物件及這些物件之間的箭頭組成的結構。有了這些小構造塊,我們就可以建立函子和單子這樣的結構。我們不會深入討論細節,只是簡單說明一下。許多領域(如集合論,甚至型別系統)都可以用範疇論來表達。

函子(Functor)

"Talk is cheap, show me the code".

函子,就是資料型別 Functor,它有一個屬性值value和一個map方法。map方法可以處理value,並生成新的Functor例項。函子的程式碼如下:

class Functor<T> {
    private value:T;

    constructor(val:T){
        this.value = val
    }

    public map<U>(fn:(val:T)=>U){
        let rst = fn(this.value)
        return new Functor(rst)
    }
}

驗證一下Functor的應用例項,是否符合我們想要的資料型別?

new Functor(3)
    .map(d=>add(d))
    .map(d=>double(d.value))
    .map(d=>square(d.value)) // Functor { value: 256 }

這就是函子,一種受規則約束,含有值(value)和值的變形關係(函式map)的資料型別(容器)。 它是一種新的函式組合方式,可以鏈式呼叫,可以用於約束傳輸的資料結構,可以對映適配函式的輸出值與下一個函式輸入值,可以一定程度上避免函式執行的副作用。

函子的用途是什麼呢?這個問題需要從前面講過的函式組合(Function Composition)講起。

函式組合是一種把多個函式組合成新函式的方式,它解決了函式巢狀呼叫的問題,還提供了函式拆分組合的方式。

函式的函子

除了函子外,需要知道的是,還有函式的函子。給定一個有任意數量的實參且返回型別T的值的一個函式。

程式語言:型別系統的本質

函子在數學與函數語言程式設計中

在數學中,特別是範疇論,函子是範疇之間的對映(範疇間的同態)。由一範疇對映至其自身的函子稱之為“自函子”。

在函數語言程式設計裡,函子是最重要的資料型別,也是基本的運算單位和功能單位。Functor 是實現了 map() 函式並遵守一些特定規則的容器型別。

我們有一個泛型型別H,它包含某個型別T的0個、1個或更多個值,還有一個從T到U的函式。在本例中,T是一個空心圓,U是一個實心圓。map()函子從H<T>例項中拆包出T,應用函式,然後把結果放回到一個H<U>中。

程式語言:型別系統的本質

其實,上面的 map(transform: (T) -> R): List<R> 高階函式就是一個函子

函子:函子是執行對映操作的函式的推廣。對於任何泛型型別,以Box<T>為例,如果map()操作接受一個Box<T>和一個從T到U的函式作為實參,並得到一個Box<U>,那麼該map()就是一個函子。
函子定義(Functor Laws )
恆等定律:fmap id = id
組合定律: fmap (g . h) = (fmap g) . (fmap h)

函子很強大,但是大部分主流語言都沒有很好的方式來表達函子,因為函子的常規定義依賴於高階型別(不是“高階函式”,是“高階型別”)的概念。

Functor 函子的程式碼實現示例
class Functor {
  // 建構函式,建立函子物件的時候接收任意型別的值,並把值賦給它的私有屬性 _value
  constructor(value) { 
    this._value = value
  }
 
  // 接收一個函式,處理值的變形並返回一個新的函子物件
  map (fn) {
    return new Functor(fn(this._value))
  }
}

let num1 = new Functor(3).map(val => val + 2)

// 輸出:Functor { _value: 5 }
console.log(num1)

let num2 = new Functor(3).map(val => val + 2).map(val => val * 2)

// 輸出:Functor { _value: 10 }
console.log(num2)

// 改變了值型別
let num3 = new Functor('webpack').map(val => `${val}-cli`).map(val => val.length)

// 輸出:Functor { _value: 11 }
console.log(num3)

單子 (Monad Functor)

函子的value支援任何資料型別,當然也可以是函子。但是這樣會造成函子巢狀的問題。

Maybe.of(3).map(n => Maybe.of(n + 2)) // Maybe { value: Maybe { value: 5 } }

單子(Monad 函子)就是解決這個問題的。

Monad Functor 總是返回一個單層的函子,避免出現巢狀的情況。因為它有一個 flatMap 方法,如果生成了一個巢狀函子,它會取出後者的value,保證返回的是一個單層函子,避免出現巢狀的情況。
程式碼如下。

class Monad<T> exteds Functor<T>{
    static of<T>(val:T){
        return new Monad(val)
    }

    isNothing() {
        return this.value === null || this.value === undefined
    }

    public map<U>(fn:(val:T)=>U){
        if (this.isNothing()) return Monad.of(null)
        let rst = fn(this.value)
        return Monad.of(rst)
    }

    public join(){
        return this.value
    }

    public flatMap<U>(fn:(val:T)=>U){
        return this.map(fn).join()
    }
}

Monad.of(3).flatMap(val => Monad.of(val + 2)) // Monad { value: 5 }

通常講,Monad函子就是實現flatMap方法的Pointed函子。

Monad 由以下三個部分組成:

  1. 一個型別建構函式(M),可以構建出一元型別 M<T>。
  2. 一個型別轉換函式(return or unit),能夠把一個原始值裝進 M 中。

    unit(x) : T -> M T
  3. 一個組合函式 bind,能夠把 M 例項中的值取出來,放入一個函式 fn: T-> M<U> 中去執行,最終得到一個新的 M 例項。

    bind:  執行 fn: T  -> M<U> 

程式語言:型別系統的本質

除此之外,它還遵守一些規則:

  • 單位元規則,通常由 unit 函式去實現。
  • 結合律規則,通常由 bind 函式去實現。

程式碼例項:

class Monad {
  value = "";
  // 建構函式
  constructor(value) {
    this.value = value;
  }
  // unit,把值裝入 Monad 建構函式中
  unit(value) {
    this.value = value;
  }
  // bind,把值轉換成一個新的 Monad
  bind(fn) {
    return fn(this.value);
  }
}

// 滿足 x-> M(x) 格式的函式
function add1(x) {
  return new Monad(x + 1);
}
// 滿足 x-> M(x) 格式的函式
function square(x) {
  return new Monad(x * x);
}

// 接下來,我們就能進行鏈式呼叫了
const a = new Monad(2)
     .bind(square)
     .bind(add1);
     //...

console.log(a.value === 5); // true

上述程式碼就是一個最基本的 Monad,它將程式的多個步驟抽離成線性的流,通過 bind 方法對資料流進行加工處理,最終得到我們想要的結果。

範疇論中的函子

Warning:下文的內容偏數學理論,不感興趣的同學跳過即可。

原文:A monad is a monoid in the category of endofunctors (Philip Wadler)。
翻譯:Monad 是一個 自函子 範疇 上的 么半群” 。

這裡標註了 3 個重要的概念:自函子、範疇、么半群,這些都是數學知識,我們分開理解一下。

什麼是範疇?

任何事物都是物件,大量的物件結合起來就形成了集合,物件和物件之間存在一個或多個聯絡,任何一個聯絡就叫做態射。

一堆物件,以及物件之間的所有態射所構成的一種代數結構,便稱之為 範疇

什麼是函子?

我們將範疇與範疇之間的對映稱之為 函子。對映是一種特殊的態射,所以函子也是一種態射。

什麼是自函子?

自函子就是一個將範疇對映到自身的函子。

什麼是么半群 Monoid?

么半群是一個存在 單位元 的半群。

什麼是半群?

如果一個集合,滿足結合律,那麼就是一個半群

什麼是單位元?

單位元是集合裡的一種特別的元素,與該集合裡的二元運算有關。當單位元和其他元素結合時,並不會改變那些元素。如:

任何一個數 + 0 = 這個數本身。 那麼 0 就是單位元(加法單位元)
任何一個數 * 1 = 這個數本身。那麼 1 就是單位元(乘法單位元)

Ok,我們已經瞭解了所有應該掌握的專業術語,那就簡單串解一下這段解釋吧:

一個 自函子 範疇 上的 么半群 ,可以理解為:

在一個滿足結合律和單位元規則的集合中,存在一個對映關係,這個對映關係可以把集合中的元素對映成當前集合自身的元素。

小結

在不涉及範疇論的情況下,針對函子和單子,做一個簡單的小結。

Functor 和 monad 都為包裝輸入提供了一些工具,返回包裝後的輸出。

Functor = unit + map(即工具)

在哪裡,

unit= 接受原始輸入並將其包裝在一個小上下文中的東西。

map= 將函式作為輸入的工具,將其應用於包裝器中的原始值,並返回包裝後的結果。

示例:讓我們定義一個將整數加倍的函式

// doubleMe :: Int a -> Int b
const doubleMe = a => 2 * a;
Maybe(2).map(doubleMe) // Maybe(4)
Monad = unit + flatMap (或繫結或鏈)

flatMapmap=顧名思義,就是將 扁平化的工具。


番外篇:自組織理論與複雜軟體系統

自組織理論是20世紀60年代末期開始建立並發展起來的一種系統理論。它的研究物件主要是複雜自組織系統(生命系統、社會系統)的形成和發展機制問題,即在一定條件下,系統是如何自動地由無序走向有序,由低階有序走向高階有序的。

程式語言:型別系統的本質

自組織是現代非線性科學和非平衡態熱力學的最令人驚異的發現之一。基於對物種起源、生物進化和社會發展等過程的深入觀察和研究,一些新興的橫斷學科從不同的角度對自組織的概念給予了界說。

從系統論的觀點來說,自組織是指一個系統在內在機制的驅動下,自行從簡單向複雜、從粗糙向細緻方向發展,不斷地提高自身的複雜度和精細度的過程;

從熱力學的觀點來說,自組織是指一個系統通過與外界交換物質、能量和資訊,而不斷地降低自身的熵含量,提高其有序度的過程;

從統計力學的觀點來說,自組織是指一個系統自發地從最可幾狀態向機率較低的方向遷移的過程;

從進化論的觀點來說,自組織是指一個系統在遺傳、變異和優勝劣汰機制的作用下,其組織結構和執行模式不斷地自我完善,從而不斷提高其對於環境的適應能力的過程。C. R. Darwin的生物進化論的最大功績就是排除了外因的主宰作用,首次從內在機制上、從一個自組織的發展過程中來解釋物種的起源和生物的進化。

什麼是複雜?

“複雜” ( Complexity )定義為由於元件之間的依賴關係、關係和互動,而難以對其行為建模的任何系統。更通俗地說,複雜系統的“整體”大於“部分”之和。也就是說,如果不檢視單個元件以及它們如何相互作用,就無法理解其整體行為的系統,同時也無法通過僅檢視單個元件而忽略系統影響來理解系統的整體行為。

程式語言:型別系統的本質

隨著軟體系統的擴充套件,它變得足夠大,以至於工作部件的數量,加上對其進行更改的工作程式設計師的數量,使得系統的行為非常難以推理。

程式語言:型別系統的本質

這種複雜性因許多組織向微服務架構的轉變而加劇,例如所謂的“死星”架構,其中圓圈圓周上的每個點代表一個微服務,服務之間的線代表它們的互動。

參考資料

相關文章