2.3 表达式

171 阅读17分钟

OCaml 语法的主要部分是表达式。就像命令式语言中的程序主要由命令构成一样,函数式语言中的程序主要由表达式构成。表达式的例子包括 2+2increment 21

OCaml 手册对语言中的所有表达式都有完整的定义。虽然这个页面以一个相当晦涩的概述开始,但如果你向下滚动,你会看到一些英文解释。现在不要担心研究那一页;只要知道它可供参考即可。

函数式语言中计算的主要任务是将表达式计算为一个。值是一个不能再进行计算的表达式。因此,所有值都是表达式,但并非所有表达式都是值。值的例子包括 2true"yay!"

OCaml 手册还定义了所有值,但同样,该页面主要用于参考而不是研究。

有时表达式可能无法计算。可能有两个原因:

  1. 表达式的计算引发异常。

  2. 表达式的计算永远不会终止(例如,它进入一个“无限循环”)。

基本类型和值

基本类型是内置的最基本类型:整数、浮点数、字符、字符串和布尔值。它们与其他编程语言中的基本类型类似。

int 类型:整数。 OCaml 整数通常写成:12 等。常用的运算符有:+-*/mod。后两者是整数除法和取模(求余):

65 / 60
 - : int = 1
65 mod 60
 - : int = 5
65 / 0
Exception: Division_by_zero.
Raised by primitive operation at unknown location
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

在现代平台上,OCaml整数的范围从 262-2^{62}26212^{62}-1 。它们是用64位机器字实现的,这是64位处理器上寄存器的大小。但是其中一位被OCaml “挪用”,导致只有63位表示。该位用于运行时区分整数和指针。对于需要真正64位整数的应用程序,标准库中有一个 [Int64 模块][ocaml.org/api/Int64.h… Zarith 库。但对于大多数用途来说,内置的 int 类型就足够了,并且提供了最佳性能。

float 类型:浮点数。 OCaml 浮点数是 IEEE 754 双精度浮点数。从语法上讲,它们必须始终包含一个点——例如,3.143.0,甚至 3.。最后一个是 float; 如果你把它写成 3,它会变成int 类型:

3.
- : float = 3.
3
- : int = 3

OCaml 刻意不支持运算符重载,浮点数上的算术运算在它们后面加上一个点。例如,浮点乘法写成 *. 而不是 *

3.14 *. 2.
- : float = 6.28
3.14 * 2.

File "[7]", line 1, characters 0-4:
1 | 3.14 * 2.
    ^^^^
Error: This expression has type float but an expression was expected of type
         int

OCaml 不会在 intfloat 之间自动转换。如果你想进行转换,有两个内置函数可用于此目的:int_of_floatfloat_of_int.

3.14 *. (float_of_int 2)
- : float = 6.28

与任何语言一样,浮点表示是近似的。这可能导致舍入错误:

0.1 +. 0.2
- : float = 0.300000000000000044

在 Python 和 Java 中也可以观察到相同的行为。如果你以前没有遇到过这种现象,这里有一个浮点表示的基本指南,你可能会喜欢阅读。

bool 类型:布尔值。 布尔值写作 truefalse。可以使用常用的逻辑与 && 和逻辑或 || 运算符。

char 类型:字符。 字符用单引号书写,例如 'a''b''c'. 它们在 ISO 9958-1 “Latin-1” 编码中表示为字节(即 8 位整数)。该范围内的前半部分字符是标准的 ASCII 字符。你可以使用 char_of_intint_of_char 函数在字符与整数之间进行转换。

string 类型:字符串。 字符串是字符序列。它们用双引号书写,例如 "abc" 字符串连接运算符是 ^

"abc" ^ "def"
- : string = "abcdef"

面向对象语言通常提供一种可重写的方法来将对象转换为字符串,例如 Java 中的 toString() 或 Python 中的 __str__()。但是大多数 OCaml 值都不是对象,因此需要另一种方法来转换为字符串。对于三种基本类型,有内置函数: string_of_intstring_of_floatstring_of_bool。奇怪的是,没有string_of_char,但库函数String.make可用于实现相同的目标。

string_of_int 42
- : string = "42"
String.make 1 'z'
- : string = "z"

同样,对于相同的三种基本类型,如果需要的话,也有内置函数可以从字符串转换回来:int_of_stringfloat_of_stringbool_of_string.

int_of_string "123"
- : int = 123
int_of_string "not an int"
Exception: Failure "int_of_string".
Raised by primitive operation at unknown location
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

没有 char_of_string,但可以通过从 0 开始的索引访问字符串的各个字符。索引运算符用点和方括号编写:

"abc".[0]
- : char = 'a'
"abc".[1]
- : char = 'b'
:tags: ["raises-exception"]
"abc".[3]
Exception: Invalid_argument "index out of bounds".
Raised by primitive operation at unknown location
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

更多运算符

我们已经在上面介绍了大多数内置运算符,但是还有更多的运算符可以在OCaml 手册中看到。

OCaml 中有两个相等运算符, ===,对应的不等式运算符是 <>!==<> 运算符检查结构上的相等性,而 ==!= 检查物理上的相等性。在我们研究 OCaml 的命令式特性之前,要解释它们之间的区别是很困难的。如果你现在感到好奇,请参阅 Stdlib.(==) 文档

现在开始训练自己使用 = 和不使用 == 。如果你来自 Java 这样的语言,这将会很困难,因为 == 是常用的相等运算符。

断言

表达式 assert e 计算 e。如果结果为 true,就不发生任何事情,整个表达式计算为一个特殊的值,称为 unit。unit 写为 (),类型为 unit。如果结果为 false,则引发异常。

If 表达式

表达式 if e1 then e2 else e3 如果 e1 的值为 true,则整个表达式为 e2,否则为 e3。我们称 e1if 表达式的守护表达式

if 3 + 5 > 2 then "yay!" else "boo!"
- : string = "yay!"

与你可能在命令式语言中使用的 if-then-else 语句不同,OCaml 中的 if-then-else 表达式与任何其他表达式一样;它们可以放在任何可以放置表达式的地方。这使得它们类似于在其他语言中使用的三元运算符 ? :

4 + (if 'a' = 'b' then 1 else 2)
- : int = 6

If 表达式可以以一种优雅的方式嵌套:

if e1 then e2
else if e3 then e4
else if e5 then e6
...
else en

无论编写的是单个 if 表达式还是高度嵌套的 if 表达式,都应该将最后一个 else 视为必须的。如果你省略了它,可能会得到一条错误消息,就目前而言,这是很难理解的 :

if 2 > 3 then 5

File "[20]", line 1, characters 14-15:
1 | if 2 > 3 then 5
                  ^
Error: This expression has type int but an expression was expected of type
       unit because it is in the result of a conditional with no else branch
错误:这个表达式的类型是 int,但预期的类型是 unit,因为它是一个没有 else 分支的条件语句的结果

语法。 if 表达式的语法如下:

if e1 then e2 else e3

字母 e 在这里用来表示任何其他 OCaml 表达式;它是一个语法变量(又叫元变量)的例子,它实际上不是 OCaml 语言本身的一个变量,而是某个语法构造的名称。字母 e 后面的数字用来区分不同表达式出现的情况。

动态语义。if 表达式的动态语义:

  • 如果 e1 计算为 true, 并且如果 e2 计算的值为 v, 则 if e1 then e2 else e3 表达式计算结果为 v
  • 如果 e1 计算为 false, 并且如果 e3 计算的值为 v, 则 if e1 then e2 else e3 表达式计算结果为 v.

我们称这些计算规则为:它们定义如何对表达式求值。请注意,描述 if 表达式的计算需要两个规则,一个用于守卫条件为 true,另一个用于守卫条件为 false。这里使用字母 v 表示任何 OCaml 值;这是元变量的另一个例子。稍后,我们将开发一种更数学的方式来表达动态语义,但现在我们将坚持这种非正式的解释风格。

静态语义。if 表达式的静态语义:

  • 如果 e1 的类型是 boole2 的类型是 te3 的类型是 t,那么 if e1 then e2 else e3 的类型也是 t

我们称这个类型规则为:它描述了如何对表达式进行类型检查。注意,描述 if 表达式的类型检查只需要一个规则。在编译时,当类型检查完成时,守卫条件是 true 还是 false 是没有区别;事实上,编译器没有办法知道守卫条件在运行时具有什么值。这里的字母 t 用于表示任何 OCaml 类型;OCaml 手册也有所有类型的定义(奇怪的是,它没有命名语言的基本类型,如 intbool)。

我们会经常写“类型是”,所以让我们为它介绍一种更简洁的表示法。当我们写“e 的类型是 t”的时候,我们把它写成 e : t。冒号的发音是“ 类型是 ”。冒号的这种用法与 toplevel 对输入的表达式计算后的响应一致:

let x = 42

val x : int = 42

在上面的例子中,变量 x 的类型是 int,即冒号所表示的类型。

Let 表达式

到目前为止,在使用 let 这个词时,我们一直在顶层和 .ml 文件中进行定义。例如,

let x = 42;;

x 定义为 42,我们可以在 toplevel 后续定义中使用 x。我们把 let 的这种用法称为 let定义

let 还有另一种用法,它是一种表达方式:

let x = 42 in x + 1

这里,我们将一个值绑定到名称 x,然后在另一个表达式 x+1 中使用该绑定。我们把 let 的这种用法称为 let 表达式。因为它是一个表达式,所以它计算为一个值。这与定义不同,定义本身不会求值。如果你试着用 let 定义来代替需要表达式的地方,你可以看到如下错误:

(let x = 42) + 1

File "[24]", line 1, characters 11-12:
1 | (let x = 42) + 1
                    ^
Error: Syntax error

从语法上讲,let 定义不允许使用在 + 运算符的左侧,因为这里需要一个值,并且这种定义的计算结果不是一个值。另一方面,let 表达式可以正常工作:

(let x = 42 in x) + 1
- : int = 43

在 toplevel 中理解 let 定义的另一种方法是, 它们就像我们还没有提供表达式主体的 let 表达式。 隐式地说,这个表达式主体是我们在后面输入的任何其他内容 。例如,

# let a = "big";;
# let b = "red";;
# let c = a ^ b;;
# ...

被 OCaml 解释为

let a = "big" in
let b = "red" in
let c = a ^ b in
...

后面一系列的 let 绑定是在给定代码块中绑定多个变量的惯用方式。

语法

let x = e1 in e2

通常,x 是一个标识符。这些标识符必须以小写字母开头,并且按照惯例使用 蛇形命名法 (snake case) 而不是 驼峰命名 (camelCase) 编写。我们称 e1 为绑定表达式,因为它与 x 绑定;我们称 e2 为表达式主体,因为这是绑定作用域内的代码体。

动态语义。

计算:let x = e1 in e2

  • 计算 e1 的值为 v1

  • v1 代入 e2 中的 x,得到一个新的表达式 e2'

  • 计算 e2' 的值为 v2

  • let 表达式的计算结果为 v2.

这里有一个例子:

    let x = 1 + 4 in x * 3
-->   (evaluate e1 to a value v1)
    let x = 5 in x * 3
-->   (substitute v1 for x in e2, yielding e2')
    5 * 3
-->   (evaluate e2' to v2)
    15
      (result of evaluation is v2)

静态语义。

  • 如果 e1 : t1 且在 x : t1 的假设下 e2 : t2 成立, 则 (let x = e1 in e2) : t2.(注:我们可以读作:如果 e1 的类型是 t1,且在 x 的类型是 t1 的假设下,e2 的类型是 t2,则 let x = e1 in e2 的类型是 t2

为了清楚起见,我们使用上面的括号。通常,编译器的类型推断器决定变量的类型,或者程序员可以用下面的语法显式注解它:

let x : t = e1 in e2

作用域

let 绑定仅在其所在的代码块中有效。这正是你在几乎所有现代编程语言中所习惯的。例如:

let x = 42 in
  (*  y在这里没有意义 *)
  x + (let y = "3110" in
         (* 这里y是有意义的*)
         int_of_string y)

变量的作用域是其名称有意义的范围。变量 y 的作用域仅在上面绑定它的 let 表达式中。

可以有相同名称的重叠绑定。例如:

let x = 5 in
  ((let x = 6 in x) + x)

但是这很令人费解,因此,强烈反对使用这种风格 —— 就像在自然语言中不提倡使用模棱两可的代词一样。尽管如此,让我们考虑一下这些代码的含义。

这段代码计算的值是什么?答案归结为每次 x 出现时如何用一个值替换它。下面是这种替换 的几种可能性。

(* possibility 1 *)
let x = 5 in
  ((let x = 6 in 6) + 5)

(* possibility 2 *)
let x = 5 in
  ((let x = 6 in 5) + 5)

(* possibility 3 *)
let x = 5 in
  ((let x = 6 in 6) + 6)

第一个是几乎所有合理的语言都会做的。很可能与你猜的一样,但为什么呢?

答案就是我们所说的名称无关原则:变量的名称本身并不重要。你们在数学中已经习惯了。例如,以下两个函数是相同的:

f(x)=x2f(y)=y2f(x) = x^2 \\ f(y) = y^2

本质上来说,函数的实参是 xx 还是 yy 并不重要;不管怎样,它仍然是平方函数。因此,在程序中,这两个函数应该是相同的:

let f x = x * x
let f y = y * y

这个原则通常被称为 α 等价(alpha equivalence) 原则:这两个函数在重命名变量之前是等价的,由于历史原因,这也被称为 α 转换(alpha conversion),但在这里并不重要。

根据名称无关原则,这两个表达式应该是相同的:

let x = 6 in x
let y = 6 in y

因此,包含上述表达式的下面两个表达式也应该是相同的:

let x = 5 in (let x = 6 in x) + x
let x = 5 in (let y = 6 in y) + x

但为了使它们完全相同,我们必须选择上面三种可能性中的第一种。它是唯一一个满足名称无关原则的变量。

对于这种现象有一个常用的术语:变量的新绑定会覆盖变量之前的绑定。打个比方,就好像新绑定暂时给之前的绑定投下了阴影。但随着新绑定作用域的结束,之前的绑定最终就会重新出现。

【覆盖是不可变赋值。】例如,下面两个表达式的求值结果都是 11:

【注】

有一种方法可以在 x 被第二个绑定遮蔽之前返回并获取它的原始值。我记得这个问题在其他语言中似乎和在 OCAML 中的解决方案是一样的:

# let foo = 42;;
val foo : int = 42

# let return_foo () = foo;;
val return_foo : unit -> int = <fun>

# let foo = 24;;
val foo : int = 24

# return_foo ();;
- : int = 42

上面的代码做了以下工作:

  1. 42 绑定到名称 foo

  2. 创建一个函数 return_foo (),返回绑定到 foo 的值。

  3. 24 绑定到名称 foo (它隐藏了 foo 之前的绑定)。

  4. 调用 return_foo () 函数,返回 42

比较一下可变值的行为(在 OCaml 中使用 ref 创建):

# let foo = ref 42;;
val foo : int ref = {contents = 42}

# let return_foo () = !foo;;
val return_foo : unit -> int = <fun>

# foo := 24;;
- : unit = ()

# return_foo ();;
- : int = 24

这个

  1. 创建一个包含 42 的可变引用,并将其绑定到名称 foo

  2. 创建一个函数 return_foo (),它返回存储在绑定到 foo 的引用中的值。

  3. 在绑定到 foo 的引用中存储 24

  4. 调用 return_foo () 函数,返回 24

let x = 5 in ((let x = 6 in x) + x)
let x = 5 in (x + (let x = 6 in x))

同样,下面的 utop 脚本也是不可变赋值语句,尽管一开始看起来是这样的:

# let x = 42;;
val x : int = 42
# let x = 22;;
val x : int = 22

回想一下,顶层的每个 let 定义实际上都是一个嵌套的 let 表达式。因此,上述内容实际上就是以下内容:

let x = 42 in
  let x = 22 in
    ... (* whatever else is typed in the toplevel 无论在顶层输入的是什么 *)

正确的思考方法是,第二个 let 绑定了一个全新的变量,这个变量恰好与第一个 let 同名。

这是另一个非常值得研究的 utop 脚本:

# let x = 42;;
val x : int = 42
# let f y = x + y;;
val f : int -> int = <fun>
# f 0;;
: int = 42
# let x = 22;;
val x : int = 22
# f 0;;
- : int = 42  (* x did not mutate! x 没有变化 *)

总之,每个 let 定义都绑定了一个全新的变量。如果新变量恰好与旧变量同名,则新变量会暂时遮盖旧变量。但是旧的变量仍然存在,它的值是不可变的:它永远不会改变。因此,尽管 let 表达式表面上看起来像命令式语言中的赋值语句,但它们实际上是完全不同的。

类型注解

OCaml 自动推断每个表达式的类型,不需要程序员手动编写。尽管如此,手动指定所需的表达式类型有时还是很有用的。类型注解 这么写:

(5 : int)
- : int = 5

错误的注解将产生编译时错误:

(5 : float)

File "[27]", line 1, characters 1-2:
1 | (5 : float)
     ^
Error: This expression has type int but an expression was expected of type
         float
  Hint: Did you mean `5.'?

这个例子说明了为什么可以在调试期间使用手动类型注解。也许你忘记了 5 不能被当作 float 类型,于是你试着这样写:

5 +. 1.1

你可以尝试手动指定 5float 类型:

(5 : float) +. 1.1

File "[28]", line 1, characters 1-2:
1 | (5 : float) +. 1.1
     ^
Error: This expression has type int but an expression was expected of type
         float
  Hint: Did you mean `5.'?

很明显,类型注解失败了。虽然对于这个小程序来说,这看起来有点傻,但随着程序变大,你可能会发现这种技术很有效。

类型注解不是 C 或 Java 中的类型强制转换。它们并不表示从一种类型到另一种类型的转换。相反,它们表示检查表达式是否确实具有给定的类型。

语法。 类型注解的语法:

(e : t)

请注意,括号是必需的。

动态语义。 类型注解没有运行时意义。它在编译期间消失,因为它表示编译时检查。没有运行时转换。

静态语义。 如果 e 的类型是 t(e : t) 的类型是 t

注:本书是康奈尔大学 CS 3110 数据结构和函数式编程的教材。原书为英文版,在学习的过程中,根据自己的理解,翻译了一些,做一个记录,版权归原作者所有,如有侵权,请联系我删除。