跳轉到內容

另一個 Haskell 教程/語言基礎

來自華夏公益教科書
Haskell
另一個 Haskell 教程
前言
簡介
入門
語言基礎 (解決方案)
型別基礎 (解決方案)
IO (解決方案)
模組 (解決方案)
高階語言 (解決方案)
高階型別 (解決方案)
單子 (解決方案)
高階 IO
遞迴
複雜度

在本章中,我們將介紹 Haskell 的基本概念。除了讓你熟悉互動式環境並向你展示如何編譯一個基本程式之外,我們還將介紹 Haskell 的基本語法,如果你習慣使用 C 和 Java 等語言,這可能對你來說非常陌生。

但是,在我們討論語言的具體細節之前,我們需要確定 Haskell 的一些一般屬性。最重要的是,Haskell 是一種惰性語言,這意味著除非強制執行計算以使用該計算的結果,否則不會進行任何計算。

例如,這意味著你可以定義無限大的資料結構,只要你從不使用整個結構。例如,使用類似命令式的虛擬碼,我們可以建立一個包含數字 的無限連結串列,方法如下

List makeList()
{
  List current = new List();
  current.value = 1;
  current.next = makeList();
  return current;
}

透過檢視這段程式碼,我們可以看到它試圖做什麼:它建立了一個新的列表,將其值設定為 ,然後遞迴地呼叫自身以建立列表的其餘部分。當然,如果你真的寫了這段程式碼並呼叫它,程式永遠不會終止,因為 makeList 會無限地遞迴呼叫自身。

這是因為我們假設這種類似命令式的語言是嚴格的,與惰性相反。嚴格語言通常被稱為“按值呼叫”,而惰性語言被稱為“按名呼叫”。在上面的虛擬碼中,當我們“執行”makeList在第五行,我們嘗試從它中獲取一個。這會導致無限迴圈。

Haskell 中的等效程式碼是

makeList = 1 : makeList

這個程式表示:我們正在定義一個名為 makeList 的東西(這是等號左側的內容)。在等號右側,我們給出 makeList 的定義。在 Haskell 中,冒號運算子用於建立列表(我們很快就會詳細討論)。這個右側表示 makeList 的值是將元素 1 附加到 makeList 的值的開頭。

但是,由於 Haskell 是惰性的(或“按名呼叫”),我們實際上並沒有試圖在此時評估 makeList 是什麼:我們只是記住,如果將來我們需要 makeList 的第二個元素,我們只需要檢視 makeList 就可以了。

現在,如果你嘗試將 makeList 寫入檔案、將其列印到螢幕或計算其元素的總和,操作將不會終止,因為它將不得不評估一個無限長的列表。但是,如果你只使用列表的有限部分(例如前 個元素),列表是否無限長並不重要。如果你只使用前 個元素,那麼只有前 個元素會被計算出來。這就是惰性。

其次,Haskell 區分大小寫。許多語言都是這樣,但 Haskell 實際上使用大小寫來賦予意義。Haskell 區分(例如,數字:);字串:"abc"、"hello"、...;字元:'a'、'b'、' '、...;甚至是函式(例如,對值進行平方運算的函式,或平方根函式);以及型別(值所屬的類別)。

這本身並不奇怪。大多數語言都有一些型別系統。不尋常的是,Haskell要求賦予函式和值的名稱以小寫字母開頭,而賦予型別的名稱以大寫字母開頭。要點是:如果你的程式在其他方面是正確的,但無法編譯,請確保你沒有將函式命名為 Foo,或者其他以大寫字母開頭的名稱。

作為一種函式式語言,Haskell 避免副作用。副作用本質上是在執行函式的過程中發生的,與該函式產生的輸出無關的事情。

例如,在 C 或 Java 等語言中,你可以從函式內部修改“全域性”變數。這是一種副作用,因為修改此全域性變數與函式產生的輸出無關。此外,修改現實世界中的狀態也被認為是一種副作用:將內容列印到螢幕、讀取檔案等,都是有副作用的操作。

沒有副作用的函式被稱為函式。判斷一個函式是否純淨的簡單測試是問自己一個簡單的問題:“這個函式的結果是否只依賴於它接收的引數,並且返回結果是它唯一做的操作?”

所有這些意味著,如果你習慣於在命令式語言(如 C 或 Java)中編寫程式碼,你將不得不開始改變思維方式。最重要的是,如果你有一個值 x,你決不應該將 x 視為暫存器、記憶體位置或其他任何類似的東西。x 只是一個名稱,就像“哈爾”是我的名字一樣。你不能隨意決定將其他人儲存在我的名字中,就像你不能隨意決定將其他值儲存在 x 中一樣。這意味著像以下 C 程式碼這樣的程式碼在 Haskell 中是無效的(而且沒有對應的程式碼)

   int x = 5;
   x = x + 1;

類似x = x + 1這樣的呼叫被稱為破壞性更新,因為我們正在破壞之前在x中的內容,並用新值替換它。破壞性更新在 Haskell 中不存在。

透過禁止破壞性更新(或任何其他副作用操作),Haskell 程式碼非常易於理解。也就是說,當我們在程式開頭定義一個函式 f 並用一個特定的引數 a 呼叫它,然後在程式結束時再次用同一個引數 a 呼叫 f 時,我們知道會得到相同的結果。這是因為我們知道 a 不會改變,並且我們知道 f 只依賴於 a(例如,它沒有增加全域性計數器)。這個屬性被稱為引用透明,它基本上說明如果兩個函式 fg 對相同引數產生相同的值,那麼我們可以用 g 替換 f(反之亦然)。

注意

沒有關於引用透明性的確切定義的共識。上面給出的定義是我最喜歡的。它們都具有相同的解釋;區別在於它們是如何形式化的。




讓我們從簡單的算術開始探索 Haskell。啟動您最喜歡的互動式 shell(Hugs 或 GHCi;有關安裝說明,請參見章節 入門)。shell 將在螢幕上輸出幾行關於自身及其操作的資訊,然後應該用游標結束在顯示以下內容的一行上:

示例

Prelude>


從這裡,您可以開始評估表示式。表示式基本上是指具有值的任何事物。例如,數字 是一個表示式(它的值為 )。值可以從其他值構建起來;例如, 是一個表示式(它的值為 )。事實上,Haskell 支援大多數簡單的算術運算,包括加法 (+)、減法 (-)、乘法 (*)、除法 (/)、冪運算 (^) 和平方根 (sqrt)。您可以透過要求互動式 shell 評估表示式並給出其值來嘗試這些運算。這樣,Haskell shell 可以用作強大的計算器。嘗試以下一些內容

示例

Prelude> 5*4+3
23
Prelude> 5^5-2
3123
Prelude> sqrt 2
1.4142135623730951
Prelude> 5*(4+3)
35

我們可以看到,除了標準的算術運算之外,Haskell 還允許使用括號進行分組,因此5*4+35*(4+3)的值不同。這是因為第一個表示式的“已知”分組是(5*4)+3,這是由於運算子優先順序

還要注意,函式引數周圍不需要括號。例如,我們只是寫了 sqrt 2,而不是 sqrt(2),如大多數其他語言中所要求的那樣。您可以使用括號寫它,但在 Haskell 中,由於函式應用非常普遍,因此不需要括號。

現在嘗試輸入2^5000它有效嗎?

注意

如果您熟悉其他語言中的程式設計,您可能會發現sqrt 2返回帶小數點的結果(即浮點數),即使函式的引數似乎是整數,這一點很奇怪。這種數值型別的可互換性是由於 Haskell 的型別類系統,將在關於 型別基礎的部分中詳細討論。


練習
我們已經看到,乘法的結合性強於加法。你能想到一種方法來確定函式應用的結合性是強於乘法還是弱於乘法嗎?



對、三元組等等

[編輯 | 編輯原始碼]

除了單個值之外,我們還應該處理多個值。例如,我們可能希望透過其 / 座標來引用位置,這將是一對整數。建立一對整數很簡單:您將它們括在括號中,並用逗號分隔。嘗試以下操作

示例

Prelude> (5,3)
(5,3)

在這裡,我們有一對整數,。在 Haskell 中,一對中的第一個元素不需要與第二個元素具有相同的型別:也就是說,對可以是異構的。例如,您可以擁有一對整數和字串。這與列表形成對比,列表必須由所有相同型別的元素組成(我們將在關於 列表的部分中進一步討論列表)。


有兩個預定義的函式允許您提取一對的第一個和第二個元素。它們分別是 fstsnd。您可以看到它們在下面是如何工作的

示例

Prelude> fst (5, "hello")
5
Prelude> snd (5, "hello")
"hello"

除了對之外,您還可以定義三元組、四元組等。要分別定義三元組和四元組,我們寫

示例

Prelude> (1,2,3)
(1,2,3)
Prelude> (1,2,3,4)
(1,2,3,4)

等等。一般來說,對、三元組等等被稱為元組,可以儲存固定數量的異構資料。

注意

fstsnd 函式不會對超過一對的任何東西起作用;如果您嘗試將它們用於更大的元組,您將收到一條訊息,說明發生了型別錯誤。本錯誤訊息的含義將在章節 型別基礎中解釋。


練習

使用fstsnd的組合來提取字元 'a'

從元組 ((1,'a'),"foo") 中提取。



元組的主要限制是它們只能容納固定數量的元素:對容納兩個元素,三元組容納三個元素,依此類推。可以容納任意數量元素的資料結構是列表。列表的組合方式與元組非常相似,只是它們使用方括號而不是括號。我們可以像這樣定義一個列表

示例

Prelude> [1,2]
[1,2]
Prelude> [1,2,3]
[1,2,3]

列表不需要包含任何元素。空列表就是 []

與元組不同,我們可以使用冒號運算子非常容易地將一個元素新增到列表的開頭。冒號被稱為“cons”運算子;新增元素的過程被稱為“consing”。這詞的詞源在於我們從一個元素和一箇舊列表中construct了一個新列表。我們可以在以下示例中看到 cons 運算子的實際應用

示例

Prelude> 0:[1,2]
[0,1,2]
Prelude> 5:[1,2,3,4]
[5,1,2,3,4]

實際上,我們可以使用 cons 運算子(冒號)和空列表來構建任何列表

示例

Prelude> 5:1:2:3:4:[]
[5,1,2,3,4]

事實上,[5,1,2,3,4] 語法是使用顯式 cons 運算子和空列表的表示式的“語法糖”。如果我們使用 [5,1,2,3,4] 符號寫東西,編譯器會簡單地將它轉換為使用 (:)[] 的表示式。

注意

一般來說,“語法糖”是一種嚴格不需要的語言特性,它是為了使語法更友好而新增的。

列表和元組之間另一個重要的區別是,雖然元組可以是異構的,但列表必須是同構的。這意味著你不能建立一個同時包含整數和字串的列表。如果你嘗試這樣做,就會出現型別錯誤。

當然,列表不一定要只包含整數或字串;它們也可以包含元組,甚至其他列表。類似地,元組也可以包含列表和其他元組。試試以下內容:

示例

Prelude> [(1,1),(2,4),(3,9),(4,16)]
[(1,1),(2,4),(3,9),(4,16)]
Prelude> ([1,2,3,4],[5,6,7])
([1,2,3,4],[5,6,7])

有兩種基本的列表函式:headtailhead 函式返回一個(非空)列表的第一個元素,tail 函式返回一個(非空)列表中除了第一個元素之外的所有元素。


要獲取列表的長度,可以使用 length 函式。

示例

Prelude> length [1,2,3,4,10]
5
Prelude> head [1,2,3,4,10]
1
Prelude> length (tail [1,2,3,4,10])
4

在 Haskell 中,String 只是一個 Char 的列表。因此,我們可以建立字串 "Hello" 如下:

示例

Prelude> 'H':'e':'l':'l':'o':[]
"Hello"

列表(當然還有字串)可以使用 ++ 運算子連線。

示例

Prelude> "Hello " ++ "World"
"Hello World"


此外,非字串值可以使用 show 函式轉換為字串,字串可以使用 read 函式轉換為非字串值。當然,如果你嘗試讀取格式錯誤的值,就會出現錯誤(注意,這是一個執行時錯誤,而不是編譯時錯誤)。

示例

Prelude> "Five squared is " ++ show (5*5)
"Five squared is 25"
Prelude> read "5" + 3
8
Prelude> read "Hello" + 3
Program error: Prelude.read: no parse

在上面,確切的錯誤訊息取決於實現。但是,直譯器已經推斷出你試圖將 3 加到某個東西上。這意味著當我們執行 read "Hello" 時,我們希望返回一個數字。但是,"Hello" 不能解析為數字,因此出現錯誤。

簡單列表函式

[編輯 | 編輯原始碼]

Haskell 程式中的大部分計算都是透過處理列表完成的。有三個主要的列表處理函式:mapfilterfoldr(以及 foldl)。

map 函式接受一個值列表和一個應該應用於每個值的函式作為引數。它返回此應用的結果。例如,有一個內建函式 Data.Char.toUpper,它接受一個 Char 作為輸入,並生成一個 Char,它是原始引數的大寫版本。因此,要將整個字串(只是一個字元列表)轉換為大寫,我們可以將 toUpper 函式對映到整個列表上。

示例

Prelude> map Data.Char.toUpper "Hello World"
"HELLO WORLD"


當你對列表進行對映時,列表的長度永遠不會改變——只有列表中的單個值會改變。

要從列表中刪除元素,可以使用 filter 函式。此函式允許你根據元素的值從列表中刪除某些元素,但不能根據它們的上下文。例如,函式 Data.Char.isLower 告訴你給定字元是否是小寫。我們可以使用它過濾掉所有非小寫字元。

示例

Prelude> filter Data.Char.isLower "Hello World"
"elloorld"

函式 foldr 需要更多時間來理解。foldr 接受三個引數:一個函式、一個初始值和一個列表。理解 foldr 的最好方法是,它用函式引數替換列表連線運算子 (:),用初始值替換空列表建構函式 ([])。因此,如果我們有一個列表:

  3 : 8 : 12 : 5 : []

並且我們將 foldr (+) 0 應用於它,我們得到:

  3 + 8 + 12 + 5 + 0

它對列表進行求和。我們可以測試它:

示例

Prelude> foldr (+) 0 [3,8,12,5]
28

我們可以執行相同型別的操作來計算列表中所有元素的乘積。

示例

Prelude> foldr (*) 1 [4,8,5]
160

我們之前說過,摺疊就像用特定函式替換 (:) 並用初始元素替換 ([]) 一樣。這引發了一個問題,即當函式不是結合的 () 時會發生什麼 (如果 )。當我們寫 時,我們需要指定括號的位置。即,我們指的是 還是 foldr 假設函式是右結合的(即,正確的括號是後者)。因此,當我們在非結合函式(如減法)上使用它時,我們可以看到它的效果。

示例

Prelude> foldr (-) 1 [4,8,5]
0

此推導的精確過程類似於:

     foldr (-) 1 [4,8,5]
==>  4 - (foldr (-) 1 [8,5])
==>  4 - (8 - foldr (-) 1 [5])
==>  4 - (8 - (5 - foldr (-) 1 []))
==>  4 - (8 - (5 - 1))
==>  4 - (8 - 4)
==>  4 - 4
==>  0

foldl 函式反向進行,實際上產生相反的括號。foldl 應用時看起來一樣,所以我們也可以用 foldl 進行求和。

示例

Prelude> foldl (+) 0 [3,8,12,5]
28

但是,當使用非結合函式減法時,我們得到不同的結果。

示例

Prelude> foldl (-) 1 [4,8,5]
-16

這是因為 foldl 使用相反的括號。它實現此操作的方式本質上是遍歷整個列表,獲取最後一個元素並使用提供的函式將其與初始值組合起來。然後,它獲取列表中的倒數第二個元素,並將它與這個新值組合起來。它會一直這樣做,直到沒有剩餘的列表。

此處的推導以相反的方式進行。

     foldl (-) 1 [4,8,5]
==>  foldl (-) (1 - 4) [8,5]
==>  foldl (-) ((1 - 4) - 8) [5]
==>  foldl (-) (((1 - 4) - 8) - 5) []
==>  ((1 - 4) - 8) - 5
==>  ((-3) - 8) - 5
==>  (-11) - 5
==>  -16

請注意,一旦 foldl 消失,括號就與 foldr 恰好相反。

注意

由於我們將在關於 列表 的部分中討論的原因,foldl 通常比 foldr 更有效。但是,foldr 可以作用於無限列表,而 foldl 則不能。這是因為在 foldl 做任何事情之前,它必須到達列表的末尾。另一方面,foldr 可以立即開始生成輸出。例如,foldr (:) [] [1,2,3,4,5] 只是返回相同的列表。即使列表是無限的,它也會產生輸出。使用 foldl 的類似函式將無法產生任何輸出。

如果關於摺疊函式的討論仍然不太清楚,沒關係。我們將在關於 列表 的部分中進一步討論它們。

練習

使用 map 將字串轉換為布林值列表,新列表中的每個元素代表原始元素是否是小寫字元。也就是說,它應該接受字串 "aBCde"

並返回 [True,False,False,True,True]
練習

使用本節中提到的函式(你需要使用其中兩個)來計算字串中小寫字母的數量。例如,對於

"aBCde",它應該返回 3。
練習

我們已經瞭解瞭如何使用摺疊函式計算總和和乘積。鑑於函式 max 返回兩個數字中的最大值,請編寫一個使用摺疊的函式,它將返回列表中的最大值(如果列表為空,則返回零)。因此,當應用於 [5,10,2,8,1] 時,它將返回 10。假設列表中的

值始終 。解釋一下為什麼它有效。
練習

編寫一個函式,它接受一個至少包含兩個元素的成對列表,並返回列表中第二個元素的第一個分量。因此,當提供 [(5,'b'),(1,'c'),(6,'a')] 時,它將返回

1.



原始碼檔案

[編輯 | 編輯原始碼]

作為程式設計師,我們不僅僅想評估這些簡單的表示式,我們想坐下來,在我們選擇的編輯器中編寫程式碼,儲存它,然後使用它。

我們已經在 GhcNhc 部分看到了如何編寫 Hello World 程式以及如何編譯它。在這裡,我們將展示如何在互動式環境中使用原始碼檔案中定義的函式。為此,建立一個名為Test.hs的檔案,並輸入以下程式碼

module Test
    where

x = 5
y = (6, "Hello")
z = x * fst y

這是一個用 Haskell 編寫的非常簡單的“程式”。它定義了一個名為“Test”的模組(通常模組名稱應該與檔名匹配;有關此方面的更多資訊,請參閱關於 模組 的部分)。在這個模組中,有三個定義:x、y 和 z。在你編寫並儲存了此檔案後,在你儲存它的目錄中,透過執行以下任一操作,在你的首選直譯器中載入它

示例

% hugs Test.hs
% ghci Test.hs

這將分別啟動 Hugs 或 GHCi,並載入該檔案。或者,如果你已經載入了其中一個,可以使用“:load”命令(或只使用“:l”)來載入一個模組,例如

示例

Prelude> :l Test.hs
...
Test>

在第一行和最後一行之間,直譯器將列印各種資料來解釋它正在做什麼。如果出現任何錯誤,你可能在檔案中輸入了錯誤的內容;仔細檢查,然後重試。

你會注意到,它曾經顯示“Prelude”,現在顯示“Test”。這意味著 Test 是當前模組。Prelude 模組(通常簡稱為“Prelude”)始終載入,幷包含標準定義(例如,用於列表的(:)運算子,或(+)或(*)、fstsnd 等等)。

現在我們已經載入了 Test,我們可以使用在其中定義的東西。例如

示例

Test> x
5
Test> y
(6,"Hello")
Test> z
30

完美,正如我們預期的那樣!

關於如何將程式編譯成獨立可執行檔案,還有一個最後的問題。為了使程式成為可執行檔案,它必須具有模組名稱“Main”,並且必須包含一個名為main的函式。因此,如果你進入 Test.hs 並將其重新命名為“Main”(將讀取module Test的行更改為module Main),我們只需要新增一個 main 函式。試試這個


main = putStrLn "Hello World"

現在,儲存該檔案,並編譯它(參考 入門 部分,瞭解如何針對你的編譯器執行此操作)。例如,在 GHC 中,你會說

示例

% ghc --make Test.hs -o test

注意

對於 Windows,它將是“-o test.exe”

這將建立一個名為“test”(或在 Windows 上名為“test.exe”)的檔案,你然後可以執行它。

示例

% ./test
Hello World

注意

或者,在 Windows 上

示例

C:\> test.exe
Hello World



現在我們已經瞭解瞭如何在檔案中編寫程式碼,我們可以開始編寫函數了。正如你可能預期的那樣,函式是 Haskell 的核心,因為它是函式式語言。這意味著程式的評估只是函式的評估。

我們可以編寫一個簡單的函式來對數字進行平方,並將其輸入到我們的Test.hs檔案中。我們可以這樣定義它

square x = x * x

在這個函式定義中,我們說我們正在定義一個名為square的函式,它接受一個引數(又名引數),我們稱之為x。然後我們說square x等於x * x

Haskell 也支援標準條件表示式。例如,我們可以定義一個函式,如果它的引數小於 ,則返回 ;如果它的引數 ,則返回 ;如果它的引數大於 ,則返回 (這被稱為符號函式)

signum x =
    if x < 0
      then -1
      else if x > 0
        then 1
        else 0

你可以像這樣進行實驗

示例

Test> signum 5
1
Test> signum 0
0
Test> signum (5-10)
-1
Test> signum (-1)
-1

請注意,最後一個示例中“ -1”周圍的括號是必需的;如果缺少,系統將認為你試圖從“signum”的值中減去“1”的值,這將導致型別錯誤。

Haskell 中的 if/then/else 結構與大多數其他程式語言中的結構非常相似;但是,你必須同時擁有then else子句。它評估條件(在本例中為 ),如果它評估為True,它將評估then子句;如果條件評估為False,它將評估else子句)。

你可以透過編輯該檔案並將其重新載入到直譯器中來測試該程式。如果Test已經是當前模組,而不是鍵入:l Test.hs再次,你只需鍵入:reload或只鍵入:r來重新載入當前檔案。這通常快得多。

Haskell,像許多其他語言一樣,也支援case結構。當你想針對多個值進行檢查時(case 表示式實際上比這強大得多 - 請參閱關於 模式匹配 的部分以瞭解所有詳細資訊)。

假設我們想要定義一個函式,當其引數為 時,其值為 ;當其引數為 時,其值為 ;當其引數為 時,其值為 ;而在所有其他情況下,其值為 。使用if語句來編寫此函式將會很長且難以閱讀;因此我們使用case語句來編寫它,如下所示(我們稱此函式為 f

f x =
    case x of
      0 -> 1
      1 -> 5
      2 -> 2
      _ -> -1

在這個程式中,我們定義了 f 來接收一個引數 x,然後檢查 x 的值。如果它與 相匹配,則 f 的值為 。如果它與 相匹配,則 f 的值為 。如果它與 相匹配,則 f 的值為 ;如果到目前為止它還沒有匹配到任何內容,則 f 的值為 (下劃線可以被認為是“萬用字元”——它將匹配任何內容)。

這裡的縮排很重要。Haskell 使用一個叫做“佈局”的系統來構建它的程式碼(Python 程式語言也使用類似的系統)。佈局系統允許您編寫程式碼,而無需其他語言(如 C 和 Java)所需的顯式分號和花括號。

佈局的通用規則是,在以下關鍵字之後插入一個開括號:where, let, doof,並記住下一條命令出現的列位置。從那時起,在每個縮排相同的新的行之前插入一個分號。如果後面的行縮排較少,則插入一個閉括號。這可能聽起來很複雜,但如果您遵循在每個關鍵字之後縮排的通用規則,您就不必記住它(有關佈局的更完整討論,請參見佈局部分)。

有些人更喜歡不使用佈局,而是顯式地編寫花括號和分號。這是完全可以接受的。在這種風格中,上面的函式可能看起來像

f x = case x of
        { 0 -> 1 ; 1 -> 5 ; 2 -> 2 ; _ -> -1 }

當然,如果您顯式地編寫花括號和分號,您可以自由地按照您希望的方式構建程式碼。以下也是同樣有效的

f x =
    case x of { 0 -> 1 ;
      1 -> 5 ; 2 -> 2
   ; _ -> -1 }

但是,像這樣構建您的程式碼只會使其難以閱讀(在這種情況下)。

函式也可以分段定義,這意味著您可以為某些引數編寫一個版本的函式,然後為其他引數編寫另一個版本。例如,上面的函式 f 也可以寫成

f 0 = 1
f 1 = 5
f 2 = 2
f _ = -1

這裡,順序很重要。如果我們把最後一行放在第一行,它將匹配所有引數,而 f 將返回 -1,無論其引數是什麼(大多數編譯器會警告您這一點,說一些關於重疊模式的內容)。如果我們沒有包含最後一行,如果對 f 應用了 0、1 或 2 以外的任何內容,f 將會產生錯誤(大多數編譯器也會警告您這一點,說一些關於不完整模式的內容)。這種分段定義的風格非常流行,在本教程中會經常使用。這兩種 f 的定義實際上是等價的——這種分段版本被翻譯成 case 表示式。

更復雜的函式可以透過使用*函式組合*從更簡單的函式中構建。函式組合只是取一個函式應用的結果,並將其用作另一個函式的引數。我們之前已經在算術中看到了這一點(有關算術的部分),當我們寫 5*4+3 時。在這個表示式中,我們計算了 ,然後對結果應用 。我們可以對 squaref 函式做同樣的事情

示例

Test> square (f 1)
25
Test> square (f 2)
4
Test> f (square 1)
5
Test> f (square 2)
-1

這些函式應用的結果都相當簡單。內部函數週圍的括號是必要的;否則,在第一行中,直譯器會認為您試圖獲取“square f”的值,而這沒有意義。像這樣的函式應用在大多數程式語言中都很常見。還有一種更面向數學的方法來表達函式組合,使用 (.)(只是一個句號)函式。這個 (.) 函式應該看起來像數學中的 () 運算子。

注意

在數學中,我們寫 表示“f 接著 g”,在 Haskell 中,我們也寫 f . g 表示“f 接著 g”。

的含義很簡單,就是 。也就是說,將值 應用於函式 與將其應用於 ,獲取結果,然後將其應用於 是相同的。


. 函式(稱為函式組合函式)接受兩個函式並將它們組合成一個。例如,如果我們寫 (square . f),這意味著它建立了一個新函式,該函式接受一個引數,將 f 應用於該引數,然後將 square 應用於結果。相反,(f . square) 意味著它建立了一個新函式,該函式接受一個引數,將 square 應用於該引數,然後將 f 應用於結果。我們可以透過像以前一樣測試它來看到這一點

示例

Test> (square . f) 1
25
Test> (square . f) 2
4
Test> (f . square) 1
5
Test> (f . square) 2
-1

這裡,我們必須將函式組合括在括號中;否則,Haskell 編譯器會認為我們試圖在第一行將 square 與值 f 1 組合,這沒有意義,因為 f 1 甚至不是函式。

花一點時間檢視 Prelude 中定義的一些函式可能是一個好主意。毫無疑問,在某些時候,你可能會意外地重新編寫一些已經存在的函式(我這樣做過不止一次),但是如果我們能將這種情況降到最低,將會節省很多時間。以下是一些簡單的函式,其中一些我們已經見過

sqrt 平方根函式
id 恆等函式:id x = x
fst 從一對中提取第一個元素
snd 從一對中提取第二個元素
null 告訴你一個列表是否為空
head 返回非空列表的第一個元素
tail 返回非空列表的除第一個元素以外的所有元素
++ 連線兩個列表
== 檢查兩個元素是否相等
/= 檢查兩個元素是否不相等

這裡,我們展示了這些函式的示例用法

示例

Prelude> sqrt 2
1.41421
Prelude> id "hello"
"hello"
Prelude> id 5
5
Prelude> fst (5,2)
5
Prelude> snd (5,2)
2
Prelude> null []
True
Prelude> null [1,2,3,4]
False
Prelude> head [1,2,3,4]
1
Prelude> tail [1,2,3,4]
[2,3,4]
Prelude> [1,2,3] ++ [4,5,6]
[1,2,3,4,5,6]
Prelude> [1,2,3] == [1,2,3]
True
Prelude> 'a' /= 'b'
True
Prelude> head []

Program error: {head []}

我們可以看到,將 head 應用於空列表會導致錯誤(確切的錯誤訊息取決於你使用的是 GHCi 還是 Hugs - 顯示的錯誤訊息來自 Hugs)。

Let 繫結

[edit | edit source]

我們通常希望為在函式中使用提供區域性宣告。例如,以下等式用於找到形如 的多項式的根(零點):。我們可以編寫以下函式來計算 的兩個值

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

不幸的是,我們在這裡重複了表示式。為了解決這個問題,Haskell 允許區域性繫結。也就是說,我們可以在函式內部建立只有該函式才能看到的變數。例如,我們可以為表示式 sqrt(b*b-4*a*c) 建立一個區域性繫結,將其命名為 discr 表示判別式,然後在出現 sqrt(b*b - 4*a*c) 的兩個地方使用它。我們可以使用一個let/in宣告

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

事實上,你可以在 let 中提供多個宣告。只要確保它們的縮排量相同,否則你將遇到佈局問題

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

中綴

[edit | edit source]

中綴函式是指由符號而不是字母組成的函式。例如,(+)(*)(++) 都是中綴函式。你可以透過將它們括在括號中來在非中綴模式下使用它們。因此,以下兩個表示式是相同的

示例

Prelude> 5 + 10
15
Prelude> (+) 5 10
15

類似地,非中綴函式(如 map)可以透過將它們括在反引號(美國鍵盤上的波浪號鍵上的引號)中來變為中綴

示例

Prelude> map Data.Char.toUpper "Hello World"
"HELLO WORLD"
Prelude> Data.Char.toUpper `map` "Hello World"
"HELLO WORLD"



註釋

[edit | edit source]

Haskell 中有兩種型別的註釋:行註釋和塊註釋。行註釋以標記 -- 開頭,一直延續到行尾。塊註釋以 {- 開頭,一直延續到相應的 -}。塊註釋可以巢狀。

注意

Haskell 中的 -- 相當於//C++ 或 Java 中的,而 {--} 則對應於/**/.

註釋用於用英文解釋你的程式,編譯器和直譯器完全忽略它們。例如

module Test2
    where

main =
  putStrLn "Hello World"  -- write a string
                          -- to the screen

{- f is a function which takes an integer and
produces integer.  {- this is an embedded
comment -} the original comment extends to the
matching end-comment token: -}
f x =
    case x of
      0 -> 1    -- 0 maps to 1
      1 -> 5    -- 1 maps to 5
      2 -> 2    -- 2 maps to 2
      _ -> -1   -- everything else maps to -1

這個示例程式展示了行註釋和(嵌入)塊註釋的用法。



在像 C 和 Java 這樣的命令式語言中,最基本的控制結構是迴圈(例如 for 迴圈)。然而,for 迴圈在 Haskell 中沒有多大意義,因為它們需要破壞性更新(索引變數不斷被更新)。相反,Haskell 使用遞迴。

如果一個函式呼叫自身,則該函式是 遞迴 的(有關更多資訊,請參見關於 遞迴 的附錄)。遞迴函式也存在於 C 和 Java 中,但它們的使用頻率低於函式式語言。

int factorial(int n) {
  int fact = 1;
  for (int i=2; i <= n; i++)
    fact = fact * i;
  return fact;
}

典型的遞迴函式是階乘函式。在命令式語言中,你可能會這樣編寫:

雖然這段程式碼片段將成功地計算出正整數的階乘,但它以某種方式忽略了階乘的基本定義,通常給出如下:

這個定義本身就是一個遞迴定義:即 的值取決於 的值。如果你將 視為一個函式,那麼它就在呼叫自身。我們可以將這個定義幾乎逐字翻譯成 Haskell 程式碼

factorial 1 = 1
factorial n = n * factorial (n-1)

這可能是你見過的最簡單的遞迴函式,但它是正確的。

注意

當然,可以編寫一個命令式的遞迴版本

int factorial(int n) {
  if (n == 1)
    return 1;
  else
    return n * factorial(n-1);
}

但它可能比 C 中的迴圈版本慢得多。

遞迴可能是一件很難掌握的事情。它與數學中的歸納法完全類似(有關更正式的處理,請參見關於 遞迴 的章節)。但是,通常一個問題可以被認為具有一個或多個基本情況和一個或多個遞迴情況。在 factorial 的情況下,有一個基本情況(當 時)和一個遞迴情況(當 時)。為了設計你自己的遞迴演算法,區分這兩種情況通常很有用。

現在轉向求冪的任務,假設我們有兩個正整數 ,我們想要計算 。這個問題有一個基本情況:即當 時。遞迴情況是當 時。我們可以將一般形式寫為

同樣,這可以直接翻譯成 Haskell 程式碼

exponent a 1 = a
exponent a b = a * exponent a (b-1)

就像我們在整數上定義遞迴函式一樣,我們也可以在列表上定義遞迴函式。在這種情況下,基本情況通常是空列表[],遞迴情況是一個 cons 列表(即,一個值與另一個列表連線)。

考慮計算列表長度的任務。我們再次可以將其分解為兩種情況:要麼我們有一個空列表,要麼我們有一個非空列表。顯然,空列表的長度為零。此外,如果我們有一個 cons 列表,那麼這個列表的長度就是它尾部的長度加一。因此,我們可以定義一個長度函式為

my_length [] = 0
my_length (x:xs) = 1 + my_length xs

注意

每當我們為標準 Haskell 函式提供備選定義時,我們會在其前面加上my_,這樣編譯器就不會混淆。

類似地,我們可以考慮filter 函式。同樣,基本情況是空列表,遞迴情況是 cons 列表。但是,這次,我們選擇是否保留一個元素,取決於一個特定的謂詞是否成立。我們可以將過濾器函式定義為

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

在這段程式碼中,當遇到一個空列表時,我們簡單地返回一個空列表。這是因為過濾器不能新增元素;它只能刪除它們。

當遇到一個形如(x:xs) 的列表時,我們需要決定是否保留值x。為此,我們使用一個if語句和謂詞p。如果p x 為真,那麼我們返回一個以x 開頭的列表,後面跟著過濾列表尾部的結果。如果p x 為假,那麼我們排除x 並返回過濾列表尾部的結果。

我們還可以使用顯式遞迴來定義map 和兩個 fold 函式。有關map 的定義,請參閱練習;有關 folds 的定義,請參閱章節 語言高階

練習

斐波那契數列定義為

編寫一個遞迴函式fib,該函式以正整數n 作為引數,並計算 .
練習
定義一個遞迴函式mult,該函式接受兩個正整數ab,並返回a*b,但只使用加法(即,不使用乘法)。首先,以上一練習和本節其餘部分的風格給出數學定義。
練習
定義一個遞迴函式my_map,它的行為與標準函式map 相同。




互動性

[edit | edit source]

如果您熟悉其他(命令式)語言的書籍,您可能想知道為什麼您沒有在其他語言的教程中看到許多標準程式(例如,那些詢問使用者姓名然後用姓名向他打招呼的程式)。原因很簡單:作為一種純函式式語言,如何處理使用者輸入並不完全清楚。

畢竟,假設您有一個從鍵盤讀取字串的函式。如果您呼叫此函式兩次,並且使用者第一次輸入了一些東西,第二次輸入了其他東西,那麼您將不再擁有一個函式,因為它會返回兩個不同的值。這個問題的解決方案是在範疇論(形式數學的一個分支)的深處找到的:單子。我們還沒有準備好正式討論單子,但現在,簡單地將它們視為表達輸入/輸出等操作的便捷方式。我們將在章節 Io 中更詳細地討論它們在上下文中的應用,然後在章節 Monads 中討論單子的本質。


假設我們想要編寫一個互動式的函式。要做到這一點,我們需要使用do關鍵字。這使我們能夠指定操作順序(請記住,通常情況下,由於 Haskell 是一種惰性語言,因此它中操作的評估順序是不確定的)。因此,要編寫一個簡單的程式,它詢問使用者姓名,然後直接稱呼他,請將以下程式碼輸入到“Name.hs”中

module Main
    where

import System.IO

main = do
  hSetBuffering stdin LineBuffering
  putStrLn "Please enter your name: "
  name <- getLine
  putStrLn ("Hello, " ++ name ++ ", how are you?")

注意

第二個putStrLn 例項需要括號,而第一個不需要。這是因為函式應用的優先順序高於++,所以如果沒有括號,第二個會被解釋為(putStrLn "Hello, ") ++ name ++ ...

然後,您可以在直譯器中載入此程式碼,並透過簡單地鍵入“main”來執行main,或者您也可以編譯它,並從命令列執行它。我將展示互動方式的結果

示例

Main> main
Please enter your name:
Hal
Hello, Hal, how are you?
Main>

這就是互動性。讓我們回顧一下程式碼,雖然。我們將模組命名為“Main”,以便我們可以編譯它。我們將主要函式命名為“main”,以便編譯器知道當程式執行時要執行的函式。在第四行,我們匯入IO 庫,以便我們可以訪問 IO 函式。在第七行,我們從do開始,告訴 Haskell 我們正在執行一系列命令。

第一個命令是hSetBuffering stdin LineBuffering,您現在應該忽略它(順便說一句,這只是 GHC 所需的——在 Hugs 中,您可以在沒有它的情況下進行操作)。需要它是因為,當 GHC 讀取輸入時,它期望以相當大的塊進行讀取。一個典型的人名遠不足以填滿這個塊。因此,當我們嘗試從stdin 讀取時,它會等待直到它獲得了一個完整的塊。我們想要擺脫它,所以我們告訴它使用LineBuffering 而不是塊緩衝。

下一個命令是putStrLn,它將字串列印到螢幕上。在第九行,我們說name <- getLine。這通常寫成name = getLine,但使用箭頭而不是等號表示getLine 不是一個真正的函式,可以返回不同的值。此命令的意思是“執行操作getLine,並將結果儲存在name 中”。

最後一行使用我們在前一行中讀取的內容構建一個字串,然後將其列印到螢幕上。


另一個不是真正函式的函式示例是返回隨機值的函式。在這種情況下,執行此操作的函式稱為randomRIO。使用它,我們可以編寫一個“猜數字”程式。將以下程式碼輸入到“Guess.hs”中

module Main
    where

import System.IO
import System.Random

main = do
  hSetBuffering stdin LineBuffering
  num <- randomRIO (1::Int, 100)
  putStrLn "I'm thinking of a number between 1 and 100"
  doGuessing num

doGuessing num = do
  putStrLn "Enter your guess:"
  guess <- getLine
  let guessNum = read guess
  if guessNum < num
    then do putStrLn "Too low!"
            doGuessing num
    else if guessNum > num
           then do putStrLn "Too high!"
                   doGuessing num
           else do putStrLn "You Win!"

讓我們檢查一下這段程式碼。在第五行,我們寫“import Random”,告訴編譯器我們要使用一些隨機函式(這些函式沒有內建到 Prelude 中)。在main 的第一行,我們請求一個範圍在 內的隨機數。我們需要寫::Int 來告訴編譯器我們在這裡使用的是整數——而不是浮點數或其他數字。我們將在關於 型別基礎 的章節中詳細討論這一點。在下一行,我們告訴使用者發生了什麼,然後在main 的最後一行,我們告訴編譯器執行命令doGuessing

doGuessing 函式以使用者嘗試猜測的數字作為引數。首先,它要求使用者猜測,然後從鍵盤接受他們的猜測(這是一個String)。該if語句首先檢查他們的猜測是否過低。但是,由於guess 是一個字串,而num 是一個整數,所以我們首先需要透過read 來將guess 轉換為整數。由於“read guess”是一個普通、純粹的函式(而不是一個 IO 操作),所以我們不需要使用<- 符號(事實上,我們不能);我們只需將值繫結到guessNum。請注意,當我們在do符號中時,我們不需要in用於lets。

如果他們猜測過低,我們會通知他們,然後重新啟動doGuessing。如果他們沒有猜測過低,我們檢查他們是否猜測過高。如果他們猜得過高,我們會告訴他們,然後重新啟動doGuessing。否則,他們沒有猜測過低,也沒有猜測過高,所以他們一定猜對了。我們會告訴他們他們贏了,然後退出。我們退出這一事實隱含在我們沒有以下命令這一事實中。我們不需要顯式的return () 語句。

您可以編譯此程式碼或將其載入到直譯器中,您將獲得類似以下內容的結果

示例

Main> main
I'm thinking of a number between 1 and 100
Enter your guess:
50
Too low!
Enter your guess:
75
Too low!
Enter your guess:
85
Too high!
Enter your guess:
80
Too high!
Enter your guess:
78
Too low!
Enter your guess:
79
You Win!

我們之前看到的遞迴操作實際上並沒有返回我們在任何地方使用的值。在它確實返回值的情況下,編寫命令的“明顯”方式實際上是錯誤的。在這裡,我們將給出錯誤的版本,解釋它為什麼是錯誤的,然後給出正確的版本。

假設我們正在編寫一個簡單的程式,它會反覆要求使用者輸入一些單詞。如果使用者在任何時候輸入空單詞(即,他只按回車鍵而不輸入任何內容),程式會打印出他到那時為止輸入的所有內容,然後退出。此程式中的主要函式(實際上是一個操作)是詢問使用者一個單詞、檢查它是否為空,然後繼續或結束。此操作的不正確公式可能看起來像這樣

askForWords = do
  putStrLn "Please enter a word:"
  word <- getLine
  if word == ""
    then return []
    else return (word : askForWords)

在繼續閱讀之前,看看你是否能找出上面程式碼有什麼問題。

錯誤出現在最後一行,具體來說是 word : askForWords 這一項。請記住,當使用 (:) 時,我們正在用一個元素(在本例中為 word)和另一個列表(在本例中為 askForWords)來建立一個列表。但是,askForWords 不是一個列表;它是一個操作,執行時會產生一個列表。這意味著在我們能夠在前面附加任何東西之前,我們需要執行該操作並獲取結果。在本例中,我們希望做類似的事情

askForWords = do
  putStrLn "Please enter a word:"
  word <- getLine
  if word == ""
    then return []
    else do
      rest <- askForWords
      return (word : rest)

在這裡,我們首先執行 askForWords,獲取結果並將其儲存在變數 rest 中。然後,我們返回由 wordrest 建立的列表。

到目前為止,你應該已經很好地理解了如何編寫簡單的函式、編譯它們、在互動式環境中測試函式和程式,以及操作列表。

練習

編寫一個程式,該程式將反覆要求使用者輸入數字,直到她輸入零,此時它將告訴她所有數字的總和、所有數字的乘積,以及每個數字的階乘。例如,一個會話可能看起來像這樣

示例

Give me a number (or 0 to stop):
5
Give me a number (or 0 to stop):
8
Give me a number (or 0 to stop):
2
Give me a number (or 0 to stop):
0
The sum is 15
The product is 80
5 factorial is 120
8 factorial is 40320
2 factorial is 2

提示:編寫一個 IO 操作,該操作讀取一個數字,如果它為零,則返回空列表。如果它不為零,則遞迴呼叫自身,然後用它剛剛讀取的數字和遞迴呼叫的結果建立一個列表。

提示:你需要使用“show”來列印數字。 putStrLn("Number " ++ show(n))
華夏公益教科書