跳转至内容

Haskell/变量和函数

来自维基教科书,开放的书籍,开放的世界

本章中的所有示例可以保存到 Haskell 源文件,然后通过将该文件加载到 GHCi 中进行求值。不要包含任何示例中的“Prelude>”提示部分。当显示该提示时,意味着您可以将以下代码输入到 GHCi 等环境中。否则,您应该将代码放入文件并运行它。

在上一章中,我们使用 GHCi 作为计算器。当然,这对于短的计算来说是有效的。对于更长的计算和编写 Haskell 程序,我们希望跟踪中间结果。

我们可以通过为中间结果分配名称来存储它们。这些名称称为变量。当程序运行时,每个变量都会被替换为它所引用的。例如,考虑以下计算

Prelude> 3.141592653 * 5^2
78.539816325

这是根据公式计算的半径为 5 的圆的近似面积。当然,输入 的数字,甚至记住超过前几位数字,都是很麻烦的。编程帮助我们避免无意义的重复和死记硬背,通过将这些任务委托给机器。这样,我们的思维就可以自由地处理更有意思的想法。对于当前的情况,Haskell 已经包含一个名为 pi 的变量,它为我们存储了超过 12 位的 数字。这不仅使代码更清晰,而且还提高了精度

Prelude> pi
3.141592653589793
Prelude> pi * 5^2
78.53981633974483

请注意,变量 pi 及其值 3.141592653589793 在计算中可以互换使用。

Haskell 源文件

[编辑 | 编辑源代码]

除了在 GHCi 中进行瞬时操作之外,您还会将代码保存在扩展名为 .hs 的 Haskell 源文件(基本上是纯文本)中。使用适合编码的文本编辑器处理这些文件(请参阅维基百科关于文本编辑器的文章)。合适的源代码编辑器将提供语法高亮,以相关的方式对代码进行颜色标记,以便更容易阅读和理解。Vim 和 Emacs 是 Haskell 程序员中流行的选择。

为了保持整洁,请在您的计算机上创建一个目录(即文件夹)来保存您在完成本书中的练习时创建的 Haskell 文件。将目录命名为类似于 HaskellWikibook 的名称。然后,在该目录中创建一个名为 Varfun.hs 的新文件,其中包含以下代码

r = 5.0

该代码将变量 r 定义为值 5.0

注意:确保行首没有空格,因为 Haskell 对空格很敏感。

接下来,在您的终端位于HaskellWikibook目录中,启动 GHCi 并使用 :load 命令加载Varfun.hs文件

Prelude> :load Varfun.hs
[1 of 1] Compiling Main             ( Varfun.hs, interpreted )
Ok, modules loaded: Main.

注意::load 可以缩写为 :l(如 :l Varfun.hs)。

如果 GHCi 给出类似于 Could not find module 'Varfun.hs' 的错误,您可能在错误的目录中运行 GHCi 或将文件保存到了错误的目录中。您可以使用 :cd 命令在 GHCi 中更改目录(例如,:cd HaskellWikibook)。

加载完文件后,GHCi 的提示从“Prelude”变为“*Main”。您现在可以在计算中使用新定义的变量 r

*Main> r
5.0
*Main> pi * r^2
78.53981633974483

因此,我们使用著名的公式计算了半径为 5.0 的圆的面积。这是因为我们在 Varfun.hs 文件中定义了 r,而 pi 来自标准 Haskell 库。

接下来,我们将通过为面积公式定义一个变量名来使其更容易快速访问。将源文件的内容更改为

r = 5.0
area = pi * r ^ 2

保存文件。然后,假设您一直保持 GHCi 运行且文件仍然加载,键入 :reload(或缩写版本 :r

*Main> :reload
Compiling Main             ( Varfun.hs, interpreted )
Ok, modules loaded: Main.
*Main>

现在我们有两个变量 rarea

*Main> area
78.53981633974483
*Main> area / r
15.707963267948966

注意

let 关键字(一个具有特殊含义的词)使我们能够在没有源文件的情况下直接在 GHCi 提示符处定义变量。这看起来像

Prelude> let area = pi * 5 ^ 2

虽然有时很方便,但以这种方式完全在 GHCi 中分配变量对于任何复杂的任务来说都是不切实际的。我们通常希望使用保存的源文件。


除了工作代码本身之外,源文件可能还包含文本注释。在 Haskell 中,有两种类型的注释。第一种以 -- 开头,一直持续到行尾

x = 5     -- x is 5.
y = 6     -- y is 6.
-- z = 7  -- z is not defined.

在这种情况下,xy 在实际的 Haskell 代码中定义,但 z 没有。

第二种注释用封闭的 {- ... -} 表示,可以跨越多行

answer = 2 * {-
  block comment, crossing lines and...
  -} 3 {- inline comment. -} * 7

通过组合单行和多行注释,可以通过分别添加或删除单个 } 字符来在执行的代码和注释掉的代码之间进行切换

{--
foo :: String
foo = "bar"
--}

vs.

{--}
foo :: String
foo = "bar"
--}

我们使用注释来解释程序的各个部分或在上下文中添加其他笔记。注意注释过度使用,因为过多的注释会使程序更难阅读。此外,我们必须在每次更改对应代码时小心地更新注释。过时的注释会导致严重混淆。

命令式语言中的变量

[编辑 | 编辑源代码]

熟悉命令式编程的读者会注意到,Haskell 中的变量与 C 等语言中的变量有很大不同。如果您没有编程经验,可以跳过本节,但这将有助于您在遇到许多情况(例如,大多数 Haskell 教材)时理解一般情况,其中人们在参考其他编程语言时讨论 Haskell。

命令式编程将变量视为计算机内存中可更改的位置。这种方法与计算机的基本操作原理相连。命令式程序明确地告诉计算机该做什么。更高级别的命令式语言与直接的计算机汇编代码指令相去甚远,但它们保留了相同的逐步思维方式。相比之下,函数式编程提供了一种以更高级别的数学术语进行思考的方式,定义变量之间如何相互关联,并将编译器留给将这些转换为计算机可以处理的逐步指令。

让我们看一个例子。以下代码在 Haskell 中不起作用

r = 5
r = 2

命令式程序员可能会将其解读为首先设置 r = 5,然后将其更改为 r = 2。然而,在 Haskell 中,编译器将对上述代码返回一个错误:“r 的多个声明”。在给定的范围内,Haskell 中的变量只能定义一次,并且不能更改。

Haskell 中的变量似乎几乎不可变,但它们就像数学中的变量一样。在数学课堂上,您永远不会看到一个变量在一个问题中改变它的值。

准确地说,Haskell 变量是不可变的。它们只根据我们输入程序的数据而变化。我们不能在同一个代码中以两种方式定义r,但我们可以通过更改文件来更改其值。让我们更新上面的代码

r = 2.0
area = pi * r ^ 2

当然,这完全可以。我们可以更改r在它被定义的唯一位置,这将自动更新使用r变量的所有其他代码的值。

现实世界的 Haskell 程序通过在代码中保留某些变量未指定来工作。然后,当程序从外部文件、数据库或用户输入获取数据时,这些值将被定义。但是,现在,我们将坚持在内部定义变量。我们将在后面的章节中介绍与外部数据的交互。

以下是一个与命令式语言的主要区别的另一个例子

r = r + 1

这段 Haskell 代码不是“递增变量r”(即更新内存中的值),而是r的递归定义(即根据自身进行定义)。我们将在后面详细解释递归。对于这种情况,如果r之前已经被定义了任何值,那么在 Haskell 中 r = r + 1 会导致错误消息。 r = r + 1 类似于在数学环境中说,这显然是错误的。

由于它们的值在程序内不会改变,因此变量可以以任何顺序定义。例如,以下代码片段执行完全相同的事情

 y = x * 2
 x = 3
 x = 3
 y = x * 2

在 Haskell 中,没有“xy之前声明”或反之的概念。当然,使用y仍然需要x的值,但这在你需要特定数值之前并不重要。

函数

[edit | edit source]

每次我们想要计算新圆形的面积时更改我们的程序既繁琐又局限于一次一个圆。我们可以通过使用新的变量r2area2来复制所有代码以计算两个圆,以便计算第二个圆:[1]

r  = 5
area  = pi * r ^ 2
r2 = 3
area2 = pi * r2 ^ 2

当然,为了消除这种无意义的重复,我们更希望只有一个用于面积的函数,然后将其应用于不同的半径。

一个函数接受一个参数值(或参数)并给出结果值(本质上与数学函数相同)。在 Haskell 中定义函数就像定义一个变量,除了我们注意到我们在等号左侧放置的函数参数。例如,以下定义了一个依赖于名为r的参数的函数area

area r = pi * r ^ 2

仔细观察语法:函数名首先出现(在我们的示例中为area),然后是一个空格,然后是参数(在示例中为r)。在=符号之后,函数定义是一个公式,该公式在与其他已定义项的上下文中使用参数。

现在,我们可以在调用函数时为参数插入不同的值。将上面的代码保存到一个文件中,将其加载到 GHCi 中,然后尝试以下操作

*Main> area 5
78.53981633974483
*Main> area 3
28.274333882308138
*Main> area 17
907.9202768874502

因此,我们可以使用不同的半径调用此函数来计算任何圆形的面积。

这里的函数在数学上定义为

在数学中,参数用括号括起来,如。许多编程语言也用括号括住参数,如max(2, 3)。但是 Haskell 省略了参数列表周围的括号(以及它们之间的逗号):max 2 3

我们仍然使用括号来对必须一起计算的表达式(任何给出值的代码)进行分组。注意以下表达式是如何被解析的不同

5 * 3 + 2       -- 15 + 2 = 17 (multiplication is done before addition)
5 * (3 + 2)     -- 5 * 5 = 25 (thanks to the parentheses)
area 5 * 3      -- (area 5) * 3
area (5 * 3)    -- area 15

注意,Haskell 函数优先于所有其他运算符,例如+*,就像在数学中,例如乘法在加法之前进行一样。

求值

[edit | edit source]

当你将一个表达式输入到 GHCi 中时,究竟发生了什么?按下回车键后,GHCi 将评估你给出的表达式。这意味着它将用每个函数的定义替换每个函数并计算结果,直到只剩下一个值。例如,area 5的求值过程如下

   area 5
=>    { replace the left-hand side  area r = ...  by the right-hand side  ... = pi * r^2 }
   pi * 5 ^ 2
=>    { replace  pi  by its numerical value }
   3.141592653589793 * 5 ^ 2
=>    { apply exponentiation (^) }
   3.141592653589793 * 25
=>    { apply multiplication (*) }
   78.53981633974483

如所示,要应用调用一个函数,意味着用其右边的定义替换其左边的定义。当使用 GHCi 时,函数调用的结果将显示在屏幕上。

一些更多函数

double x    = 2 * x
quadruple x = double (double x)
square x    = x * x
half   x    = x / 2
练习
  • 解释 GHCi 如何评估quadruple 5
  • 定义一个从其参数的一半中减去 12 的函数。

多个参数

[edit | edit source]

函数也可以接受多个参数。例如,一个计算矩形面积的函数,给定其长度和宽度

areaRect l w = l * w
*Main> areaRect 5 10
50

另一个计算三角形面积的示例

areaTriangle b h = (b * h) / 2
*Main> areaTriangle 3 9
13.5

如你所见,多个参数用空格隔开。这也是为什么你有时需要使用括号来对表达式进行分组的原因。例如,要将值x翻倍,你不能写

quadruple x = double double x     -- error

这将把名为double的函数应用于doublex这两个参数。请注意,函数可以是其他函数的参数(你将在后面看到原因)。为了使此示例正常工作,我们需要将参数放在括号内

quadruple x = double (double x)

参数总是按照给定的顺序传递。例如

minus x y = x - y
*Main> minus 10 5
5
*Main> minus 5 10
-5

在这里,minus 10 5 评估为10 - 5,但minus 5 10 评估为5 - 10,因为顺序发生了改变。

练习
  • 编写一个函数来计算盒子的体积。
  • 吉萨大金字塔大约由多少块石头构成?提示:你需要对金字塔的体积和每块的体积进行估计。

关于组合函数

[edit | edit source]

当然,你可以使用你已经定义的函数来定义新的函数,就像你可以使用预定义的函数一样,比如加法(+)或乘法(*)(运算符在 Haskell 中被定义为函数)。例如,要计算正方形的面积,我们可以重用我们计算矩形面积的函数

areaRect l w = l * w
areaSquare s = areaRect s s
*Main> areaSquare 5
25

毕竟,正方形只是一个边长相等的矩形。

练习
  • 编写一个函数来计算圆柱的体积。圆柱的体积是底面积(你已经在这章中编写了这个函数,所以重用它)乘以高度。

局部定义

[edit | edit source]

where 语句

[edit | edit source]

在定义函数时,我们有时希望定义一些对函数来说是局部的中间结果。例如,考虑海伦公式 ,用于计算边长为 abc 的三角形的面积。

heron a b c = sqrt (s * (s - a) * (s - b) * (s - c))
    where
    s = (a + b + c) / 2

变量 s 是三角形周长的一半,在平方根函数 sqrt 的参数中写出它四次会很繁琐。

简单地按顺序编写定义不起作用……

heron a b c = sqrt (s * (s - a) * (s - b) * (s - c))
s = (a + b + c) / 2                                   -- a, b, and c are not defined here

……因为变量 abc 仅在函数 heron 的右侧可用,但此处编写的 s 定义不是 heron 右侧的一部分。为了将其作为右侧的一部分,我们使用 where 关键字。

请注意,where 和局部定义都向右缩进了 4 个空格,以区别于后续定义。以下是一个显示局部定义和顶层定义混合的另一个示例

areaTriangleTrig  a b c = c * height / 2   -- use trigonometry
    where
    cosa   = (b ^ 2 + c ^ 2 - a ^ 2) / (2 * b * c)
    sina   = sqrt (1 - cosa ^ 2)
    height = b * sina
areaTriangleHeron a b c = result           -- use Heron's formula
    where
    result = sqrt (s * (s - a) * (s - b) * (s - c))
    s      = (a + b + c) / 2

作用域

[edit | edit source]

如果你仔细观察前面的例子,你会注意到我们两次使用了变量名 abc,每个面积函数使用一次。这怎么做到的?

考虑以下 GHCi 序列

Prelude> let r = 0
Prelude> let area r = pi * r ^ 2
Prelude> area 5
78.53981633974483

由于之前 let r = 0 定义的干扰,返回面积为 0 会让人感到意外。但这种情况不会发生,因为你在第二次定义 r 时实际上是在谈论一个不同的 r。这可能看起来很混乱,但想想有多少人叫约翰,然而对于任何只有一个约翰的语境,我们都可以毫无混淆地谈论“约翰”。编程有一个类似语境的 概念,叫做作用域

我们现在不会解释作用域背后的技术细节。只要记住,参数的值严格是你调用函数时传入的值,无论你在函数定义中将该变量命名为何。也就是说,适当唯一的变量名确实可以使代码更容易被人类读者理解。

总结

[edit | edit source]
  1. 变量存储值(可以是任意 Haskell 表达式)。
  2. 变量在一个作用域内不会改变。
  3. 函数帮助你编写可重用代码。
  4. 函数可以接受多个参数。

我们还学习了源文件中的非代码文本注释。

注意

  1. 如本例所示,变量名可能包含数字以及字母。Haskell 中的变量必须以小写字母开头,但之后可以包含任何由字母、数字、下划线 (_) 或撇号 (') 组成的字符串。
华夏公益教科书