对Haskell一直挺感兴趣,也学习了一段时间。奈何IO太离谱,做不出实际的东西,导致学了忘忘了学痛苦万分。于是只好信奉好记性不如烂笔头,写几篇笔记记录下来。
这是第一篇,主题是Haskell的介绍和基础语法。
给其它语言学习者的忠告
如果你之前学习了一大票主流语言,比如C、C++、JAVA、Python、Shell、JS、PHP、汇编等等。我的建议是:忘记它们。
之前为C++选手写过一篇入门Python的文章,本来也想写一篇基于C++基础入门Haskell的文章。但一上手发现,使用C++的思维还不如完全失忆更好接受Haskell。
这就牵扯到了编程范式的问题。上述一大票语言,不管多么千差万别,大体上的运行方式就是:从某一条语句开始,按顺序一条一条的往下执行。这就是所谓的命令式编程:告诉计算机它要做什么。
而Haskell和这些语言不一样,它没有一个固定的语句执行顺序,甚至于,你无法改变一个变量的值(我更倾向于将Haskell中的变量理解成没有参数的函数,也就是说,根本没有变量,一切都是函数)。你的程序里满是无所谓先后的函数定义,你要做的事情是使用一个函数描述出你要解决的问题。也就是函数式编程:告诉计算机问题是什么。
因为这一个How和What的区别,在座的各位步骤流程大师的很多经验失去了用武之地。既然这样,不如索性给它忘了,从零开始推开新世界的大门。
什么是Haskell
Haskell是一门惰性求值、纯函数式的静态强类型编程语言。
函数式:
对于函数式并没有明确的定义,但它通常意味着:
- 函数是“一等公民”,就是说,函数可以在任何需要一个值的地方作为值来使用。具体点说就是你可以把函数直接作为另一个函数的参数进行传递,而不用使用类似于函数指针或std::function之类的东西把它包起来。
- 程序围绕着“计算表达式”而不是“执行指令”来运行。
学习Haskell花费时间最多的地方就在于这种从命令式到函数式的思维转换。
纯:
“让函数式的归函数式,纯的归纯。”——《藏狐箴言》。
并不是所有函数式语言都是纯函数式,一个纯的语言意味着:
- 没有改变,任何值都是不可变的。
- 表达式永远没有副作用,比如改变了某个变量的值或在屏幕上显示消息或发射一枚核弹。
- 使用相同的输入调用一个函数总能得到相同的输出。
相信大家看到这已经懵了,这不是啥也干不了了吗?这语言什么用?rnm,退钱!
其实大可不必,面包还是会有的,只要亿点点思维转变,比如:
- 等价代换:你永远可以使用一个等价的东西替换另一个东西,就像你还是一个炼金术士的时候那样。
- 并行:在没有副作用的世界,并行计算表达式会很轻松。
- 更少的头痛(?):简单堆积,随意改动,各种行为作用会使程序非常难Debug和原因定位。
惰性求值:
在Haskell中,一个表达式的值只有在真正被需要的时候才被计算出来(就像你只有在马上考试的时候才开始学习一样)。这种特性将会随着学习的深入加深理解。这里举几个浅显的小例子:
- 可以简单的使用函数定义一个控制结构。
- 让使用无限数据的结构成为可能。
- 开启了一种更有创造性的编程风格(wholemeal programming直译为全麦编程,一种全局思考的编程风格)。
- 但它也带来了一个负面影响:非常难以计算时空复杂度。
静态强类型:
Haskell中所有表达式都有一个类型,并且会在编译期提供类型检查。并且,Haskell中不允许隐式类型转换。
静态/动态类型是指在程序执行过程中变量的类型是否允许改变。
弱/强类型是指程序是否允许隐式类型转换。
三个主题
在这个系列的学习中,会重点学习三个主题:
类型
Haskell的严格的类型系统带来了以下好处:
(被迫)更清晰的思考和表达程序结构。
一个函数的定义往往从思考并写下它的类型开始。
程序具有类似文档的形式。
每个函数的类型定义可以让使用者清楚地明白这个函数接受什么输出什么。
将运行期错误提前到编译期
更早发现错误的好处不言而谕。
抽象
编程世界中有一句经常出现的话:“不要重复”,也叫抽象原则。意思是代码中的任何东西都不该在多处出现,所有的算法、数据段等内容都只该在一个确定的地方出现一次。比如相同的代码可以用函数封装起来供其它代码使用。
Haskell非常擅长抽象:像多态参数,高级函数和类型类这样的特性都是为了与重复斗争而加入的。
全麦编程
这词太离谱,总之就是在更整体的层面去思考问题。比如思考对一整个列表的操作,而不是列表里的一个个元素。开发一个解集而不是某个特定的解。想像一整个图,而不是某条路径。
在工程中体现为,先解决一个更普遍的问题,然后思考如何将普遍问题变换为一个特殊的问题。
举个例子,在C++或Java中,以下代码:
int acc = 0;
for (int i = 0; i < lst.length; ++i) {
acc = acc + 3 * lst[i];
}
这段代码的目的其实就是:将lst中的所有元素乘以3,再计算它们的总和。
在Haskell中,可以写成:
sum (map (3*) lst)
Haskell需要我们将思维转变成一种更加高屋建瓴的方式,而这种思维方式可以帮助我们写出更便于理解的代码。比如,在C++中:
// 先定义出两个基础函数,如果大量使用的话,这样看似繁琐的写法是值得的。因为它提供了更强大的抽象。
// 简单起见,暂不实现泛型的map和sum。
std::vector<int> map(std::function<void(int&)> func, std::vector<int> &n) {
for (auto &i : n) {
func(i);
}
return n;
}
int sum(const std::vector<int> n) {
int total = 0;
for (auto i : n) {
total += i;
}
return total;
}
...
// 使用上面的函数
void foo() {
int acc = sum(map([](int &a){a += 3;}, lst));
}
文档化的Haskell
Haskell中支持一种以文档为主的文件格式.lhs
,在这种格式下,以>
和一个空格开头的才被看做代码。这种文件格式可以更方便的写出大篇幅算法的解释等,毕竟没有人希望一个文件的主体全是注释。
声明与变量
观察以下代码:
x :: Int
x = 3
-- 单行注释以两个横线开头
{- 多行注释以两个
括号-横线对包覆 -}
这段代码声明了一个Int类型的变量x(::后面用于定义类型说明),并且将其值声明为3。此后x的值不能被改变。也不能对x进行重定义。
不难看出=号在Haskell中的含义与在其它语言中的不同。它并不是赋值运算符(Haskell中无值可赋),而是用于定义。x = 4
不应理解成将x的值置为4,应该理解为x被定义为4。
考虑下面定义:
y :: Int
y = y + 1
多少人初学编程,无法理解这种等于自身+1的定义。后来终于将自己的思维扭转,只可惜历史是个圈。这个定义不再是y的值加1了,而是将y定义为y + 1。这是一个无限值,但由于Haskell的惰性求值特性,不使用它时并不会导致异常情况。
基本类型
Haskell提供了一些耳熟能详的类型Int
、Char
、Bool
、Float
、Double
与String
,注意类型首字母要大写,以及几个注意事项。
Int
大概相当于C++的int类型,长度取决于运行代码的机器。Char
是Unicode编码字符。Bool
的值为True
和False
,首字母依然要大写。String
实际上是List的语法糖。
Haskell也提供了无限大小的整形Integer
,需要注意Integer
与Int
是不同的类型,不可混用。
注意Haskell中标识符使用小驼峰命名法。
GHCi
GHCi是Haskell的解释器,提供一个Haskell语言的解释运行环境,基本用法如下:
:l
加载Haskell文件:r
重新加载已加载文件:q
退出GHCi:?
打印帮助信息
在GHCi中,可以很方便的测试简单代码。
算术运算
可以在GHCi中进行一些简单运算的尝试:
-- 四则
ex01 = 3 + 2
ex02 = 19 - 27
ex03 = 2.35 * 8.6
ex04 = 8.7 / 3.1
-- 取模、乘方
ex05 = mod 19 3
ex06 = 19 `mod` 3
ex07 = 7 ^ 222
-- 负数
ex08 = (-3) * (-7)
注意:
- Haskell在缺省类型说明时可以自动地推导类型。
- 所有运算符号本质都是函数。这些函数有的是中缀函数而有的是前缀函数,这是在定义时决定的。
- 默认情况下Haskell中的函数都是前缀函数。
- 前缀函数可以通过反引号当做中缀函数使用(ex06中的mod)。
- 函数调用不需要使用括号(函数调用符)。
- 出现负数时要使用括号括起来,因为Haskell中没有函数调用符,负号与减号存在二义性(Haskell中为数不多的丑陋语法之一)。
Haskell中不存在隐式类型转换,需要时必须使用显式类型转换,比如:
-- 将整型(Int, Integer)转换为任意其它类型时
fromIntegral n
-- 将浮点型转换为整型时,根据截断方式使用
round d
floor d
ceiling d
/
运算符无法作用于整型,只能作用于符点类型。整除函数为div
,是一个前缀函数。
布尔运算
与或非运算分别为:&&
、||
、not
。注意非运算不再是!号了。
比较运算有:==
、/=
、<
、>
、<=
、>=
。同样注意不等于不再是!=而是/=
。
Haskell中也有if condition then sth else sth
的表达式。但if表达式不是if语句,最大的区别在于if表达式不可省略else后的部分。
对于一个c++函数:
void foo(int x) {
// ...
if (condition) {
// modify x
}
return x;
}
其意义为满足一定条件时,返回对x进行一些操作后的值,否则输出x本身。
而对于Haskell函数,需要这样写:
foo n = if condition then modify n else n
这样上述写法才在语义上等效,其中的关键思路是:Haskell中的表达式总是需要一个结果,省略掉else将导致程序无法产生结果。
这也是Haskell与命令式语言不同之处的体现:并非逐步执行,而是计算表达式。
不过Haskell中并不常用到if表达式,更多时候使用的是模式匹配和一种被称为守卫(guards)的机制。
定义基础函数
一个函数可以这样定义
suntorial :: Integer -> Integer
sumtorial 0 = 0
sumtorial n = n + sumtorial (n - 1)
其中的语法:
sumtorial :: Integer -> Integer
说明了函数的类型,接收一个Integer
作为参数,返回一个Integer
- 其后可以跟随多个从句,运行时使用参数从最上方定义的从句开始逐条匹配,并返回第一条匹配成功的从句定义的计算结果。这个过程就是模式匹配,比如说:
- 计算
sumtorial 0
,首先使用参数0与第一个从句的参数:0比较,匹配成功,返回第一个从句的值:0。 - 计算
sumtorial 3
,首先使用参数3与第一个从句的参数:0比较,匹配失败,再与第二个从句的参数n比较,n是一个变量,可以接受任何值,匹配成功,返回第二个从句的值:3 + sumtorial (3 - 1)
。- 由于Haskell是惰性求值的,只有用到这个结果时才会将表达式展开作下一步运算,这里暂且不管。
- 计算
也可以使用布尔表达式来筛选参数,也就是守卫机制:
hailstone :: Integer -> Integer
hailstone n
| n `mod` 2 == 0 = n `div` 2
| otherwise = 3 * n + 1
守卫通过从句中的缩进(Haskell使用相同的缩进层级来划分代码块)和|
来定义,从上而下进行判定,返回第一个满足条件的结果。otherwise
表示无条件接收。
如果没有从句可以匹配变量,程序将报错退出。
一个细节,守卫是从句的下级机制,也就是说每个从句都可以拥有守卫:
foo :: Integer -> Integer
foo 0 = 16
foo 1
| "Haskell" > "C++" = 3
| otherwise = 4
foo n
| n < 0 = 0
| n `mod` 17 == 2 = -43
| otherwise = n + 3
-- = 号并不需要对齐,这里只是出于美观对齐的
这个例子也没啥意义,就是给看看怎么混合使用。
下面的程序是完全正确的,但有些啰嗦,考虑下问题在哪:
isEven :: Integer -> Bool
isEven n
n `mod` 2 == 0 = True
otherwise = False
-- 其实真正有效的部分只有n `mod` 2 == 0,所以可以写成以下形式
-- 函数命名使用单引号是合法的
isEven' :: Integer -> Bool
isEven' = n `mod` 2 == 0
序对
可以使用序对(Pair)将两个东西组合起来,比如:
p :: (Int, Char)
p = (3, 'x')
注意:(x, y)
这种语法既可以表示序对类型也可以表示序对的值。
可以使用模式匹配将序对中的值提取出来:
sumPair :: (Int, Int) -> Int
sumPair (x, y) = x + y
Haskell中含有三元组和多元组,但很少使用,因为有更好的方法,这个方法容我日后再说。
接受多个参数的函数
要让函数接受多个参数,只要在类型声明时使用更多的->就可以了:
f :: Int -> Int -> Int -> Int
f x y z = x + y + z
useF = f 1 2 3
在多个->组成的串中,前几项依序表示参数,最后一项表示返回类型。你可能会疑惑为什么使用这样一个似乎很容易混淆的形式,而不是类似于f :: Int Int Int -> Int
这样的形式。这背后是一个很优雅的语言特性,但这个特性也得留待后议。
注意前缀函数的运算优先级比中缀函数要高,所以以下写法是错误的:
f 3 n + 1 7
因为它实际上会被解析成:
(f 3 n) + (1 7)
正确的写法是加上括号:
f 3 (n + 1) 7
列表
列表(List)是Haskell中最基本的类型之一,使用[]
表示,其中元素以,
分隔:
nums :: [Integer]
nums = [1, 2, 3, 19]
之前提到过String
是List的语法糖,
实际上String
类型就是[Char]
类型,比如:
hello1 :: [Char]
hello1 = ['h', 'e', 'l', 'l', 'o']
hello2 :: String
hello2 = "hello"
-- helloSame 为 True
helloSame = hello1 == hello2
构建列表
-- 最简单的列表就是空列表了
emptyList = []
--使用构建运算符(:)将元素连接成列表, :运算符左边是一个元素,而右边是一个列表。
ex18 = 1 : []
-- :运算符符合右结合律,以便连接多个元素时可以省略括号。
ex19 = 3 : (1 : [])
ex20 = 2 : 3 : 4 : []
--[e1, e2, e3]实际上是e1 : e2 : e3 : []的语法糖
ex21 = [2, 3, 4] == 2 : 3 : 4 : []
--[e1, e2..en]的写法可以根据前两个元素自动展开列表(等差数列),省略第二个元素的情况下差值为1
-- 1, 2, 3 ... 100
range1 = [1 .. 100]
-- 2, 4, 6 ... 100
range2 = [2,4 .. 100]
-- a, b, c, d ... z
range3 = ['a' .. 'z']
-- 10, 9 ... 1
range4 = [10, 9 .. 1]
-- 无限列表1,2,3...
range5 = [1, 2 ..]
-- 使用浮点类型时要小心精度问题带来的异常情况
-- 实际生成[0.1, 0.3, 0.5, 0.7, 0.89999999, 1.09999999]
range6 = [0.1, 0.3 .. 1]
-- 列表生成式[expr | elem1<-[range1], elem2<-[range2]..., condition1, condition2...]
-- 看似复杂,其实记住:表达式,表达式中的变量怎么来的,变量的约束条件(条件需全部满足,即与关系)。
-- 很类似数学中集合的表示
list = [x * y | x<-[4, 5, 6], y<-[-1, 1, 2, 3], x > y, y > 0]
-- 使用函数生成列表, hailstone作为守卫机制的例子定义过了,可以翻回去看
hailstoneSeq :: Integer -> [Integer]
hailstoneSeq 1 = [1]
hailstoneSeq n = n : hailstoneSeq (hailstone n)
操作列表
列表的基本操作如下:
-- 使用++运算符拼接两个列表
-- [1,2,3,4,5,6]
list = [1,2,3] ++ [4,5,6]
-- 使用!!运算符取出列表中某个元素,类似数组下标,从0开始计数
-- n = 2
n = list!!1
处理列表的函数
可以使用模式匹配来处理列表:
intListLength :: [Integer] -> Integer
intListLength [] = 0
intListLength (x:xs) = 1 + intListLength xs
-- 对于仅仅用来表示模式而不实际使用的变量,如上例中x,可以使用下划线_占位
intListLength (_:xs) = 1 + intListLength xs
sumEveryTwo :: [Integer] -> [Integer]
sumEveryTwo [] = []
sumEveryTwo (x : []) = [x]
sumEveryTwo (x : y : zs) = (x + y) : sunEveryTow zs
组合函数
在Haskell中要尽可能地使用简单的函数组合成复杂的功能,比如要求hailstone数的数量,可以这样编写:
hailstoneLen :: Integer -> Integer
hailstoneLen n = intListLength (hailstoneSeq n) - 1
在这个函数中通过之前的例子定义的函数组合起来达成目的,其实这些函数本身也是由简单的函数组合成的,这样层层抽象将使得我们的心智负担更小。
关于错误信息
不要害怕错误信息,它可以很好地帮助我们找出并改正代码中的错误。比如,在GHCi中:
Prelude> 'x' ++ "foo"
将导致以下报错:
<interactive>:1:1:
Coundn't match expected type '[a0]' with actual type 'Char'
In the first argument of '(++)', namely 'x'
In the expression: 'x' ++ "foo"
In an equation for 'it' : it = 'x' ++ "foo"
乍一看头都大了,怎么这么长一串报错。实际上耐心看下去就会发现,错误信息包括了出错原因与地点,还层层递进的显示了出错的语法,是非常友好的。