万字长文带你入门Haskell

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 IntContainer 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' 的异常。

相关推荐
Oberon9 天前
从零开始的函数式编程(2) —— Church Boolean 编码
数学·函数式编程·λ演算
桦说编程17 天前
CompletableFuture 超时功能有大坑!使用不当直接生产事故!
java·性能优化·函数式编程·并发编程
桦说编程21 天前
如何安全发布 CompletableFuture ?Java9新增方法分析
java·性能优化·函数式编程·并发编程
桦说编程24 天前
【异步编程实战】如何实现超时功能(以CompletableFuture为例)
java·性能优化·函数式编程·并发编程
鱼樱前端1 个月前
Vue3之ref 实现源码深度解读
vue.js·前端框架·函数式编程
RJiazhen1 个月前
前端项目中的函数式编程初步实践
前端·函数式编程
再思即可3 个月前
sicp每日一题[2.77]
算法·lisp·函数式编程·sicp·scheme
桦说编程3 个月前
把 CompletableFuture 当做 monad 使用的潜在问题与改进
后端·设计模式·函数式编程
蜗牛快跑2133 个月前
面向对象编程 vs 函数式编程
前端·函数式编程·面向对象编程
大福是小强3 个月前
002-Kotlin界面开发之Kotlin旋风之旅
kotlin·函数式编程·lambda·语法·运算符重载·扩展函数