从零开始的Haskell(一)——Haskell基础

Xilong Yang
2021-06-10

对Haskell一直挺感兴趣,也学习了一段时间。奈何IO太离谱,做不出实际的东西,导致学了忘忘了学痛苦万分。于是只好信奉好记性不如烂笔头,写几篇笔记记录下来。

这是第一篇,主题是Haskell的介绍和基础语法。

给其它语言学习者的忠告

如果你之前学习了一大票主流语言,比如C、C++、JAVA、Python、Shell、JS、PHP、汇编等等。我的建议是:忘记它们

之前为C++选手写过一篇入门Python的文章,本来也想写一篇基于C++基础入门Haskell的文章。但一上手发现,使用C++的思维还不如完全失忆更好接受Haskell。

这就牵扯到了编程范式的问题。上述一大票语言,不管多么千差万别,大体上的运行方式就是:从某一条语句开始,按顺序一条一条的往下执行。这就是所谓的命令式编程:告诉计算机它要做什么。

而Haskell和这些语言不一样,它没有一个固定的语句执行顺序,甚至于,你无法改变一个变量的值(我更倾向于将Haskell中的变量理解成没有参数的函数,也就是说,根本没有变量,一切都是函数)。你的程序里满是无所谓先后的函数定义,你要做的事情是使用一个函数描述出你要解决的问题。也就是函数式编程:告诉计算机问题是什么。

因为这一个How和What的区别,在座的各位步骤流程大师的很多经验失去了用武之地。既然这样,不如索性给它忘了,从零开始推开新世界的大门。

什么是Haskell

Haskell是一门惰性求值、纯函数式的静态强类型编程语言。

函数式:

对于函数式并没有明确的定义,但它通常意味着:

学习Haskell花费时间最多的地方就在于这种从命令式到函数式的思维转换。

纯:

“让函数式的归函数式,纯的归纯。”——《藏狐箴言》。

并不是所有函数式语言都是纯函数式,一个纯的语言意味着:

相信大家看到这已经懵了,这不是啥也干不了了吗?这语言什么用?rnm,退钱!

其实大可不必,面包还是会有的,只要亿点点思维转变,比如:

惰性求值:

在Haskell中,一个表达式的值只有在真正被需要的时候才被计算出来(就像你只有在马上考试的时候才开始学习一样)。这种特性将会随着学习的深入加深理解。这里举几个浅显的小例子:

静态强类型:

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提供了一些耳熟能详的类型IntCharBoolFloatDoubleString,注意类型首字母要大写,以及几个注意事项。

Haskell也提供了无限大小的整形Integer,需要注意IntegerInt是不同的类型,不可混用。

注意Haskell中标识符使用小驼峰命名法。

GHCi

GHCi是Haskell的解释器,提供一个Haskell语言的解释运行环境,基本用法如下:

在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中不存在隐式类型转换,需要时必须使用显式类型转换,比如:

-- 将整型(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)

其中的语法:

也可以使用布尔表达式来筛选参数,也就是守卫机制:

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"

乍一看头都大了,怎么这么长一串报错。实际上耐心看下去就会发现,错误信息包括了出错原因与地点,还层层递进的显示了出错的语法,是非常友好的。

© 2019- Xilong Yang | CC BY-NC 4.0 | Powered by LaTeX.css, Prism, MathJax