跳轉至內容

Haskell/變數和函式

來自華夏公益教科書,開放的書籍,開放的世界

本章中的所有示例可以儲存到 Haskell 原始檔,然後透過將該檔案載入到 GHCi 中進行求值。不要包含任何示例中的“Prelude>”提示部分。當顯示該提示時,意味著您可以將以下程式碼輸入到 GHCi 等環境中。否則,您應該將程式碼放入檔案並執行它。

在上一章中,我們使用 GHCi 作為計算器。當然,這對於短的計算來說是有效的。對於更長的計算和編寫 Haskell 程式,我們希望跟蹤中間結果。

我們可以透過為中間結果分配名稱來儲存它們。這些名稱稱為變數。當程式執行時,每個變數都會被替換為它所引用的。例如,考慮以下計算

Prelude> 3.141592653 * 5^2
78.539816325

這是根據公式計算的半徑為 5 的圓的近似面積。當然,輸入 的數字,甚至記住超過前幾位數字,都是很麻煩的。程式設計幫助我們避免無意義的重複和死記硬背,透過將這些任務委託給機器。這樣,我們的思維就可以自由地處理更有意思的想法。對於當前的情況,Haskell 已經包含一個名為 pi 的變數,它為我們儲存了超過 12 位的 數字。這不僅使程式碼更清晰,而且還提高了精度

Prelude> pi
3.141592653589793
Prelude> pi * 5^2
78.53981633974483

請注意,變數 pi 及其值 3.141592653589793 在計算中可以互換使用。

Haskell 原始檔

[編輯 | 編輯原始碼]

除了在 GHCi 中進行瞬時操作之外,您還會將程式碼儲存在副檔名為 .hs 的 Haskell 原始檔(基本上是純文字)中。使用適合編碼的文字編輯器處理這些檔案(請參閱維基百科關於文字編輯器的文章)。合適的原始碼編輯器將提供語法高亮,以相關的方式對程式碼進行顏色標記,以便更容易閱讀和理解。Vim 和 Emacs 是 Haskell 程式設計師中流行的選擇。

為了保持整潔,請在您的計算機上建立一個目錄(即資料夾)來儲存您在完成本書中的練習時建立的 Haskell 檔案。將目錄命名為類似於 HaskellWikibook 的名稱。然後,在該目錄中建立一個名為 Varfun.hs 的新檔案,其中包含以下程式碼

r = 5.0

該程式碼將變數 r 定義為值 5.0

注意:確保行首沒有空格,因為 Haskell 對空格很敏感。

接下來,在您的終端位於HaskellWikibook目錄中,啟動 GHCi 並使用 :load 命令載入Varfun.hs檔案

Prelude> :load Varfun.hs
[1 of 1] Compiling Main             ( Varfun.hs, interpreted )
Ok, modules loaded: Main.

注意::load 可以縮寫為 :l(如 :l Varfun.hs)。

如果 GHCi 給出類似於 Could not find module 'Varfun.hs' 的錯誤,您可能在錯誤的目錄中執行 GHCi 或將檔案儲存到了錯誤的目錄中。您可以使用 :cd 命令在 GHCi 中更改目錄(例如,:cd HaskellWikibook)。

載入完檔案後,GHCi 的提示從“Prelude”變為“*Main”。您現在可以在計算中使用新定義的變數 r

*Main> r
5.0
*Main> pi * r^2
78.53981633974483

因此,我們使用著名的公式計算了半徑為 5.0 的圓的面積。這是因為我們在 Varfun.hs 檔案中定義了 r,而 pi 來自標準 Haskell 庫。

接下來,我們將透過為面積公式定義一個變數名來使其更容易快速訪問。將原始檔的內容更改為

r = 5.0
area = pi * r ^ 2

儲存檔案。然後,假設您一直保持 GHCi 執行且檔案仍然載入,鍵入 :reload(或縮寫版本 :r

*Main> :reload
Compiling Main             ( Varfun.hs, interpreted )
Ok, modules loaded: Main.
*Main>

現在我們有兩個變數 rarea

*Main> area
78.53981633974483
*Main> area / r
15.707963267948966

注意

let 關鍵字(一個具有特殊含義的詞)使我們能夠在沒有原始檔的情況下直接在 GHCi 提示符處定義變數。這看起來像

Prelude> let area = pi * 5 ^ 2

雖然有時很方便,但以這種方式完全在 GHCi 中分配變數對於任何複雜的任務來說都是不切實際的。我們通常希望使用儲存的原始檔。


除了工作程式碼本身之外,原始檔可能還包含文字註釋。在 Haskell 中,有兩種型別的註釋。第一種以 -- 開頭,一直持續到行尾

x = 5     -- x is 5.
y = 6     -- y is 6.
-- z = 7  -- z is not defined.

在這種情況下,xy 在實際的 Haskell 程式碼中定義,但 z 沒有。

第二種註釋用封閉的 {- ... -} 表示,可以跨越多行

answer = 2 * {-
  block comment, crossing lines and...
  -} 3 {- inline comment. -} * 7

透過組合單行和多行註釋,可以透過分別新增或刪除單個 } 字元來在執行的程式碼和註釋掉的程式碼之間進行切換

{--
foo :: String
foo = "bar"
--}

vs.

{--}
foo :: String
foo = "bar"
--}

我們使用註釋來解釋程式的各個部分或在上下文中新增其他筆記。注意註釋過度使用,因為過多的註釋會使程式更難閱讀。此外,我們必須在每次更改對應程式碼時小心地更新註釋。過時的註釋會導致嚴重混淆。

命令式語言中的變數

[編輯 | 編輯原始碼]

熟悉指令式程式設計的讀者會注意到,Haskell 中的變數與 C 等語言中的變數有很大不同。如果您沒有程式設計經驗,可以跳過本節,但這將有助於您在遇到許多情況(例如,大多數 Haskell 教材)時理解一般情況,其中人們在參考其他程式語言時討論 Haskell。

指令式程式設計將變數視為計算機記憶體中可更改的位置。這種方法與計算機的基本操作原理相連。命令式程式明確地告訴計算機該做什麼。更高級別的命令式語言與直接的計算機彙編程式碼指令相去甚遠,但它們保留了相同的逐步思維方式。相比之下,函數語言程式設計提供了一種以更高級別的數學術語進行思考的方式,定義變數之間如何相互關聯,並將編譯器留給將這些轉換為計算機可以處理的逐步指令。

讓我們看一個例子。以下程式碼在 Haskell 中不起作用

r = 5
r = 2

命令式程式設計師可能會將其解讀為首先設定 r = 5,然後將其更改為 r = 2。然而,在 Haskell 中,編譯器將對上述程式碼返回一個錯誤:“r 的多個宣告”。在給定的範圍內,Haskell 中的變數只能定義一次,並且不能更改。

Haskell 中的變數似乎幾乎不可變,但它們就像數學中的變數一樣。在數學課堂上,您永遠不會看到一個變數在一個問題中改變它的值。

準確地說,Haskell 變數是不可變的。它們只根據我們輸入程式的資料而變化。我們不能在同一個程式碼中以兩種方式定義r,但我們可以透過更改檔案來更改其值。讓我們更新上面的程式碼

r = 2.0
area = pi * r ^ 2

當然,這完全可以。我們可以更改r在它被定義的唯一位置,這將自動更新使用r變數的所有其他程式碼的值。

現實世界的 Haskell 程式透過在程式碼中保留某些變數未指定來工作。然後,當程式從外部檔案、資料庫或使用者輸入獲取資料時,這些值將被定義。但是,現在,我們將堅持在內部定義變數。我們將在後面的章節中介紹與外部資料的互動。

以下是一個與命令式語言的主要區別的另一個例子

r = r + 1

這段 Haskell 程式碼不是“遞增變數r”(即更新記憶體中的值),而是r的遞迴定義(即根據自身進行定義)。我們將在後面詳細解釋遞迴。對於這種情況,如果r之前已經被定義了任何值,那麼在 Haskell 中 r = r + 1 會導致錯誤訊息。 r = r + 1 類似於在數學環境中說,這顯然是錯誤的。

由於它們的值在程式內不會改變,因此變數可以以任何順序定義。例如,以下程式碼片段執行完全相同的事情

 y = x * 2
 x = 3
 x = 3
 y = x * 2

在 Haskell 中,沒有“xy之前宣告”或反之的概念。當然,使用y仍然需要x的值,但這在你需要特定數值之前並不重要。

函式

[edit | edit source]

每次我們想要計算新圓形的面積時更改我們的程式既繁瑣又侷限於一次一個圓。我們可以透過使用新的變數r2area2來複制所有程式碼以計算兩個圓,以便計算第二個圓:[1]

r  = 5
area  = pi * r ^ 2
r2 = 3
area2 = pi * r2 ^ 2

當然,為了消除這種無意義的重複,我們更希望只有一個用於面積的函式,然後將其應用於不同的半徑。

一個函式接受一個引數值(或引數)並給出結果值(本質上與數學函式相同)。在 Haskell 中定義函式就像定義一個變數,除了我們注意到我們在等號左側放置的函式引數。例如,以下定義了一個依賴於名為r的引數的函式area

area r = pi * r ^ 2

仔細觀察語法:函式名首先出現(在我們的示例中為area),然後是一個空格,然後是引數(在示例中為r)。在=符號之後,函式定義是一個公式,該公式在與其他已定義項的上下文中使用引數。

現在,我們可以在呼叫函式時為引數插入不同的值。將上面的程式碼儲存到一個檔案中,將其載入到 GHCi 中,然後嘗試以下操作

*Main> area 5
78.53981633974483
*Main> area 3
28.274333882308138
*Main> area 17
907.9202768874502

因此,我們可以使用不同的半徑呼叫此函式來計算任何圓形的面積。

這裡的函式在數學上定義為

在數學中,引數用括號括起來,如。許多程式語言也用括號括住引數,如max(2, 3)。但是 Haskell 省略了引數列表周圍的括號(以及它們之間的逗號):max 2 3

我們仍然使用括號來對必須一起計算的表示式(任何給出值的程式碼)進行分組。注意以下表達式是如何被解析的不同

5 * 3 + 2       -- 15 + 2 = 17 (multiplication is done before addition)
5 * (3 + 2)     -- 5 * 5 = 25 (thanks to the parentheses)
area 5 * 3      -- (area 5) * 3
area (5 * 3)    -- area 15

注意,Haskell 函式優先於所有其他運算子,例如+*,就像在數學中,例如乘法在加法之前進行一樣。

求值

[edit | edit source]

當你將一個表示式輸入到 GHCi 中時,究竟發生了什麼?按下回車鍵後,GHCi 將評估你給出的表示式。這意味著它將用每個函式的定義替換每個函式並計算結果,直到只剩下一個值。例如,area 5的求值過程如下

   area 5
=>    { replace the left-hand side  area r = ...  by the right-hand side  ... = pi * r^2 }
   pi * 5 ^ 2
=>    { replace  pi  by its numerical value }
   3.141592653589793 * 5 ^ 2
=>    { apply exponentiation (^) }
   3.141592653589793 * 25
=>    { apply multiplication (*) }
   78.53981633974483

如所示,要應用呼叫一個函式,意味著用其右邊的定義替換其左邊的定義。當使用 GHCi 時,函式呼叫的結果將顯示在螢幕上。

一些更多函式

double x    = 2 * x
quadruple x = double (double x)
square x    = x * x
half   x    = x / 2
練習
  • 解釋 GHCi 如何評估quadruple 5
  • 定義一個從其引數的一半中減去 12 的函式。

多個引數

[edit | edit source]

函式也可以接受多個引數。例如,一個計算矩形面積的函式,給定其長度和寬度

areaRect l w = l * w
*Main> areaRect 5 10
50

另一個計算三角形面積的示例

areaTriangle b h = (b * h) / 2
*Main> areaTriangle 3 9
13.5

如你所見,多個引數用空格隔開。這也是為什麼你有時需要使用括號來對錶達式進行分組的原因。例如,要將值x翻倍,你不能寫

quadruple x = double double x     -- error

這將把名為double的函式應用於doublex這兩個引數。請注意,函式可以是其他函式的引數(你將在後面看到原因)。為了使此示例正常工作,我們需要將引數放在括號內

quadruple x = double (double x)

引數總是按照給定的順序傳遞。例如

minus x y = x - y
*Main> minus 10 5
5
*Main> minus 5 10
-5

在這裡,minus 10 5 評估為10 - 5,但minus 5 10 評估為5 - 10,因為順序發生了改變。

練習
  • 編寫一個函式來計算盒子的體積。
  • 吉薩大金字塔大約由多少塊石頭構成?提示:你需要對金字塔的體積和每塊的體積進行估計。

關於組合函式

[edit | edit source]

當然,你可以使用你已經定義的函式來定義新的函式,就像你可以使用預定義的函式一樣,比如加法(+)或乘法(*)(運算子在 Haskell 中被定義為函式)。例如,要計算正方形的面積,我們可以重用我們計算矩形面積的函式

areaRect l w = l * w
areaSquare s = areaRect s s
*Main> areaSquare 5
25

畢竟,正方形只是一個邊長相等的矩形。

練習
  • 編寫一個函式來計算圓柱的體積。圓柱的體積是底面積(你已經在這章中編寫了這個函式,所以重用它)乘以高度。

區域性定義

[edit | edit source]

where 語句

[edit | edit source]

在定義函式時,我們有時希望定義一些對函式來說是區域性的中間結果。例如,考慮海倫公式 ,用於計算邊長為 abc 的三角形的面積。

heron a b c = sqrt (s * (s - a) * (s - b) * (s - c))
    where
    s = (a + b + c) / 2

變數 s 是三角形周長的一半,在平方根函式 sqrt 的引數中寫出它四次會很繁瑣。

簡單地按順序編寫定義不起作用……

heron a b c = sqrt (s * (s - a) * (s - b) * (s - c))
s = (a + b + c) / 2                                   -- a, b, and c are not defined here

……因為變數 abc 僅在函式 heron 的右側可用,但此處編寫的 s 定義不是 heron 右側的一部分。為了將其作為右側的一部分,我們使用 where 關鍵字。

請注意,where 和區域性定義都向右縮進了 4 個空格,以區別於後續定義。以下是一個顯示區域性定義和頂層定義混合的另一個示例

areaTriangleTrig  a b c = c * height / 2   -- use trigonometry
    where
    cosa   = (b ^ 2 + c ^ 2 - a ^ 2) / (2 * b * c)
    sina   = sqrt (1 - cosa ^ 2)
    height = b * sina
areaTriangleHeron a b c = result           -- use Heron's formula
    where
    result = sqrt (s * (s - a) * (s - b) * (s - c))
    s      = (a + b + c) / 2

作用域

[edit | edit source]

如果你仔細觀察前面的例子,你會注意到我們兩次使用了變數名 abc,每個面積函式使用一次。這怎麼做到的?

考慮以下 GHCi 序列

Prelude> let r = 0
Prelude> let area r = pi * r ^ 2
Prelude> area 5
78.53981633974483

由於之前 let r = 0 定義的干擾,返回面積為 0 會讓人感到意外。但這種情況不會發生,因為你在第二次定義 r 時實際上是在談論一個不同的 r。這可能看起來很混亂,但想想有多少人叫約翰,然而對於任何只有一個約翰的語境,我們都可以毫無混淆地談論“約翰”。程式設計有一個類似語境的 概念,叫做作用域

我們現在不會解釋作用域背後的技術細節。只要記住,引數的值嚴格是你呼叫函式時傳入的值,無論你在函式定義中將該變數命名為何。也就是說,適當唯一的變數名確實可以使程式碼更容易被人類讀者理解。

總結

[edit | edit source]
  1. 變數儲存值(可以是任意 Haskell 表示式)。
  2. 變數在一個作用域內不會改變。
  3. 函式幫助你編寫可重用程式碼。
  4. 函式可以接受多個引數。

我們還學習了原始檔中的非程式碼文字註釋。

注意

  1. 如本例所示,變數名可能包含數字以及字母。Haskell 中的變數必須以小寫字母開頭,但之後可以包含任何由字母、數字、下劃線 (_) 或撇號 (') 組成的字串。
華夏公益教科書