跳至主要內容

一、入门

hahg大约 24 分钟

一、入门

  • 学习Haskell的契机是想要学习 函数式编程

  • 函数式编程语言有很多,为什么选择 Haskell?因为 Haskell 是纯函数编程语言中最热门的

  • 代码编辑器选择 VSCode,需要配置插件

下面会介绍一些会遇到的名词,可能会不太对(2023年3月12日之前 自己理解的)

  • Haskell:编程语言,官网 https://www.haskell.org/
  • GHCup:必装,管理工具,可以管理 Haskell 所需要的所有工具。注意和 ghc 不是一样的东西
    • ghc:必装,Haskell 编译器,可以使用其来编译 .hs 文件
    • HLS:必装,Haskell Language Server,Haskell 语言服务器。不会直接 HLS,但 VSCode 需要在后台使用 HLS
    • Cabal:Haskell 项目构建工具。简单说就是脚手架,用于生成项目结构
    • Stack:Haskell 项目构建工具。也是脚手架,学习视频会使用 Cabal 或者 Stack,建议两个都转上。
    • 以上在 GHCup 安装的时候都会提示安装,直接全部使用推荐版本,如果使用最新版本可能会有兼容性问题。

二、安装

2.1 安装GHCup

  • GHCup 官网:https://www.haskell.org/ghcup/

  • 理论上一行代码就可以完成安装,就像官网所示。但是由于网络原因,需要手动安装。空降 https://www.haskell.org/ghcup/install/#manual-installation

  • 下载 x86_64-mingw64-ghcup.exe,不要运行,放到一个不要那么深的目录下,例如 C:\ghcup\bin

2.2 安装MSYS2

  • 安装这工具因为 GHCup 需要使用
  • 下载链接: https://repo.msys2.org/distrib/msys2-x86_64-latest.exe
  • 运行安装,要记住安装路径,例如 C:\msys64

2.3 配置环境变量

  1. 在 Path 添加 GHCup 的位置,例如 C:\ghcup\bin
  2. 在用户变量新建一个变量,变量名填写 GHCUP_MSYS2,变量值填写 C:\msys64
  3. 再新建一个变量,变量名为 GHCUP_CURL_OPTS ,变量值填写 -k 。这个变量值的作用是跳过 CURL 的安全检查,CURL 在下载的时候会检查证书,会因为网络原因失败
  4. 然后在 GHCup 的配置文件配置镜像,配置文件位置 :安装路径下的 config.yaml
  5. TODO:第3点和第4点是否只需要配置一个?需要后续验证
url-source:
  OwnSource: https://mirrors.ustc.edu.cn/ghcup/ghcup-metadata/ghcup-0.0.7.yaml

然后打开 powershell ,运行下面代码

  • ghcup install ghc
  • ghcup install cabal
  • ghcup install stack
  • ghcup install hls

安装好后,可以运行 ghcup list 查看已安装的版本

如果想要切换工具链的版本,ghcup set ghc/stack/cabal/hls V.V.V ,其中 V.V.V 代表工具的bj版本

2.4 配置stack

  1. 首先找到 stack 的安装位置,我的安装位置是 C:\Users\xxxxx\AppData\Roaming\stack ,其中 xxxxx 是电脑的用户名。如果不知道的话,可以输入命令 stack path ,然后在输出信息的第 4 行,找到变量名为 stack-root
  2. 然后打开 config.yaml ,这个是 stack 的主配置文件,并复制下面的代码
# 配置使用系统ghc
system-ghc: true

# 配置镜像
setup-info-locations: ["https://mirrors.tuna.tsinghua.edu.cn/stackage/stack-setup.yaml"]
urls:
  latest-snapshot: https://mirrors.tuna.tsinghua.edu.cn/stackage/snapshots.json

snapshot-location-base: https://mirrors.tuna.tsinghua.edu.cn/stackage/stackage-snapshots/
  1. 在这里也可以配置创建项目的时候,自动添加作者名字、版权声明等信息
templates:
  params:
#    author-name:
#    author-email:
#    copyright:
#    github-username:
  1. 安装 msys2,输入 stack setup 。我暂时没找到使用在 2.2 节安装的 msys2,所以就只能再安装一个。

2.5 创建项目

  1. 输入命令 stack new project_name
  2. 然后启动命令 stack build ,如果没有报错则没有问题
  3. 配置插件,将下面代码添加到 json 文件里
// 设置用GHCup来管理HLS
"haskell.manageHLS": "GHCup",
// 设置GHCup的安装位置
"haskell.serverEnvironment": {
  "PATH": "C:/ghcup/bin"
},
// 设置使用全局的工具链
"haskell.toolchain": {
  "ghc": null,
  "cabal": null,
  "stack": null
},
  1. 最后 打开/重启打开 vscode,最后的效果如下

三、基本语法

学习基本语法,先不使用 vscode,先使用最简便的 ghci ,直接在命令行输入 ghci ,就可以进入。

历史:函数式编程和数学关系非常紧密,不像平常的面向对象和面向过程,很少使用到数学知识。如果说要用到数学知识的方向,估计就是算法之类的了。(陋见:平常写业务代码也使用不到红黑树之类的吧)

所以先从数学方面来学习基本语法,而不是像平常语言一样学习语句。

本章书籍链接:

  • https://flaneur2020.github.io/lyah/ready-begin.htm
  • http://cnhaskell.com/

3.1 简单运算

  1. 加减乘除没问题
ghci> 2 + 15
17
ghci> 1823 - 1354
469
ghci> 7 / 4
1.75
ghci> 3 * 39
117
  1. 使用括号改变计算顺序
ghci> 10 + 8 * 20
170
ghci> (10 + 8) * 20
360
  1. 负数也要加上括号,就像 小学/初中 课本一样。但也和现在默认的一样,只要负号前面没有运算符,括号可以省略
# 负号在前面可以省略括号
ghci> -3 * 5
-15

# 负号在前面有运算符需要加上括号
ghci> 5 * -3
<interactive>:12:1: error:
    Precedence parsing error
        cannot mix ‘*’ [infixl 7] and prefix `-' [infixl 6] in the same infix expression
ghci> 5 * (-3)
-15
  1. 逻辑运算,与、或、非,TrueFalse 记住 首字母要大写
ghci> True && False   
False   
ghci> True && True   
True   
ghci> False || True   
True    
ghci> not False   
True   
  1. 相等性判断,不等于使用 /= ,而不是 != 了, /= 更像不等号
ghci> 6 == 6   
True   
ghci> 6 == 0   
False   
ghci> 6 /= 6   
False   
ghci> 6 /= 0   
True   
ghci> "hello" == "hello"   
True  
  1. 进行运算的时候,需要左右两边都是一样的类型。不然就像现实生活中说,你的年龄比我昨天睡得还要晚。(胡言乱语.jpg)
ghci> 5 + "1"

<interactive>:19:3: error:
    ? No instance for (Num String) arising from a use of ‘+’
    ? In the expression: 5 + "1"
      In an equation for ‘it’: it = 5 + "1"
ghci> 5 == "1"

<interactive>:20:1: error:
    ? No instance for (Num String) arising from the literal ‘5’
    ? In the first argument of ‘(==)’, namely ‘5’
      In the expression: 5 == "1"
      In an equation for ‘it’: it = 5 == "1"
  1. 我们可以用命令 :info 来查看某个操作符的优先级,或者某个类型的详细信息
  • 第 1 ~ 4 行可以看到 String 其实是 Char 字符类型的数组,这点和 C 语句很像;以及这个类型在 GHC的Base 里定义的(是文件还是类?)
  • 第 6 ~ 12 行可以看到 + 函数需要传入两个 Num 值;在 GHC 的 Num 里定义的;infixl 6 +,表示 (+)左结合优先级 是 6;而 infixr 8 ,表示 (^)右结合优先级 是 8
ghci> :info String
type String :: *
type String = [Char]
        -- Defined in ‘GHC.Base’
        
ghci> :info +
type Num :: * -> Constraint
class Num a where
  (+) :: a -> a -> a
  ...
        -- Defined in ‘GHC.Num’
infixl 6 +

ghci> :info ^
(^) :: (Num a, Integral b) => a -> b -> a       -- Defined in ‘GHC.Real’
infixr 8 ^
  1. 乘方操作符的两种形式,如果指数是整数,使用 ^ 符号;如果是小数,使用 ** 符号。

42.5=4240.5=16412=1642=162=324 ^ {2.5} = 4 ^ {2} * 4 ^ {0.5} = 16 * 4 ^ {\frac{1}{2}} = 16 * \sqrt[2]{4} = 16 * 2 = 32

ghci> 4 ** 2.5
32.0

3.2 基本类型

1)列表

一个列表由方括号以及被逗号分隔的元素组成。列表里所有的元素必须是相同类型。(万恶的 Javascript)

ghci> [1, 2, "3"]
<interactive>:15:2: error:
    ? No instance for (Num String) arising from the literal ‘1’
    ? In the expression: 1
      In the expression: [1, 2, "3"]
      In an equation for ‘it’: it = [1, 2, "3"]
      
ghci> [1, 2, 3]
[1,2,3]

如果用 列举符号 .. 来表示一系列元素,Haskell则会根据规律自动填充内容,但只使用于等差数列。在计算一些数学公式的时候会很有用。

ghci> [1, 2..10]
[1,2,3,4,5,6,7,8,9,10]

ghci> [1, 3..10]
[1,3,5,7,9]

ghci> [10, 9..1]
[10,9,8,7,6,5,4,3,2,1]

ghci> [2, 4, 8, 16..64] # 原意是 [2, 4, 8, 16, 32, 64]
<interactive>:27:13: error: parse error on input ‘..

如果类型是浮点数的话,也会像常见的编程语言一样,会很怪异。(详情可以搜索浮点数在计算机里的存储方式)

ghci> [1, 1.2..2]
[1.0,1.2,1.4,1.5999999999999999,1.7999999999999998,1.9999999999999998]

列表的操作大概有两种:一种是 ++ ,用于连接两个列表,很简单,毕竟在平常使用中要连接两个列表只需要擦掉中间连接的括号;一种是 元素 : 列表 ,用于在列表的头部添加元素。

ghci> [1, 2] ++ [3, 4]
[1,2,3,4]
ghci> "Hello " ++ "World"
"Hello World"
ghci> 1 : [2, 3, 4]
[1,2,3,4]

2)开启类型显示

  1. 使用 :set +t 开启类型显示;it 代表最近一次求的值所存到的变量
ghci> ['h', 'e', 'l']
"hel"
it :: [Char]
ghci> "hel"
"hel"
it :: String
  1. 使用 :unset +t 关闭类型显示
ghci> "World"
"World"
  1. 在关闭类型显示的时候,可以使用 :type xxxxx 来显示某个值的类型
ghci> :type "!"
"!" :: String

3)数字

数字在 Haskell 里比较复杂。简单来说,非小数的数字有两种,一个是 Integer ,无界整数;另一个是 Int ,有界整数,他们的父类(构造函数)是 Integral

无界整数,换句话来说就是 bignums 类型,可以用来防止数字溢出。

注意

ghc 9.2.5 版本会报错,在 ghc 9.2.8 修复了这个 Bug

ghci> 2 ^ 1000
10715086071862673209484250490600018105614048117055336074437503883703510511249361224931983788156958581275946729175531468251871452856923140435984577574698574803934567774824230985421074605062371141877954182153046474983581941267398767559165543946077062914571196477686542167660429831652624386837205668069376

有小数的数字可以查看官方文档:https://www.haskell.org/tutorial/numbers.html

4)元组

  • 元组长度固定,里面的每个元素类型可以不同。用于存放一个东西的不同信息,例如一本书的书名、价格、持有数量等
  • 列表长度不固定,里面的每个元素类型必须相同。用于存放不同东西的相同信息,例如一个图书馆的所有书的价格

从下面的代码可知元组的类型就是 信息类型的集合

ghci> ("Kiniro Mosaic", "YUI", 2010)
("Kiniro Mosaic","YUI",2010)
ghci> :type ("Kiniro Mosaic", "YUI", 2010)
("Kiniro Mosaic", "YUI", 2010) :: Num c => (String, String, c)

5)变量

  • 变量在 Haskell 可以成为常量,因为当与一个表达式绑定后,那么这个变量的值就不会改变。

  • 实际原因是 Haskell 的初心,一个表达式无论外界发生了什么变化,其返回的值都不应该变化

如果在编辑器上像下面这样写,就会报错,但在 ghci 里这样写可以正常替换

x = 2
x = 1
-- Multiple declarations of ‘x’
-- Declared at: E:\haskell\test.hs:1:1
--              E:\haskell\test.hs:3:1 typecheck

3.3 类型推导

Haskell 最大的一个特点就是类型推导,会将各种变量在编译的时候推导出来,然后再判断是否正确。既能在编译过程中找出错误,而且不用写那么多类型签名。

3.3.1 变量推导

可以在 ghci 命令行中进行变量推导

  • 如果直接输入值,即使不指定类型,也合法
  • 如果指定正确的类型也合法
  • 如果指定错误的类型会报错
ghci> 'a'  -- 自动推导
'a'

ghci> 'a' :: Char  -- 正确的类型
'a'

ghci> 'a' :: Int  -- 错误的类型

<interactive>:3:1: error:
    ? Couldn't match expected type ‘Int’ with actual type ‘Char’
    ? In the expression: 'a' :: Int
      In an equation for ‘it’: it = 'a' :: Int

3.3.2 函数类型

使用 :type 函数名 ,可以看到该函数的类型推导,从类型推导可以知道如何使用函数。

例如 :type odd

  • 大概的格式是 函数名 :: 变量声明 => 传参, ... -> 返回值
  • 则根据下面的代码可知:
    • 函数名是 odd ,奇数的意思
    • 声明了一个类型为 Integral 的变量 a
    • 然后参数需要传入类型为 Integral 的值,然后返回一个 Bool 类型的值
ghci> :type odd
odd :: Integral a => a -> Bool

3.4 函数使用

  1. 其实我们一直在使用函数,分为前缀函数 和 中缀函数。中缀函数就是 3.1 所提及的基本运算,运算符/函数 在中间;前端函数就是最常见函数的调用方法:函数名 参数1 参数2 ...
ghci> succ 8  <# succ函数返回一个数的后继 #>
9  
ghci> min 9 11  <# min函数返回两个数的最小值 #> 
9      
ghci> max 99 101  <# max函数返回两个数的最大值 #>  
101  
  1. 函数调用拥有最高的优先级,如下两句是等效的
ghci> succ 9 + max 5 4 + 1  
16   
ghci> (succ 9) + (max 5 4) + 1   <# 10 + 5 + 1 #>  
16 
  1. 如果某函数有两个参数,也可以用 [`] 符号将它括起,以中缀函数的形式调用它。例如 例如除法的 div 函数,中缀函数更符合数学的思维。注:[`] 符号一定需要,否则会报错
ghci> div 90 9
10
ghci> 90 `div` 9
10
ghci> 90 div 9
<interactive>:4:1: error:
  1. 对于同一个运算优先级的要注意 运算的先后顺序,尤其是函数调用。例如下面代码。

如果没用括号,compare 函数会认为我们传了四个参数

Prelude> compare (sqrt 3) (sqrt 6)
LT

Prelude> compare sqrt 3 sqrt 6

<interactive>:17:1:
    The function `compare' is applied to four arguments,
    but its type `a0 -> a0 -> Ordering' has only two
    In the expression: compare sqrt 3 sqrt 6
    In an equation for `it': it = compare sqrt 3 sqrt 6

3.3 定义函数

  • 定义函数: 函数名 参数1 参数2 ... = 返回值 ,例如 doubleUs x y = x*2 + y*2
  • 在 ghci 里加载写好的 haskell 文件::load xxxx.hs ,当然命令行和加载的文件要在同一个位置,否则要输入与命令行的相对位置
  • 使用 :? 可以查看全部的 ghci 命令
  • 不使用 return 关键字来返回函数值,因为一个函数就是一个表达式,不是多个语句,表达式算出来的值就是返回值
  • 变量其实是常量:在 Haskell 程序里面, 当变量和表达式绑定之后, 程序会用变量替换成这个表达式,如果变了后面使用这个变量就会出错
  • 因为函数就代表一个表达式,所以在函数里起一行写 x = 1 是不行的,函数里如果要用变量有其他方式。

3.4 条件求值

如果函数有两个分支,可以使用 if 表达式,下面为实例代码

  • if 表达式
    • then 一个分支返回的值
  • else 另一个分支返回的值
englishOne :: (Eq a, Num a) => a -> String
englishOne x =
  if x == 1
    then "one"
  else "None"
  • 因为函数是用表达式来返回值的,所以 if 语句的 thenelse 后面也是接表达式
  • 因为函数有自动推导,所以一定要有 else ,而且 thenelse 返回的值的类型一定要一致,否则就会报错

3.5 Haskell里的多态

Haskell 里有很多多态,例如下面代码,last 函数可以传入数字数组和字符串数组,且都能正确输出结果

Prelude> last [1, 2, 3, 4, 5]
5

Prelude> last "baz"
'z'

查看 last 的函数签名,可以看到下面说明。传入了个类型为 a 的数组,然后返回一个类型为 a 的值。

这个 a 就是类型变量,也就是说这个 a 代表类型,而且是可以变的

Prelude> :type last
last :: [a] -> a
  • a 在函数传入 [1, 2, 3, 4, 5] ,就会变成 Int ,则函数签名就变成了 [Int] -> Int
  • a 在函数传入 "baz" ,就会变成 Char ,则函数签名就变成了 [Char] -> Char

3.6 多参数函数

可以传入多个参数的函数,称为多参数函数。但多参数函数不一定要传入多个函数,因为 Haskell 自带柯里化。

例如 take 函数,查看它的函数签名:

Prelude> :type take
take :: Int -> [a] -> [a]

通过函数签名可知,传入 Int 后再传入 [a] ,最后才会返回 [a],以常用编程语言的角度来想其实也没错。

而以 Haskell 的角度来看,其实是 Int -> ([a] -> [a]) ,就是传入 Int 后会 返回一个新函数,而这个新函数,以 [a] 为参数,并返回 [a]

当然也可以查看这个新函数的签名

ghci> :type take 2
take 2 :: [a] -> [a]

四、定义类型

4.1 定义新的数据类型

新的数据类型,就有点像 Java 里的类。不同的数据类型,或者说不同的类,里面的属性不一致。例如定义一个书类型,里面有 id 属性,书名属性和作者属性。

下面是 Java 定义的类

public class BookEntity {
  int id;
  String bookName;
  String[] bookAuthors;
}

下面 Haskell 定义新的数据类型

-- file: ch03/BookStore.hs
data BookInfo = Book Int String [String]
                deriving (Show)
  • BookInfo :叫做类型构造器,但他是数据类型,而不是创造类型的。个人感觉叫为 【值构造器工厂】会好点。
  • Book :值/数据 构造器,和 Java 的构造器意思一样。因为使用构造器,可以创造出 值/数据 来,所以称为 值/数据 构造器。(其实很少人这么说吧)
  • 后面跟着的数据类型,是属性的类型,Int 是书 ID 的类型,而 String 书名的类型,而 [String] 则是作者们的类型

下面在 ghci 加载 BookStore 文件,并用 Book 来创造一个对象

ghci> myInfo = Book 9780135072455 "Algebra of Programming" ["Richard Bird", "Oege de Mo]
ghci> myInfo
Book 9780135072455 "Algebra of Programming" ["Richard Bird","Oege de Moor"]

但要注意一点的是,myInfo 的类型是 BookInfo ,而不是 Book ,和上面说的一样,这个是和 Java 有点不像了。

  • Book 是值构造器,换句话来说是个 函数,而不是类型
  • BookInfo 是数据类型

下面使用 :type:info 来验证一下

ghci> :type myInfo
myInfo :: BookInfo
ghci> test = "233"
ghci> :type test  
test :: String

ghci> :info BookInfo 
type BookInfo :: *
data BookInfo = Book Int String [String]
ghci> :info String
type String :: *
type String = [Char]
        -- Defined in ‘GHC.Base’

4.2 定义别名

当我们定义数据类型的时候,值构造器后面接着只有类型,并不知道这些类型代表着什么。String 类型,不仅可能代表书名,也可能代表出版社等。

-- file: ch03/BookStore.hs
data BookInfo = Book Int String [String]
                deriving (Show)

所以使用 type 关键词可以为这些类型取个别名

type CustomerID = Int
type ReviewBody = String

data BetterReview = BetterReview BookInfo CustomerID ReviewBody

4.3 代数数据类型

标题有点难懂,换句话来说就是 Java 的重载。例如下面代码

type CardHolder = String
type CardNumber = String
type Address = [String]
data BillingInfo = CreditCard CardNumber CardHolder Address
                 | CashOnDelivery
                 | Invoice CustomerID
                   deriving (Show)

这段代码代表了三种付款方式:

  • 第一种是用信用卡支付,需要输入卡号,卡的持有者和住址;
  • 第二种方式是现金支付,不用任何参数;
  • 第三种是货到付款,只需要填写客户的ID。

对应的就是 Java 的构造函数的重载,三参、无参和一参构造函数

4.4 代数数据类型和元组区别

  • 元组:当两个变量里的类型和数据相等时,这个两变量比较就是相等的
  • 代数数据类型:当两个变量即使里面的类型和数据相等时,这两个变量比较也是不相等的

总的来说代数数据类型就是 元组 加上了类型。下面代码分别定义了两个变量,然后分别赋值成相同的内容,进行比较发现相同

ghci> a = (1, "2", "3")
ghci> b = (1, "2", "3")
ghci> a == b
True

下面代码用两种方式表示了二维向量,分别是极坐标坐标和笛卡尔坐标。很明显这两个类型虽然都是由两个 double 组成,但是两个类型和这两个 double 代表的意思完全不一样。

-- file: ch03/AlgebraicVector.hs
data Cartesian2D = Cartesian2D Double Double
                   deriving (Eq, Show)

-- Angle and distance (magnitude).
data Polar2D = Polar2D Double Double
               deriving (Eq, Show)

4.5 和C的区别

1. 结构

当只有一个值构造器时,代数数据类型和元组很相似,因为它将很多的值打包成一个复合值,和 C 里的 struct 、Java 里的 Object 一样。

以下是一个 C 结构,它等同于我们前面定义的 BookInfo 类型:

struct book_info {
    int id;
    char *name;
    char **authors;
};

以下是一个 Java 结构,它等同于我们前面定义的 BookInfo 类型:

class BookInfo {
		int id;
  	String name;
  	String authors;
}

而 haskell 是如下定义的

--file: ch03/BookStore.hs
data BookInfo = Book Int String [String]
                deriving (Show)

2. 枚举

C 的 enum 通常用于表示一系列符号值排列。以下是一个 enum 例子:

enum roygbiv {
    red,
    orange,
    yellow,
    green,
    blue,
    indigo,
    violet,
};

以下是等价的 Haskell 代码:

-- file: ch03/Roygbiv.hs
data Roygbiv = Red
             | Orange
             | Yellow
             | Green
             | Blue
             | Indigo
             | Violet
               deriving (Eq, Show)

然后在 ghci 里面测试:

ghci> Red
Red
ghci> :type Red
Red :: Roygbiv
ghci> Red == Yellow
False
ghci> Red == Red
True

3. 联合

如果一个数据类型有多个备选,那么可以将它看作是 C 里的 union。也就是说这个数据类型是可以变的,分配内存以占用最大的类型为准。

以下是一个 union 例子:里面有个 shapetype 用于存放当前 union 里是什么类型,因为我们使用的时候不知道联合体里是什么类型。如果搞错了备选的信息,那么对 union 的使用就会出错。

enum shape_type {
    shape_circle,
    shape_poly,
};

struct circle {
    struct vector centre;
    float radius;
};

struct poly {
    size_t num_vertices;
    struct vector *vertices;
};

struct shape
{
    enum shape_type type;
    union {
      struct circle circle;
      struct poly poly;
    } shape;
};

一方面,Haskell 版本不仅简单,而且更为安全:因为我们把值构造器开放出来了,新建时因为有 模式匹配 不会出错,使用时,因为 值会记录是哪个值构造器创建的,也不会出错。

-- file: ch03/ShapeUnion.hs
type Vector = (Double, Double)

data Shape = Circle Vector Double
           | Poly [Vector]
             deriving (Show)

4.6 模式匹配

1. 组成和解构

对于 Haskell 来说,模式匹配 可以做到以下两点:

  • 如果这个类型有一个以上的值构造器,我们可以知道这个值是由哪个构造器创建的。
  • 如果一个值构造器包含不同的数据成分,那么可以获取到这些成分。

模式匹配允许我们查看值的内部,并将值绑定到变量上。以下是一个对 Bool 类型值进行模式匹配的例子,它的作用和 not 函数一样:

-- file: ch03/myNot.hs
myNot True = False
myNot False = True

初看上去,代码似乎同时定义了两个 myNot 函数,但实际情况并不是这样。Haskell 将 Java 里的重载函数定义为一系列等式。Java 的重载函数如果是相同类型则不能构成重载,而 Haskell 的重载更宽松点。

在 ghci 加载上面的代码,然后调用这个函数:

ghci> a = myNot False
ghci> a
True

当输入 False 时,然后在 myNot 里进行查找,首先匹配第一个 myNot ,因为输入的是 False ,不是 True ,所以继续查找。查找到第二个,发现匹配则运行等号后面的语句,发现只有个返回值,就将 True 返回出去

换句话来说就有点像 if + return 语句

以下是一个复杂一点的例子,这个函数使用递归来计算出列表所有元素之和:

-- file:: ch03/sumList.hs
sumList (x:xs) = x + sumList xs
sumList []  = 0

这里要说下 (x:xs) 是什么意思。(:) 是一个运算符,会将数组的第一个元素提取出来赋值给 x,然后将已被提取元素的数据赋值给 xs 。这个就有点像 Javascript 中的 解构

但这个运算符不仅可以解构,而且可以进行运算。这是个可以双向运算的运算符。

首先定义两个函数

headTest (x:xs) = x

shiftTest (x:xs) = xs

然后在 ghci 测试:

ghci> headTest [1, 2, 3, 4]
1
ghci> shiftTest [1, 2, 3, 4]
[2,3,4]
ghci> (1: [2, 3, 4])
[1,2,3,4]
ghci>

再回到刚才的代码:很明显是个递归,首先先将第一个元素提取出来,然后将剩余的数组再调用当前的函数,递归的结束条件是最后的数组是空的

-- file:: ch03/sumList.hs
sumList (x:xs) = x + sumList xs
sumList []  = 0

假设我们输入 [1, 2, 3, 4] ,然后代码会如下运行:

  • sumList (1: [2, 3, 4]) = 1 + sumList [2, 3, 4]

  • sumList (1: [2, 3, 4]) = 1 + 2 + sumList [3, 4]

  • sumList (1: [2, 3, 4]) = 1 + 2 + 3 + sumList [4]

  • sumList (1: [2, 3, 4]) = 1 + 2 + 3 + 4 + sumList []

  • sumList (1: [2, 3, 4]) = 1 + 2 + 3 + 4 + 0

  • sumList (1: [2, 3, 4]) = 10

其实标准函数库里已经有 sum 函数,它和我们定以的 sumList 一样,都可以用于计算表元素的和。

对于 对象/值 也可以进行模式匹配:以下代码的作用是获取对象里的某个属性,类似于 Java 类中的 get 函数

data BookInfo = Book Int String [String]
                deriving (Show)

bookID      (Book id title authors) = id
bookTitle   (Book id title authors) = title
bookAuthors (Book id title authors) = authors

然后在 ghci 测试:

ghci> let book = (Book 3 "Probability Theory" ["E.T.H. Jaynes"])
ghci> book
Book 3 "Probability Theory" ["E.T.H. Jaynes"]
ghci> bookID book
3
ghci> bookTitle book
"Probability Theory"
ghci> bookAuthors book
["E.T.H. Jaynes"]

2. 通配符模式匹配

如果在模式匹配中我们不在乎某个值的类型,那么可以用下划线字符 _ 作为符号来进行标识,它也叫做 通配符。它的用法如下。

nicerID      (Book id _     _      ) = id
nicerTitle   (Book _  title _      ) = title
nicerAuthors (Book _  _     authors) = authors

使用通配符有两个好处,第一个好处是能知道这个函数的用意,_ 就像挖出来的空,我们不需要留意这些内容;第二个好处是可以防止编译器发出警告,因为把所有的属性列出来但不使用。这些属性就会看作没有使用的变量,而一个好的编程规范要删除没有使用到的变量。

3. 通配符的另一个作用

通配符的另一个作用是可以保底函数的返回值,就像其他语言中只在 ifelse ifreturn 了,这时编译器会有个 警告/错误 提示我们要在最后面加多一个 return ,这个最后面的 return 就是来保证这个函数一定有返回值。

下面举个不是很好的例子:我们写这个代码时,使用了 [] 来作为最终的返回值,其实这个没问题的,因为我们已经知道了函数传入的值是 列表 以及递归的最后参数是 空列表,所以 [] 就已经包括了全部情况。

sumList (x:xs) = x + sumList xs
sumList []  = 0

如果不想考虑这么多,将 [] 替换成 _ 也是没问题的

sumList (x:xs) = x + sumList xs
sumList _ = 0

4.7 记录语法

在通配符那节我们如下写了代码,虽然使用了通配符减少了一点工作量,但还是很麻烦。如果属性多的情况下,这些 "get" 方法就会占用很多空间,以及创建它们的时候也会做很多无用功。

nicerID      (Book id _     _      ) = id
nicerTitle   (Book _  title _      ) = title
nicerAuthors (Book _  _     authors) = authors

Java 中将这些 "get" 的方法一般都是使用注解来自动生成,而 Haskell 是使用 记录语法

记录语法是在定义类型的时候,同时构造出属性的访问器,下面是记录语法的例子:customerID 是获取 ID 的访问器名称,: 后面是类型。

data Customer = Customer
  { customerID :: Int,
    customerName :: String,
    customerAddress :: [String]
  }
  deriving (Show)

定义了类型,我们有 两种方式 来新建对象。第一种就是正常的按定义的顺序来放置值,例如下面所示:

ghci> a = Customer 1 "1" ["1"]
ghci> a
Customer {customerID = 1, customerName = "1", customerAddress = ["1"]}

第二种是用键值对的形式来新建:下面代码特意将属性换了个位置,这种方式就算位置随便放也是可以成功新建的。

customer2 :: Customer
customer2 =
  Customer
    { customerName = "hahg",
      customerAddress = ["123"],
      customerID = 123
    }

虽然属性变换位置不会影响到新建对象,但打印输出的时候还是会有所不同

ghci> customer2
Customer {customerName = "hahg", customerID = 123, customerAddress = ["123"]}

标准库里的 System.Time 模块open in new window 就是一个使用记录语法的好例子,虽然这个模块已废弃。例如其中定义了这样一个类型:

data CalendarTime
 = CalendarTime  {
       ctYear    :: Int         -- ^ Year (pre-Gregorian dates are inaccurate)
     , ctMonth   :: Month       -- ^ Month of the year
     , ctDay     :: Int         -- ^ Day of the month (1 to 31)
     , ctHour    :: Int         -- ^ Hour of the day (0 to 23)
     , ctMin     :: Int         -- ^ Minutes (0 to 59)
     , ctSec     :: Int         -- ^ Seconds (0 to 61, allowing for up to
                                -- two leap seconds)
     , ctPicosec :: Integer     -- ^ Picoseconds
     , ctWDay    :: Day         -- ^ Day of the week
     , ctYDay    :: Int         -- ^ Day of the year
                                -- (0 to 364, or 365 in leap years)
     , ctTZName  :: String      -- ^ Name of the time zone
     , ctTZ      :: Int         -- ^ Variation from UTC in seconds
     , ctIsDST   :: Bool        -- ^ 'True' if Daylight Savings Time would
                                -- be in effect, and 'False' otherwise
 }
 deriving (Eq,Ord,Read,Show)

4.8 多态类型

根据之前可知列表中的元素可以是任何类型,所以可以说列表里的类型是多态的。我们当然也可以给 自定义的类型 添加多态性,可以使用一个叫做 Maybe 的类型来添加多态性,它既可以表示有值也可能空缺。

下面是 Haskell 中如何定义 Maybe

  • Just 代表有值的意思
    • 这里的 a 代表任何的值,而 Just 值构造器可以接受任何类型的值,然后返回 Maybe a的类型
    • 当传入 a 后,返回的 Maybe 类型的值就叫 Just a ,虽然看起来像是在调用值构造器,但其实代表一个值
  • Nothing 代表没有值的意思
data Maybe a = Just a
             | Nothing

下面用 ghci 测试上面所提到的:

  • 使用 Just 传入 1 后,Just 1 就变成了一个 Maybe Num
ghci> :type Just
Just :: a -> Maybe a
ghci> :type Just 1
Just 1 :: Num a => Maybe a
ghci> :type Just "111"
Just "111" :: Maybe String