跳转到内容

另一个 Haskell 教程/语言基础

来自维基教科书,开放世界中的开放书籍
Haskell
另一个 Haskell 教程
前言
简介
入门
语言基础 (解决方案)
类型基础 (解决方案)
IO (解决方案)
模块 (解决方案)
高级语言 (解决方案)
高级类型 (解决方案)
单子 (解决方案)
高级 IO
递归
复杂度

在本章中,我们将介绍 Haskell 的基本概念。除了让你熟悉交互式环境并向你展示如何编译一个基本程序之外,我们还将介绍 Haskell 的基本语法,如果你习惯使用 C 和 Java 等语言,这可能对你来说非常陌生。

但是,在我们讨论语言的具体细节之前,我们需要确定 Haskell 的一些一般属性。最重要的是,Haskell 是一种惰性语言,这意味着除非强制执行计算以使用该计算的结果,否则不会进行任何计算。

例如,这意味着你可以定义无限大的数据结构,只要你从不使用整个结构。例如,使用类似命令式的伪代码,我们可以创建一个包含数字 的无限链表,方法如下

List makeList()
{
  List current = new List();
  current.value = 1;
  current.next = makeList();
  return current;
}

通过查看这段代码,我们可以看到它试图做什么:它创建了一个新的列表,将其值设置为 ,然后递归地调用自身以创建列表的其余部分。当然,如果你真的写了这段代码并调用它,程序永远不会终止,因为 makeList 会无限地递归调用自身。

这是因为我们假设这种类似命令式的语言是严格的,与惰性相反。严格语言通常被称为“按值调用”,而惰性语言被称为“按名调用”。在上面的伪代码中,当我们“运行”makeList在第五行,我们尝试从它中获取一个。这会导致无限循环。

Haskell 中的等效代码是

makeList = 1 : makeList

这个程序表示:我们正在定义一个名为 makeList 的东西(这是等号左侧的内容)。在等号右侧,我们给出 makeList 的定义。在 Haskell 中,冒号运算符用于创建列表(我们很快就会详细讨论)。这个右侧表示 makeList 的值是将元素 1 附加到 makeList 的值的开头。

但是,由于 Haskell 是惰性的(或“按名调用”),我们实际上并没有试图在此时评估 makeList 是什么:我们只是记住,如果将来我们需要 makeList 的第二个元素,我们只需要查看 makeList 就可以了。

现在,如果你尝试将 makeList 写入文件、将其打印到屏幕或计算其元素的总和,操作将不会终止,因为它将不得不评估一个无限长的列表。但是,如果你只使用列表的有限部分(例如前 个元素),列表是否无限长并不重要。如果你只使用前 个元素,那么只有前 个元素会被计算出来。这就是惰性。

其次,Haskell 区分大小写。许多语言都是这样,但 Haskell 实际上使用大小写来赋予意义。Haskell 区分(例如,数字:);字符串:"abc"、"hello"、...;字符:'a'、'b'、' '、...;甚至是函数(例如,对值进行平方运算的函数,或平方根函数);以及类型(值所属的类别)。

这本身并不奇怪。大多数语言都有一些类型系统。不寻常的是,Haskell要求赋予函数和值的名称以小写字母开头,而赋予类型的名称以大写字母开头。要点是:如果你的程序在其他方面是正确的,但无法编译,请确保你没有将函数命名为 Foo,或者其他以大写字母开头的名称。

作为一种函数式语言,Haskell 避免副作用。副作用本质上是在执行函数的过程中发生的,与该函数产生的输出无关的事情。

例如,在 C 或 Java 等语言中,你可以从函数内部修改“全局”变量。这是一种副作用,因为修改此全局变量与函数产生的输出无关。此外,修改现实世界中的状态也被认为是一种副作用:将内容打印到屏幕、读取文件等,都是有副作用的操作。

没有副作用的函数被称为函数。判断一个函数是否纯净的简单测试是问自己一个简单的问题:“这个函数的结果是否只依赖于它接收的参数,并且返回结果是它唯一做的操作?”

所有这些意味着,如果你习惯于在命令式语言(如 C 或 Java)中编写代码,你将不得不开始改变思维方式。最重要的是,如果你有一个值 x,你决不应该将 x 视为寄存器、内存位置或其他任何类似的东西。x 只是一个名称,就像“哈尔”是我的名字一样。你不能随意决定将其他人存储在我的名字中,就像你不能随意决定将其他值存储在 x 中一样。这意味着像以下 C 代码这样的代码在 Haskell 中是无效的(而且没有对应的代码)

   int x = 5;
   x = x + 1;

类似x = x + 1这样的调用被称为破坏性更新,因为我们正在破坏之前在x中的内容,并用新值替换它。破坏性更新在 Haskell 中不存在。

通过禁止破坏性更新(或任何其他副作用操作),Haskell 代码非常易于理解。也就是说,当我们在程序开头定义一个函数 f 并用一个特定的参数 a 调用它,然后在程序结束时再次用同一个参数 a 调用 f 时,我们知道会得到相同的结果。这是因为我们知道 a 不会改变,并且我们知道 f 只依赖于 a(例如,它没有增加全局计数器)。这个属性被称为引用透明,它基本上说明如果两个函数 fg 对相同参数产生相同的值,那么我们可以用 g 替换 f(反之亦然)。

注意

没有关于引用透明性的确切定义的共识。上面给出的定义是我最喜欢的。它们都具有相同的解释;区别在于它们是如何形式化的。




让我们从简单的算术开始探索 Haskell。启动您最喜欢的交互式 shell(Hugs 或 GHCi;有关安装说明,请参见章节 入门)。shell 将在屏幕上输出几行关于自身及其操作的信息,然后应该用光标结束在显示以下内容的一行上:

示例

Prelude>


从这里,您可以开始评估表达式。表达式基本上是指具有值的任何事物。例如,数字 是一个表达式(它的值为 )。值可以从其他值构建起来;例如, 是一个表达式(它的值为 )。事实上,Haskell 支持大多数简单的算术运算,包括加法 (+)、减法 (-)、乘法 (*)、除法 (/)、幂运算 (^) 和平方根 (sqrt)。您可以通过要求交互式 shell 评估表达式并给出其值来尝试这些运算。这样,Haskell shell 可以用作强大的计算器。尝试以下一些内容

示例

Prelude> 5*4+3
23
Prelude> 5^5-2
3123
Prelude> sqrt 2
1.4142135623730951
Prelude> 5*(4+3)
35

我们可以看到,除了标准的算术运算之外,Haskell 还允许使用括号进行分组,因此5*4+35*(4+3)的值不同。这是因为第一个表达式的“已知”分组是(5*4)+3,这是由于运算符优先级

还要注意,函数参数周围不需要括号。例如,我们只是写了 sqrt 2,而不是 sqrt(2),如大多数其他语言中所要求的那样。您可以使用括号写它,但在 Haskell 中,由于函数应用非常普遍,因此不需要括号。

现在尝试输入2^5000它有效吗?

注意

如果您熟悉其他语言中的编程,您可能会发现sqrt 2返回带小数点的结果(即浮点数),即使函数的参数似乎是整数,这一点很奇怪。这种数值类型的可互换性是由于 Haskell 的类型类系统,将在关于 类型基础的部分中详细讨论。


练习
我们已经看到,乘法的结合性强于加法。你能想到一种方法来确定函数应用的结合性是强于乘法还是弱于乘法吗?



对、三元组等等

[编辑 | 编辑源代码]

除了单个值之外,我们还应该处理多个值。例如,我们可能希望通过其 / 坐标来引用位置,这将是一对整数。创建一对整数很简单:您将它们括在括号中,并用逗号分隔。尝试以下操作

示例

Prelude> (5,3)
(5,3)

在这里,我们有一对整数,。在 Haskell 中,一对中的第一个元素不需要与第二个元素具有相同的类型:也就是说,对可以是异构的。例如,您可以拥有一对整数和字符串。这与列表形成对比,列表必须由所有相同类型的元素组成(我们将在关于 列表的部分中进一步讨论列表)。


有两个预定义的函数允许您提取一对的第一个和第二个元素。它们分别是 fstsnd。您可以看到它们在下面是如何工作的

示例

Prelude> fst (5, "hello")
5
Prelude> snd (5, "hello")
"hello"

除了对之外,您还可以定义三元组、四元组等。要分别定义三元组和四元组,我们写

示例

Prelude> (1,2,3)
(1,2,3)
Prelude> (1,2,3,4)
(1,2,3,4)

等等。一般来说,对、三元组等等被称为元组,可以存储固定数量的异构数据。

注意

fstsnd 函数不会对超过一对的任何东西起作用;如果您尝试将它们用于更大的元组,您将收到一条消息,说明发生了类型错误。本错误消息的含义将在章节 类型基础中解释。


练习

使用fstsnd的组合来提取字符 'a'

从元组 ((1,'a'),"foo") 中提取。



元组的主要限制是它们只能容纳固定数量的元素:对容纳两个元素,三元组容纳三个元素,依此类推。可以容纳任意数量元素的数据结构是列表。列表的组合方式与元组非常相似,只是它们使用方括号而不是括号。我们可以像这样定义一个列表

示例

Prelude> [1,2]
[1,2]
Prelude> [1,2,3]
[1,2,3]

列表不需要包含任何元素。空列表就是 []

与元组不同,我们可以使用冒号运算符非常容易地将一个元素添加到列表的开头。冒号被称为“cons”运算符;添加元素的过程被称为“consing”。这词的词源在于我们从一个元素和一个旧列表中construct了一个新列表。我们可以在以下示例中看到 cons 运算符的实际应用

示例

Prelude> 0:[1,2]
[0,1,2]
Prelude> 5:[1,2,3,4]
[5,1,2,3,4]

实际上,我们可以使用 cons 运算符(冒号)和空列表来构建任何列表

示例

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

事实上,[5,1,2,3,4] 语法是使用显式 cons 运算符和空列表的表达式的“语法糖”。如果我们使用 [5,1,2,3,4] 符号写东西,编译器会简单地将它转换为使用 (:)[] 的表达式。

注意

一般来说,“语法糖”是一种严格不需要的语言特性,它是为了使语法更友好而添加的。

列表和元组之间另一个重要的区别是,虽然元组可以是异构的,但列表必须是同构的。这意味着你不能创建一个同时包含整数和字符串的列表。如果你尝试这样做,就会出现类型错误。

当然,列表不一定要只包含整数或字符串;它们也可以包含元组,甚至其他列表。类似地,元组也可以包含列表和其他元组。试试以下内容:

示例

Prelude> [(1,1),(2,4),(3,9),(4,16)]
[(1,1),(2,4),(3,9),(4,16)]
Prelude> ([1,2,3,4],[5,6,7])
([1,2,3,4],[5,6,7])

有两种基本的列表函数:headtailhead 函数返回一个(非空)列表的第一个元素,tail 函数返回一个(非空)列表中除了第一个元素之外的所有元素。


要获取列表的长度,可以使用 length 函数。

示例

Prelude> length [1,2,3,4,10]
5
Prelude> head [1,2,3,4,10]
1
Prelude> length (tail [1,2,3,4,10])
4

字符串

[编辑 | 编辑源代码]

在 Haskell 中,String 只是一个 Char 的列表。因此,我们可以创建字符串 "Hello" 如下:

示例

Prelude> 'H':'e':'l':'l':'o':[]
"Hello"

列表(当然还有字符串)可以使用 ++ 运算符连接。

示例

Prelude> "Hello " ++ "World"
"Hello World"


此外,非字符串值可以使用 show 函数转换为字符串,字符串可以使用 read 函数转换为非字符串值。当然,如果你尝试读取格式错误的值,就会出现错误(注意,这是一个运行时错误,而不是编译时错误)。

示例

Prelude> "Five squared is " ++ show (5*5)
"Five squared is 25"
Prelude> read "5" + 3
8
Prelude> read "Hello" + 3
Program error: Prelude.read: no parse

在上面,确切的错误消息取决于实现。但是,解释器已经推断出你试图将 3 加到某个东西上。这意味着当我们执行 read "Hello" 时,我们希望返回一个数字。但是,"Hello" 不能解析为数字,因此出现错误。

简单列表函数

[编辑 | 编辑源代码]

Haskell 程序中的大部分计算都是通过处理列表完成的。有三个主要的列表处理函数:mapfilterfoldr(以及 foldl)。

map 函数接受一个值列表和一个应该应用于每个值的函数作为参数。它返回此应用的结果。例如,有一个内置函数 Data.Char.toUpper,它接受一个 Char 作为输入,并生成一个 Char,它是原始参数的大写版本。因此,要将整个字符串(只是一个字符列表)转换为大写,我们可以将 toUpper 函数映射到整个列表上。

示例

Prelude> map Data.Char.toUpper "Hello World"
"HELLO WORLD"


当你对列表进行映射时,列表的长度永远不会改变——只有列表中的单个值会改变。

要从列表中删除元素,可以使用 filter 函数。此函数允许你根据元素的值从列表中删除某些元素,但不能根据它们的上下文。例如,函数 Data.Char.isLower 告诉你给定字符是否是小写。我们可以使用它过滤掉所有非小写字符。

示例

Prelude> filter Data.Char.isLower "Hello World"
"elloorld"

函数 foldr 需要更多时间来理解。foldr 接受三个参数:一个函数、一个初始值和一个列表。理解 foldr 的最好方法是,它用函数参数替换列表连接运算符 (:),用初始值替换空列表构造函数 ([])。因此,如果我们有一个列表:

  3 : 8 : 12 : 5 : []

并且我们将 foldr (+) 0 应用于它,我们得到:

  3 + 8 + 12 + 5 + 0

它对列表进行求和。我们可以测试它:

示例

Prelude> foldr (+) 0 [3,8,12,5]
28

我们可以执行相同类型的操作来计算列表中所有元素的乘积。

示例

Prelude> foldr (*) 1 [4,8,5]
160

我们之前说过,折叠就像用特定函数替换 (:) 并用初始元素替换 ([]) 一样。这引发了一个问题,即当函数不是结合的 () 时会发生什么 (如果 )。当我们写 时,我们需要指定括号的位置。即,我们指的是 还是 foldr 假设函数是右结合的(即,正确的括号是后者)。因此,当我们在非结合函数(如减法)上使用它时,我们可以看到它的效果。

示例

Prelude> foldr (-) 1 [4,8,5]
0

此推导的精确过程类似于:

     foldr (-) 1 [4,8,5]
==>  4 - (foldr (-) 1 [8,5])
==>  4 - (8 - foldr (-) 1 [5])
==>  4 - (8 - (5 - foldr (-) 1 []))
==>  4 - (8 - (5 - 1))
==>  4 - (8 - 4)
==>  4 - 4
==>  0

foldl 函数反向进行,实际上产生相反的括号。foldl 应用时看起来一样,所以我们也可以用 foldl 进行求和。

示例

Prelude> foldl (+) 0 [3,8,12,5]
28

但是,当使用非结合函数减法时,我们得到不同的结果。

示例

Prelude> foldl (-) 1 [4,8,5]
-16

这是因为 foldl 使用相反的括号。它实现此操作的方式本质上是遍历整个列表,获取最后一个元素并使用提供的函数将其与初始值组合起来。然后,它获取列表中的倒数第二个元素,并将它与这个新值组合起来。它会一直这样做,直到没有剩余的列表。

此处的推导以相反的方式进行。

     foldl (-) 1 [4,8,5]
==>  foldl (-) (1 - 4) [8,5]
==>  foldl (-) ((1 - 4) - 8) [5]
==>  foldl (-) (((1 - 4) - 8) - 5) []
==>  ((1 - 4) - 8) - 5
==>  ((-3) - 8) - 5
==>  (-11) - 5
==>  -16

请注意,一旦 foldl 消失,括号就与 foldr 恰好相反。

注意

由于我们将在关于 列表 的部分中讨论的原因,foldl 通常比 foldr 更有效。但是,foldr 可以作用于无限列表,而 foldl 则不能。这是因为在 foldl 做任何事情之前,它必须到达列表的末尾。另一方面,foldr 可以立即开始生成输出。例如,foldr (:) [] [1,2,3,4,5] 只是返回相同的列表。即使列表是无限的,它也会产生输出。使用 foldl 的类似函数将无法产生任何输出。

如果关于折叠函数的讨论仍然不太清楚,没关系。我们将在关于 列表 的部分中进一步讨论它们。

练习

使用 map 将字符串转换为布尔值列表,新列表中的每个元素代表原始元素是否是小写字符。也就是说,它应该接受字符串 "aBCde"

并返回 [True,False,False,True,True]
练习

使用本节中提到的函数(你需要使用其中两个)来计算字符串中小写字母的数量。例如,对于

"aBCde",它应该返回 3。
练习

我们已经了解了如何使用折叠函数计算总和和乘积。鉴于函数 max 返回两个数字中的最大值,请编写一个使用折叠的函数,它将返回列表中的最大值(如果列表为空,则返回零)。因此,当应用于 [5,10,2,8,1] 时,它将返回 10。假设列表中的

值始终 。解释一下为什么它有效。
练习

编写一个函数,它接受一个至少包含两个元素的成对列表,并返回列表中第二个元素的第一个分量。因此,当提供 [(5,'b'),(1,'c'),(6,'a')] 时,它将返回

1.



源代码文件

[编辑 | 编辑源代码]

作为程序员,我们不仅仅想评估这些简单的表达式,我们想坐下来,在我们选择的编辑器中编写代码,保存它,然后使用它。

我们已经在 GhcNhc 部分看到了如何编写 Hello World 程序以及如何编译它。在这里,我们将展示如何在交互式环境中使用源代码文件中定义的函数。为此,创建一个名为Test.hs的文件,并输入以下代码

module Test
    where

x = 5
y = (6, "Hello")
z = x * fst y

这是一个用 Haskell 编写的非常简单的“程序”。它定义了一个名为“Test”的模块(通常模块名称应该与文件名匹配;有关此方面的更多信息,请参阅关于 模块 的部分)。在这个模块中,有三个定义:x、y 和 z。在你编写并保存了此文件后,在你保存它的目录中,通过执行以下任一操作,在你的首选解释器中加载它

示例

% hugs Test.hs
% ghci Test.hs

这将分别启动 Hugs 或 GHCi,并加载该文件。或者,如果你已经加载了其中一个,可以使用“:load”命令(或只使用“:l”)来加载一个模块,例如

示例

Prelude> :l Test.hs
...
Test>

在第一行和最后一行之间,解释器将打印各种数据来解释它正在做什么。如果出现任何错误,你可能在文件中输入了错误的内容;仔细检查,然后重试。

你会注意到,它曾经显示“Prelude”,现在显示“Test”。这意味着 Test 是当前模块。Prelude 模块(通常简称为“Prelude”)始终加载,并包含标准定义(例如,用于列表的(:)运算符,或(+)或(*)、fstsnd 等等)。

现在我们已经加载了 Test,我们可以使用在其中定义的东西。例如

示例

Test> x
5
Test> y
(6,"Hello")
Test> z
30

完美,正如我们预期的那样!

关于如何将程序编译成独立可执行文件,还有一个最后的问题。为了使程序成为可执行文件,它必须具有模块名称“Main”,并且必须包含一个名为main的函数。因此,如果你进入 Test.hs 并将其重命名为“Main”(将读取module Test的行更改为module Main),我们只需要添加一个 main 函数。试试这个


main = putStrLn "Hello World"

现在,保存该文件,并编译它(参考 入门 部分,了解如何针对你的编译器执行此操作)。例如,在 GHC 中,你会说

示例

% ghc --make Test.hs -o test

注意

对于 Windows,它将是“-o test.exe”

这将创建一个名为“test”(或在 Windows 上名为“test.exe”)的文件,你然后可以运行它。

示例

% ./test
Hello World

注意

或者,在 Windows 上

示例

C:\> test.exe
Hello World



现在我们已经了解了如何在文件中编写代码,我们可以开始编写函数了。正如你可能预期的那样,函数是 Haskell 的核心,因为它是函数式语言。这意味着程序的评估只是函数的评估。

我们可以编写一个简单的函数来对数字进行平方,并将其输入到我们的Test.hs文件中。我们可以这样定义它

square x = x * x

在这个函数定义中,我们说我们正在定义一个名为square的函数,它接受一个参数(又名参数),我们称之为x。然后我们说square x等于x * x

Haskell 也支持标准条件表达式。例如,我们可以定义一个函数,如果它的参数小于 ,则返回 ;如果它的参数 ,则返回 ;如果它的参数大于 ,则返回 (这被称为符号函数)

signum x =
    if x < 0
      then -1
      else if x > 0
        then 1
        else 0

你可以像这样进行实验

示例

Test> signum 5
1
Test> signum 0
0
Test> signum (5-10)
-1
Test> signum (-1)
-1

请注意,最后一个示例中“ -1”周围的括号是必需的;如果缺少,系统将认为你试图从“signum”的值中减去“1”的值,这将导致类型错误。

Haskell 中的 if/then/else 结构与大多数其他编程语言中的结构非常相似;但是,你必须同时拥有then else子句。它评估条件(在本例中为 ),如果它评估为True,它将评估then子句;如果条件评估为False,它将评估else子句)。

你可以通过编辑该文件并将其重新加载到解释器中来测试该程序。如果Test已经是当前模块,而不是键入:l Test.hs再次,你只需键入:reload或只键入:r来重新加载当前文件。这通常快得多。

Haskell,像许多其他语言一样,也支持case结构。当你想针对多个值进行检查时(case 表达式实际上比这强大得多 - 请参阅关于 模式匹配 的部分以了解所有详细信息)。

假设我们想要定义一个函数,当其参数为 时,其值为 ;当其参数为 时,其值为 ;当其参数为 时,其值为 ;而在所有其他情况下,其值为 。使用if语句来编写此函数将会很长且难以阅读;因此我们使用case语句来编写它,如下所示(我们称此函数为 f

f x =
    case x of
      0 -> 1
      1 -> 5
      2 -> 2
      _ -> -1

在这个程序中,我们定义了 f 来接收一个参数 x,然后检查 x 的值。如果它与 相匹配,则 f 的值为 。如果它与 相匹配,则 f 的值为 。如果它与 相匹配,则 f 的值为 ;如果到目前为止它还没有匹配到任何内容,则 f 的值为 (下划线可以被认为是“通配符”——它将匹配任何内容)。

这里的缩进很重要。Haskell 使用一个叫做“布局”的系统来构建它的代码(Python 编程语言也使用类似的系统)。布局系统允许您编写代码,而无需其他语言(如 C 和 Java)所需的显式分号和花括号。

布局的通用规则是,在以下关键字之后插入一个开括号:where, let, doof,并记住下一条命令出现的列位置。从那时起,在每个缩进相同的新的行之前插入一个分号。如果后面的行缩进较少,则插入一个闭括号。这可能听起来很复杂,但如果您遵循在每个关键字之后缩进的通用规则,您就不必记住它(有关布局的更完整讨论,请参见布局部分)。

有些人更喜欢不使用布局,而是显式地编写花括号和分号。这是完全可以接受的。在这种风格中,上面的函数可能看起来像

f x = case x of
        { 0 -> 1 ; 1 -> 5 ; 2 -> 2 ; _ -> -1 }

当然,如果您显式地编写花括号和分号,您可以自由地按照您希望的方式构建代码。以下也是同样有效的

f x =
    case x of { 0 -> 1 ;
      1 -> 5 ; 2 -> 2
   ; _ -> -1 }

但是,像这样构建您的代码只会使其难以阅读(在这种情况下)。

函数也可以分段定义,这意味着您可以为某些参数编写一个版本的函数,然后为其他参数编写另一个版本。例如,上面的函数 f 也可以写成

f 0 = 1
f 1 = 5
f 2 = 2
f _ = -1

这里,顺序很重要。如果我们把最后一行放在第一行,它将匹配所有参数,而 f 将返回 -1,无论其参数是什么(大多数编译器会警告您这一点,说一些关于重叠模式的内容)。如果我们没有包含最后一行,如果对 f 应用了 0、1 或 2 以外的任何内容,f 将会产生错误(大多数编译器也会警告您这一点,说一些关于不完整模式的内容)。这种分段定义的风格非常流行,在本教程中会经常使用。这两种 f 的定义实际上是等价的——这种分段版本被翻译成 case 表达式。

更复杂的函数可以通过使用*函数组合*从更简单的函数中构建。函数组合只是取一个函数应用的结果,并将其用作另一个函数的参数。我们之前已经在算术中看到了这一点(有关算术的部分),当我们写 5*4+3 时。在这个表达式中,我们计算了 ,然后对结果应用 。我们可以对 squaref 函数做同样的事情

示例

Test> square (f 1)
25
Test> square (f 2)
4
Test> f (square 1)
5
Test> f (square 2)
-1

这些函数应用的结果都相当简单。内部函数周围的括号是必要的;否则,在第一行中,解释器会认为您试图获取“square f”的值,而这没有意义。像这样的函数应用在大多数编程语言中都很常见。还有一种更面向数学的方法来表达函数组合,使用 (.)(只是一个句号)函数。这个 (.) 函数应该看起来像数学中的 () 运算符。

注意

在数学中,我们写 表示“f 接着 g”,在 Haskell 中,我们也写 f . g 表示“f 接着 g”。

的含义很简单,就是 。也就是说,将值 应用于函数 与将其应用于 ,获取结果,然后将其应用于 是相同的。


. 函数(称为函数组合函数)接受两个函数并将它们组合成一个。例如,如果我们写 (square . f),这意味着它创建了一个新函数,该函数接受一个参数,将 f 应用于该参数,然后将 square 应用于结果。相反,(f . square) 意味着它创建了一个新函数,该函数接受一个参数,将 square 应用于该参数,然后将 f 应用于结果。我们可以通过像以前一样测试它来看到这一点

示例

Test> (square . f) 1
25
Test> (square . f) 2
4
Test> (f . square) 1
5
Test> (f . square) 2
-1

这里,我们必须将函数组合括在括号中;否则,Haskell 编译器会认为我们试图在第一行将 square 与值 f 1 组合,这没有意义,因为 f 1 甚至不是函数。

花一点时间查看 Prelude 中定义的一些函数可能是一个好主意。毫无疑问,在某些时候,你可能会意外地重新编写一些已经存在的函数(我这样做过不止一次),但是如果我们能将这种情况降到最低,将会节省很多时间。以下是一些简单的函数,其中一些我们已经见过

sqrt 平方根函数
id 恒等函数:id x = x
fst 从一对中提取第一个元素
snd 从一对中提取第二个元素
null 告诉你一个列表是否为空
head 返回非空列表的第一个元素
tail 返回非空列表的除第一个元素以外的所有元素
++ 连接两个列表
== 检查两个元素是否相等
/= 检查两个元素是否不相等

这里,我们展示了这些函数的示例用法

示例

Prelude> sqrt 2
1.41421
Prelude> id "hello"
"hello"
Prelude> id 5
5
Prelude> fst (5,2)
5
Prelude> snd (5,2)
2
Prelude> null []
True
Prelude> null [1,2,3,4]
False
Prelude> head [1,2,3,4]
1
Prelude> tail [1,2,3,4]
[2,3,4]
Prelude> [1,2,3] ++ [4,5,6]
[1,2,3,4,5,6]
Prelude> [1,2,3] == [1,2,3]
True
Prelude> 'a' /= 'b'
True
Prelude> head []

Program error: {head []}

我们可以看到,将 head 应用于空列表会导致错误(确切的错误消息取决于你使用的是 GHCi 还是 Hugs - 显示的错误消息来自 Hugs)。

Let 绑定

[edit | edit source]

我们通常希望为在函数中使用提供局部声明。例如,以下等式用于找到形如 的多项式的根(零点):。我们可以编写以下函数来计算 的两个值

roots a b c =
    ((-b + sqrt(b*b - 4*a*c)) / (2*a),
     (-b - sqrt(b*b - 4*a*c)) / (2*a))

不幸的是,我们在这里重复了表达式。为了解决这个问题,Haskell 允许局部绑定。也就是说,我们可以在函数内部创建只有该函数才能看到的变量。例如,我们可以为表达式 sqrt(b*b-4*a*c) 创建一个局部绑定,将其命名为 discr 表示判别式,然后在出现 sqrt(b*b - 4*a*c) 的两个地方使用它。我们可以使用一个let/in声明

roots a b c =
    let discr = sqrt (b*b - 4*a*c)
    in  ((-b + discr) / (2*a),
         (-b - discr) / (2*a))

事实上,你可以在 let 中提供多个声明。只要确保它们的缩进量相同,否则你将遇到布局问题

roots a b c =
    let discr = sqrt (b*b - 4*a*c)
        twice_a = 2*a
    in  ((-b + discr) / twice_a,
         (-b - discr) / twice_a)

中缀

[edit | edit source]

中缀函数是指由符号而不是字母组成的函数。例如,(+)(*)(++) 都是中缀函数。你可以通过将它们括在括号中来在非中缀模式下使用它们。因此,以下两个表达式是相同的

示例

Prelude> 5 + 10
15
Prelude> (+) 5 10
15

类似地,非中缀函数(如 map)可以通过将它们括在反引号(美国键盘上的波浪号键上的引号)中来变为中缀

示例

Prelude> map Data.Char.toUpper "Hello World"
"HELLO WORLD"
Prelude> Data.Char.toUpper `map` "Hello World"
"HELLO WORLD"



注释

[edit | edit source]

Haskell 中有两种类型的注释:行注释和块注释。行注释以标记 -- 开头,一直延续到行尾。块注释以 {- 开头,一直延续到相应的 -}。块注释可以嵌套。

注意

Haskell 中的 -- 相当于//C++ 或 Java 中的,而 {--} 则对应于/**/.

注释用于用英文解释你的程序,编译器和解释器完全忽略它们。例如

module Test2
    where

main =
  putStrLn "Hello World"  -- write a string
                          -- to the screen

{- f is a function which takes an integer and
produces integer.  {- this is an embedded
comment -} the original comment extends to the
matching end-comment token: -}
f x =
    case x of
      0 -> 1    -- 0 maps to 1
      1 -> 5    -- 1 maps to 5
      2 -> 2    -- 2 maps to 2
      _ -> -1   -- everything else maps to -1

这个示例程序展示了行注释和(嵌入)块注释的用法。



在像 C 和 Java 这样的命令式语言中,最基本的控制结构是循环(例如 for 循环)。然而,for 循环在 Haskell 中没有多大意义,因为它们需要破坏性更新(索引变量不断被更新)。相反,Haskell 使用递归。

如果一个函数调用自身,则该函数是 递归 的(有关更多信息,请参见关于 递归 的附录)。递归函数也存在于 C 和 Java 中,但它们的使用频率低于函数式语言。

int factorial(int n) {
  int fact = 1;
  for (int i=2; i <= n; i++)
    fact = fact * i;
  return fact;
}

典型的递归函数是阶乘函数。在命令式语言中,你可能会这样编写:

虽然这段代码片段将成功地计算出正整数的阶乘,但它以某种方式忽略了阶乘的基本定义,通常给出如下:

这个定义本身就是一个递归定义:即 的值取决于 的值。如果你将 视为一个函数,那么它就在调用自身。我们可以将这个定义几乎逐字翻译成 Haskell 代码

factorial 1 = 1
factorial n = n * factorial (n-1)

这可能是你见过的最简单的递归函数,但它是正确的。

注意

当然,可以编写一个命令式的递归版本

int factorial(int n) {
  if (n == 1)
    return 1;
  else
    return n * factorial(n-1);
}

但它可能比 C 中的循环版本慢得多。

递归可能是一件很难掌握的事情。它与数学中的归纳法完全类似(有关更正式的处理,请参见关于 递归 的章节)。但是,通常一个问题可以被认为具有一个或多个基本情况和一个或多个递归情况。在 factorial 的情况下,有一个基本情况(当 时)和一个递归情况(当 时)。为了设计你自己的递归算法,区分这两种情况通常很有用。

现在转向求幂的任务,假设我们有两个正整数 ,我们想要计算 。这个问题有一个基本情况:即当 时。递归情况是当 时。我们可以将一般形式写为

同样,这可以直接翻译成 Haskell 代码

exponent a 1 = a
exponent a b = a * exponent a (b-1)

就像我们在整数上定义递归函数一样,我们也可以在列表上定义递归函数。在这种情况下,基本情况通常是空列表[],递归情况是一个 cons 列表(即,一个值与另一个列表连接)。

考虑计算列表长度的任务。我们再次可以将其分解为两种情况:要么我们有一个空列表,要么我们有一个非空列表。显然,空列表的长度为零。此外,如果我们有一个 cons 列表,那么这个列表的长度就是它尾部的长度加一。因此,我们可以定义一个长度函数为

my_length [] = 0
my_length (x:xs) = 1 + my_length xs

注意

每当我们为标准 Haskell 函数提供备选定义时,我们会在其前面加上my_,这样编译器就不会混淆。

类似地,我们可以考虑filter 函数。同样,基本情况是空列表,递归情况是 cons 列表。但是,这次,我们选择是否保留一个元素,取决于一个特定的谓词是否成立。我们可以将过滤器函数定义为

my_filter p [] = []
my_filter p (x:xs) =
  if p x
    then x : my_filter p xs
    else my_filter p xs

在这段代码中,当遇到一个空列表时,我们简单地返回一个空列表。这是因为过滤器不能添加元素;它只能删除它们。

当遇到一个形如(x:xs) 的列表时,我们需要决定是否保留值x。为此,我们使用一个if语句和谓词p。如果p x 为真,那么我们返回一个以x 开头的列表,后面跟着过滤列表尾部的结果。如果p x 为假,那么我们排除x 并返回过滤列表尾部的结果。

我们还可以使用显式递归来定义map 和两个 fold 函数。有关map 的定义,请参阅练习;有关 folds 的定义,请参阅章节 语言高级

练习

斐波那契数列定义为

编写一个递归函数fib,该函数以正整数n 作为参数,并计算 .
练习
定义一个递归函数mult,该函数接受两个正整数ab,并返回a*b,但只使用加法(即,不使用乘法)。首先,以上一练习和本节其余部分的风格给出数学定义。
练习
定义一个递归函数my_map,它的行为与标准函数map 相同。




交互性

[edit | edit source]

如果您熟悉其他(命令式)语言的书籍,您可能想知道为什么您没有在其他语言的教程中看到许多标准程序(例如,那些询问用户姓名然后用姓名向他打招呼的程序)。原因很简单:作为一种纯函数式语言,如何处理用户输入并不完全清楚。

毕竟,假设您有一个从键盘读取字符串的函数。如果您调用此函数两次,并且用户第一次输入了一些东西,第二次输入了其他东西,那么您将不再拥有一个函数,因为它会返回两个不同的值。这个问题的解决方案是在范畴论(形式数学的一个分支)的深处找到的:单子。我们还没有准备好正式讨论单子,但现在,简单地将它们视为表达输入/输出等操作的便捷方式。我们将在章节 Io 中更详细地讨论它们在上下文中的应用,然后在章节 Monads 中讨论单子的本质。


假设我们想要编写一个交互式的函数。要做到这一点,我们需要使用do关键字。这使我们能够指定操作顺序(请记住,通常情况下,由于 Haskell 是一种惰性语言,因此它中操作的评估顺序是不确定的)。因此,要编写一个简单的程序,它询问用户姓名,然后直接称呼他,请将以下代码输入到“Name.hs”中

module Main
    where

import System.IO

main = do
  hSetBuffering stdin LineBuffering
  putStrLn "Please enter your name: "
  name <- getLine
  putStrLn ("Hello, " ++ name ++ ", how are you?")

注意

第二个putStrLn 实例需要括号,而第一个不需要。这是因为函数应用的优先级高于++,所以如果没有括号,第二个会被解释为(putStrLn "Hello, ") ++ name ++ ...

然后,您可以在解释器中加载此代码,并通过简单地键入“main”来执行main,或者您也可以编译它,并从命令行运行它。我将展示交互方式的结果

示例

Main> main
Please enter your name:
Hal
Hello, Hal, how are you?
Main>

这就是交互性。让我们回顾一下代码,虽然。我们将模块命名为“Main”,以便我们可以编译它。我们将主要函数命名为“main”,以便编译器知道当程序运行时要运行的函数。在第四行,我们导入IO 库,以便我们可以访问 IO 函数。在第七行,我们从do开始,告诉 Haskell 我们正在执行一系列命令。

第一个命令是hSetBuffering stdin LineBuffering,您现在应该忽略它(顺便说一句,这只是 GHC 所需的——在 Hugs 中,您可以在没有它的情况下进行操作)。需要它是因为,当 GHC 读取输入时,它期望以相当大的块进行读取。一个典型的人名远不足以填满这个块。因此,当我们尝试从stdin 读取时,它会等待直到它获得了一个完整的块。我们想要摆脱它,所以我们告诉它使用LineBuffering 而不是块缓冲。

下一个命令是putStrLn,它将字符串打印到屏幕上。在第九行,我们说name <- getLine。这通常写成name = getLine,但使用箭头而不是等号表示getLine 不是一个真正的函数,可以返回不同的值。此命令的意思是“运行操作getLine,并将结果存储在name 中”。

最后一行使用我们在前一行中读取的内容构建一个字符串,然后将其打印到屏幕上。


另一个不是真正函数的函数示例是返回随机值的函数。在这种情况下,执行此操作的函数称为randomRIO。使用它,我们可以编写一个“猜数字”程序。将以下代码输入到“Guess.hs”中

module Main
    where

import System.IO
import System.Random

main = do
  hSetBuffering stdin LineBuffering
  num <- randomRIO (1::Int, 100)
  putStrLn "I'm thinking of a number between 1 and 100"
  doGuessing num

doGuessing num = do
  putStrLn "Enter your guess:"
  guess <- getLine
  let guessNum = read guess
  if guessNum < num
    then do putStrLn "Too low!"
            doGuessing num
    else if guessNum > num
           then do putStrLn "Too high!"
                   doGuessing num
           else do putStrLn "You Win!"

让我们检查一下这段代码。在第五行,我们写“import Random”,告诉编译器我们要使用一些随机函数(这些函数没有内置到 Prelude 中)。在main 的第一行,我们请求一个范围在 内的随机数。我们需要写::Int 来告诉编译器我们在这里使用的是整数——而不是浮点数或其他数字。我们将在关于 类型基础 的章节中详细讨论这一点。在下一行,我们告诉用户发生了什么,然后在main 的最后一行,我们告诉编译器执行命令doGuessing

doGuessing 函数以用户尝试猜测的数字作为参数。首先,它要求用户猜测,然后从键盘接受他们的猜测(这是一个String)。该if语句首先检查他们的猜测是否过低。但是,由于guess 是一个字符串,而num 是一个整数,所以我们首先需要通过read 来将guess 转换为整数。由于“read guess”是一个普通、纯粹的函数(而不是一个 IO 操作),所以我们不需要使用<- 符号(事实上,我们不能);我们只需将值绑定到guessNum。请注意,当我们在do符号中时,我们不需要in用于lets。

如果他们猜测过低,我们会通知他们,然后重新启动doGuessing。如果他们没有猜测过低,我们检查他们是否猜测过高。如果他们猜得过高,我们会告诉他们,然后重新启动doGuessing。否则,他们没有猜测过低,也没有猜测过高,所以他们一定猜对了。我们会告诉他们他们赢了,然后退出。我们退出这一事实隐含在我们没有以下命令这一事实中。我们不需要显式的return () 语句。

您可以编译此代码或将其加载到解释器中,您将获得类似以下内容的结果

示例

Main> main
I'm thinking of a number between 1 and 100
Enter your guess:
50
Too low!
Enter your guess:
75
Too low!
Enter your guess:
85
Too high!
Enter your guess:
80
Too high!
Enter your guess:
78
Too low!
Enter your guess:
79
You Win!

我们之前看到的递归操作实际上并没有返回我们在任何地方使用的值。在它确实返回值的情况下,编写命令的“明显”方式实际上是错误的。在这里,我们将给出错误的版本,解释它为什么是错误的,然后给出正确的版本。

假设我们正在编写一个简单的程序,它会反复要求用户输入一些单词。如果用户在任何时候输入空单词(即,他只按回车键而不输入任何内容),程序会打印出他到那时为止输入的所有内容,然后退出。此程序中的主要函数(实际上是一个操作)是询问用户一个单词、检查它是否为空,然后继续或结束。此操作的不正确公式可能看起来像这样

askForWords = do
  putStrLn "Please enter a word:"
  word <- getLine
  if word == ""
    then return []
    else return (word : askForWords)

在继续阅读之前,看看你是否能找出上面代码有什么问题。

错误出现在最后一行,具体来说是 word : askForWords 这一项。请记住,当使用 (:) 时,我们正在用一个元素(在本例中为 word)和另一个列表(在本例中为 askForWords)来创建一个列表。但是,askForWords 不是一个列表;它是一个操作,运行时会产生一个列表。这意味着在我们能够在前面附加任何东西之前,我们需要运行该操作并获取结果。在本例中,我们希望做类似的事情

askForWords = do
  putStrLn "Please enter a word:"
  word <- getLine
  if word == ""
    then return []
    else do
      rest <- askForWords
      return (word : rest)

在这里,我们首先运行 askForWords,获取结果并将其存储在变量 rest 中。然后,我们返回由 wordrest 创建的列表。

到目前为止,你应该已经很好地理解了如何编写简单的函数、编译它们、在交互式环境中测试函数和程序,以及操作列表。

练习

编写一个程序,该程序将反复要求用户输入数字,直到她输入零,此时它将告诉她所有数字的总和、所有数字的乘积,以及每个数字的阶乘。例如,一个会话可能看起来像这样

示例

Give me a number (or 0 to stop):
5
Give me a number (or 0 to stop):
8
Give me a number (or 0 to stop):
2
Give me a number (or 0 to stop):
0
The sum is 15
The product is 80
5 factorial is 120
8 factorial is 40320
2 factorial is 2

提示:编写一个 IO 操作,该操作读取一个数字,如果它为零,则返回空列表。如果它不为零,则递归调用自身,然后用它刚刚读取的数字和递归调用的结果创建一个列表。

提示:你需要使用“show”来打印数字。 putStrLn("Number " ++ show(n))
华夏公益教科书