跳到主要內容
黯羽輕揚每天積累一點點

基礎語法_Haskell 筆記 1

免費2018-04-21#Functional_Programming#Haskell入门指南#纯函数式语言#函数式思维#Haskell教程#Haskell tutorial

再次撿起這個很純的函數式語言,並開始喜歡上它

一、簡介

Haskell 是一種純函數式語言(purely functional programming language),其函數式特性的純度沒有爭議

命令式語言要求你提供求解的步驟,Haskell 則傾向於讓你提供問題的描述

  • 非函數式思維:通過命令告訴電腦要做什麼,比如求和是通過循環結構遍歷所有的數,相加並記錄其和

  • 函數式思維:通過函數來描述出問題是什麼,比如求和是把第一個數與其餘樹的和相加

P.S. 關於思維模式的差異,請查看 一場函數式思維模式的洗禮

Haskell 的特點:

  • 變量不可變:函數式裡的變量與常量概念一樣,源自數學思維,令 x=1,那麼 x 永遠都是 1

  • 引用透明:函數調用能被直接替換成相應的值,而不會影響函數的行為。即函數僅用來求值,沒有副作用(不會影響外部狀態),相同輸入總能得到相同的輸出

  • 惰性求值:真正需要值的時候才現算,所以此時的一連串計算(函數調用)只是作用於輸入數據的一系列變換公式,具體來看就是 array.map().filter().reduce() 只需要遍歷 array 一遍,而不是 3 遍

  • 靜態類型:編譯器會做靜態類型檢查,這沒什麼奇怪的,但還支持強大的自動類型推斷,所以多數情況不必聲明類型,這樣既擁有了靜態類型檢查的好處,還保證了代碼簡潔程度

P.S. 引用透明(Referential transparency)的詳細解釋:

An expression is said to be referentially transparent if it can be replaced with its corresponding value without changing the program's behavior. As a result, evaluating a referentially transparent function gives the same value for same arguments. Such functions are called pure functions. An expression that is not referentially transparent is called referentially opaque.

二、基本運算

負數與一元減號

-3

表示對數字 3 使用一元運算符 -,求得其相反數 -3。也就是說,-3-不是數值字面量的一部分,而是個運算符

2 + -3

會得到報錯:

cannot mix '+' [infixl 6] and prefix `-' [infixl 6] in the same infix expression

二元運算符和一元運算符不能混用在同一個中綴表達式裡,這會帶來解析時的不確定性(有歧義,編譯器該怎樣理解)。所以,經驗原則是給所有負數字面量都帶上括號,如 (-3)

P.S. Haskell 只有一個一元運算符,就是一元減號 -,具體見 Unary operator

邏輯運算

3 個運算符:與(&&),或(||),非(not

兩個 Bool 字面量:TrueFalse

相等性判斷

  • ==:判斷等於,可以用於字符串

  • /=:判斷不等於(數學家的語言,所以不等號長這樣

注意,類型必須嚴格一致才能比較,否則報錯認為沒有可比性(1 == True 會報錯),但認為整型與浮點型是可比的(1 == 1.0True

運算符優先級

在 GHCi 環境可以通過 :info 命令查看運算符優先級,例如:

> :i *
class Num a where
  ...
  (*) :: a -> a -> a
  ...
    -- Defined in 'GHC.Num'
infixl 7 *

> :i -
class Num a where
  ...
  (-) :: a -> a -> a
  ...
    -- Defined in 'GHC.Num'
infixl 6 -

乘法比減法優先級高(分別是 76),都是中綴函數(infixlinfix),都是左結合的(infixll 表示 left associative),函數簽名也相同(Num a => a -> a -> a

優先級的範圍是 0-9,值越大越優先

P.S. infixl 7 * 稱為 fixity 聲明,用來指定運算符(函數)的結合性與優先級,例如:

infixr 5 <+
x <+ xs = x : xs
infixl 5 +>
x +> xs = xs ++ [x]

聲明了 2 個自定義中綴運算符 <++>,分別為右結合與左結合,優先級都是 5(與 : 一樣)

三、函數調用

語法格式

Haskell 裡的函數調用默認是前綴語法,例如:

succ 2
min 1 (-2)

與 Bash 腳本的函數調用語法一樣,函數名 參數 1 參數 2

但運算符作為特殊的函數,默認要以中綴形式調用,例如:

1 + 2

實際上,任意一個函數(包括運算符),都可以以前綴或者中綴形式調用,規則如下:

--  前綴轉中綴
prefixFunc a b
a `prefixFunc` b

--  中綴轉前綴
a infixFunc b
(infixFunc) a b

例如:

1 `min` (-2)
(+) 1 2

dollar 函數

$ 也是個函數:

($)
forall (r :: GHC.Types.RuntimeRep) a (b :: TYPE r).
(a -> b) -> a -> b
    -- Defined in 'GHC.Base'
infixr 0 $

優先級最低的中綴右結合函數,從簽名來看,只是個函數調用符,相當於在右邊加括號

-- 求自然數的平方和加到第多少個時超過 1000
length (takeWhile (< 1000) (scanl (+) 0 (map sqrt [1..])))
-- 等價於
length $ takeWhile (< 1000) $ scanl (+) 0 $ map sqrt [1..]

優先級最低,不影響運算,只調整運算順序:

> max 5 3 * 2 + 1
11
> max 5 $ 3 * 2 + 1
7

簡單地把 $ 理解成做括號的替代品是不合適的,比如:

> 3 * $ 5 - 2 + 1

<interactive>:21:5: error:
    parse error on input '$'
    Perhaps you intended to use TemplateHaskell

換個姿勢:

> (3 *) $ 5 - 2 + 1
12

因為 $ 是個中綴函數,要求左邊是函數,右邊是其參數

P.S. 還有一個很有意思的東西:($ 2) sqrt,中綴函數柯里化的小把戲

柯里化

Haskell函數默認都是柯里化的,都只接受一個參數

In Haskell, all functions are considered curried: That is, all functions in Haskell take just one argument.

例如:

> :t (/)
(/) :: Fractional a => a -> a -> a
> :t (/ 2)
(/ 2) :: Fractional a => a -> a

其中,(/ 2) 是對 (/) :: Fractional a => a -> a -> a 函數的不全調用(partially applied),所以沒有得到計算結果,而是返回了函數 (/ 2) :: Fractional a => a -> a

P.S. (-) 函數比較特殊,因為 (- 2) 表示負 2,而不返回一個新函數,非要的話,用 (subtract 2)

可以通過 curryuncurry 函數進行柯里化或去柯里化:

-- uncurry 去柯里化
> :t uncurry (/)
uncurry (/) :: Fractional c => (c, c) -> c
> (uncurry (/)) (4, 2)
2.0

-- curry 柯里化
> :t curry (uncurry (/))
curry (uncurry (/)) :: Fractional c => c -> c -> c
> (curry (uncurry (/))) 4 2
2.0

注意:多參函數要以元組形式傳參,例如 f (4, 2)

利用柯里化特性時還需要注意參數順序,例如:

> (/ 2) 4
2.0
> (2 /) 4
0.5

偏函數應用

偏函數應用(partial application)與柯里化(currying)的最大區別是對參數數量的影響,從調用函數求值的角度來看,柯里化並不改變參數數量,而偏函數應用會減少參數數量,因為預填了幾個,例如:

fn (a, b) = a + b
curriedFn = curry fn
partialFn = curriedFn 2

// 調用函數求值
fn (2, 3)
--  加上括號讓結合性更清楚一些
(curriedFn 2) 3
partialFn 3

所以,二者的聯繫是,可以通過柯里化函數來創建偏函數(partialFn = curriedFn 2)。區別是目的不同,偏函數應用是為了減少函數所需參數數量(通過固定一些參數值),柯里化是為了把一個多���函數轉換成單參函數,這個單參函數返回另一個單參函數(參數數量不足),或者求值(參數數量夠了)

四、函數聲明

doubledouble x = double (double x)

double x = x * 2
isEven x = x - x `div` 2 * 2 == 0
x `mod'` y = x - (x `div` y) * y

形式與函數調用差不多,函數名加空格分隔的參數列表,= 後面是函數體

2 個特點:

  • 聲明順序無所謂

  • 函數名首字母不能大寫,不能數字開頭

P.S. 數學裡把相似的東西用 x x' x'' 的命名習慣表示,在 Haskell 裡也可以這樣做:

y x = x ^ 2
y' x = x ^ 2 + 1

另外,中綴形式轉換在函數聲明中也可以用:

x `mod'` y = x - (x `div` y) * y

一些場景下能夠提升函數聲明的可讀性

無參函數

常量可以理解成無參函數,例如:

> :t 2
2 :: Num t => t

或者更生動的例子:

-- 無參函數,就是 const
two = 1 + 1

匿名函數

匿名函數即函數表達式,在 Haskell 中稱之為 lambda。語法格式如下:

反斜線 + 參數列表 -> 函數體

例如:

sum' = \x y -> x + y

P.S. 類似於 JS 的 const sum = (x, y) => x + y

從應用場景來看,lambda 的特點是:

  • 用完即扔:不要求複用

  • 足夠簡單:自解釋,單從函數體一眼就能看明白其功能

例如:

map (\x -> x + 1) [1, 2, 3]
map (\([x, y]) -> x + y) [[1, 1], [2, 2], [3, 3]]

但很多時候並不需要顯式地通過 lambda 語法來造函數,可以充分利用柯里化、List Comprehension 等特性:

map (+1) [1, 2, 3]
[ x + y | [x, y] <- [[1, 1], [2, 2], [3, 3]]]

另外,有個有趣的東西:

addThree = \x -> \y -> \z -> x + y + z
-- 因為 haskell 自帶 currying,所以等價於
-- addThree x y z = x + y + z

P.S. 匿名函數中的 -> 與類型聲明中的 -> 語義相同,都表示「映射到」(maps to

函數組合

數學中的函數組合的表達方式是 f·g(x) = f(g(x)),Haskell 與之類似:

fg = f . g

用到的運算符是 .

(.) :: (b -> c) -> (a -> b) -> a -> c 	-- Defined in 'GHC.Base'
infixr 9 .

右結合,所以 f . g . h x 等價於 f (g (h x))

((/7) . (*2) . (+3)) 4

函數組合可以用來製造新函數,並且能夠把參數抽出來,例如:

-- f x = 2 * (sqrt x) + 1
-- 對應的 pointfree style
f = (+ 1) . (* 2) . sqrt

通過組合把內層參數抽離出來,並利用柯里化特性去掉。這種只通過函數組合得到的,不涉及實際參數的函數風格被稱為 pointfree style

P.S. 注意,巨長的函數鏈會降低可讀性,不鼓勵這樣做,應該通過 let/where 等聲明把函數鏈拆開並賦予語義

五、條件語句和模式匹配

條件語句

-- if then else
gt3 x = if x > 3
  then True
  else False

注意:if-then-else 完整結構,else 部分不可省略

有趣的是,if 語句也是表達式,所可以這樣做:

lt10 x = if x < 10 then True else False
gte10 x = not (if x < 10 then True else False)

模式匹配

模式匹配是基本的函數調用機制,例如:

sayOneTwoThree :: (Integral a) => a -> String
sayOneTwoThree x = "Not between 1 and 3"
sayOneTwoThree 1 = "One!"
sayOneTwoThree 2 = "Two!"
sayOneTwoThree 3 = "Three!"

調用函數時會按聲明順序匹配參數類型,所以上面的 sayOneTwoThree 2 只會返回 "Not between 1 and 3"

再比如利用模式匹配遞歸求階乘:

fact 0 = 1
fact n = n * fact (n - 1)

注意,如果模式匹配失敗,就會報錯:

mod10 0 = 0
mod10 1 = 1
-- 如果最後不用萬能匹配兜住,mod10 2 就會報錯
-- mod10 x = x `mod` 10

匹配失敗時:

> mod10 2
*** Exception: t.hs:(27,1)-(28,11): Non-exhaustive patterns in function mod10

提示 mod10 函數的模式定義有遺漏

除了用於函數參數定義,模式匹配還可以用於 List Comprehension 和 let-in 表達式、where 子句等場景,例如:

[ x + y | (x, y) <- [(1, 2), (3, 4)] ]
sumOneTwoThree = let (a, b, c) = (1, 2, 3) in a + b + c

常用的模式匹配技巧如下:

--  拆開 list 首元與尾巴,要求 length >= 1,否則報錯
x:xs
--  拆開 list 前兩個元素與尾巴
x:y:ys
--  拆分同時保留原引用
xs @(x:y:ys)

P.S. @ 保留原引用稱為 as 模式

Guard

一個簡單的 guard 模式示例:

plus'' a b
  | a > 0, b > 0 = "sum is a positive value"
  | a < 0, b < 0 = "sum is a negative value"
  | otherwise = "what?"

參數列表後面多了 | 條件 表示不同的函數體分支,被調用時滿足條件就執行對應函數體並返回,否則就按順序依次向下檢查

注意,最後的 otherwise 比較有意思,因為:

> :i otherwise
otherwise :: Bool 	-- Defined in 'GHC.Base'
> otherwise == True
True

所以 otherwise只是語義需要,直接用 True 作為默認分支的條件也可以

P.S. 單行形式也是合法的(但可讀性差,不建議用),例如:

isPositive n | n > 0 = True | otherwise = False

where 關鍵字

where 關鍵字跟在 guard 後面,用來定義函數聲明中需要用到的變量,及輔助函數

checkArea r
  | area < little = addSpace "little circle" ++ wrap sArea
  | area < normal = addSpace "normal circle" ++ wrap sArea
  | otherwise = addSpace  "big big circle" ++ wrap sArea
  where area = pi * r ^ 2
        -- ! 必須對齊,有點傻
        --little = 10
        --normal = 50
        -- where 中可以用模式匹配
        (little, normal) = (10, 50)
        sArea = show area
        -- 可以定義函數
        addSpace s = ' ' : s
        -- where 可以嵌套,在輔助函數中定義輔助函數
        wrap s = wrapLeft (wrapRight s)
          where wrapLeft s = " " ++ s
                wrapRight s = s ++ " "

where 子句的幾個特點:

  • 多行聲明必須對齊縮進,否則編譯器無法正確解析(不知道要定義的變量/函數列表結束了沒)

  • 子句中聲明的變量和函數的作用域是當前函數及其 guard,且不包括同名函數的其它模式

  • 子句中可以用模式匹配

  • 允許嵌套使用,輔助函數也可以在自己的 where 子句中聲明需要的變量和輔助函數

注意,where 是一種語法結構,用來在函數底部聲明變量/函數,作用域是包括 guard 在內的整個函數

P.S. 非要單行的話,可以用分號隔開多個聲明,例如:

sayHello = hello ++ " " ++ greeting
  where hello = "Hi"; greeting = "girls"

let 關鍵字

語法格式:

let [bindings] in [expressions]

例如:

cylinder r h =
    let sideArea = 2 * pi * r * h
        bottomArea = pi * r ^ 2
    in sideArea + 2 * bottomArea

-- 表達式形式一般用法類似於 js 解構賦值
oneTwoThree = let (a, b, c) = (1, 2, 3) in a + b + c

let-in 的作用與 where 類似,都用來定義局部變量/函數,區別如下:

  • 形式上:let xxx in......where xxx 的聲明位置區別,let 把定義放在前面了

  • 語法上:let-in 是表達式,而 where 是語法結構,前者可以隨便放

  • 作用域上:let-in 的作用域限制更嚴格,在 let 部分定義的變量/函��只對 in 部分可見

注意,同樣要求多行聲明要嚴格對齊,非要單行就用分號隔開

P.S. let-inin 部分可以省略,作用域擴展到當前函數/List Comprehension,如果是在 GHCi 環境,在整個交互過程都可見

Case 表達式

最常見的 case 表達式就是函數定義時參數的模式匹配(case 表達式的語法糖):

tail' [] = "empty list"
tail' [x] = "no tail"
tail' (_:xs) = show xs

等價於

tail'' xs = case xs of  [] -> "empty list"
                        [x] -> "no tial"
                        (_:xs) -> show xs

語法格式如下:

case expression of  pattern -> result
                    pattern -> result
                    pattern -> result
                    ...

expression 依次嘗試匹配 pattern,匹配成功就執行對應的代碼塊並返回結果,否則嘗試下一個,都不匹配就報錯

P.S. 同樣,作為表達式,case-of 可以用於任何地方,比模式匹配靈活得多(模式匹配只能用於函數聲明、wherelet、List Comprehension 等特定場景)

六、數據結構

List

Haskell 中的 List 是單一類型數組,例如:

emptyArr = []
numbers = [1, 2, 3, 4]
chars = ['a', 'b', 'c']

實際上,字符串就是 Char 類型元素的 List,例如:

> str = "okay"
> :i str
str :: [Char] 	-- Defined at <interactive>:51:1

> charArr = ['o', 'k', 'a', 'y']
> :i charArr
charArr :: [Char] 	-- Defined at <interactive>:54:1

所以,字符串字面量只是個 List 語法糖

List 操作

++ 連接:

> "Is life always this hard" ++ " ,or is it just when you are a kid ?"
"Is life always this hard ,or is it just when you are a kid ?"
--  或者
> ['A', 'l', 'w', 'a', 'y', 's'] ++ [' '] ++ "like this."
"Always like this."

注意,++ 操作會遍歷左邊的 List,所以性能不高

: 左端插入:

> 0 : [1, 2, 3]
[0,1,2,3]

另外,[1, 2, 3] 實際上是 1 : 2 : 3 : [] 的語法糖,: 右結合得到完整 List

!! 按索引取元素:

> "hello there" !! 4
'o'
> "hello there" !! 14
*** Exception: Prelude.!!: index too large

索引從 0 開始,越界會報錯

多維數組

> [[1], [2, 3]] !! 1 !! 1
3

允許鋸齒數組,同樣要求元素類型一致

List 比較

如果 List 中元素可比較,則 List 可比較(遍歷比較各元素):

> "hello" == ['h', 'e', 'l', 'l', 'o']
True
> "0110" > "0101"
True

常用函數

--  head 取首元
> head [1, 2, 3]
1
--  tail 取尾巴(去首元)
> tail [1, 2, 3]
[2,3]
--  last 取尾元
> last [1, 2, 3]
3
--  init 取身子(去尾元)
> init [1, 2, 3]
[1,2]

注意:如果 List 為空,這 4 個函數會報錯

--  length 求數組長度
> length [1, 2]
2
--  null 判空
> null []
True
--  reverse 首尾轉置
> reverse [1, 3, 2]
[2,3,1]
--  take 取前 n 個元素
> take 5 [1, 2]
[1,2]
--  drop 去掉前 n 個元素
> drop 5 [1, 2]
[]

take/drop 越界不會報錯

--  maximum 求最大值
> maximum [1, -2, 3 , 0]
3
--  sum 求和
> sum [1..10]
55
--  elem 判斷包含性
> 3 `elem` [1, 2]
False

其中 [1..10] 是 Range 語法,elem 以中綴形式調用更符合語義習慣

Range

一種用來生成可枚舉 List 的便捷方式,例如:

> [1..7]
[1,2,3,4,5,6,7]
> ['A'..'Z']
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"

允許通過前兩項來指定間隔(step),可以是負間隔(遞減序列):

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

浮點數存在精度的問題,不建議在 Range 中使用:

> [0.1, 0.3..1]
[0.1,0.3,0.5,0.7,0.8999999999999999,1.0999999999999999]

另外,還允許無限序列,例如:

--  不設上限的 Range
> take 3 [1..]
[1,2,3]
--  或者 cycle 函數無限重複
> take 7 (cycle [1..3])
[1,2,3,1,2,3,1]
--  或者 repeat 生成單元素無限重複的 List
> take 6 (repeat 3)
[3,3,3,3,3,3]
--  上一句結果等價於
> replicate 6 3
[3,3,3,3,3,3]

List Comprehension

列表推導,是指從既有 List 按照一定規則產生另一個 List。所以需要 map, filter 等操作的場景都可以用 List Comprehension 來完成

語法形式與數學集合定義類似,比如用集合描述 10 以內的偶數為 S = {2 * x | x <- N, x <= 5},對應的 List Comprehension 語法如下:

> [ 2 * x | x <- [1..5] ]
[2,4,6,8,10]

P.S. 用 <- 表示屬於符號,非常形象

還可以添加更多限制條件(predicate):

> [ 2 * x | x <- [1..5], 2 * x `mod` 3 == 1, x `mod` 5 /= 0 ]
[4]

除了加限制條件過濾,還可以通過 let 聲明變量/函數:

> [ doubleX | x <- [1..5], let doubleX =  2 * x, doubleX `mod` 3 == 1, x `mod` 5 /= 0 ]
[4]

P.S. 省略 in 部分的話,聲明的變量/函數對其右側限制條件和變量,以及豎線左側部分可見。帶上的話,僅作用於當前條件

複雜一點的,比如求 1 到 100 的所有素數:

isPrime n = null [ x | x <- [2..n-1], n `mod` x == 0 ]
[ x | x <- [1..100], isPrime x ]

看起來與數學公式沒什麼區別,isPrime 的判定規則是 n 無法被 2..n-1 中的任何一個數整除,1 到 100 中所有滿足該判定規則的元素組成的集合即為所求

像集合定義一樣,元素可以來自多個既有集合,例如:

> [ x * y | x <- [1, 2, 3], y <- [4, 5] ]
[4,5,8,10,12,15]

可以利用 List Comprehension 自己實現個 length 函數:

length' arr = sum [ 1 | x <- arr ]
--  或者更符合習慣的
length' xs = sum [ 1 | _ <- xs ]

P.S. 其中 _ 表示佔位符,不關心其值是什麼,算一種地道的編碼習慣

另外,List Comprehension 是可以嵌套的:

--  濾掉二維數組中的奇數
[ [ x | x <- xs, even x ] | xs <- [[1,2], [3, 4]] ]
[[2],[4]]

Tuple

元組不要求單一元素類型,從類型約束來看,相當於結構體

例如:

> :t (1, "Leon")
(1, "Leon") :: Num t => (t, [Char])
--  List 要求類型單一,所以把二元組和三元組放到一個 List 裡會報錯
> [(1, "Leon"), (2, "Milk"), (3, "Little", "Girl")]
<interactive>:148:28: error:
    ? Couldn't match expected type '(t, [Char])'
                  with actual type '(Integer, [Char], [Char])'

與 List 一樣,如果元組中的元素可比較,那麼同類型元組也可以比較

複雜一點的例子,求所有三邊長度皆為整數且小於等於 10,周長為 24 的直角三角形:

[ (a, b, c) | c <- [1..10], b <- [1..c], a <- [1..b], a + b + c == 24, a^2 + b^2 == c^2 ]

注意其中隱含的邊長關係:c >= b >= a,算作去重規則

常用函數

fst/snd 取二元組的首元/尾元:

fst (1, 2)
snd (1, 2)

注意,這兩個函數只能用於二元組。一般元組沒有類似的工具函數,但可以通過模式匹配來自己實現:

-- 取三元組首元
first (x, _, _) = x

zip 從 List 組合出元組:

> zip [1, 2] ['A', 'B', 'C']
[(1,'A'),(2,'B')]

多餘的單身元素會被丟掉

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論