OCaml 语法的主要部分是表达式。就像命令式语言中的程序主要由命令构成一样,函数式语言中的程序主要由表达式构成。表达式的例子包括 2+2 和 increment 21 。
OCaml 手册对语言中的所有表达式都有完整的定义。虽然这个页面以一个相当晦涩的概述开始,但如果你向下滚动,你会看到一些英文解释。现在不要担心研究那一页;只要知道它可供参考即可。
函数式语言中计算的主要任务是将表达式计算为一个值。值是一个不能再进行计算的表达式。因此,所有值都是表达式,但并非所有表达式都是值。值的例子包括 2、true 和 "yay!"。
OCaml 手册还定义了所有值,但同样,该页面主要用于参考而不是研究。
有时表达式可能无法计算。可能有两个原因:
-
表达式的计算引发异常。
-
表达式的计算永远不会终止(例如,它进入一个“无限循环”)。
基本类型和值
基本类型是内置的最基本类型:整数、浮点数、字符、字符串和布尔值。它们与其他编程语言中的基本类型类似。
int 类型:整数。 OCaml 整数通常写成:1、2 等。常用的运算符有:+、-、*、/ 和 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整数的范围从 到 。它们是用64位机器字实现的,这是64位处理器上寄存器的大小。但是其中一位被OCaml “挪用”,导致只有63位表示。该位用于运行时区分整数和指针。对于需要真正64位整数的应用程序,标准库中有一个 [Int64 模块][ocaml.org/api/Int64.h… Zarith 库。但对于大多数用途来说,内置的 int 类型就足够了,并且提供了最佳性能。
float 类型:浮点数。 OCaml 浮点数是 IEEE 754 双精度浮点数。从语法上讲,它们必须始终包含一个点——例如,3.14 或 3.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 不会在 int 和 float 之间自动转换。如果你想进行转换,有两个内置函数可用于此目的:int_of_float 和 float_of_int.
3.14 *. (float_of_int 2)
- : float = 6.28
与任何语言一样,浮点表示是近似的。这可能导致舍入错误:
0.1 +. 0.2
- : float = 0.300000000000000044
在 Python 和 Java 中也可以观察到相同的行为。如果你以前没有遇到过这种现象,这里有一个浮点表示的基本指南,你可能会喜欢阅读。
bool 类型:布尔值。 布尔值写作 true 和 false。可以使用常用的逻辑与 && 和逻辑或 || 运算符。
char 类型:字符。 字符用单引号书写,例如 'a' 、 'b' 和 'c'. 它们在 ISO 9958-1 “Latin-1” 编码中表示为字节(即 8 位整数)。该范围内的前半部分字符是标准的 ASCII 字符。你可以使用 char_of_int 和 int_of_char 函数在字符与整数之间进行转换。
string 类型:字符串。 字符串是字符序列。它们用双引号书写,例如 "abc" 字符串连接运算符是 ^:
"abc" ^ "def"
- : string = "abcdef"
面向对象语言通常提供一种可重写的方法来将对象转换为字符串,例如 Java 中的 toString() 或 Python 中的 __str__()。但是大多数 OCaml 值都不是对象,因此需要另一种方法来转换为字符串。对于三种基本类型,有内置函数: string_of_int、string_of_float、string_of_bool。奇怪的是,没有string_of_char,但库函数String.make可用于实现相同的目标。
string_of_int 42
- : string = "42"
String.make 1 'z'
- : string = "z"
同样,对于相同的三种基本类型,如果需要的话,也有内置函数可以从字符串转换回来:int_of_string、float_of_string和 bool_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。我们称 e1 为 if 表达式的守护表达式。
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的类型是bool,e2的类型是t,e3的类型是t,那么if e1 then e2 else e3的类型也是t
我们称这个类型规则为:它描述了如何对表达式进行类型检查。注意,描述 if 表达式的类型检查只需要一个规则。在编译时,当类型检查完成时,守卫条件是 true 还是 false 是没有区别;事实上,编译器没有办法知道守卫条件在运行时具有什么值。这里的字母 t 用于表示任何 OCaml 类型;OCaml 手册也有所有类型的定义(奇怪的是,它没有命名语言的基本类型,如 int 和 bool)。
我们会经常写“类型是”,所以让我们为它介绍一种更简洁的表示法。当我们写“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)
第一个是几乎所有合理的语言都会做的。很可能与你猜的一样,但为什么呢?
答案就是我们所说的名称无关原则:变量的名称本身并不重要。你们在数学中已经习惯了。例如,以下两个函数是相同的:
本质上来说,函数的实参是 还是 并不重要;不管怎样,它仍然是平方函数。因此,在程序中,这两个函数应该是相同的:
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上面的代码做了以下工作:
将
42绑定到名称foo。创建一个函数
return_foo (),返回绑定到foo的值。将
24绑定到名称foo(它隐藏了foo之前的绑定)。调用
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这个
创建一个包含
42的可变引用,并将其绑定到名称foo。创建一个函数
return_foo (),它返回存储在绑定到foo的引用中的值。在绑定到
foo的引用中存储24。调用
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
你可以尝试手动指定 5 为 float 类型:
(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 数据结构和函数式编程的教材。原书为英文版,在学习的过程中,根据自己的理解,翻译了一些,做一个记录,版权归原作者所有,如有侵权,请联系我删除。