跳轉至內容

Haskell/型別基礎 II

來自華夏公益教科書

在本章中,我們將展示 Haskell 如何處理數值型別,並介紹型別系統的一些重要特性。 不過,在深入文字之前,請暫停片刻,思考以下問題:(+) 函式的型別應該是什麼?[1]

數學對我們可以加在一起的數字型別幾乎沒有限制。 例如,考慮 (兩個自然數)、(一個負整數和一個有理數)或 (一個有理數和一個無理數)。 所有這些都是有效的。 事實上,任何兩個實數都可以加在一起。 為了以最簡單的方式儘可能地捕捉這種通用性,我們需要在 Haskell 中使用通用的 Number 型別,這樣 (+) 的簽名就簡單地為

(+) :: Number -> Number -> Number

然而,這種設計與計算機執行算術的方式很不相符。 雖然計算機可以將整數作為記憶體中的一系列二進位制數字來處理,但這種方法不適用於實數,[2] 因此需要更復雜的編碼來處理它們:浮點數。 雖然浮點數提供了一種處理一般實數的合理方法,但它也有一些不便之處(最值得注意的是精度損失),這使得使用更簡單的編碼處理整數值變得更有價值。 所以,我們至少有兩種不同的儲存數字的方法:一種用於整數,另一種用於一般實數。 每個方法都應該對應不同的 Haskell 型別。 此外,計算機只有在兩個數字格式相同的情況下才能執行像 (+) 這樣的運算。

所以,擁有一個通用的 Number 型別就更不用說了——似乎我們甚至不能讓 (+) 混合整數和浮點數。 然而,Haskell 可以至少使用相同的 (+) 函式來處理整數或浮點數。 在 GHCi 中自己驗證一下

Prelude> 3 + 4
7
Prelude> 4.34 + 3.12
7.46

在討論列表和元組時,我們看到,如果函式是多型的,它們可以接受不同型別的引數。 本著這種精神,這裡有一個可能的 (+) 型別簽名,它將說明上述事實

(+) :: a -> a -> a

有了這個型別簽名,(+) 將接受兩個相同型別 a 的引數(可以是整數或浮點數),並計算出一個型別為 a 的結果(只要兩個引數型別相同即可)。 但是,這個型別簽名表示任何型別,我們知道我們不能對兩個 Bool 值或兩個 Char 值使用 (+)。 新增兩個字母或兩個真值是什麼意思? 所以,(+)實際型別簽名使用了一種語言特性,允許我們表達語義限制,即 a 可以是任何型別,只要它是數字型別

(+) :: (Num a) => a -> a -> a

Num 是一個型別類——一組型別——它包含所有被視為數字的型別。 [3] 簽名中的 (Num a) => 部分將 a 限制為數字型別——或者,用 Haskell 的術語來說,是 Num例項

數值型別

[編輯 | 編輯原始碼]

那麼,實際的數字型別有哪些(也就是說,簽名中的 a 可以代表的 Num 例項)? 最重要的數值型別是 IntIntegerDouble

  • Int 對應於大多數語言中常見的普通整數型別。 它具有固定的最大值和最小值,這些值取決於計算機的處理器。 (在 32 位機器中,範圍從 -2147483648 到 2147483647)。
  • Integer 也用於整數,但它支援任意大的值——以犧牲一些效率為代價。
  • Double 是雙精度浮點數型別,在絕大多數情況下,它是實數的良好選擇。 (Haskell 還有 Float,它是 Double 的單精度對應物,通常由於精度進一步損失而不太有吸引力。)

還有其他幾種數值型別可用,但這些型別涵蓋了日常任務中的大多數數值型別。

多型推測

[編輯 | 編輯原始碼]

如果你已經仔細閱讀了到目前為止的內容,你就知道我們不需要總是指定型別,因為編譯器可以推斷型別。 你還知道,當函式需要匹配型別時,我們不能混合型別。 將此與我們對數字的新理解結合起來,瞭解 Haskell 如何處理像這樣的基本算術

Prelude> (-7) + 5.12
-1.88

這似乎添加了兩種不同型別的數字——一個整數和一個非整數。 讓我們看看我們輸入的數字的實際型別是什麼

Prelude> :t (-7)
(-7) :: (Num a) => a

所以,(-7) 不是 Int 也不是 Integer! 相反,它是一個多型的值,可以“變形”為任何數字型別。 現在,讓我們看一下另一個數字

Prelude> :t 5.12
5.12 :: (Fractional t) => t

5.12 也是一個多型的值,但它是 Fractional 類的值,Fractional 類是 Num 的子集(每個 Fractional 都是 Num,但並非每個 Num 都是 Fractional;例如,IntInteger 不是 Fractional)。

當 Haskell 程式計算 (-7) + 5.12 時,它必須為數字確定一個實際的匹配型別。 型別推斷考慮了類規範:(-7) 可以是任何 Num,但 5.12 有額外的限制,所以它是限制因素。 在沒有其他限制的情況下,5.12 將假定 Double 的預設 Fractional 型別,因此 (-7) 也將成為 Double。 然後,加法運算將正常進行,並返回一個 Double[4]

以下測試將讓你更好地瞭解這個過程。 在一個原始檔中,定義

x = 2

然後在 GHCi 中載入檔案並檢查 x 的型別。 然後,將檔案更改為新增一個 y 變數,

x = 2
y = x + 3

重新載入它並檢查 xy 的型別。 最後,將 y 修改為

x = 2
y = x + 3.1

看看兩個變數的型別會發生什麼。

單態問題

[編輯 | 編輯原始碼]

數值型別和類的複雜性有時會導致一些問題。例如,考慮常見的除法運算子(/)。它具有以下型別簽名

(/) :: (Fractional a) => a -> a -> a

a限制為分數型別是必須的,因為兩個整數的除法通常會導致非整數。然而,我們仍然可以寫類似的東西

Prelude> 4 / 3
1.3333333333333333

因為字面量43是多型值,因此在(/)的支配下,它們假設型別Double。然而,假設我們想要將一個數字除以列表中的元素數量。[5] 很明顯應該使用length函式,它接受一個列表並返回其中的元素數量

Prelude> length [1,2,3]
3
Prelude> 4 / length [1,2,3]

不幸的是,它會爆炸

<interactive>:1:0:
    No instance for (Fractional Int)
      arising from a use of `/' at <interactive>:1:0-17
    Possible fix: add an instance declaration for (Fractional Int)
    In the expression: 4 / length [1, 2, 3]
    In the definition of `it': it = 4 / length [1, 2, 3]

像往常一樣,可以透過檢視length的型別簽名來理解問題

length :: (Foldable t) => t a -> Int

現在,讓我們關注length結果的型別。它是Int;結果不是多型的。由於Int不是Fractional,Haskell不會讓我們將其與(/)一起使用。

為了解決這個問題,我們有一個特殊的函式。在繼續文字之前,嘗試根據名稱和簽名猜測它的功能

fromIntegral :: (Integral a, Num b) => a -> b

fromIntegral接受某個Integral型別的引數(如IntInteger),並將其轉換為多型值。透過將其與length結合,我們可以使列表的長度符合(/)的簽名

Prelude> 4 / fromIntegral (length [1,2,3])
1.3333333333333333

在某些方面,這個問題很煩人且乏味,但它是嚴格處理數字的必然結果。在Haskell中,如果你定義了一個帶有Int引數的函式,它永遠不會轉換為IntegerDouble,除非你明確使用fromIntegral之類的函式。作為其完善的型別系統的直接結果,Haskell 在處理數字方面具有令人驚訝的多樣化類別和函式。

超出數字的類

[編輯 | 編輯原始碼]

Haskell 擁有超越算術的型別類。例如,(==)的型別簽名是

(==) :: (Eq a) => a -> a -> Bool

(+)(/)類似,(==)是一個多型函式。它比較兩個相同型別的的值,這些值必須屬於Eq類,並返回一個BoolEq只是用於可以比較相等性的值的型別的類,它包含所有基本非函式型別。 [6]

我們已經忽略的一個完全不同的型別類示例出現在length的型別中。鑑於length接受一個列表並返回一個Int,我們可能期望它的型別為

length :: [a] -> Int

然而,實際型別有點複雜

length :: (Foldable t) => t a -> Int

除了列表之外,還有其他型別的結構可以用來以不同的方式對值進行分組。許多這些結構(以及列表本身)屬於一個稱為Foldable的型別類。length的型別簽名告訴我們,它不僅適用於列表,也適用於所有其他Foldable結構。在本書的後面,我們將看到這種結構的示例,並詳細討論Foldable。在此之前,每當你在型別簽名中看到類似Foldable t => t a的內容時,可以隨意將其在腦海中替換為[a]

型別類為型別系統增添了巨大的力量。我們將在後面回到這個主題,看看如何在自定義方式中使用它們。

注意

  1. 如果你按照我們在“型別基礎”中的建議,你可能已經透過使用:t測試看到了相當奇特的答案...... 如果是這樣,請將以下分析視為理解該簽名的含義的途徑。
  2. 除了其他問題,在任何兩個實數之間,有不可數的實數——無論我們做什麼,這個事實都無法直接對映到記憶體中的表示形式。
  3. 這是一個寬鬆的定義,但在我們更詳細地討論型別類之前足夠了。
  4. 對於經驗豐富的程式設計師:這似乎具有與 C(以及許多其他語言)中的程式使用隱式轉換(其中整數字面量被靜默轉換為雙精度浮點數)相同的效果。然而,在 C 中,轉換是在你的背後完成的,而在 Haskell 中,只有當變數/字面量是多型值時才會發生。這個區別很快就會變得更清楚,當我們展示一個反例時。
  5. 一個合理的場景——想想計算列表中值的平均值。
  6. 比較兩個函式的相等性被認為是棘手的
華夏公益教科書