跳轉至內容

Haskell/類與型別

來自Wikibooks,開放世界中的開放書籍

型別基礎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(==)定義依賴於其欄位(即IntegerString)的值也是Eq的成員這一事實。事實上,Haskell 中幾乎所有型別都是Eq的成員(最顯著的例外是函式)。
  • 使用type關鍵字定義的類型別名不能成為類的例項。

由於值之間的相等性測試很常見,因此在任何實際程式中建立的大多數資料型別都應該是Eq的成員。其中許多也將是Prelude中其他類的成員,例如OrdShow。為了避免為每個新型別編寫大量樣板程式碼,Haskell 提供了一種方便的方法來使用關鍵字宣告“明顯的”例項定義deriving。所以,Foo將寫成

data Foo = Foo {x :: Integer, str :: String}
    deriving (Eq, Ord, Show)

這使得Foo成為Eq的例項,並自動生成==的定義,與我們剛剛編寫的完全相同,並且也使其成為OrdShow的例項。

你只能使用deriving與一組有限的內建類一起使用,這些類在下面非常簡要地描述

Eq
相等運算子==/=
Ord
比較運算子< <= > >=; min, max,和compare.
Enum
僅用於列舉。允許使用列表語法,例如[Blue .. Green].
Bounded
也用於列舉,但也可用於只有一個建構函式的型別。提供minBoundmaxBound作為型別可以取的最低和最高值。
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

這裡,引數xy必須是相同的型別,並且該型別必須同時是NumShow的例項。此外,最終引數t必須是某種(可能不同的)型別,該型別也是Show此示例也清晰地展示了約束如何從定義中使用的函式(在本例中為(+)show)傳播到正在定義的函式。

其他用途

[編輯 | 編輯原始碼]

除了簡單的型別簽名之外,型別約束還可以引入到許多其他地方

  • instance宣告(通常用於引數化型別);
  • class宣告(約束可以像往常一樣在方法簽名中引入,用於除定義類的型別變數之外的任何型別變數[5]);
  • data宣告,[6] 它們充當建構函式簽名的約束。

注意

data宣告中的型別約束不像乍看起來那麼有用。考慮一下

data (Num a) => Foo a = F1 a | F2 a String

這裡,Foo是一種具有兩個建構函式的型別,這兩個建構函式都接受型別為a的引數,該引數必須在Num中。但是,(Num a) =>約束僅對F1F2建構函式有效,而不是對涉及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之前,您不需要了解任何有關這些型別的資訊。在接下來的章節中,我們將探討庫中的一些重要型別類;它們提供了適合於類的功能型別的良好示例。

註釋

  1. 對於來自面嚮物件語言的程式設計師:Haskell 中的類很可能不是您期望的——不要讓術語混淆您。雖然型別類的一些用途類似於抽象類或 Java 介面的用途,但存在一些根本的區別,隨著我們的深入,這些區別將變得清晰。
  2. 這是與大多數面嚮物件語言的主要區別,在面嚮物件語言中,類本身也是一種型別。
  3. 有一些方法可以使魔法適用於其他類。GHC 擴充套件允許為一些其他常用類deriving,這些類只有一種編寫例項的正確方法,並且 GHC 通用機制可以自動為自定義類生成例項。
  4. 如果您檢視Prelude規範中的完整定義,原因就會變得很清楚:預設實現涉及將(==)應用於正在比較的值。
  5. 定義類的型別的約束應透過類繼承設定。
  6. 以及newtype宣告,但不包括type
  7. 好奇者的額外說明:此問題與高階跟蹤的“型別樂趣”章節中討論的一些高階功能解決的問題相關。
華夏公益教科書