Haskell/類與型別
在型別基礎II中,我們簡要地接觸了型別類,它是用於數字型別的機制。然而,正如我們當時暗示的那樣,類還有許多其他用途。
廣義地說,型別類的目的是確保某些操作可用於所選型別的值。例如,如果我們知道一個型別屬於(或者,用行話來說,例項化)類Fractional,那麼我們保證能夠使用其值執行實數除法。[1]
到目前為止,我們已經看到了現有型別類如何在簽名中出現,例如
(==) :: (Eq a) => a -> a -> Bool
現在是時候轉換視角了。首先,我們引用Prelude中Eq類的定義
class Eq a where
(==), (/=) :: a -> a -> Bool
-- Minimal complete definition:
-- (==) or (/=)
x /= y = not (x == y)
x == y = not (x /= y)
該定義指出,如果要使型別a成為類Eq的例項,則它必須支援函式(==)和(/=) - 類方法 - 它們都具有型別a -> a -> Bool。此外,該類提供(==)和(/=)的預設定義相互之間。因此,Eq中的型別無需提供這兩個定義 - 給定其中一個,另一個將自動生成。
定義了類之後,我們繼續使現有型別成為其例項。這是一個將代數資料型別透過例項宣告變為Eq例項的任意示例
data Foo = Foo {x :: Integer, str :: String}
instance Eq Foo where
(Foo x1 str1) == (Foo x2 str2) = (x1 == x2) && (str1 == str2)
現在我們可以像往常一樣對Foo值應用(==)和(/=)
*Main> Foo 3 "orange" == Foo 6 "apple" False *Main> Foo 3 "orange" /= Foo 6 "apple" True
一些重要的說明
- 類
Eq在標準Prelude中定義。此程式碼示例定義了型別Foo,然後將其宣告為Eq的例項。這三個定義(類、資料型別和例項)是完全獨立的,並且沒有關於它們如何分組的規則。這雙向有效:您可以同樣輕鬆地建立一個新的類Bar,然後宣告型別Integer成為其例項。
- 類不是型別,而是型別的類別,因此類的例項是型別而不是值。[2]
Foo的(==)定義依賴於其欄位(即Integer和String)的值也是Eq的成員這一事實。事實上,Haskell 中幾乎所有型別都是Eq的成員(最顯著的例外是函式)。
- 使用type關鍵字定義的類型別名不能成為類的例項。
由於值之間的相等性測試很常見,因此在任何實際程式中建立的大多數資料型別都應該是Eq的成員。其中許多也將是Prelude中其他類的成員,例如Ord和Show。為了避免為每個新型別編寫大量樣板程式碼,Haskell 提供了一種方便的方法來使用關鍵字宣告“明顯的”例項定義deriving。所以,Foo將寫成
data Foo = Foo {x :: Integer, str :: String}
deriving (Eq, Ord, Show)
這使得Foo成為Eq的例項,並自動生成==的定義,與我們剛剛編寫的完全相同,並且也使其成為Ord和Show的例項。
你只能使用deriving與一組有限的內建類一起使用,這些類在下面非常簡要地描述
- Eq
- 相等運算子==和/=
- Ord
- 比較運算子< <= > >=; min, max,和compare.
- Enum
- 僅用於列舉。允許使用列表語法,例如[Blue .. Green].
- Bounded
- 也用於列舉,但也可用於只有一個建構函式的型別。提供minBound和maxBound作為型別可以取的最低和最高值。
- Show
- 定義函式show,它將值轉換為字串,以及其他相關函式。
- Read
- 定義函式read,它將字串解析為該型別的值,以及其他相關函式。
語言報告中給出了派生相關函式的精確規則。但是,它們通常可以被認為在大多數情況下是“正確的事情”。資料型別內部元素的型別也必須是您正在派生的類的例項。
為一組有限的預定義類提供特殊的“魔法”函式合成違反了 Haskell 的普遍理念“內建事物並不特殊”,但這確實節省了很多輸入。除此之外,派生例項可以阻止我們以錯誤的方式編寫它們(例如,Eq的一個例項,使得x == y不等於y == x將是完全錯誤的)。[3]
類可以從其他類繼承。例如,以下是Prelude中Ord的主要部分定義
class (Eq a) => Ord a where
compare :: a -> a -> Ordering
(<), (<=), (>=), (>) :: a -> a -> Bool
max, min :: a -> a -> a
實際定義要長得多,並且包括大多數函式的預設實現。這裡的重點是Ord繼承自Eq。這由第一行中的=>表示法表示,它反映了類在型別簽名中出現的方式。在這裡,這意味著對於一個型別要成為Ord的例項,它也必須是Eq的例項,因此需要實現==和/=操作。[4]
一個類可以從多個其他類繼承:只需將所有其超類放在=>之前的括號中即可。讓我們用另一個Prelude引用來說明這一點
class (Num a, Ord a) => Real a where
-- | the rational equivalent of its real argument with full precision
toRational :: a -> Rational
此圖改編自 Haskell 報告,顯示了標準 Prelude 中類和型別之間的關係。粗體名稱是類,而非粗體文字代表每個類的例項型別((->)指的是函式,而[]指的是列表)。連線類的箭頭表示繼承關係,指向繼承類。

準備好所有部分後,我們可以透過返回本書中涉及類的第一個示例來完整地迴圈一遍
(+) :: (Num a) => a -> a -> a
(Num a) =>是型別約束,它將型別a限制為類Num的例項。事實上,(+)是Num的一個方法,以及其他一些函式(特別是(*)和(-);但不是(/))。
您可以在這樣的型別簽名中設定多個限制
foo :: (Num a, Show a, Show b) => a -> a -> b -> String
foo x y t =
show x ++ " plus " ++ show y ++ " is " ++ show (x+y) ++ ". " ++ show t
這裡,引數x和y必須是相同的型別,並且該型別必須同時是Num和Show的例項。此外,最終引數t必須是某種(可能不同的)型別,該型別也是Show此示例也清晰地展示了約束如何從定義中使用的函式(在本例中為(+)和show)傳播到正在定義的函式。
除了簡單的型別簽名之外,型別約束還可以引入到許多其他地方
instance宣告(通常用於引數化型別);
class宣告(約束可以像往常一樣在方法簽名中引入,用於除定義類的型別變數之外的任何型別變數[5]);
data宣告,[6] 它們充當建構函式簽名的約束。
注意
data宣告中的型別約束不像乍看起來那麼有用。考慮一下
data (Num a) => Foo a = F1 a | F2 a String
這裡,Foo是一種具有兩個建構函式的型別,這兩個建構函式都接受型別為a的引數,該引數必須在Num中。但是,(Num a) =>約束僅對F1和F2建構函式有效,而不是對涉及Foo的其他函式有效。因此,在以下示例中...
fooSquared :: (Num a) => Foo a -> Foo a
fooSquared (F1 x) = F1 (x * x)
fooSquared (F2 x s) = F2 (x * x) s
... 即使建構函式確保a將是Num中的某種型別,我們也無法避免在fooSquared的簽名中重複約束。[7]
為了更好地瞭解型別、類和約束之間的相互作用,我們將提供一個非常簡單且有些牽強的示例。我們將定義一個Located類,一個繼承自它的Movable類,以及一個具有Movable約束的函式,該函式使用父類的(即Located)方法實現。
-- Location, in two dimensions.
class Located a where
getLocation :: a -> (Int, Int)
class (Located a) => Movable a where
setLocation :: (Int, Int) -> a -> a
-- An example type, with accompanying instances.
data NamedPoint = NamedPoint
{ pointName :: String
, pointX :: Int
, pointY :: Int
} deriving (Show)
instance Located NamedPoint where
getLocation p = (pointX p, pointY p)
instance Movable NamedPoint where
setLocation (x, y) p = p { pointX = x, pointY = y }
-- Moves a value of a Movable type by the specified displacement.
-- This works for any movable, including NamedPoint.
move :: (Movable a) => (Int, Int) -> a -> a
move (dx, dy) p = setLocation (x + dx, y + dy) p
where
(x, y) = getLocation p
不要對上面提到的Movable示例過度解讀;它僅僅是類相關語言特性的一個演示。認為每個可能被概括的功能,例如setLocation,都需要一個它自己的型別類,這是一個錯誤。特別是,如果所有Located例項都應該能夠移動,那麼Movable就是不必要的——如果只有一個例項,則根本不需要型別類!當有多個型別例項化它(或者您期望其他人編寫其他例項)並且您不希望使用者知道或關心型別之間的差異時,最好使用類。一個極端的例子是Show:由大量型別實現的通用功能,在呼叫show之前,您不需要了解任何有關這些型別的資訊。在接下來的章節中,我們將探討庫中的一些重要型別類;它們提供了適合於類的功能型別的良好示例。
註釋
- ↑ 對於來自面嚮物件語言的程式設計師:Haskell 中的類很可能不是您期望的——不要讓術語混淆您。雖然型別類的一些用途類似於抽象類或 Java 介面的用途,但存在一些根本的區別,隨著我們的深入,這些區別將變得清晰。
- ↑ 這是與大多數面嚮物件語言的主要區別,在面嚮物件語言中,類本身也是一種型別。
- ↑ 有一些方法可以使魔法適用於其他類。GHC 擴充套件允許為一些其他常用類
deriving,這些類只有一種編寫例項的正確方法,並且 GHC 通用機制可以自動為自定義類生成例項。 - ↑ 如果您檢視Prelude規範中的完整定義,原因就會變得很清楚:預設實現涉及將
(==)應用於正在比較的值。 - ↑ 定義類的型別的約束應透過類繼承設定。
- ↑ 以及
newtype宣告,但不包括type。 - ↑ 好奇者的額外說明:此問題與高階跟蹤的“型別樂趣”章節中討論的一些高階功能解決的問題相關。