跳轉到內容

另一個 Haskell 教程/語言進階

來自 Wikibooks,為開放世界提供開放書籍
Haskell
另一個 Haskell 教程
前言
介紹
入門
語言基礎 (解決方案)
型別基礎 (解決方案)
IO (解決方案)
模組 (解決方案)
高階語言 (解決方案)
高階型別 (解決方案)
單子 (解決方案)
高階 IO
遞迴
複雜度

節和中綴運算子

[編輯 | 編輯原始碼]

我們已經看到如何使用 map 將列表中元素的值加倍

示例

Prelude> map (\x -> x*2) [1,2,3,4]
[2,4,6,8]

但是,有一個更簡潔的方法來編寫它

示例

Prelude> map (*2) [1,2,3,4]
[2,4,6,8]

這種型別的事情可以對任何中綴函式執行

示例

Prelude> map (+5) [1,2,3,4]
[6,7,8,9]
Prelude> map (/2) [1,2,3,4]
[0.5,1.0,1.5,2.0]
Prelude> map (2/) [1,2,3,4]
[2.0,1.0,0.666667,0.5]

你可能很想嘗試透過對映 -2 到列表來從列表中的元素中減去值。但是,這將不起作用,因為雖然 +2 中的 + 被解析為標準加號運算子(因為沒有歧義),但 -2 中的 - 被解釋為一元減號,而不是二元減號。因此,這裡的 -2數字 ,而不是函式 .

一般來說,這些被稱為節。對於二元中綴運算子(如 +),我們可以透過將其括在括號中使其成為字首。例如

示例

Prelude> (+) 5 3
8
Prelude> (-) 5 3
2

此外,我們可以提供它的任何引數以建立一個節。例如

示例

Prelude> (+5) 3
8
Prelude> (/3) 6
2.0
Prelude> (3/) 6
0.5

非中綴函式可以透過將其括在反引號 ("\`") 中使其成為中綴。例如

示例

Prelude> (+2) `map` [1..10]
[3,4,5,6,7,8,9,10,11,12]
Prelude> (`map` [1..10]) (+2)
[3,4,5,6,7,8,9,10,11,12]



區域性宣告

[編輯 | 編輯原始碼]

回想一下關於 函式 的部分,有許多計算需要在函式中的多個地方使用同一個計算的結果。在那裡,我們考慮了計算二次多項式根的函式

roots a b c =
    ((-b + sqrt(b*b - 4*a*c)) / (2*a),
     (-b - sqrt(b*b - 4*a*c)) / (2*a))

除了let繫結在那裡介紹,我們可以使用where子句。where 子句緊隨函式定義之後,並引入了新的佈局級別(參見關於 佈局 的部分)。我們將其寫成

roots a b c =
    ((-b + det) / (2*a), (-b - det) / (2*a))
    where det = sqrt(b*b-4*a*c)

在任何定義在where子句中的值都會覆蓋任何具有相同名稱的其他值。例如,如果我們有以下程式碼塊

det = "Hello World"

roots a b c =
    ((-b + det) / (2*a), (-b - det) / (2*a))
    where det = sqrt(b*b-4*a*c)

f _ = det

roots 的值不會注意到 det 的頂層宣告,因為它被區域性定義(型別不匹配這一事實也不重要)所覆蓋。此外,由於 f 無法“看到” roots 的內部,因此它只知道關於 det 的內容是頂層可用的,即字串“Hello World”。

我們也可以提取 2*a 計算,得到以下程式碼

roots a b c =
    ((-b + det) / (a2), (-b - det) / (a2))
    where det = sqrt(b*b-4*a*c)
          a2 = 2*a

子表示式在where子句中必須出現在函式定義之後。有時在函式的實際表示式之前放置區域性定義會更方便。這可以透過使用let/in子句來完成。我們已經看到了let子句;where子句與它們的let子句表親幾乎相同,除了它們的位置。同一個 roots 函式可以使用letas

roots a b c =
    let det = sqrt (b*b - 4*a*c)
        a2 = 2*a
    in  ((-b + det) / a2, (-b - det) / a2)

使用where子句,它看起來像

roots a b c = ((-b + det) / a2, (-b - det) / a2)
  where
    det = sqrt (b*b - 4*a*c)
    a2  = 2*a

這兩種型別的子句可以混合(即,你可以編寫一個既有let子句又有where子句的函式)。強烈建議不要這樣做,因為它往往會使程式碼難以閱讀。但是,如果你選擇這樣做,let子句中的值會覆蓋where子句中的值。因此,如果你定義函式

f x =
    let y = x+1
    in  y
    where y = x+2

f 5 的值為 6,而不是 7。當然,我懇請你永遠不要編寫看起來像這樣的程式碼。沒有人應該記住這個規則,並且透過覆蓋where- 在let子句中定義的值只會使你的程式碼難以理解。

一般來說,是否應該使用let子句或where子句很大程度上取決於個人喜好。通常,你給子表示式賦予的名稱應該足夠具有表現力,使得在沒有閱讀它們的定義的情況下,任何閱讀你程式碼的人都能弄清楚它們的作用。在這種情況下,where子句可能更可取,因為它們允許讀者立即看到函式的作用。但是,在現實生活中,值通常會使用神秘的名稱。



偏應用

[編輯 | 編輯原始碼]

偏應用是指當你獲取一個接受 個引數的函式,然後你為它提供 個引數。當討論 時,我們看到了“偏應用”的一種形式,其中像 + 這樣的函式被部分應用。例如,在表示式 map (+1) [1,2,3] 中,節 (+1)+ 的偏應用。這是因為 + 實際上接受兩個引數,但我們只給它提供了一個引數。

偏應用在函式定義中非常常見,有時被稱為“η(eta)歸約”。例如,假設我們正在編寫一個函式 lcaseString,它將整個字串轉換為小寫。我們可以將其寫成

lcaseString s = map toLower s

這裡,沒有部分應用(儘管你可以爭辯說,對toLower不應用任何引數可以被認為是部分應用)。然而,我們注意到s的應用出現在lcaseStringmap toLower的末尾。事實上,我們可以透過進行eta歸約來刪除它,得到

lcaseString = map toLower

現在,我們對map進行了部分應用:它期望一個函式和一個列表,但我們只給它提供了函式。

這一切都與map的型別有關,它的型別是(a -> b) -> ([a] -> [b]),當包含所有括號時。在我們的例子中,toLower的型別是Char -> Char。因此,如果我們將這個函式提供給map,我們將得到一個型別為[Char] -> [Char]的函式,如預期的那樣。

現在,考慮將字串轉換為小寫並刪除所有非字母字元的任務。我們可以這樣寫

lcaseLetters s = map toLower (filter isAlpha s)

但請注意,我們實際上可以用函式組合來寫這個

lcaseLetters s = (map toLower . filter isAlpha) s

同樣,我們又得到了一個可進行eta歸約的函式

lcaseLetters = map toLower . filter isAlpha

以這種風格編寫函式在高階Haskell使用者中非常常見。事實上,它有一個名字:無點程式設計(不要與無意義程式設計混淆)。之所以稱為無點,是因為在lcaseLetters的原始定義中,我們可以將值s視為函式操作的點。透過從函式定義中刪除這個點,我們得到了一個無點函式。

(.)類似的函式是($)(.)是函式組合,而($)是函式應用。($)在Prelude中的定義非常簡單

f $ x = f x

然而,這個函式的固定性非常低,這意味著它可以用來替換括號。例如,我們可以寫一個函式

foo x y = bar y (baz (fluff (ork x)))

但是,使用函式應用函式,我們可以將其改寫為

foo x y = bar y $ baz $ fluff $ ork x

這與函式組合語法有點相似。($)函式與其他中綴函式組合起來也很有用。例如,我們不能寫

示例

Prelude> putStrLn "5+3=" ++ show (5+3)

因為這被解釋為(putStrLn "5+3=") ++ (show (5+3)),這沒有意義。但是,我們可以透過改寫來解決這個問題

示例

Prelude> putStrLn $ "5+3=" ++ show (5+3)

這樣就可以正常工作了。

現在考慮從元組列表中提取所有第一個元素大於零的元組的任務。一種寫法是

fstGt0 l = filter (\ (a,b) -> a>0) l

我們可以首先對整個函式進行eta歸約,得到

fstGt0 = filter (\ (a,b) -> a>0)

現在,我們可以將lambda函式改寫為使用fst函式而不是模式匹配

fstGt0 = filter (\x -> fst x > 0)

現在,我們可以使用fst>之間的函式組合來得到

fstGt0 = filter (\x -> ((>0) . fst) x)

最後,我們可以進行eta歸約

fstGt0 = filter ((>0).fst)

這個定義比原來的定義更簡潔,也更容易理解。我們可以清楚地看到它在做什麼:我們透過檢查某個東西是否大於零來過濾列表。我們在檢查什麼?fst元素。

雖然轉換為無點風格通常會導致更清晰的程式碼,但這當然並不總是如此。例如,將以下對映轉換為無點風格會導致幾乎無法理解的結果

foo = map (\x -> sqrt (3+4*(x^2)))
foo = map (sqrt . (3+) . (4*) . (^2))

Prelude中定義了一些用於無點程式設計的組合子

  • uncurry接收一個型別為a -> b -> c的函式,並將其轉換為一個型別為(a,b) -> c的函式。這在對成對列表進行對映時很有用,例如

示例

Prelude> map (uncurry (*)) [(1,2),(3,4),(5,6)]
[2,12,30]
  • curryuncurry相反,它接收一個型別為(a,b) -> c的函式,併產生一個型別為a -> b -> c的函式。
  • flip反轉函式前兩個引數的順序。也就是說,它接收一個型別為a -> b -> c的函式,併產生一個型別為b -> a -> c的函式。例如,我們可以使用flip compare對列表進行反向排序

示例

Prelude> List.sortBy compare [5,1,8,3]
[1,3,5,8]
Prelude> List.sortBy (flip compare) [5,1,8,3]
[8,5,3,1]

這與說相同

示例

Prelude> List.sortBy (\a b -> compare b a) [5,1,8,3]
[8,5,3,1]

只是更短而已。

當然,並非所有函式都可以用無點風格編寫。例如

square x = x*x

不能用無點風格編寫,除非使用其他一些組合子。例如,如果我們可以定義其他函式,我們可以寫

pair x = (x,x)
square = uncurry (*) . pair

但在這種情況下,這並不是非常有用。

練習

如果可能,將以下函式轉換為無點風格。

func1 x l = map (\y -> y*x) l

func2 f g l = filter f (map g l)

func3 f l = l ++ map f l

func4 l = map (\y -> y+2)
              (filter (\z -> z `elem` [1..10])
                      (5:l))

func5 f l = foldr (\x y -> f (y,x)) 0 l


模式匹配

[edit | edit source]

模式匹配是Haskell(以及大多數函數語言程式設計語言)中最強大的特性之一。它最常與case表示式一起使用,我們在函式部分已經見過它了。讓我們回到資料型別部分的Color示例。我將重複我們已經為資料型別定義的內容

data Color
    = Red
    | Orange
    | Yellow
    | Green
    | Blue
    | Purple
    | White
    | Black
    | Custom Int Int Int  -- R G B components
    deriving (Show,Eq)

然後我們想要寫一個函式,將型別為Color的東西轉換為三個Int元組,它們分別對應於RGB值。具體來說,如果我們看到一個Color,它的值為Red,我們想要返回(255,0,0),因為這是紅色的RGB值。所以我們這樣寫(記住,分段函式定義只是一些case語句)

colorToRGB Red = (255,0,0)

如果我們看到一個Color,它的值為Orange,我們想要返回(255,128,0);如果我們看到Yellow,我們想要返回(255,255,0),等等。最後,如果我們看到一個自定義顏色,它由三個組成部分組成,我們想要用這些組成部分建立一個元組,所以我們寫

colorToRGB Orange = (255,128,0)
colorToRGB Yellow = (255,255,0)
colorToRGB Green  = (0,255,0)
colorToRGB Blue   = (0,0,255)
colorToRGB Purple = (255,0,255)
colorToRGB White  = (255,255,255)
colorToRGB Black  = (0,0,0)
colorToRGB (Custom r g b) = (r,g,b)

然後,在我們的直譯器中,如果我們鍵入

示例

Color> colorToRGB Yellow
(255,255,0)

發生的事情是這樣的:我們建立一個值,稱之為,它的值為Yellow。然後我們將它應用於colorToRGB。我們檢查是否可以將Red匹配。這個匹配失敗,因為根據Eq{Color}的定義,Red不等於Yellow。我們繼續向下遍歷colorToRGB的定義,並嘗試將YellowOrange匹配。這也失敗了。然後我們嘗試將YellowYellow匹配,這成功了,所以我們使用這個函式定義,它簡單地返回(255,255,0),如預期的那樣。

假設相反,我們使用了一個自定義顏色

示例

Color> colorToRGB (Custom 50 200 100)
(50,200,100)

我們應用相同的匹配過程,在從RedBlack的所有值上都失敗了。然後我們嘗試將Custom 50 200 100Custom r g b匹配。我們可以看到,Custom部分匹配,所以我們繼續檢視子元素是否匹配。在匹配過程中,變數rgb本質上是萬用字元,因此將r與50匹配、將g與200匹配以及將b與100匹配沒有任何問題。作為這個匹配的“副作用”,r獲得了值50,g獲得了值200,b獲得了值100。因此,整個匹配成功,我們檢視函式這一部分的定義,並使用匹配到的rgb的值來捆綁三元組。

我們也可以寫一個函式來檢查一個Color是否是一個自定義顏色

isCustomColor (Custom _ _ _) = True
isCustomColor _ = False


當我們將一個值應用於isCustomColor時,它會嘗試將該值與Custom _ _ _匹配。如果該值為Custom x y z,則此匹配將成功,無論xyz的值是什麼。_(下劃線)字元是一個“萬用字元”,它會匹配任何東西,但不會執行將變數名放在那裡時會發生的繫結。如果這個匹配成功,該函式返回True;但是,如果這個匹配失敗,它會繼續到下一行,它會匹配任何東西,然後返回False

出於某種原因,我們可能想要定義一個函式,它告訴我們給定的顏色是否是“亮”的,我定義的“亮”是它的一個RGB分量等於255(這 admittedly an arbitrary definition,但這只是一個例子)。我們可以將這個函式定義為

isBright = isBright' . colorToRGB
    where isBright' (255,_,_) = True
          isBright' (_,255,_) = True
          isBright' (_,_,255) = True
          isBright' _         = False

讓我們仔細研究一下這個定義。isBright函式是我們之前定義的函式colorToRGB和輔助函式isBright'的組合,它告訴我們給定的RGB值是否亮。我們可以將這裡的第一行替換為isBright c = isBright' (colorToRGB c),但這裡沒有必要顯式地寫出引數,所以我們沒有寫。同樣,這種函式組合程式設計風格需要一些時間來適應,所以我會在本教程中經常使用它。


isBright'輔助函式接收由colorToRGB產生的RGB三元組。它首先嚐試將其與(255,_,_)匹配,如果該值在第一個位置有255,則匹配成功。如果這個匹配成功,isBright'返回TrueisBright也返回True。定義的第二行和第三行分別檢查三元組中第二個位置和第三個位置是否為255。第四行是直通,它匹配所有其他內容並報告它不亮。

我們可能還想編寫一個函式,用於在 RGB 三元組和Color之間進行轉換。我們可以簡單地將所有內容都放在Custom建構函式中,但這會違揹我們的目的;我們只想將Custom槽用於與預定義顏色不匹配的值。但是,我們不希望允許使用者構造像 (600,-40,99) 這樣的自定義顏色,因為這些是非法的 RGB 值。如果給出這樣的值,我們可以丟擲錯誤,但這可能難以處理。相反,我們使用Maybe資料型別。這在 Prelude 中定義為

data Maybe a = Nothing
             | Just a

我們使用它的方式如下:我們的rgbToColor函式返回型別為Maybe Color的值。如果傳遞給函式的 RGB 值無效,則返回Nothing,這對應於失敗。另一方面,如果 RGB 值有效,則會建立相應的Color值並返回Just。執行此操作的程式碼如下

rgbToColor 255   0   0 = Just Red
rgbToColor 255 128   0 = Just Orange
rgbToColor 255 255   0 = Just Yellow
rgbToColor   0 255   0 = Just Green
rgbToColor   0   0 255 = Just Blue
rgbToColor 255   0 255 = Just Purple
rgbToColor 255 255 255 = Just White
rgbToColor   0   0   0 = Just Black
rgbToColor   r   g   b =
    if 0 <= r && r <= 255 &&
       0 <= g && g <= 255 &&
       0 <= b && b <= 255
      then Just (Custom r g b)
      else Nothing   -- invalid RGB value

前八行將 RGB 引數與預定義的值進行匹配,如果匹配,rgbToColor將返回Just相應的顏色。如果這些都沒有匹配,則rgbToColor的最後一個定義將第一個引數與r匹配,第二個引數與g匹配,第三個引數與b匹配(這會導致將這些值繫結為副作用)。然後,它檢查這些值是否有效(每個值都大於或等於零且小於或等於 255)。如果是,則返回Just (Custom r g b);否則,返回Nothing,表示無效顏色。

使用此功能,我們可以編寫一個函式來檢查正確的 RGB 值是否有效

rgbIsValid r g b = rgbIsValid' (rgbToColor r g b)
    where rgbIsValid' (Just _) = True
          rgbIsValid' _        = False

這裡,我們將輔助函式rgbIsValid'與我們的函式rgbToColor組合起來。輔助函式檢查rgbToColor返回的值是否為Just任何內容(萬用字元)。如果是,則返回True。否則,它將匹配任何內容並返回False

模式匹配並不是魔法。你只能對資料型別進行匹配;你不能對函式進行匹配。例如,以下是無效的

f x = x + 1

g (f x) = x

即使g的預期含義很清楚(即,g x = x - 1),編譯器通常並不知道f是否有逆函式,因此它不能執行這種匹配。



守衛

[edit | edit source]

守衛可以被認為是模式匹配功能的擴充套件。它們允許你根據任意布林表示式,對分段函式定義進行取捨。守衛出現在函式的所有引數之後,但在等號之前,並以豎線開頭。我們可以使用守衛來編寫一個簡單的函式,該函式返回一個字串,告訴你比較兩個元素的結果

comparison x y | x < y = "The first is less"
               | x > y = "The second is less"
               | otherwise = "They are equal"

你可以將豎線讀作“使得”。因此,我們說comparison x y的值“使得”x 小於 y 是“第一個更小”。使得 x 大於 y 的值是“第二個更小”,而否則是“它們相等”。關鍵字otherwise被簡單地定義為等於True,因此它與任何到達該處的匹配項都匹配。因此,我們可以看到它可以工作

示例

Guards> comparison 5 10
"The first is less"
Guards> comparison 10 5
"The second is less"
Guards> comparison 7 7
"They are equal"

守衛與模式匹配一起使用。當一個模式匹配時,會依次嘗試它的所有守衛,直到一個守衛匹配為止。如果沒有任何守衛匹配,則模式匹配會繼續下一個模式。

關於守衛的一個優點是where子句對所有守衛都通用。因此,我們上一節中的isBright函式的另一個可能的定義將是

isBright2 c | r == 255 = True
            | g == 255 = True
            | b == 255 = True
            | otherwise = False
    where (r,g,b) = colorToRGB c

該函式等效於之前的版本,但執行計算的方式略有不同。它接受一個顏色c,並對其應用colorToRGB,生成一個 RGB 三元組,該三元組與(r,g,b)進行匹配(使用模式匹配!)。此匹配成功,並且值rgb被繫結到它們各自的值。第一個守衛檢查r是否為 255,如果是,則返回 true。第二個和第三個守衛分別將gb與 255 進行匹配,如果匹配,則返回 true。最後一個守衛作為最後手段觸發,並返回False



例項宣告

[edit | edit source]

為了將型別宣告為類的例項,你需要為它提供一個例項宣告。大多數類提供所謂的“最小完整定義”。這意味著為了滿足類的定義,必須為該類實現的函式。在你為你的型別編寫了這些函式之後,你可以將其宣告為該類的例項。


Eq

[edit | edit source]

Eq類有兩個成員(即兩個函式)

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

第一個型別簽名表明==函式是一個函式,它接受兩個a(它們是Eq的成員)並生成一個Bool/=(不等)的型別簽名是相同的。Eq類的最小完整定義要求定義其中一個函式(如果你定義了==,則/=會透過取==結果的反值來自動定義,反之亦然)。這些宣告必須在例項宣告中提供。

這最好用示例來演示。假設我們有我們的顏色示例,這裡為了方便重複一遍

data Color
    = Red
    | Orange
    | Yellow
    | Green
    | Blue
    | Purple
    | White
    | Black
    | Custom Int Int Int  -- R G B components

我們可以透過以下宣告來定義ColorEq的例項

instance Eq Color where
    Red == Red = True
    Orange == Orange = True
    Yellow == Yellow = True
    Green == Green = True
    Blue == Blue = True
    Purple == Purple = True
    White == White = True
    Black == Black = True
    (Custom r g b) == (Custom r' g' b') =
        r == r' && g == g' && b == b'
    _ == _ = False

這裡的第一行以關鍵字instance開頭,告訴編譯器我們正在進行例項宣告。然後它指定了類Eq和型別Color,它將成為該類的例項。在此之後,是where關鍵字。最後是方法宣告。

方法宣告的前八行基本相同。例如,第一行表示表示式Red == Red的值等於True。第二行到第八行相同。自定義顏色的宣告略有不同。我們對==兩邊的Custom進行模式匹配。在左側,我們將rgb分別繫結到元件。在右側,我們將r'g'b'繫結到元件。然後我們說,這兩個自定義顏色只有在r == r'g == g'b == b'都相等時才相等。繼續匹配表示任何我們之前沒有宣告為相等的對都不相等。

Show

[edit | edit source]

Show類用於將任意值顯示為字串。該類有三種方法

show :: Show a => a -> String
showsPrec :: Show a => Int -> a -> String -> String
showList :: Show a => [a] -> String -> String

最小完整定義是showshowsPrec(我們將在稍後討論showsPrec - 它用於效率目的)。我們可以使用以下例項宣告來定義我們的Color資料型別為Show的例項

instance Show Color where
    show Red = "Red"
    show Orange = "Orange"
    show Yellow = "Yellow"
    show Green = "Green"
    show Blue = "Blue"
    show Purple = "Purple"
    show White = "White"
    show Black = "Black"
    show (Custom r g b) =
        "Custom " ++ show r ++ " " ++
        show g ++ " " ++ show b

此宣告準確指定了如何將Color型別的轉換為String。同樣,前八行相同,只需獲取一個Color並生成一個字串。處理自定義顏色的最後一行匹配 RGB 元件,並透過串聯單獨顯示元件的結果(在中間加空格,並在開頭加上“Custom”)來建立一個字串。

其他重要類

[edit | edit source]

還有一些其他重要的類,我會簡要介紹一下,因為它們要麼被廣泛使用,要麼是我們很快就會使用到的。我不會提供示例例項宣告;到目前為止,你應該清楚如何做到這一點。


Ord

[edit | edit source]

排序類,函式為

compare :: Ord a => a -> a -> Ordering
(<=) :: Ord a => a -> a -> Bool
(>) :: Ord a => a -> a -> Bool
(>=) :: Ord a => a -> a -> Bool
(<) :: Ord a => a -> a -> Bool
min :: Ord a => a -> a -> a
max :: Ord a => a -> a -> a

幾乎任何一個函式都可以作為最小完整定義;但是,如果只實現一個函式,建議實現compare。此函式返回型別為Ordering的值,它被定義為

data Ordering = LT | EQ | GT

因此,例如,我們得到

示例

Prelude> compare 5 7
LT
Prelude> compare 6 6
EQ
Prelude> compare 7 5
GT

為了將型別宣告為Ord的例項,你必須先將其宣告為Eq的例項(換句話說,OrdEq子類 - 關於這部分內容,請參閱部分)。

Enum

[edit | edit source]

Enum類用於列舉型別;也就是說,用於每個元素都有後繼和前驅的型別。它的方法為

pred :: Enum a => a -> a
succ :: Enum a => a -> a
toEnum :: Enum a => Int -> a
fromEnum :: Enum a => a -> Int
enumFrom :: Enum a => a -> [a]
enumFromThen :: Enum a => a -> a -> [a]
enumFromTo :: Enum a => a -> a -> [a]
enumFromThenTo :: Enum a => a -> a -> a -> [a]

最小完整定義包含toEnumfromEnum,它們分別將型別轉換為和從Intpredsucc函式分別給出前驅和後繼。enum函式列舉元素列表。例如,enumFrom x列出所有元素(在x之後);enumFromThen x step列出所有元素(從x開始,步長為step)。To函式在給定元素處結束列舉。

Num

[edit | edit source]

Num類提供了標準的算術運算

(-) :: Num a => a -> a -> a
(*) :: Num a => a -> a -> a
(+) :: Num a => a -> a -> a
negate :: Num a => a -> a
signum :: Num a => a -> a
abs :: Num a => a -> a
fromInteger :: Num a => Integer -> a

除了negate之外,所有這些都是顯而易見的,negate是一元負號。也就是說,negate x表示.


Read

[編輯 | 編輯原始碼]

Read 類與 Show 類相反。它是一種將字串轉換為任意型別的值的方法。Read 的方法有

readsPrec :: Read a => Int -> String -> [(a, String)]
readList :: String -> [([a], String)]

最小完整定義是 readsPrec。與此相關的最重要函式是 read,它使用 readsPrec 作為

read s = fst (head (readsPrec 0 s))

如果解析字串失敗,這將失敗。您可以定義一個 maybeRead 函式,如下所示

maybeRead s =
    case readsPrec 0 s of
      [(a,_)] -> Just a
      _ -> Nothing

如何在示例中進一步討論如何直接編寫和使用 readsPrec


類上下文

[編輯 | 編輯原始碼]

假設我們正在從頭開始定義 Maybe 資料型別。定義類似於

data Maybe a = Nothing
             | Just a

現在,當我們去編寫例項宣告時,例如 Eq,我們需要知道 aEq 的例項,否則我們無法編寫宣告。我們將此表示為

instance Eq a => Eq (Maybe a) where
    Nothing == Nothing = True
    (Just x) == (Just x') = x == x'

第一行可以解釋為“aEq 的例項 意味著 (=>) Maybe aEq 的例項。”

派生類

[編輯 | 編輯原始碼]

編寫像這樣的顯而易見的 EqOrdReadShow 類很乏味,應該自動化。幸運的是,它可以自動化。如果您編寫一個“足夠簡單”的資料型別(除非您開始編寫不動點型別,否則幾乎任何資料型別都可以編寫),編譯器可以自動派生一些最基本類。為此,您只需新增一個派生子句到資料型別宣告之後,例如


data Color
    = Red
    | ...
    | Custom Int Int Int  -- R G B components
    deriving (Eq, Ord, Show, Read)

這將自動為 Color 資料型別的命名類建立例項。類似地,宣告

data Maybe a = Nothing
             | Just a
             deriving (Eq, Ord, Show, Read)

僅在 a 適當的時候派生這些類。

總而言之,您可以派生 EqOrdEnumBoundedShowRead 的例項。在“多型程式設計”或“泛型程式設計”領域做了大量的工作,這些工作在其他方面允許為任何類派生例項宣告。這遠遠超出了本教程的範圍;相反,我將您推薦給相關文獻。



資料型別再探

[編輯 | 編輯原始碼]

我知道到目前為止您可能已經厭倦了聽到關於資料型別的內容。但是,它們非常重要,否則我不會將如此多的時間投入到它們身上。如果您有一個例如包含許多許多值的資料型別,資料型別會提供一種符號上的便利。這些被稱為命名欄位。


命名欄位

[編輯 | 編輯原始碼]

考慮一個數據型別,其目的是儲存配置設定。通常,當您從這種型別中提取成員時,您實際上只關心許多設定中的一兩個。此外,如果許多設定具有相同的型別,您可能會經常發現自己想知道“等等,這是第四個還是第五個元素?”您可以做的一件事是編寫訪問器函式。考慮以下為終端程式製作的配置型別

data Configuration =
    Configuration String          -- user name
                  String          -- local host
                  String          -- remote host
                  Bool            -- is guest?
                  Bool            -- is super user?
                  String          -- current directory
                  String          -- home directory
                  Integer         -- time connected
              deriving (Eq, Show)

然後,您可以編寫訪問器函式,例如(我只列出了幾個)

getUserName (Configuration un _ _ _ _ _ _ _) = un
getLocalHost (Configuration _ lh _ _ _ _ _ _) = lh
getRemoteHost (Configuration _ _ rh _ _ _ _ _) = rh
getIsGuest (Configuration _ _ _ ig _ _ _ _) = ig
...

您還可以編寫更新函式來更新單個元素。當然,現在如果您在配置中新增一個元素或刪除一個元素,所有這些函式現在都必須採用不同的引數數量。這非常煩人,並且很容易導致錯誤。但是,有一個解決方案。我們只需在資料型別宣告中為欄位命名,如下所示

data Configuration =
    Configuration { username      :: String,
                    localhost     :: String,
                    remotehost    :: String,
                    isguest       :: Bool,
                    issuperuser   :: Bool,
                    currentdir    :: String,
                    homedir       :: String,
                    timeconnected :: Integer
                  }

這將自動為我們生成以下訪問器函式

username :: Configuration -> String
localhost :: Configuration -> String
...

此外,它為我們提供了非常方便的更新方法。以下是一個針對“工作後目錄”和“更改目錄”的簡短示例,這些函式適用於 Configuration

changeDir :: Configuration -> String -> Configuration
changeDir cfg newDir =
    -- make sure the directory exists
    if directoryExists newDir
      then -- change our current directory
           cfg{currentdir = newDir}
      else error "directory does not exist"

postWorkingDir :: Configuration -> String
  -- retrieve our current directory
postWorkingDir cfg = currentdir cfg

因此,一般來說,要將資料型別 y 中的欄位 x 更新為 z,您將編寫 y{x=z}。您可以更改多個欄位;每個欄位都應由逗號分隔,例如 y{x=z, a=b, c=d}

當然,您可以繼續像以前一樣對 Configuration 進行模式匹配。命名欄位只是語法糖;您仍然可以編寫類似於以下的內容

getUserName (Configuration un _ _ _ _ _ _ _) = un

但沒有理由這樣做。最後,您可以像以下示例一樣對命名欄位進行模式匹配

getHostData (Configuration {localhost=lh,remotehost=rh})
  = (lh,rh)

這將變數 lhConfiguration 上的 localhost 欄位匹配,並將變數 rhConfiguration 上的 remotehost 欄位匹配。當然,這些匹配會成功。您還可以透過在這些位置放置值而不是變數名來限制匹配,就像您對標準資料型別一樣。

您可以像下面第一個定義中所示的那樣以舊方式建立 Configuration 的值,也可以像下面第二個定義中所示的那樣以命名欄位的型別建立 Configuration 的值

initCFG =
    Configuration "nobody" "nowhere" "nowhere"
                  False False "/" "/" 0
initCFG' =
    Configuration
       { username="nobody",
         localhost="nowhere",
         remotehost="nowhere",
         isguest=False,
         issuperuser=False,
         currentdir="/",
         homedir="/",
         timeconnected=0 }

儘管第二個可能更容易理解,除非您在程式碼中新增大量註釋。




更多列表

[編輯 | 編輯原始碼]

待辦事項:在此處新增內容

標準列表函式

[編輯 | 編輯原始碼]

回想一下,內建 Haskell 列表資料型別的定義等效於

data List a = Nil
            | Cons a (List a)

除了 Nil 被稱為 []Cons x xs 被稱為 x:xs 之外。這只是為了使模式匹配更容易,程式碼更小。讓我們研究一些標準列表函式是如何編寫的。考慮 map。下面給出了一個定義

map _ [] = []
map f (x:xs) = f x : map f xs

這裡,第一行說當您對空列表進行 map 操作時,無論函式是什麼,您都會得到一個空列表作為結果。第二行說當您對以 x 作為頭部、以 xs 作為尾部的列表進行 map 操作時,結果是 f 應用於 x,然後連線到 fxs 進行對映的結果。


filter 可以類似地定義

filter _ [] = []
filter p (x:xs) | p x = x : filter p xs
                | otherwise = filter p xs

這應該很清楚。對於空列表,我們返回一個空列表。對於非空列表,我們返回尾部的過濾器,可能在前面加上頭部,這取決於它是否滿足謂詞 p


我們可以將 foldr 定義為

foldr _ z [] = z
foldr f z (x:xs) = f x (foldr f z xs)

這裡,最好的解釋是我們將空列表 ([]) 替換為特定值,並將列表構造器 (:) 替換為某些函式。在第一行中,我們可以看到用 z 替換 []。使用反引號使 f 成為中綴運算子,我們可以將第二行寫成

foldr f z (x:xs) = x `f` (foldr f z xs)

由此,我們可以直接看到 : 如何被 f 替換。


最後,foldl

foldl _ z [] =  z
foldl f z (x:xs) = foldl f (f z x) xs

這有點複雜。請記住,z 可以被認為是當前狀態。因此,如果我們對一個空列表進行摺疊,我們只需返回當前狀態。另一方面,如果列表不為空,它將是 x:xs 的形式。在這種情況下,我們透過將 f 應用於當前狀態 z 和當前列表元素 x 來獲得一個新的狀態,然後遞迴地呼叫 foldlxs 使用這個新的狀態。


還有一類函式:zipunzip 函式,它們分別採用多個列表並生成一個列表,或者採用一個列表並將它們拆分。例如,zip 執行以下操作

示例

Prelude> zip "hello" [1,2,3,4,5]
[('h',1),('e',2),('l',3),('l',4),('o',5)]

基本上,它將兩個列表的第一個元素配對,並將其作為新列表的第一個元素。然後,它將兩個列表的第二個元素配對,並將其作為第二個元素,依此類推。如果列表的長度不相等怎麼辦?它會在較短的列表結束時停止。zip 的一個合理的定義是

zip [] _ = []
zip _ [] = []
zip (x:xs) (y:ys) = (x,y) : zip xs ys

unzip 函式做相反的事情。它接受一個壓縮列表並返回兩個“原始”列表

示例

Prelude> unzip [('f',1),('o',2),('o',3)]
("foo",[1,2,3])

有一系列 zipunzip 函式,名為 zip3unzip3zip4unzip4 等等;...3 函式使用三元組而不是對;...4 函式使用四元組,等等。


最後,函式take接受一個整數和一個列表,並返回列表中的前個元素。相應地,drop接受一個整數和一個列表,並返回丟棄列表中前個元素後的結果。這兩個函式都不會產生錯誤;如果過大,它們都會返回較短的列表。


列表推導式

[edit | edit source]

對於處理元素屬於Enum類(參見例項部分)的列表,例如IntChar,有一些語法糖。如果我們想建立一個包含從的所有元素的列表,我們可以簡單地寫


示例

Prelude> [1..10]
[1,2,3,4,5,6,7,8,9,10]

我們也可以引入一個步長

示例

Prelude> [1,3..10]
[1,3,5,7,9]
Prelude> [1,4..10]
[1,4,7,10]

這些表示式分別是enumFromToenumFromThenTo的簡寫形式。當然,您不需要指定上限。試試以下程式碼(但準備好按Control+C停止計算!)

示例

Prelude> [1..]
[1,2,3,4,5,6,7,8,9,10,11,12{Interrupted!}

你可能打印出來的元素比這多幾千個。正如我們之前所說,Haskell 是惰性的。這意味著從 1 開始的所有數字的列表是完全有效的,這就是這個列表的樣子。當然,如果你嘗試列印這個列表(我們在直譯器中輸入它時隱式地這樣做),它不會停止。但如果我們只評估這個列表的初始部分,我們就沒事了

示例

Prelude> take 3 [1..]
[1,2,3]
Prelude> take 3 (drop 5 [1..])
[6,7,8]

這很有用,例如,如果我們想為列表中的每個元素分配一個 ID。如果沒有惰性,我們將不得不編寫類似於下面的程式碼

assignID :: [a] -> [(a,Int)]
assignID l = zip l [1..length l]

這意味著列表將被遍歷兩次。然而,由於惰性,我們可以簡單地寫

assignID l = zip l [1..]

我們會得到我們想要的東西。我們可以看到它是有效的

示例

Prelude> assignID "hello"
[('h',1),('e',2),('l',3),('l',4),('o',5)]

最後,對於mapfilter,有一些基於數學中標準集合表示法的有用語法糖。在數學中,我們可以寫類似於表示當應用於滿足元素時,的所有值的集合。這等效於Haskell語句map f (filter p s)。然而,我們也可以使用更像數學的表示法,寫成[f x | x <- s, p x]。雖然在數學中,管道後的語句順序是自由的,但在Haskell中並非如此。我們不能將p x放在x <- s之前,否則編譯器將不知道x是什麼。我們可以使用它來進行簡單的字串處理。假設我們想獲取一個字串,只保留大寫字母,並將這些大寫字母轉換為小寫字母。我們可以用以下兩種等效方式來實現

示例

Prelude> map toLower (filter isUpper "Hello World")
"hw"
Prelude> [toLower x | x <- "Hello World", isUpper x]
"hw"

這兩種方式是等效的,並且,根據您使用的具體函式,其中一種可能比另一種更易讀。但我們還可以做更多的事情。假設你想建立一個包含對的列表,每個對都表示 (0,0) 和 (5,7) 之間的點,且在對角線下方。使用列表和對映手動執行此操作將很麻煩,並且可能難以閱讀。使用列表推導式再簡單不過了

示例

Prelude> [(x,y) | x <- [1..5], y <- [x..7]]
[(1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(1,7),(2,2),(2,3),
(2,4),(2,5),(2,6),(2,7),(3,3),(3,4),(3,5),(3,6),(3,7),
(4,4),(4,5),(4,6),(4,7),(5,5),(5,6),(5,7)]

如果您反轉x <-y <-子句的順序,遍歷空間的順序將被反轉(當然,在這種情況下,y將不再依賴於x,您需要使x依賴於y,但這很簡單)。




陣列

[edit | edit source]

列表在很多方面都很棒。它很容易向列表的開頭新增元素,並以多種方式操作列表,從而改變列表的長度。然而,它們不適合隨機訪問,因為訪問任意元素的平均複雜度為(如果您不知道的含義,您可以忽略它,或者快速繞道閱讀附錄章節複雜度,這是一篇關於複雜度理論的雙頁介紹)。因此,如果您願意放棄快速插入和刪除,因為您需要隨機訪問,那麼您應該使用陣列而不是列表。


為了使用陣列,您必須匯入Array模組。有幾種建立陣列的方法,array函式、listArray函式和accumArray函式。array函式接受一對,即陣列的邊界,以及一個關聯列表,用於指定陣列的初始值。listArray函式接受邊界,然後簡單地接受一個值列表。最後,accumArray函式接受一個累加函式、一個初始值和一個關聯列表,並將列表中的對累加到陣列中。以下是一些建立陣列的示例

示例

Prelude> :m Array
Prelude Array> array (1,5) [(i,2*i) | i <- [1..5]]
array (1,5) [(1,2),(2,4),(3,6),(4,8),(5,10)]
Prelude Array> listArray (1,5) [3,7,5,1,10]
array (1,5) [(1,3),(2,7),(3,5),(4,1),(5,10)]
Prelude Array> accumArray (+) 2 (1,5) [(i,i) | i <- [1..5]]
array (1,5) [(1,3),(2,4),(3,5),(4,6),(5,7)]

當陣列被打印出來(透過 show 函式)時,它們會被列印成一個關聯列表。例如,在第一個例子中,關聯列表表明陣列在 的值是 ,陣列在 的值是 ,等等。


你可以使用 `!` 函式提取陣列中的元素,該函式接受一個數組和一個索引,例如:

示例

Prelude Array> (listArray (1,5) [3,7,5,1,10]) ! 3
5

此外,你可以使用 `//` 函式更新陣列中的元素。該函式接受一個數組和一個關聯列表,並更新列表中指定的元素位置。

示例

Prelude Array> (listArray (1,5) [3,7,5,1,10]) // [(2,99),(3,-99)]
array (1,5) [(1,3),(2,99),(3,-99),(4,1),(5,10)]

還有一些其他的函式值得關注


bounds 返回陣列的邊界
indices 返回陣列中所有索引的列表
elems 按順序返回陣列中所有值的列表
assocs 返回陣列的關聯列表

如果我們將 `arr` 定義為 `listArray (1,5) [3,7,5,1,10]`,這些函式應用於 `arr` 的結果將是

示例

Prelude Array> bounds arr
(1,5)
Prelude Array> indices arr
[1,2,3,4,5]
Prelude Array> elems arr
[3,7,5,1,10]
Prelude Array> assocs arr
[(1,3),(2,7),(3,5),(4,1),(5,10)]

請注意,雖然陣列是 訪問,但它們不是 更新。實際上它們是 更新,因為為了保持純淨性,必須複製陣列才能進行更新。因此,函式式陣列幾乎只在您需要一次性填充並只讀取時才有用。如果您需要快速訪問和更新,您可能應該使用 `FiniteMap`,它將在 Finitemaps 部分進行討論,並且具有 訪問和更新。



對映

[edit | edit source]

來自 `Data.Map` 模組的 `Map` 資料型別是平衡樹的純函式實現。對映可以與列表和陣列進行比較,比較的是在固定大小為 的這些資料型別上執行各種操作所需的時間。簡要比較如下:

列表 陣列 對映
插入
更新
刪除
查詢
對映

我們可以看到,列表提供了快速插入(但其他操作都很慢),陣列提供了快速查詢(但其他操作都很慢),而對映則提供了中等速度的所有操作。

對映的型別為 `Map k a`,其中 `k` 是鍵的型別,`a` 是元素的型別。也就是說,對映是從型別 `k` 到型別 `a` 的查詢表。

基本的對映函式有

empty  :: Map k a
insert :: k -> a -> Map k a -> Map k a
delete :: k -> Map k a -> Map k a
member :: k -> Map k a -> Bool
lookup :: k -> Map k a -> a

在所有這些情況下,型別 `k` 必須是 `Ord` 的例項(因此也是 `Eq` 的例項)。

還有一些函式 `fromList` 和 `toList` 用於將列表轉換為對映或從對映轉換為列表。請嘗試以下操作

示例

Prelude> :m Data.Map
Prelude Data.Map> let mymap = fromList [('a',5),('b',10),('c',1),('d',2)]
Prelude Data.Map> let othermap = insert 'e' 6 mymap
Prelude Data.Map> toList mymap
[('a',5),('b',10),('c',1),('d',2)]
Prelude Data.Map> toList othermap
[('a',5),('b',10),('c',1),('d',2),('e',6)]
Prelude Data.Map> Data.Map.lookup 'e' othermap
6
Prelude Data.Map> Data.Map.lookup 'e' mymap
*** Exception: user error (Data.Map.lookup: Key not found)








佈局

[edit | edit source]

關於列表的最後一句話

[edit | edit source]

您可能已經厭倦了聽到關於列表的內容,但它們是 Haskell(以及所有函數語言程式設計)的基礎,因此不進一步討論它們將是一件糟糕的事情。

事實證明,`foldr` 實際上是一個非常強大的函式:它可以計算一個原始遞迴函式。原始遞迴函式本質上是隻能使用“for”迴圈,但不能使用“while”迴圈來計算的函式。

實際上,我們可以很容易地用 `foldr` 來定義 `map`

map2 f = foldr (\a b -> f a : b) []

這裡,`b` 是累加器(即結果列表),`a` 是當前正在考慮的元素。實際上,我們可以透過一系列步驟來簡化這個定義

     foldr (\a b -> f a : b) []
==>  foldr (\a b -> (:) (f a) b) []
==>  foldr (\a -> (:) (f a)) []
==>  foldr (\a -> ((:) . f) a) []
==>  foldr ((:) . f) []

這與 `foldr (:) []` 是列表上的恆等函式直接相關。因為,如前所述,`foldr f z` 可以被認為是將列表中的 `[]` 替換為 `z`,將 `:` 替換為 `f`。在這種情況下,我們保持兩者不變,因此它是恆等函式。

實際上,你可以將以下樣式的任何函式轉換為 `foldr`

myfunc [] = z
myfunc (x:xs) = f x (myfunc xs)

透過以中綴形式編寫最後一行,這應該是顯而易見的

myfunc [] = z
myfunc (x:xs) = x `f` (myfunc xs)

顯然,我們只是將 `[]` 替換為 `z`,將 `:` 替換為 `f`。考慮 `filter` 函式

filter p [] = []
filter p (x:xs) =
  if p x
    then x : filter p xs
    else filter p xs

此函式也遵循上述形式。根據第一行,我們可以推斷出z應該等於[],就像map的情況一樣。現在,假設我們將呼叫filter p xs的結果簡稱為b,那麼我們可以將其改寫為

filter p [] = []
filter p (x:xs) =
  if p x then x : b else b

鑑於此,我們可以將filter轉換為一個摺疊

filter p = foldr (\a b -> if p a then a:b else b) []

讓我們考慮一個稍微複雜一點的函式:++++的定義是

(++) []     ys = ys
(++) (x:xs) ys = x : (xs ++ ys)

現在,問題是我們是否可以用摺疊符號表示它。首先,我們可以對第一行應用 eta 歸約,得到

(++) [] = id

透過一系列步驟,我們也可以對第二行進行 eta 歸約

     (++) (x:xs) ys = x : ((++) xs ys)
==>  (++) (x:xs) ys = (x:) ((++) xs ys)
==>  (++) (x:xs) ys = ((x:) . (++) xs) ys
==>  (++) (x:xs) = (x:) . (++) xs

因此,我們得到++的 eta 歸約定義為

(++) []     = id
(++) (x:xs) = (x:) . (++) xs

現在,我們可以嘗試將其轉換為摺疊符號。首先,我們注意到基本情況將[]轉換為id。現在,如果我們假設(++) xs被呼叫為bx被呼叫為a,我們可以得到以下用foldr表示的定義

(++) = foldr (\a b -> (a:) . b) id

這在直覺上是有意義的。如果我們只考慮對++應用一個引數,我們可以將其視為一個函式,它接受一個列表並建立一個函式,該函式在應用時會將此列表附加到另一個列表的前面。在 lambda 函式中,我們假設我們有一個函式b,它將對列表的其餘部分執行此操作,我們需要建立一個函式,它對b和單個元素a執行此操作。為了做到這一點,我們首先應用b,然後將a新增到前面。

我們可以透過以下序列將此表示式進一步簡化為無點風格

==>  (++) = foldr (\a b -> (a:) . b) id
==>  (++) = foldr (\a b -> (.) (a:) b) id
==>  (++) = foldr (\a -> (.) (a:)) id
==>  (++) = foldr (\a -> (.) ((:) a)) id
==>  (++) = foldr (\a -> ((.) . (:)) a) id
==>  (++) = foldr ((.) . (:)) id

這個最終版本是無點的,但並不一定容易理解。推測原始版本更清晰。

作為最後一個例子,考慮concat。我們可以將其寫成

concat []     = []
concat (x:xs) = x ++ concat xs

應該很清楚,摺疊的z元素是[],遞迴函式是++,得到

concat = foldr (++) []
練習
  1. 函式and接受一個布林值列表,當且僅當所有值都為True時返回True。它在空列表上也返回True。用foldr表示此函式。
  2. 函式concatMap的行為是這樣的:concatMap fconcat . map f相同。用foldr表示此函式。
華夏公益教科書