初探變數與函式
January 29, 2022來寫個簡單的程式,可以接受使用者輸入整數,判斷其為奇數或偶數,下面這個範例是以〈Hello, Haskell〉中的範例為樣版略做擴充:
import System.IO
main = do
putStr "請輸入整數:"
hFlush stdout
input <- getLine
let number = read input
result = if number `mod` 2 == 0 then "偶數" else "奇數"
putStrLn (input ++ "是 " ++ result ++ "!")
使用 ghc
編譯,以下是個執行示範:
>ghc is_odd.hs
[1 of 1] Compiling Main ( is_odd.hs, is_odd.o )
Linking is_odd.exe ...
>is_odd
請輸入整數:10
10 是偶數!
>is_odd
請輸入整數:5
5 是奇數!
let 關鍵字
在這邊的新範例中,第一個看到新元素是 let
關鍵字,你可以用它來定義一個變數,let number =
就字面意義上可以理解為「令 number
為 …」,另一個是 let ... in ...
,「令變數 … 在 … 有效」。例如:
ghci> let a = 10 in a + 20
30
ghci> a
<interactive>:2:1: error: Variable not in scope: a
ghci>
在以上的範例中可以看到,let ... in ...
是個運算式,執行後會傳回結果,因為令 a
只在 a + 20
有效,在其他範圍中不可見。如果你省略了 in
,那 let
指定的變數,在接下來的整個程式互動過程中都有效。
Haskell 的「變數」,並不是命令式語言中的變數,而是數學上的變數。
對於命令式語言,變數是可變動的(mutable),也就是 x = 10 的話,能夠有 x = x + 1 這類的動作,這是因為 =
被當成是指定,而 x
可以被重複指定不同的值,以 x = x + 1 為例,會先運算 x + 1 的值,然後將結果值指定給 x。
然而,數學上的變數,令 x = 10,x 就是 10,不會代表別的,不會有 x = x + 1 的可能性,這是因為數學上 =
是等於,數學上不可能有「x 等於 x + 1」的可能性。
從命令式語言的角度來看,這就像是限制變數不可重新指定值,也就是變數是不可變(immutable),在 Haskell 若寫下 let a = 10
,試圖指定 a
為其他的東西:
main = do
let a = 10
a = a + a
putStrLn a
會因為定義衝突而引發編譯錯誤:
test.hs:2:9: error:
Conflicting definitions for ‘a’
Bound at: test.hs:2:9
test.hs:3:9
|
2 | let a = 10
| ^^^^^^^...
因此命令式程式語言的變數並不是純函數式語言,例如 Haskell 中的變數,兩者定義不同!
另外,ghci 環境因為只是個簡單的程式碼片段測試環境,變數看來似乎是沒有限制,這只是為了測試小片段程式方便罷了,別被誤導了:
ghci> let a = 10
ghci> a = a + a
ghci>
重新使用 a = 20
時,其實是建立了另一個範圍,而這個新範圍有個新的 a
,跟前一個範圍的 a
沒有關係。
getLine/read/mod 與 if/else
如果你在 ghci
使用 :t getLine
,會告訴你 getLine
函式的型態為 getLine :: IO String
,簡單來說,這表示 getLine
傳回 IO String
,IO
中包含了 String
,進一步使用 <-
的話,表示可以從 IO
取出 String
。
然而,我們需要的是整數,而不是字串,read
函式可以將字串轉換為指定的型態,因為 read
的形態是 Read a => String -> a
,也就是接受字串傳回具 Read
行為的值,白話來說,表示傳回值是個可讀的型態,可以進一步被剖析為後續運算時需要的型態。
如果從程式碼的前後文可以推斷出形態,那麼 read
時不用特別標註型態,不過若無法從前後文推斷型態的話,會引發編譯錯誤:
ghci> let n = read "10"
ghci> n
*** Exception: Prelude.read: no parse
ghci>
如果真的想通過編譯,可以在 read
時標註型態:
ghci> let n = read "10" :: Int
ghci> n
10
ghci>
mod
函式是餘除函式,它會傳回兩數相除後的餘數,可以使用 mod 10 2
呼叫函式,表示要計算出 10
除以 2
的餘數,askell 函式的指定引數時,並不使用括號,若希望這類有兩個引數的函式呼叫,可以比較像數學上的二元運彪,可以使用使用 ``` 來括住函式:
ghci> mod 10 2
0
ghci> 10 `mod` 3
1
ghci>
在其他程式語言中,有 if/else
流程控制語法,在 Haskell 是 if ... then ... else ...
,而且它是個運算式,會有執行結果:
ghci> if True then 10 else 20
10
ghci> if False then 10 else 20
20
ghci>
定義函式
一開始的範例,程式碼都寫在 main
,這樣並不好懂,使用函式來稍微整理一下會比較好:
import System.IO
isOdd n =
n `mod` 2 == 0
evenOdd n =
if (isOdd n) then "偶數" else "奇數"
answer input =
let n = read input
in input ++ "是 " ++ (evenOdd n) ++ "!"
prompt text = do
putStr text
hFlush stdout
main = do
prompt "請輸入整數:"
input <- getLine
putStrLn (answer input)
定義函式時首先是指定名稱,之後接上參數,若有多個參數要使用空白區隔,然後接上 =
,定義這個函式該如何運算。
以上的範例中,isOdd
函式判斷整數為奇數或偶數,傳回 True
或 False
,evenOdd
函式接受字串,傳回 "偶數"
或 "奇數"
字串值作為結果,answer
函式產生整個程式的結果描述,接受字串傳回字串,prompt
函式可以指定提示文字,在標準輸出顯示。
Haskell 具型態推斷能力,基本上是可以完全不用定義型態相關資訊,不過對於函式,Haskell 是鼓勵定義型態的,這可以讓你對函式可接受的參數以及傳回值,多做一層思考,就閱讀原始碼而言,也能增加可讀性。
例如,來為以上範例中的函式標註型態:
import System.IO
isOdd :: Int -> Bool
isOdd n =
n `mod` 2 == 0
evenOdd :: Int -> String
evenOdd n =
if (isOdd n) then "偶數" else "奇數"
answer :: String -> String
answer input =
let n = read input
in input ++ "是 " ++ (evenOdd n) ++ "!"
prompt :: String -> IO ()
prompt text = do
putStr text
hFlush stdout
main :: IO ()
main = do
prompt "請輸入整數:"
input <- getLine
putStrLn (answer input)
在標註函式型態時,->
左邊是參數型態,右邊是傳回型態,Haskell 的函式都要有傳回值,因此就算是有副作用、將資料送至標準輸出的 prompt
,也會有傳回值,型態會是 IO ()
,在 Haskell 的函式型態上若看到 IO
,表示這是個跟輸入輸出相關的操作結果,也就是有副作用(side effect)的函式,IO ()
表示無法從 IO
取得值。
在 main
可以看到,prompt
、getLine
與 putStrLn
(型態為 String -> IO ()
),傳回型態都有 IO
,這表示它們都是輸入輸出相關、具有副作用的函式。
基本上 main
不用標註型態,因為它只會傳回 IO ()
,就目前而言可以先知道的是,想將副作用的函式操作串起來,需要使用 do
,就 IO
的處理而言,do
是個將 IO
銜接至下一個 IO
的簡易方式(細節方面是與 Monad 相關,這在以後的文件才會談),do
將副作用函式的 IO
逐層銜接,最後一個(最內層) IO
會是 main
的傳回值。
另外,isOdd
、evenOdd
的型態標註,將參數標註為 Int
,這麼一來,就真的只接受 Int
,若要更廣泛一些,還能接受 Integer
的話,可以使用型態類別 Integral
標註:
isOdd :: Integral a => a -> Bool
isOdd n =
n `mod` 2 == 0
even_odd :: Integral a => a -> String
even_odd n =
if (isOdd n) then "偶數" else "奇數"
純綷與不純綷
許多初接觸函數式程式設計的開發者,遇到無法做 x = x + 1 這種事,往往有個疑問,這樣可以寫出實用的應用程式嗎?畢竟,還是要接受使用者輸入、儲存資料等副作用啊?
首先,你得先清楚一件事,什麼是副作用?副作用就是使用了狀態不歸自己管的東西!如果可以做 x = x + 1,像是 JavaScript,就可能寫出這種函式:
x = 0;
function foo() {
x = x + 1;
return x;
}
他可能在其他地方會呼叫 foo
,你也會在一些地方執行 foo
,foo
狀態脫離了你們的管控,也就沒辦法預期 foo
的執行結果;如果語言一開始就不能 x = x + 1,程式自身世界就沒有寫出以上這類函式的可能性。
當然,程式會有接受使用者輸入、儲存資料等需求,若語言不能 x = x + 1,最後副作用的來源,就是來自程式外部的世界了,因為你只是拿來輸入的資料來用,或者將運算結果丟給輸出處理,你不清楚輸入、輸出如何管理外部世界的狀態,它們狀態不歸你管,你卻拿來用,就有了副作用。
函數式典範的不可變動特性,最後會造成一個情況,你的函式會有無副作用的純函式,以及不歸自己管、具有副作用的函式,例如,上面的 isOdd
、evenOdd
、answer
,都是純函式,而 prompt
、main
是有副作用的函式。
Haskell 的函式如果傳回 IO
,就是使用在告知「你使用了狀態不歸自己管的東西」,如果 IO
包含了來自該東西的值,你雖然可以綁定到變數,不過這東西狀態怎麼變動的,有許多你不清楚、無法掌控的細節,你綁定到變數的值,當然也就不是你能掌控!
副作用本質上就是難以掌控,函式中有副作用與無副作用的邏輯混在一起,整個系統又是基於這類函式建立起來的話,日後遇到了副作用的問題,要追蹤處理就難了。
如果你一開始就運用了不可變動,應用程式在成長的過程中,就會被區分有、無副作用兩個部份。
對於無副作用的部份,狀態都會很好預測,畢竟其中每個函式都是純函式,每個純函式中若運用到其他函式,被運用的函式也會是純函式。
也就是說,若日後遇到了副作用的問題,要追蹤處理的話,你就只要專心面對有副作用的那部份,當然,副作用本質上還是難以處理,然而,至少不用面對有副作用與無副作用的邏輯混在一起的問題,只要專心面對有副作用的部份!
當然,這篇文件標題寫明了是「初探」,這表示在 Haskell 中,函式還有更多值得探討的地方…