一.简介
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.
二.基本运算
负数与一元减号
代码语言:javascript复制-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字面量:True
,False
相等性判断
==
:判断等于,可以用于字符串/=
:判断不等于(数学家的语言,所以不等号长这样)
注意,类型必须严格一致才能比较,否则报错认为没有可比性(1 == True
会报错),但认为整型与浮点型是可比的(1 == 1.0
是True
)
运算符优先级
在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 -
乘法比减法优先级高(分别是7
和6
),都是中缀函数(infixl
的infix
),都是左结合的(infixl
的l
表示left associative),函数签名也相同(Num a => a -> a -> a
)
优先级的范围是0-9
,值越大越优先
三.函数调用
语法格式
Haskell里的函数调用默认是前缀语法,例如:
代码语言:javascript复制succ 2
min 1 (-2)
与Bash脚本的函数调用语法一样,函数名 参数1 参数2
但运算符作为特殊的函数,默认要以中缀形式调用,例如:
代码语言:javascript复制1 2
实际上,任意一个函数(包括运算符),都可以以前缀或者中缀形式调用,规则如下:
代码语言:javascript复制-- 前缀转中缀
prefixFunc a b
a `prefixFunc` b-- 中缀转前缀
a infixFunc b
(infixFunc) a b
例如:
代码语言:javascript复制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 $
优先级最低的中缀右结合函数,从签名来看,只是个函数调用符,相当于在右边加括号:
代码语言:javascript复制-- 求自然数的平方和加到第多少个时超过1000
length (takeWhile (< 1000) (scanl ( ) 0 (map sqrt [1..])))
-- 等价于
length $ takeWhile (< 1000) $ scanl ( ) 0 $ map sqrt [1..]
优先级最低,不影响运算,只调整运算顺序:
代码语言:javascript复制> 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
换个姿势:
代码语言:javascript复制> (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.
例如:
代码语言:javascript复制> :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)
可以通过curry
与uncurry
函数进行柯里化或去柯里化:
-- 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)
利用柯里化特性时还需要注意参数顺序,例如:
代码语言:javascript复制> (/ 2) 4
2.0
> (2 /) 4
0.5
偏函数应用
偏函数应用(partial application)与柯里化(currying)的最大区别是对参数数量的影响,从调用函数求值的角度来看,柯里化并不改变参数数量,而偏函数应用会减少参数数量,因为预填了几个,例如:
代码语言:javascript复制fn (a, b) = a b
curriedFn = curry fn
partialFn = curriedFn 2// 调用函数求值
fn (2, 3)
-- 加上括号让结合性更清楚一些
(curriedFn 2) 3
partialFn 3
所以,二者的联系是,可以通过柯里化函数来创建偏函数(partialFn = curriedFn 2
)。区别是目的不同,偏函数应用是为了减少函数所需参数数量(通过固定一些参数值),柯里化是为了把一个多参函数转换成单参函数,这个单参函数返回另一个单参函数(参数数量不足),或者求值(参数数量够了)
四.函数声明
代码语言:javascript复制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
另外,中缀形式转换在函数声明中也可以用:
代码语言:javascript复制x `mod'` y = x - (x `div` y) * y
一些场景下能够提升函数声明的可读性
无参函数
常量可以理解成无参函数,例如:
代码语言:javascript复制> :t 2
2 :: Num t => t
或者更生动的例子:
代码语言:javascript复制-- 无参函数,就是const
two = 1 1
匿名函数
匿名函数即函数表达式,在Haskell中称之为lambda。语法格式如下:
代码语言:javascript复制反斜线 参数列表 -> 函数体
例如:
代码语言:javascript复制sum' = x y -> x y
P.S.类似于JS的const sum = (x, y) => x y
从应用场景来看,lambda的特点是:
- 用完即扔:不要求复用
- 足够简单:自解释,单从函数体一眼就能看明白其功能
例如:
代码语言:javascript复制map (x -> x 1) [1, 2, 3]
map (([x, y]) -> x y) [[1, 1], [2, 2], [3, 3]]
但很多时候并不需要显式地通过lambda语法来造函数,可以充分利用柯里化、List Comprehension等特性:
代码语言:javascript复制map ( 1) [1, 2, 3]
[ x y | [x, y] <- [[1, 1], [2, 2], [3, 3]]]
另外,有个有趣的东西:
代码语言:javascript复制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
函数组合可以用来制造新函数,并且能够把参数抽出来,例如:
代码语言:javascript复制-- f x = 2 * (sqrt x) 1
-- 对应的pointfree style
f = ( 1) . (* 2) . sqrt
通过组合把内层参数抽离出来,并利用柯里化特性去掉。这种只通过函数组合得到的,不涉及实际参数的函数风格被称为pointfree style
P.S.注意,巨长的函数链会降低可读性,不鼓励这样做,应该通过let/where
等声明把函数链拆开并赋予语义
五.条件语句和模式匹配
条件语句
代码语言:javascript复制-- 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)
模式匹配
模式匹配是基本的函数调用机制,例如:
代码语言:javascript复制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"
再比如利用模式匹配递归求阶乘:
代码语言:javascript复制fact 0 = 1
fact n = n * fact (n - 1)
注意,如果模式匹配失败,就会报错:
代码语言:javascript复制mod10 0 = 0
mod10 1 = 1
-- 如果最后不用万能匹配兜住,mod10 2就会报错
-- mod10 x = x `mod` 10
匹配失败时:
代码语言:javascript复制> 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
常用的模式匹配技巧如下:
代码语言:javascript复制-- 拆开list首元与尾巴,要求length >= 1,否则报错
x:xs
-- 拆开list前两个元素与尾巴
x:y:ys
-- 拆分同时保留原引用
xs@(x:y:ys)
P.S.@
保留原引用称为as
模式
Guard
一个简单的guard模式示例:
代码语言:javascript复制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.单行形式也是合法的(但可读性差,不建议用),例如:
代码语言:javascript复制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.非要单行的话,可以用分号隔开多个声明,例如:
代码语言:javascript复制sayHello = hello " " greeting
where hello = "Hi"; greeting = "girls"
let关键字
语法格式:
代码语言:javascript复制let [bindings] in [expressions]
例如:
代码语言:javascript复制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-in
的in
部分可以省略,作用域扩展到当前函数/List Comprehension,如果是在GHCi环境,在整个交互过程都可见
Case表达式
最常见的case表达式就是函数定义时参数的模式匹配(case表达式的语法糖):
代码语言:javascript复制tail' [] = "empty list"
tail' [x] = "no tail"
tail' (_:xs) = show xs
等价于
代码语言:javascript复制tail'' xs = case xs of [] -> "empty list"
[x] -> "no tial"
(_:xs) -> show xs
语法格式如下:
代码语言:javascript复制case expression of pattern -> result
pattern -> result
pattern -> result
...
用expression
依次尝试匹配pattern
,匹配成功就执行对应的代码块并返回结果,否则尝试下一个,都不匹配就报错
P.S.同样,作为表达式,case-of
可以用于任何地方,比模式匹配灵活得多(模式匹配只能用于函数声明、where
、let
、List Comprehension等特定场景)
六.数据结构
List
Haskell中的List是单一类型数组,例如:
代码语言:javascript复制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
开始,越界会报错
多维数组
代码语言:javascript复制> [[1], [2, 3]] !! 1 !! 1
3
允许锯齿数组,同样要求元素类型一致
List比较
如果List中元素可比较,则List可比较(遍历比较各元素):
代码语言:javascript复制> "hello" == ['h', 'e', 'l', 'l', 'o']
True
> "0110" > "0101"
True
常用函数
代码语言:javascript复制-- 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个函数会报错
代码语言:javascript复制-- 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的便捷方式,例如:
代码语言:javascript复制> [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中使用:
代码语言:javascript复制> [0.1, 0.3..1]
[0.1,0.3,0.5,0.7,0.8999999999999999,1.0999999999999999]
另外,还允许无限序列,例如:
代码语言:javascript复制-- 不设上限的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):
代码语言:javascript复制> [ 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的所有素数:
代码语言:javascript复制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中所有满足该判定规则的元素组成的集合即为所求
像集合定义一样,元素可以来自多个既有集合,例如:
代码语言:javascript复制> [ 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是可以嵌套的:
代码语言:javascript复制-- 滤掉二维数组中的奇数
[ [ x | x <- xs, even x ] | xs <- [[1,2], [3, 4]] ]
[[2],[4]]
Tuple
元组不要求单一元素类型,从类型约束来看,相当于结构体
例如:
代码语言:javascript复制> :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的直角三角形:
代码语言:javascript复制[ (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)
注意,这两个函数只能用于二元组。一般元组没有类似的工具函数,但可以通过模式匹配来自己实现:
代码语言:javascript复制-- 取三元组首元
first (x, _, _) = x
zip
从List组合出元组:
> zip [1, 2] ['A', 'B', 'C']
[(1,'A'),(2,'B')]
多余的单身元素会被丢掉
参考资料
- Infix Functions In Haskell
- Currying
- Currying versus partial application (with JavaScript code)
- 4.4.2 Fixity Declarations:运算符优先级表