基础语法_Haskell笔记1

2019-06-12 14:39:56 浏览数 (2)

一.简介

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-不是数值字面量的一部分,而是个运算符

代码语言:javascript复制
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:命令查看运算符优先级,例如:

代码语言:javascript复制
> :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,值越大越优先

三.函数调用

语法格式

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函数

$也是个函数:

代码语言:javascript复制
($) ::
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

简单地把$理解成做括号的替代品是不合适的,比如:

代码语言:javascript复制
> 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)

可以通过curryuncurry函数进行柯里化或去柯里化:

代码语言:javascript复制
-- 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里也可以这样做:

代码语言:javascript复制
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与之类似:

代码语言:javascript复制
fg = f . g

用到的运算符是.

代码语言:javascript复制
(.) :: (b -> c) -> (a -> b) -> a -> c   -- Defined in ‘GHC.Base’
infixr 9 .

右结合,所以f . g . h x等价于f (g (h x))

代码语言:javascript复制
((/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语句也是表达式,所可以这样做:

代码语言:javascript复制
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子句等场景,例如:

代码语言:javascript复制
[ 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比较有意思,因为:

代码语言:javascript复制
> :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后面,用来定义函数声明中需要用到的变量,及辅助函数

代码语言:javascript复制
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-inin部分可以省略,作用域扩展到当前函数/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可以用于任何地方,比模式匹配灵活得多(模式匹配只能用于函数声明、wherelet、List Comprehension等特定场景)

六.数据结构

List

Haskell中的List是单一类型数组,例如:

代码语言:javascript复制
emptyArr = []
numbers = [1, 2, 3, 4]
chars = ['a', 'b', 'c']

实际上,字符串就是Char类型元素的List,例如:

代码语言:javascript复制
> 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操作

连接:

代码语言:javascript复制
> "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,所以性能不高

:左端插入:

代码语言:javascript复制
> 0 : [1, 2, 3]
[0,1,2,3]

另外,[1, 2, 3]实际上是1 : 2 : 3 : []的语法糖,:右结合得到完整List

!!按索引取元素:

代码语言:javascript复制
> "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越界不会报错

代码语言:javascript复制
--  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),可以是负间隔(递减序列):

代码语言:javascript复制
> [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语法如下:

代码语言:javascript复制
> [ 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声明变量/函数:

代码语言:javascript复制
> [ 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函数:

代码语言:javascript复制
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取二元组的首元/尾元:

代码语言:javascript复制
fst (1, 2)
snd (1, 2)

注意,这两个函数只能用于二元组。一般元组没有类似的工具函数,但可以通过模式匹配来自己实现:

代码语言:javascript复制
-- 取三元组首元
first (x, _, _) = x

zip从List组合出元组:

代码语言:javascript复制
> 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:运算符优先级表

0 人点赞