Why Haskell
如果想了解函数式编程,那么 haskell 作为一门经典的函数式编程语言是非常值得学习的。其中 haskell 内置的 monad 等概念如今也推广到了例如 scala, java 等语言中。虽然学习 haskell 不一定对我们找工作有帮助,但是借助对 haskell 的学习可以了解到函数式编程的思维。并将这些思维与方式推广到其他语言中。
安装
所谓工欲善其事,必先利其器件。如果你还有一个 haskell 开发环境,请参考 juejin.cn/spost/73284... 来进行配置.
Hello World
学习一门语言一般先从 Hello World 开始,修改 Main.hs
为如下内容:
arduino
main :: IO()
main = do
print "Hello World"
可以看出非常的简洁。至于 ::
以及 IO
的含义我们在下文中会陆续提到。这里只需要只要使用 print
能够打印到控制台即可。通过 stack run --silent
来运行项目,添加 --silent
可以避免输出编译信息。
函数与模式匹配
函数
函数式编程语言当然要先学习函数。在 haskell 中定义一个函数包括两个部分,函数类型声明(可以省略,编译器会自动推导)和定义。
我们先从编写一个简单的函数开始。该函数的作用是判断输入值是否等于 5,如果等于 5 则返回 Yes 否则返回 No:
sql
--isFive 为函数名,:: 之后为函数类型签名。Int -> String 表示输入为 Int 类型,输出为 String
isFive :: Int -> String
isFive num = if num == 5 then "Yes" else "No"
从这里可以看出 haskell 的函数定义与其他语言有一点差别: 函数参数既不需要括起来,也不需要用逗号分割
:
functionName args1 args2 args3 ... = experssion
在 haskell 中通过函数名 参数1 参数2 ... 参数n
的方式来调用函数:
ini
isFive :: Int -> String
isFive num = if num == 5 then "Yes" else "No"
main :: IO()
main = do
let res = isFive 5 -- 调用 isFive 函数,输入值为 5
print res -- Yes
isFive
的定义非常简单,它只接受一个参数进行处理。虽然在实际的开发中函数往往不止一个参数,但有一个不得不相信的事实,在 haskell 中所有的函数都只有一个参数
。等等,如果所有的函数只有一个参数,那么我们应该怎么定义参数数量超过一个的函数呢?实际上,对于有两个输出参数的函数,例如 (Int, Int) -> Int
,我们可总可以写为 Int -> Int -> Int
例如对于函数 add x y = x + y
,可以改写为:
ini
add = x -> y -> x + y
add_with_1 = add 1 -- add_with_1 为一个函数
res = add_with_1 2 -- 3
因此我们在 haskell 中定义 add
方法应该为: 例如想定义一个函数判断两个数字是否相等,在 haskell 中为:
sql
add :: Int -> Int -> Int
add x y = x + y
main :: IO()
main = do
let res = add 1 2
print res
这里的 add
实际上只有一个参数,其返回值为一个函数。即 add 1
实际上为一个函数,而不是一个值。add 1 2
的结果才是最终的值 3。当我们调用 add 1 2
时,其等价于:
ini
add :: Int -> Int -> Int
add x y = x + y
main :: IO()
main = do
let add1 = add 1
let res = add1 2
print res
高阶函数
在 haskell 中,函数也可以作为参数输入,这种方式可以称之为高阶函数。例如我们定义一个函数,它接受一个函数,并将这个函数对一个参数调用两次:
ini
runTwiceInt :: (Int -> Int) -> Int -> Int
runTwiceInt f x = f (f x)
main :: IO()
main = do
let res = runTwiceInt (add 5 ) 3
print res -- 13
函数签名为 (Int -> Int) -> Int -> Int
这里的小括号就无法省略,因为必须让编译器知道第一个参数为 接收一个 Int 类型参数并返回 Int 类型值的函数
。再看调用的方式 runTwiceInt (add 5 ) 3
回忆一下刚刚提到的柯里化,add 虽然是接收两个 Int 类型参数的函数但是 add 5
却是接收一个 Int 类型参数
。我们将 add 5
这个函数对 3 执行了两次,结果为 13。
中缀表达式
在 haskell 中,对于普通的函数加上 `` 则可以通过中缀表达式来调用函数,例如:
csharp
main :: IO()
main = do
let res = 1 `add` 2 -- 与 add 1 2 相同
print res
聪明的你一定想到了 1 + 2
中的 +
也是一个函数。每次,在 hasekll 中的运算符都是函数。因此下面都是合法的表达式:
ini
main :: IO()
main = do
let res = (+) 1 2
print res
let res1 = 1 + 2
print res1
由于 +
也是一个函数,因此刚刚的 runTwice
也可以这样使用:
ini
main :: IO()
main = do
let res = runTwiceInt (+5) 3
print res
$ 函数
在 haskell 中,函数调用是左结合
的。这意味着当以下表达式是错误的:
arduino
print add 1 2
因为 haskell 会从左往右扫描表达式,并且认为 add 1 2
都是 print 的输入参数。可以通过添加 ()
来避免这种情况:
bash
print (add 1 2)
在 hasekll 中还有一个神奇的函数 $
,它可以帮助我们少写一个字符:
arduino
print $ add 1 2
我们看一看它的定义:
rust
$ :: (a -> b) -> a -> b
$ f x = f x
实际上就是将一个函数直接应用到其参数上,其作用为:
ini
result3 = f (g x) -- 使用括号明确函数应用顺序
result4 = f $ g x -- 使用 $ 省略括号
这里 $
能起作用的原因是,$
是一个优先级非常低的右结合运算符 (在 haskell 中运算符的优先级和结合性都可以自定义),它可以将 $
右边的参数作为一个整体。因此 f $ g x = f $ (g x)
由于 $
什么操作都不做,因此 f $ g x = f $ (g x) = f (g x)
。 haskell 为了让我们少写一个括号还真是操碎了心。
模式匹配
参数匹配
在 haskel 中,模式匹配是非常重要的内容。haskell 的函数本身就支持模式匹配,例如刚刚定义的 isFive
可以改写为:
ini
isFive 5 = "Yes"
isFive _ = "No"
可以看到这里的参数不再是一个 形式参数
, 而是一个字面量。在 haskell 中如果参数是一个字面量的话,则如果输入的参数等于该字面量会直接命中该分支。例如要实现一个 isFiveOrSix
可以直接:
ini
isFive 5 = "Yes"
isFive 6 = "Yes"
isFive _ = "No"
这里需要注意的是 isFive _
表示一个默认模式,如果其他模式都没匹配上,则命中这个分支。
卫兵模式
刚刚提到的使用参数进行匹配都是固定值,如果我们想匹配某个区间该怎么办呢?例如,有这样一个打分函数:
arduino
0 - 59 分输出 "Oh God"
60 - 80 分输出 "So Lucky"
80 - 100 分输出 "Great"
在 hasekll 中可以实现为:
sql
sayScores :: Int -> String
sayScores score
| score <= 59 = "Oh God"
| score >= 60 && score <= 80 = "So Lucky"
| otherwise = "Great"
这里通过 |
后面的表达式来进行匹配,这种形式有点类似于其他语言中的 if - else if -else 的模式,不过更加简洁。
case of
在大部分语言中都有 switch case 这样的语法,在 haskell 中可以通过 case of
来表示:
sql
case expression of pattern -> result
pattern -> result
pattern -> result
...
例如使用它来实现 isFiveOrSix
:
rust
isFiveOrSix :: Int -> String
isFiveOrSix num = case num of
5 -> "Yes"
6 -> "Yes"
_ -> "No"
这看起来和使用函数参数进行模式匹配差不多,实际上这两个的确区别不大,使用哪一个看个人喜好了。
where
在 haskell 中并没有其他语言中定义变量的方式。有的时候我们想定义一个中间变量并重复使用,在 hasekll 中可以使用 where。例如我们定义一个函数,来计算 bmi:
ini
bmiTell :: (RealFloat a) => a -> a -> String
bmiTell weight height
| weight / height ^ 2 <= 18.5 = "You're underweight, you emo, you!"
| weight / height ^ 2 <= 25.0 = "You're supposedly normal. Pffft, I bet you're ugly!"
| weight / height ^ 2 <= 30.0 = "You're fat! Lose some weight, fatty!"
| otherwise = "You're a whale, congratulations!"
可以看到 weight / height ^ 2
这个值被计算了多次。因此可以用 where 来定义一个中间变量,避免多次计算:
ini
bmiTell :: (RealFloat a) => a -> a -> String
bmiTell weight height
| bmi <= skinny = "You're underweight, you emo, you!"
| bmi <= normal = "You're supposedly normal. Pffft, I bet you're ugly!"
| bmi <= fat = "You're fat! Lose some weight, fatty!"
| otherwise = "You're a whale, congratulations!"
where bmi = weight / height ^ 2
skinny = 18.5
normal = 25.0
fat = 30.0
类型
类型变量
haskell 是一种静态类型语言,在编译器会执行严格的类型检查。在不少其他语言中,都有 泛型
的概念。泛型实际上就表示一类型变量,例如 List[T]
中的 T 就为一个类型变量,当 T = String 则 List[T]=List[String]
。当 T = Int 则为 List[T]=List[Int]
。在 haskell 中也有对应的概念,称为类型变量。例如在 haskell 中,List 类型为 []
。如果我们想定义一个获取 List 第一项的函数 head',那么应该如何定义呢?毕竟 List 中可以装各种类型的值,我们无法在编写函数时确定值的类型,此时就需要用到类型变量:
less
head' :: [a] -> a
head' (x: _) = x
注意这里的 a 并不表示一个变量,而是一种类型
。即当输入类型为 [String]
时输出类型为 a
。
复杂类型
在 haskell 中有以下常用的类型:
- Int: 整数类型
- String: 字符串类型
- Float: 单精度浮点型
- Double: 双精度浮点型
- Bool: 布尔类型
[a]
: 数组类型
在开发的时候,往往都会自定义类型。例如我想用号,姓名,年龄这几个信息来表示一个 Student
,在 haskell 中可以使用 data
关键字来定义这个 Student
类型:
bash
data Student = CreateStudent Int Int String
main :: IO()
main = do
let res = CreateStudent 1321332 22 "lili"
let (CreateStudent number age name) = res
print number
print age
print name
可以使用模式匹配的方式来获取其中的值。这里的 CreateStudent
则称为值构造器
。类比到其他语言,Student
可以看作为一个类型,CreateStudent
可以看作构造函数。实际上类型和值构造器命名是可以完全相同:
kotlin
data Student = Student Int Int String
值构造器实际上和函数并没有什么区别,例如值构造器也能直接柯里化:
ini
data Student = Student Int Int String
main :: IO()
main = do
let createPartital = Student 1111 23
let s1 = createPartital "lily"
let s2 = createPartital "john"
在 haskell 中,data 可以定义一个类型,也可以定义一个类型构造器。值构造器可以通过已有的值创建一个新值。而类型构造器则可以通过类型来构造一个新类型。这有点类似于我们刚刚提到的类型变量
。假如我们想创建一个容器类型,这个容器可以保存其他类型的值。但是由于这个值的类型是未知的,因此需要使用类型变量:
haskell
data Container a = Container a
这里等号左边的 Container 就成为一个类型构造器,而 Container Int
或 Container String
才是一个完整的类型。
sql
data Container a = Container a
type IntContainer = Container Int -- 这个 Container 是类型构造器
main :: IO()
main = do
let oneIntContainer :: IntContainer = Container (1 :: Int) -- 这个 Container 是值构造器
let (Container v) = oneIntContainer
print v -- 1
对于复杂类型进行模式匹配时可以直接提取出其中的值,例如对于刚刚定义的 Container :
ini
data Container a = Container a
type IntContainer = Container Int -- 这个 Container 是类型构造器
main :: IO()
main = do
let oneIntContainer :: IntContainer = Container (1 :: Int) -- 这个 Container 是值构造器
let (Container v) = oneIntContainer
print v
记录语法
刚刚使用 Student
举例构造了一个复杂类型,但是仔细一看,这个类型看起来和元组没什么区别。好像都没办法通过名字来获取属性。因此在 haskell 中还有记录语法Record
来构造一个命名的属性,例如:
ini
data Student = Student{
sid :: Int,
age :: Int,
name :: String
}
main :: IO()
main = do
let student = Student{
sid = 1,
age = 22,
name = "john"
}
let Student{sid=sid', age=age', name=name'} = student
print sid' -- 1
print age' -- 22
print name' -- john
print $ sid student -- 1, sid 是编译器自动生成的函数
定义一个记录之后,haskell 会自动生成与属性同名的函数 。例如 sid student
就可以获得 student
中 属性名为sid
的值。同样,通过模式匹配也可以对记录进行解构 let Student{sid=sid', age=age', name=name'} = student
中,sid=sid'
就是将名为 sid
的属性赋值给 sid'
。注意 xxx'
这种方式是 haskell 中的一种技巧,xxx'
在 hakell 中也是一个合法的变量,可以用来处理一些变量同名的问题。
类型类
类型类似 haskell 中非常重要的一个概念。在 java 中,两个对象的比较一般都可以通过 equals 方法来进行。这是因为 java 所有的类都继承至 Object,而 equals 这个方法在 Object 中就已经定义了。因此对于所有的对象都可以通过调用这个方法来进行比较。那么在 haskell, 我们也希望自定义的容器类型 Student 也能通过 ==
来比较,此时就需要通过类型类来实现。
如果说 java 中的接口定义了一种类型应该具有什么样的行为 ,那么 haskell 的类型类就定义了函数应该怎么作用某些类型 。在 haskell 中 ==
也是一个函数,是定义在 Eq
这个类型类中的,其定义为:
rust
class Eq a where
== :: a -> a -> Bool
\= :: a -> a -> Bool
其定义了等于 == 以及不等于 = 两个函数。即如果某个类型实现了这个类型类,即可以使用 ==
这个函数进行比较。这里的 Eq 表示类型类的名称,而 a 则表示任意实现了 Eq 类型类的某个类型。签名a -> a -> Bool
则表示 输出两个实现了Eq 的类型,输出一个 Bool 类型
。 此时对于我们自己定义的 Student 类型可以这样实现(姑且认为学号相同的两个 Student 对象就相同,只是举个例子):
ini
data Student = Student{
sid :: Int,
age :: Int,
name :: String
}
instance Eq Student where
s1 == s2 = name s1 == name s2
main :: IO()
main = do
let student1 = Student{
sid = 1,
age = 22,
name = "john"
}
let student2 = Student{
sid = 1,
age = 22,
name = "john"
}
print $ student1 == student2 --True
可以看出,在实现了 Eq
类型类之后确实可以通过 == 进行比较。再介绍一个常用的类型类 Show,其定义了一个 show 方法,类似于 java 中的 toString。用于将变量转换为一个可以输出的字符串。Show 的定义如下:
rust
class Show a where
show :: a -> String
尝试一下为 Student 定义一个 show:
ini
instance Show Student where
show Student{sid=sid', age=age', name=name'} = "Student{" ++ show sid' ++ " " ++ show age' ++ " " ++ name' ++ "}"
main :: IO()
main = do
let student = Student{
sid = 1,
age = 22,
name = "john"
}
print student
当我们调用 print 的时候会自动调用 show 方法并输出对应的字符串。注意这里采用了 ++
对字符串进行拼接。由于 age, sid
都不是 String
类型,所以先调用了 show
方法将其转换为 String
。
如果对于每种类型都要去定义如何实现 show 方法未免有点麻烦,因此在 haskell 可以通过 deriving 来为我们自动生成各种常用的实现,例如:
ini
data Student = Student{
sid :: Int,
age :: Int,
name :: String
} deriving (Show)
main :: IO()
main = do
let student = Student{
sid = 1,
age = 22,
name = "john"
}
print student -- Student {sid = 1, age = 22, name = "john"}
通过这种方式,我们就不用手动的实现 Show 这个类型类了。
类型约束
方法类型约束
在 java 中一般可以通过限制函数的参数来控制类型。例如要编写一个排序的方法,那么输入参数必须限定为实现了 Comparable
的类型。同理在 haskell 中也有类型的场景。假如要定义一个函数名为 isEqual
,该函数输入两个变量,若这两个变量相等则返回 equal 否则返回 not equal。由于这个函数并不针对某个特定类型,因此我们定义函数时应该使用类型变量 isEqual :: a -> a -> String
,例如:
ini
isEqual :: a -> a -> String
isEqual v1 v2
| v1 == v2 = "equal"
| otherwise = "not equal"
这时候我们会发现一个问题,由于 a 可以是任何类型,因此完全有可能 a 并未实现 Eq
类型类。因此 v1 == v2
实际上无法正常执行。这里就需要采用 isEqual :: Eq a => a -> a -> String
来限定类型 a 必须是一个实现了 Eq
的类型。这里的 Eq a
就表示类型 a 必须实现 Eq 类型类。此时 isEqual
才能正常工作:
ini
isEqual :: Eq a => a -> a -> String
isEqual v1 v2
| v1 == v2 = "equal"
| otherwise = "not equal"
main :: IO()
main = do
print $ isEqual (Container 1) (Container 2) -- not equal
实现类型约束
在定义方法时需要类型约束,同样在实现类型类时也需要进行类型约束。例如如果想对 Container
实现 Show
类型类,我们可能会这样写:
sql
instance Show(Container a) where
show (Container v) = show v
由于 Container 是一个类型构造器,因此我们需要写出 Show(Container a)
这里的 a 同样表示任意类型。聪明的你已经发现 show v
是不能正常工作的。因此 a 既然是任何类型,那么它不一定实现了 Show
。因此这里需要限定一个,仅仅为实现了 Show
类型的 a
构造出的 Container a
来实现类型类 Show
:
ini
instance Show a => Show(Container a) where
show (Container v) = show v
main :: IO()
main = do
let c = Container 1
print c -- 1
这里的 Show a
则表示限定类型 a 必须实现 Show 。假如我们传入一个未实现 Show
的类型,编译器就会给出提示:
ini
data MayType = MayType String
main :: IO()
main = do
let c = Container $ MayType "tttt" -- No instance for (Show MayType) arising from a use of 'show'
let res = show c
print res
由于 MayType
实现 Show
因此 Container $ MayType "tttt"
不满足类型限定,编译器会抛出 No instance for (Show MayType) arising from a use of 'show'
的异常。