跳轉到內容

另一個 Haskell 教程/語言基礎

來自 Wikibooks,開放世界中的開放書籍
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只是一個名稱,就像“Hal”是我的名字一樣。您不能隨意決定將不同的人儲存在我的名字中,就像您不能隨意決定將不同的值儲存在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 的定義實際上是等價的——這個分段版本被翻譯成 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 繫結

[編輯 | 編輯原始碼]

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

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)

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

示例

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"



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函式。同樣,基本情況是空列表,遞迴情況是有頭列表。但是,這次,我們會根據某個特定謂詞是否成立來選擇是否保留元素。我們可以將filter函式定義為

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

在此程式碼中,當遇到空列表時,我們只需返回一個空列表。這是因為filter不能新增元素;它只能刪除元素。

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

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

練習

斐波那契數列定義為

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




互動性

[編輯 | 編輯原始碼]

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

畢竟,假設您有一個從鍵盤讀取字串的函式。如果您呼叫此函式兩次,並且使用者第一次輸入一個內容,第二次輸入另一個內容,那麼您將不再擁有一個函式,因為它將返回兩個不同的值。這個問題的解決方案在形式數學的一個分支——範疇論中被發現:單子。我們還沒有準備好正式討論單子,但現在,可以簡單地將它們視為表達輸入/輸出等操作的便捷方式。我們將在章節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是一個整數,因此我們首先需要透過readguess轉換為整數。由於“read guess”是一個普通的、純的函式(而不是IO操作),因此我們不需要使用<-符號(實際上,我們不能使用);我們只需將值繫結到guessNum。請注意,當我們在do符號中時,我們不需要in用於let

如果他們猜得太低,我們通知他們,然後再次開始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))
華夏公益教科書