万字长文带你入门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' 的异常。

相关推荐
再思即可1 个月前
sicp每日一题[2.77]
算法·lisp·函数式编程·sicp·scheme
桦说编程1 个月前
把 CompletableFuture 当做 monad 使用的潜在问题与改进
后端·设计模式·函数式编程
蜗牛快跑2131 个月前
面向对象编程 vs 函数式编程
前端·函数式编程·面向对象编程
大福是小强2 个月前
002-Kotlin界面开发之Kotlin旋风之旅
kotlin·函数式编程·lambda·语法·运算符重载·扩展函数
再思即可2 个月前
sicp每日一题[2.63-2.64]
算法·lisp·函数式编程·sicp·scheme
老章学编程i2 个月前
Java函数式编程
java·开发语言·函数式编程·1024程序员节·lanmbda
安冬的码畜日常2 个月前
【玩转 JS 函数式编程_014】4.1 JavaScript 纯函数的相关概念(下):纯函数的优势
开发语言·javascript·ecmascript·函数式编程·js·functional·原生js
Dylanioucn2 个月前
【编程进阶知识】Java 8 函数式编程接口全解析:Supplier、Runnable、Function、Consumer、Apply
java·开发语言·函数式编程
矢心3 个月前
函数式编程---js的链式调用理解与实现方法
前端·javascript·函数式编程
安冬的码畜日常3 个月前
【玩转 JS 函数式编程_006】2.2 小试牛刀:用函数式编程(FP)实现事件只触发一次
开发语言·前端·javascript·函数式编程·tdd·fp·jasmine