跳轉到內容

標準 ML 程式設計/型別

來自華夏公益教科書

標準 ML 具有特別強大的靜態型別系統。與許多語言不同,它沒有子型別(“is-a”關係)或型別之間的隱式轉換。

靜態型別

[編輯 | 編輯原始碼]

在內部,計算機中的所有資料都由(二進位制數字,要麼是 0 要麼是 1)組成,這些位被分組為位元組(最小的可定址位組 - 在所有現代機器中都是 8 位),通常又進一步分組為(CPU 可以作為一個單元處理的位元組組 - 如今通常是 4 個位元組,即 32 位,或 8 個位元組,即 64 位)。

然而,程式旨在對位、位元組和字本身進行操作的情況相當罕見。通常程式需要對人類可理解的抽象資料型別進行操作:例如整數,或實數,或字元字串 - 或者所有這些。程式主要透過位和位元組進行操作,因為這些是計算機實現或表示這些資料型別的機制。程式語言解決這種差異的方法有很多種

  • 一種語言可以不提供任何資料型別抽象,並要求程式設計師程式碼顯式地處理位和位元組。它可以提供旨在用於某些抽象資料型別的操作(例如 32 位整數的加法),但完全由程式程式碼來確保它只將這些操作用於表示這些型別的位元組。這種方法是組合語言的特徵,在一定程度上也是 C 語言的特徵。
  • 一種語言可以提供只有一種資料型別抽象,供所有程式設計師程式碼使用。這種方法是 shell 指令碼語言的特徵,這些語言通常幾乎完全操作字元字串。
  • 一種語言可以為每個資料片段(每個)分配一個型別,並將該型別分配與值本身一起儲存。當嘗試對不合適資料型別的值進行操作時,語言要麼自動將其轉換為合適的型別(例如,將整數值提升為等效的實數型別的值),要麼發出錯誤。這種方法,其中型別資訊僅在執行時存在,被稱為動態型別,它是 Lisp、Python、Ruby 等語言的特徵。
  • 一種語言可以為每個程式碼片段(每個表示式)分配一個型別。如果一段程式碼對不合適資料型別的表示式應用操作,編譯器要麼推斷出執行型別轉換的附加程式碼,要麼發出錯誤。這種方法,其中型別資訊僅在編譯時存在,被稱為靜態型別,它是標準 ML、OCaml、Haskell 等語言的特徵。

大多數語言並不嚴格地遵循上述方法之一,而是使用多種方法的元素。然而,標準 ML 的型別系統幾乎完全使用靜態型別。這意味著一個型別錯誤的程式甚至不會編譯。(ML 程式設計師認為這是一件好事,因為它允許在編譯時捕獲許多程式設計錯誤,而動態型別的語言只會在執行時捕獲這些錯誤。)在標準 ML 支援動態型別的程度上,它是在靜態型別框架內的。

強型別

[編輯 | 編輯原始碼]

術語強型別在很多不同的情況下使用(參見維基百科文章“強型別程式語言”以獲得一個相當完整的列表);儘管如此,可以說標準 ML 型別系統在幾乎所有定義下都提供強型別。SML 程式中的每個表示式在編譯時都有一個特定型別,並且它在執行時的型別永遠不會違反這一點。所有型別轉換都是顯式的(使用諸如real之類的函式,該函式接受一個整數並返回一個等效的實數),並且採取有意義的轉換形式,而不是僅僅重新解釋原始位。

基本型別

[編輯 | 編輯原始碼]

有許多可以被認為是“內建”的基本型別,首先是因為它們是由標準 ML 基礎庫預定義的(因此標準 ML 程式不需要定義它們),其次是因為語言為它們提供了文字表示法,例如34(這是一個整數),或"34"(這是一個字元字串)。一些最常用的型別是

  • int(整數),例如3~12。(注意,波浪號~用於負數。)
  • real(浮點數),例如4.2~6.4
    • 標準 ML 不會隱式地將整數提升為浮點數;因此,諸如2 + 5.67之類的表示式是無效的。它必須寫成2.0 + 5.67,或real(2) + 5.67(使用real函式將2轉換為2.0)。
  • string(字元字串),例如"this is a string"""。(後者是空字串,它不包含任何字元。)
  • char(一個字元),例如#"y"#"\n"。(後者表示換行符,ASCII 碼 10。)
  • bool(布林值),它要麼是true要麼是false

以下程式碼片段聲明瞭兩個變數

val n : int = 66
val x : real = ~23.0

在這個程式碼片段之後,n 的型別為int,值為 66;x 的型別為real,值為 -23。注意,與某些語言不同,這些變數繫結是永久的;這個n將始終具有值 66(雖然程式中其他地方可能存在其他不相關的變數,也具有名稱n,這些變數可以具有完全不同的型別和值)。

型別推斷

[編輯 | 編輯原始碼]

在上面的例子中,我們提供了顯式的型別註釋來告知編譯器變數的型別。但是,這種型別註釋是可選的,很少有必要。在大多數情況下,編譯器會簡單地推斷出正確的型別。因此,以下兩個程式碼片段是等效的

val s : string = "example"
val b : bool = true
val s = "example"
val b = true

在下面的例子中,我們偶爾會提供型別註釋作為一種文件形式;這有一個很好的屬性,即文件的正確性是可以強制執行的,因為編譯器會報告任何型別註釋不正確的錯誤。在其他情況下,我們可能會包含普通的註釋,形式為(* this is a comment *);這是一種更靈活的文件形式,因為它可以包含任何型別的文字(而不僅僅是型別資訊),但當然它的準確性無法由編譯器強制執行。

型別,包括上面的基本型別,可以以多種方式組合。一種方式是使用元組,它是一個有序的值集;例如,表示式(1, 2)的型別為int * int,而("foo", false)的型別為string * bool。還有一個 0 元組(),其型別用unit表示。然而,沒有 1 元組;或者更確切地說,(例如)(1)1之間沒有區別,兩者都具有型別int

元組可以巢狀,並且(與某些數學形式主義不同),(1,2,3)((1,2),3)(1,(2,3))都不同。第一個的型別為int * int * int;後兩個的型別分別為(int * int) * intint * (int * int)

表示式 型別 註釋
() unit 0 元組
(3, "yes", "yes") int * string * string 3 元組(有序三元組)
(3, "yes", true) int * string * bool 3 元組(有序三元組)
((1, 2), 3) (int * int) * int 2 元組(有序對),其第一個元素是另一個 2 元組

以下程式碼片段聲明瞭四個變數。右側顯示了執行後的環境。請注意使用模式匹配將型別和值分配給ab,以及在分配also_a時使用投影。這使得符號表示非常方便。

val pair = ("a", "b")
val (a, b) = pair
val also_a = #1 pair
識別符號 型別
("a", "b") string * string
a "a" string
b "b" string
also_a "a" string

記錄

[edit | edit source]

另一種組合值的方式是使用記錄。記錄很像元組,區別在於記錄的元件是命名的,而不是有序的;例如,{ a = 5.0, b = "five" } 的型別是 { a : real, b : string }(與 { b : string, a : real } 型別相同)。

事實上,在 Standard ML 中,元組只是記錄的一種特殊情況;例如,int * string * bool 型別與 { 1 : int, 2 : string, 3 : bool } 型別相同。

函式

[edit | edit source]

函式接受一個值,通常會返回一個值。例如,我們在引言中定義的factorial函式

fun factorial n =  if n < 1  then 1  else n * factorial (n - 1)

的型別是 int -> int,意味著它接受一個 int 型別的值,並返回一個 int 型別的值。

即使函式在執行時不返回值——例如,如果它引發異常,或者如果它進入無限迴圈——它在編譯時仍然具有靜態返回型別。

與其他型別一樣,我們可以提供顯式的型別註釋

fun factorial (n : int) : int = if n < 1  then 1  else n * factorial (n - 1)

如果需要。

元組作為引數

[edit | edit source]

雖然 Standard ML 函式必須接受恰好一個值(而不是接受引數列表),但元組和前面提到的模式匹配完全沒有限制。例如,這段程式碼

fun sum (a, b) = a + b
fun average pair = sum pair div 2

建立了兩個型別為 int * int -> int 的函式。這種方法也可以用來建立中綴運算子。這段程式碼

infix averaged_with
fun a averaged_with b = average (a, b)
val five = 3 averaged_with 7

averaged_with 建立為中綴運算子,然後將其建立為 int * int -> int 型別的函式。

由於元組是一種普通型別,因此函式也可以返回一個元組。在這段程式碼中

fun pair (n : int) = (n, n)

pair 的型別是 int -> int * int

多型資料型別

[edit | edit source]

在這段程式碼中

fun pair x = (x, x)

編譯器無法推斷出pair的特定型別;它可能是int -> int * intreal -> real * real,甚至(int * real -> string) -> (int * real -> string) * (int * real -> string)。幸運的是,它不需要推斷;它可以簡單地為其分配多型型別'a -> 'a * 'a,其中'a(讀作“alpha”)是一個型別變數,表示任何可能的型別。在上述定義之後,pair 3pair "x"都是有效的,分別產生(3, 3)("x", "x")。函式甚至可以依賴多個型別變數;在這個片段中

fun swap (x, y) = (y, x)

swap 的型別是 'a * 'b -> 'b * 'a。所有或部分內容可以顯式指示

fun swap (x : 'a, y : 'b) : 'b * 'a = (y, x)

函式作為引數,以及柯里化函式

[edit | edit source]

函式可以接受另一個函式作為引數。例如,考慮這段程式碼

fun pair_map (f, (x, y)) = (f x, f y)

這將建立一個型別為 ('a -> 'b) * ('a * 'a) -> ('b * 'b) 的函式 pair_map,它將第一個引數(函式)應用於第二個引數(對)的每個元素,並返回結果對。

相反,函式可以返回一個函式。在上面,我們看到了建立二元函式的一種方法:接受一個二元組。另一種方法稱為柯里化,就是隻接受第一個引數,然後返回一個接受第二個引數的函式

fun sum i j = i + j
val add_three = sum 3
val five = add_three 2
val ten = sum 5 5

這將建立一個型別為 int -> int -> int(意思是 int -> (int -> int))的函式 sum,一個型別為 int -> int 的函式 add_three,它返回其引數加三,以及整數 fiveten

型別宣告

[edit | edit source]

type 關鍵字可以用來建立現有資料型別的同義詞。例如,這段程式碼

type int_pair = int * int

為資料型別 int * int 建立了同義詞 int_pair。在建立該同義詞後,像這樣的宣告

fun swap_int_pair ((i,j) : int_pair) = (j,i)

與像這樣的宣告完全等效

fun swap_int_pair (i : int, j : int) = (j,i)

正如我們將看到的,這在模組化程式設計中非常有用,當建立結構以匹配給定的簽名時。

資料型別宣告

[edit | edit source]

datatype 關鍵字可以用來宣告新的資料型別。例如,這段程式碼

datatype int_or_string = INT of int | STRING of string | NEITHER

建立一個全新的資料型別 int_or_string,以及新的建構函式(一種特殊的函式或值)INTSTRINGNEITHER;該型別中的每個值要麼是一個帶有整數的 INT,要麼是一個帶有字串的 STRING,要麼是一個 NEITHER。然後我們可以寫

val i = INT 3
val s = STRING "qq"
val n = NEITHER
val INT j = i

其中最後一個宣告使用模式匹配功能將j繫結到整數 3。

從概念上講,這些型別類似於 C++ 等語言的列舉或聯合,但它們是完全型別安全的,因為編譯器會將 int_or_string 型別與所有其他型別區分開來,並且在執行時,值的建構函式將可用以區分型別的不同變體(不同的分支/分支/備選方案)。

這些資料型別可以是遞迴的

datatype int_list = EMPTY | INT_LIST of int * int_list

建立一個新的型別 int_list,其中該型別的每個值要麼是 EMPTY(空列表),要麼是整數與另一個 int_list 的連線。

這些資料型別,像函式一樣,可以是多型的

datatype 'a pair = PAIR of 'a * 'a

建立一個新的型別族 'a pair,例如 int pairstring pair 等。

列表

[edit | edit source]

Basis 提供的一種複雜資料型別是 list。這是一種遞迴的、多型的資料型別,定義等效於

datatype 'a list = nil | :: of 'a * 'a list

其中 :: 是一箇中綴運算子。因此,例如,3 :: 4 :: 5 :: nil 是一個包含三個整數的列表。列表是 ML 程式中最常見的資料型別之一,該語言還提供特殊符號 [3, 4, 5] 來生成它們。

Basis 還提供了一些用於處理列表的函式。其中一個是 length,它的型別是 'a list -> int,它計算列表的長度。它可以這樣定義

fun length nil = 0
|   length (_::t) = 1 + length t

另一個是 rev,型別為 'a list -> 'a list,它計算列表的反轉——例如,它將 [ "a", "b", "c" ] 對映到 [ "c", "b", "a" ]——並且可以這樣定義

local
  fun rev_helper (nil, ret) = ret
  |   rev_helper (h::t, ret) = rev_helper (t, h::ret)
in
  fun rev L = rev_helper (L, nil)
end

異常宣告

[edit | edit source]

內建型別 exn異常)類似於使用 datatype 宣告建立的型別:它具有變體,每個變體都有自己的建構函式。但是,與這些型別不同的是,可以使用 exception 宣告向該型別新增新的變體,以及新的建構函式。這段程式碼

exception StringException of string
val e = StringException "example"
val StringException s = e

建立一個型別為 string -> exn 的建構函式 StringException,一個型別為 exn 的變數 e,以及一個型別為 string 的變數 s(它的值為 "example")。

exn 型別在這方面是獨一無二的;程式中建立的型別不能以這種方式“新增”。

引用

[edit | edit source]

以上所有內容都描述了不可變的儲存形式;例如,一旦建立了一個元組,它就不能更改(變異)。在以下語句之後

val x = (3, 4)

無法將 x 的值更改為,例如,(3, 5)。(確實有可能建立一個全新的 x,它“覆蓋”了舊的 x,並且具有完全不同的值——甚至完全不同的型別——但這只是隱藏了舊的 x,並且不會影響任何其他引用它的值。)

初始的基礎還提供可變儲存,以引用的形式。在某些方面,引用表現得好像它們是像這樣定義的

datatype 'a ref = ref of 'a

例如,以下程式碼片段

val reference : int ref = ref 12
val ref (twelve : int) = reference

將變數reference繫結到包含值 12 的引用,並將變數twelve繫結到值 12。

但是,上面的程式碼片段只指定了引用的初始內容;內容可以更改。此程式碼片段

val () = reference := 13
val ref (thirteen : int) = reference

使用內建:=函式修改reference的內容。然後,它將新變數thirteen繫結到新值。

Standard ML 基礎庫定義了一個便利函式!,它檢索引用的內容。它可以這樣定義

fun ! (ref x) = x

並像這樣使用

val () = reference := 14
val fourteen = ! reference

等式型別

[編輯 | 編輯原始碼]

上面討論了多型型別的概念,我們已經看到了諸如'a * 'b -> 'b * 'a'a list之類的示例。在這些示例中,型別適用於所有可能的型別'a'b。還存在一種稍微更嚴格的多型型別,它僅限於等式型別,表示為''a''b等。這種型別多型由多型等式運算子=生成,它確定兩個值是否相等,並且具有型別''a * ''a -> bool。這意味著它的兩個運算元必須是相同型別,並且該型別必須是等式型別^

在上面討論的“基本型別”中——intrealstringcharbool——除了real之外,所有型別都是等式型別。這意味著3 = 3"3" = "3"#"3" = #"3"true = true都是求值為true的有效表示式;3 = 4"3" = "4"#"3" = #"4"true = false都是求值為false的有效表示式;3.0 = 3.03.0 = 4.0是編譯器將拒絕的無效表示式。造成這種情況的原因是 IEEE 浮點等式違反了 ML 中等式的一些要求。特別是,nan 不等於自身,因此關係不是自反的。

元組型別和記錄型別當且僅當每個元件型別都是等式型別時,才是等式型別;例如,int * string{ b : bool, c : char }unit是等式型別,而int * real{ x : real }則不是。

函式型別永遠不是等式型別,因為在一般情況下,無法確定兩個函式是否等效。

datatype宣告建立的型別是等式型別,如果每個變體要麼是空建構函式(沒有引數的建構函式),要麼是帶有等式型別引數的建構函式,並且(在多型型別的情況下)每個型別引數都是等式型別。例如,此程式碼片段

datatype suit = HEARTS | CLUBS | DIAMONDS | SPADES
datatype int_pair = INT_PAIR of int * int
datatype real_pair = REAL_PAIR of real * real
datatype 'a option = NONE | SOME of 'a

建立了等式型別suit(只有空建構函式)、int_pair(只有一個建構函式,它的引數是等式型別int * int)和int option(一個空建構函式,以及一個帶有等式型別引數int的建構函式),更不用說char optionstring option等等了。它還建立了等式型別real_pair(一個帶有非等式型別real * real引數的建構函式)、real option(int -> int) option等等。

如果可能,遞迴型別是等式型別,否則就不是。例如,考慮上面提到的多型型別'a list

datatype 'a list = nil | :: of 'a * 'a list

當然real list不是等式型別,因為它的型別引數不是等式型別,並且因為real * real list::引數的型別)不能是等式型別。但是,沒有理由int list不能是等式型別,所以它就是一個。請注意,這種等式型別化只在型別內;類似(nil : int list) = (nil : char list)的東西將是無效的,因為這兩個表示式是不同型別的,即使它們具有相同的值。但是,nil = nil(nil : int list) = nil都是有效的(並且都求值為true)。

可變型別'a ref是等式型別,即使它的元件型別不是。這是因為兩個引用被認為是相等的,如果它們標識相同的ref cell(即,相同的指標,由對ref建構函式的相同呼叫生成)。因此,例如,(ref 1) = (ref 1)(ref 1.0) = (ref 1.0)都是有效的——並且都求值為false,因為即使兩個引用碰巧指向相同的值,引用本身是分開的,並且每個引用都可以獨立於另一個進行變異。

但是,像這樣的程式碼片段

datatype 'a myref = myref of 'a ref

生成等式型別real myref,因為它的型別引數不是等式型別——即使它的唯一建構函式接受等式型別引數。如上所述,使用datatype宣告建立的多型型別只有在它們的型別引數是等式型別時才是等式型別,雖然內建引用不受此限制,但它們不能用於規避此限制。如果希望myref型別始終是等式型別,則必須使用此方法

datatype ''a myref = myref of ''a ref

這完全禁止real myref(因為等式型別變數''a不能表示非等式型別real)。

華夏公益教科書