第2章 串行编程

翻译:连城

本章介绍用于编写串行Erlang程序的概念。我们首先讨论变量赋值的基本机制和如何实现控制流程。为此,我们要先了解一下项式模式模式匹配

项式

Erlang中以下数据类型[1]被称为项式

  • 常量类型
    • 数值
      • 整数,用于存储自然数
      • 浮点数,用于存储实数
    • 原子式
    • Pid(进程标识符process identifier的缩写),用于存储进程标识
    • 引用,用于存储系统范围内唯一的引用
  • 复合数据类型
    • 元组,用于存储固定数目的多个项式
    • 列表,用于存储可变数目的多个项式

数值

以下实例都属于数值:

123 -34567 12.345 -27.45e-05

整数精度与实现相关,但任何Erlang系统都应保证至少24位的整数精度。

$<Char>标记表示字符Char对应的ASCII值,如$A表示整数65。

不以10为基数的整数可写作<Base>#<Value>,如16#ffff表示十进制整数65535。Base的取值范围为2 .. 16。

浮点数以传统方式书写。

原子式

原子式是有名称的常量。例如在某个用于日历计算的程序中可使用mondaytuesday等等表示一星期中的各天。原子式用于增强程序的可读性。

一些原子式实例:

friday unquoted_atoms_cannot_contain_blanks
'A quoted atom which contains several blanks'
'hello \n my friend'

原子式以小写字母(a..z)开头,以非字母数字字符结尾——否则就必须用引号括起来。

通过将原子式以引号括起来,原子式中便可以出现任意字符。原子式总是以可被 Erlang 读取程序读入的格式输出。原子式引号内的字符遵循如下规范:

字符 含义
\b 退格符
\d 删除符
\e 转义符(ESC)
\f 换页符
\n 换行符
\r 回车符
\t 制表符
\v 垂直制表符
\\ 反斜线
\^A .. \^Z control A到control Z(即0 .. 26)
\' 单引号
\" 双引号
\OOO 使用八进制格式OOO表示的字符

在引号括起来的原子式中如果包含字符序列\C,其中C的ASCII值小于32,则表示\C的这部分源码被忽略(这样我们在编程时就可以使用一个反斜线加换行符来将长原子式分隔为几行)。

元组

以花括号包围的一系列以逗号分隔的项式称为元组。元组用于存储固定数目个项式。它们与传统编程语言中的结构记录类似。

元组{E1,E2,...,En},其中n大于0,称为大小n的元组。元组中的单个项式称为元素

以下是一些元组实例:

{a, 12, 'hello'}
{1, 2, {3, 4}, {a, {b, c}}}
{}

列表

以方括号包围的一系列以逗号分隔的项式成为列表。列表用于存储可变数目个项式。

对于列表[E1,E2,...En],其中n >= 0 ,称其长度为n

以下是一些元组实例:

[1, abc, [12], 'foo bar']
[]
[a,b,c]
"abcd"

被我们称之为字符串的"..."标记,实际上是引号中各个字符组成的列表的ASCII简写形式。因此"abc"对应于[97,98,99]。在原子式中使用的转义规则在字符串中通用。

在对列表进行处理时,往往需要一种方便的手段来引用列表的第一个元素以及除掉第一个元素以外列表的剩余部分。方便起见,我们将列表的第一个元素称为头部,将剩余部分称为 尾部

我们使用[E1,E2,E3,...,En|Variable]来标记一个前n个元素分别为E1,E2,E3,...,En而剩余部分记为Variable的列表。

注意“|”之后的项式不一定要是列表,它可以是任意一个合法的Erlang项式。最后一个尾部为项式[]的列表称为列表或格式良好的列表——大多数(尽管不是全部)Erlang程序都是被编写来处理格式良好的列表的。

模式匹配

模式与项式有着相同的结构,但它们还可以包含变量。变量名都以大写字母开头。

模式示例:

{A, a, 12, [12,34|{a}]}
{A, B, 23}
{x, {X_1}, 12, My_cats_age}
[]

以上的ABX_1My_cats_age都是变量。

模式匹配为变量赋值提供了基本的机制。被赋值后,变量便被绑定——否则便是未绑定变量。给变量赋值的动作称作“绑定”。变量一旦被绑定便不可更改。这种变量属性被称为一次性绑定单次赋值。这种属性与传统命令式语言的破坏性赋值[2]相反。

如果一个模式与一个项式在结构上同构,且在模式中任一位置出现的原子数据类型也都在项式的相应位置上出现,则称他们它们相互匹配。如果模式中包含未绑定变量,则该变量在匹配过程中将被绑定到项式中相应的元素。如果在模式中相同的变量多次出现,则项式中对应位置的元素也必须相同。

模式匹配在以下情况下发生:

  • 计算形如Lhs = Rhs的表达式时
  • 调用函数时
  • casereceive原语中对指定模式进行匹配时

Pattern = Expression

表达式Pattern = Expression将致使Expression被求值,并将其结果与Pattern进行匹配。匹配要么成功要么失败。若匹配成功则Pattern中的所有(未绑定)变量都将被绑定。

以下我们将假设模式匹配总是成功。对失败的处理将在第??章详细讨论。

示例:

{A, B} = {12, apple}

匹配成功后建立绑定关系A12[3]Bapple

{C, [Head|Tail]} = {{222, man}, [a,b,c]}

匹配成功后建立绑定关系C{222, man}HeadaTail[b, c]

[{person, Name, Age, _}|T] =
        [{person, fred, 22, male},
         {person, susan, 19, female}, ...]

匹配成功后建立绑定关系T[{person, susan, 19, female}, ...]}NamefredAge22。在最后一个例子中我们利用了写作“_”的匿名变量——在语法上需要一个变量出现,但我们又不关心该变量的值的时候便可以使用匿名变量。

当一个变量在一个模式中多次出现时,只有被匹配的对应元素的值都相同时匹配才会成功。因此,举例来说,{A, foo, A} = {123, foo, 123}将成功匹配,并将A绑定到123,然而{A, foo, A} = {123, foo, abc}就会失败,因为我们不能将A同时绑定到123abc

=”是一个右结合的中缀运算符。因此A = B = C = D将被解析为A = (B = (C = D))。这种用法可能只有在{A, B} = X = ...这样的构造中才有用,这时我们可以同时获悉表达式的值及其组成部分。表达式Lhs = Rhs的值被定义为Rhs

函数调用中的模式匹配

Erlang通过模式匹配来提供选择和控制流程。例如,程序2.1定义了一个函数classify_day/1,当调用参数为saturdaysunday时返回weekEnd,否则返回weekDay

程序 2.1

-module(dates).
-export([classify_day/1]).

classify_day(saturday) -> weekEnd;
classify_day(sunday)   -> weekEnd;
classify_day(_)        -> weekDay.

进行函数求值时,会将函数的参数与函数定义中出现的模式一一进行匹配。一旦发现一个成功的匹配,“->”之后的符号便被求值,因此:

> dates:classify_day(saturday).
weekEnd
> dates:classify_day(friday).
weekDay

如果所有的子句都不匹配,则函数调用失败(失败将引发第??章描述的错误捕捉机制)。

当执行流程进入一个函数的某个子句时,描述该子句的模式所包含的变量将被绑定。因此,举例来说,对程序1.3的math3:area({square, 5})进行求值将致使变量Side被绑定到5

表达式求值

表达式具备与模式相同的语法,同时表达式还可以包含函数调用或传统的中序算术表达式。函数调用的写法很传统,如:area:triangle(A, B, C)便代表以参数ABC调用函数area:triangle

Erlang 表达式的求值机制如下。

对项式求值得到其本身:

> 222.
222
> abc.
abc
> 3.1415926.
3.14159
> {a,12,[b,c|d]}.
{a,12,[b,c|d]}
> {{},[{}],{a,45,'hello world'}}.
{{},[{}],{a,45,'hello world'}}

浮点数的输出格式可能与它们的输入格式不完全一致。当表达式与项式同构且表达式中的函数调用都已求值完毕时,表达式将被求值为项式。应用一个函数时其参数首先被求值。

求值过程可以被认为是一个将表达式归结为基础项式的函数:

ε(X) when Constant(X)→X
ε({t1,t2,...,tn})→{ε(t1),ε(t2),...,ε(tn)}
ε([t1,t2,...,tn])→[ε(t1),ε(t2),...,ε(tn)]
ε(functionName(t1,t2,...,tn)→
    APPLY(functionName,[ε(t1),ε(t2),...,ε(tn)])

其中APPLY表示一个将参数应用到函数的函数。

函数求值

函数调用的写法如以下实例所示:

> length([a,b,c]).
3
> lists:append([a,b], [1,2,3]).
[a,b,1,2,3]
> math:pi().
3.14159

带冒号形式的函数将在和模块相关的章节中解释。调用没有参数的函数必须加上一对空的小括号(以此与原子式相区别)。

求值顺序

函数参数的求值顺序是不确定的。例如,f({a},b(),g(a,h(b),{f,X}))表示一个函数调用。对函数f的调用有三个参数:{a}b()g(a,h(b),{f,X})。第一个参数是一个只包含一个原子项a的元组。第二个参数是一个函数调用b()。第三个参数是函数调用g(a,h(b),{f,X})。在对f/3求值时,对b/0g/3的求值顺序是不确定的,不过h(b)g/3被求值。对b()h(b)的求值顺序也是不确定的。

在对形如[f(a), g(b), h(k)]的表达式进行求值时,f(a)g(b)h(k)的求值顺序是不确定的。

如果f(a)g(b)h(k)的求值过程没有副作用(即不发送消息、不创建进程等等),则[f(a), g(b), h(k)]与求值顺序无关[4]。这种属性叫作引用透明性[5]

应用

BIF apply(Mod, Func, ArgList)apply({Mod, Func}, ArgList)用于将模块Mod中的函数Func应用到参数列表ArgList

> apply(dates, classify_day, [monday]).
weekDay
> apply(math, sqrt, [4]).
2.0
> apply({erlang, atom_to_list}, [abc]).
[97,98,99]

使用apply对BIF进行求值时,可以使用erlang作为模块名。

模块系统

Erlang具备一套模块系统以便我们将大型程序切分为一组模块。每个模块都有自己的名称空间;这样我们就可以在不同的模块中自由地使用相同的函数名而不会有任何冲突。

模块系统以对给定模块中函数的可见性进行限制的方式来工作的。函数的调用方式取决于模块名、函数名以及函数名是否在模块的导入或导出声明中出现。

程序 2.2

-module(lists1).
-export([reverse/1]).

reverse(L) ->
        reverse(L, []).

reverse([H|T], L) ->
        reverse(T, [H|L]);
reverse([], L) ->
        L.

程序2.2定义了一个颠倒列表元素顺序的函数reverse/1reverse/1是该模块中唯一可以从该模块之外被调用的函数。需要从模块外部调用的函数必须出现在模块的导出声明中。

该模块中定义的其他函数,reverse/2,仅可供模块内部使用。注意reverse/1reverse/2是完全不同的函数。在Erlang中,名字相同但参数数目不同的两个函数是完全不同的函数。

模块间调用

从其他模块中调用函数的方法有两种:

程序 2.3

-module(sort1).
-export([reverse_sort/1, sort/1]).

reverse_sort(L) ->
        lists1:reverse(sort(L)).
sort(L) ->
        lists:sort(L).

reverse/1完全限定名称被调用。

你还可以借助import声明使用隐式限定函数名,如程序2.4所示。

程序 2.4

-module(sort2).
-import(lists1, [reverse/1]).
-export([reverse_sort/1, sort/1]).

reverse_sort(L) ->
        reverse(sort(L)).
sort(L) ->
        lists:sort(L).

两种形式都是为了解决二义性。比如,当两个不同的模块导出了重名的函数,则必须显式限定函数名。

函数定义

以下章节更详细地描述了Erlang函数的语法。首先我来给函数的各个语法元素命名。接着将详细描述这些元素。

术语

考虑以下模块:

程序 2.5

-module(lists2).

-export([flat_length/1]).

%% flat_length(List)
%%  Calculate the length of a list of lists.

flat_length(List) ->
    flat_length(List, 0).

flat_length([H|T], N) when list(H) ->
    flat_length(H, flat_length(T, N));
flat_length([H|T], N) ->
    flat_length(T, N + 1);
flat_length([], N) ->
    N.

以“%”打头的是注释。注释可以从一行的任意位置开始,一直持续到行末。

第1行包含模块声明。该行必须出现在任何其他声明或代码之前。

第1行和第3行开头的“-”称为属性前缀module(list2)便是属性的一个例子。

第2、第4等行是空行——连续的单个或多个空白符、空行、制表符、换行符等,都被当作单个空白符处理。

第3行声明了一个具有一个参数的函数flag_length,该行意味着该函数存在于模块中并会被从模块中导出。

第5、6行是注释。

第8、9行包含了函数flat_length/1的定义。它由单个子句组成。

表达式flat_length(List)称为子句的头部。“->”之后的部分为子句的主体

第11至16行函数flat_length/2的定义——该函数包含三个子句;子句间以分号“;”分隔,在最后的结尾处以“.”结尾。

第11行中flat_length/2的第一个参数为列表[H|T]H表示列表的头部T代表列表的尾部。在关键字when和箭头“->”之间的表达式list(H)称作保护式。只有在参数与函数头部的模式相匹配且保护式断言成立时,函数体才会被求值。

flat_length/2的第一个子句称为保护子句;其他的子句称为无保护子句

flat_length/2是一个局部函数——即不可从模块外部被调用(因为它没有出现在export属性中)。

模块lists2包含了函数flat_length/1flat_length/2的定义。它们代表两个完全不同的函数——这与C或Pascal等语言不通,在这些语言中一个函数名只能出现一次,且只能有固定个数的参数。

子句

每个函数都由一组子句组成。子句间以分号“;”分隔。每个子句都包含一个子句头部、一个可选的保护式和子句主体。下面将详细解释。

子句头部

子句的头部包含一个函数名和一组以逗号分隔的参数。每个参数都是一个合法的模式。

当函数调用发生时,将会按顺序对函数定义中的子句头部依次进行匹配。

子句保护式

保护式是子句被选中前必须要满足的条件。

保护式可以是一个简单的断言或是一组由逗号分隔的简单断言。一个简单断言可以是一个算数比较、项式比较,或是一个系统预定义的断言函数。保护式可以看作是模式匹配的一种扩展。用户自定义的函数不能用在保护式内。

对保护式求值时所有的断言都将被求值。若所有断言都为真,则保护式成立,否则就失败。保护式中各个断言的求值顺序是不确定的。

如果保护式成立,则会对子句的主体进行求值。如果保护式失败,则尝试下一个候选子句。

一旦子句的头部和保护式都匹配成功,系统将指定这条子句并对其主体求值。

我们可以写一个保护式版本的factorial

factorial(N) when N == 0 -> 1;
factorial(N) when N > 0  -> N * factorial(N - 1).

注意对于以上示例,我们可以调换子句的顺序,即:

factorial(N) when N > 0  -> N * factorial(N - 1);
factorial(N) when N == 0 -> 1.

在这个示例中子句首部模式与保护式的组合可以唯一确定一个正确的子句。

保护式断言

保护式断言的完整集合如下:

保护式 成立条件
atom(X) X是一个原子式
constant(X) X不是列表或元组
float(X) X是一个浮点数
integer(X) X是一个整数
list(X) X是一个列表或 []
number X是一个整数或浮点数
pid(X) X是一个进程标识符
port(X) X是一个端口
reference(X) X是一个引用
tuple(X) X是一个元组
binary(X) X是一段二进制数据

另外,一些BIF和算术表达式的组合也可以作为保护式。它们是:

element/2, float/1, hd/1, length/1, round/1, self/0, size/1
trunc/1,   tl/1, abs/1, node/1, node/0, nodes/0

项式比较

可以出现在保护式中的项式比较运算符如下:

运算符 描述 类型
X > Y X大于Y coerce
X < Y X小于Y coerce
X =< Y X小于或等于Y coerce
X >= Y X大于或等于Y coerce
X == Y X等于Y coerce
X /= Y X不等于Y coerce
X =:= Y X等于Y exact
X =/= Y X不等于Y exact

比较运算符工作机制如下:首先对运算符两边求值(如,在表达式两边存在算术表达式或包含BIF保护式函数时);然后再进行比较。

为了进行比较,定义如下的偏序关系:

number < atom < reference < port < pid < tuple < list

元组首先按大小排序,然后再按元素排序。列表的比较顺序是先头部,后尾部。

如果比较运算符的两个参数都是数值类型且运算符为coerce型,则如果一个参数是integer另一个是float,那么integer将被转换为float再进行比较。

exact类型的运算符则不做这样的转换。

因此5.0 == 1 + 4为真,而5.0 =:= 4 + 1为假。

保护函数子句示例:

foo(X, Y, Z) when integer(X), integer(Y), integer(Z), X == Y + Z ->
foo(X, Y, Z) when list(X), hd(X) == {Y, length(Z)}  ->
foo(X, Y, Z) when {X, Y, size(Z)} == {a, 12, X} ->
foo(X) when list(X), hd(X) == c1, hd(tl(X)) == c2 ->

注意在保护式中不可引入新的变量。

子句主体

子句的主体有一个或多个有逗号分隔的表达式序列组成。序列中的表达式依次被求值。表达式序列的值被定义为序列中最后一个表达式的值。例如,factorial的第二个子句可以写成:

factorial(N) when N > 0 ->
    N1 = N - 1,
    F1 = factorial(N1),
    N * F1.

在对序列求值的过程中,表达式的求值结果要么与一个模式进行匹配,要么被直接丢弃。将函数主体拆分为序列的原因有这么几条:

  • 确保代码的顺序执行——函数主体中的表达式是依次求值的,而在嵌套的函数调用中的函数则可能以任意顺序执行。
  • 增强代码可读性——将函数写成表达式序列可以令程序更清晰。
  • (通过模式匹配)拆解函数的返回值。
  • 重用函数调用的返回值。

对函数返回值的多次重用的示例如下:

good(X) ->
        Temp = lic(X),
        {cos(Temp), sin(Temp)}.

上面的写法比下面这么写要好:

bad(X) ->
        {cos(lic(X)), sin(lic(X)}.

二者表达的是同一个含义。lic代表长而复杂的计算过程(Long and Involved Calculation),即那些计算代价高的函数。

原语

Erlang提供了元语caseif,这样在子句中无需借助其他函数便可以直接进行条件求值。

Case

case表达式允许在子句主体内部于多个选项中进行选择,语法如下:

case Expr of
    Pattern1 [when Guard1] -> Seq1;
    Pattern2 [when Guard2] -> Seq2;
    ...
    PatternN [when GuardN] -> SeqN
end

首先,对Expr求值,然后,Expr的值将依次与模式Pattern1Pattern2……PatternN进行匹配,直到匹配成功。如果找到一个匹配并且(可选的)的保护式成立,则对应的调用序列将被求值。注意case保护式与函数保护式形式相同。case原语的值就是被选中的序列的值。

至少得有一个模式必须得以匹配——否则就会产生一个运行时错误并引发第??章中的错误处理机制。

举个例子,比方说我们我有个函数allocate(Resource)用于分配某种资源Resource。假设这个函数只返回{yes, Address}no。这样,这个函数便可以放在一个case结构里:

...
case allocate(Resource) of
    {yes,Address} when Address > 0, Address =< Max ->
        Sequence 1 ... ;
    no ->
        Sequence 2 ...
end
...

Sequence 1 ...中,变量Address已经被绑定在了allocate/1的返回结果上。

为了避免匹配错误的发生,我们常常追加一个必会匹配的模式[6]作为case原语的最后一个分支:

case Fn of
    ...
    _ ->
        true
end

If

if表达式的语法如下:

if
    Guard1 ->
        Sequence1 ;
    Guard2 ->
        Sequence2 ;
    ...
end

在这种情况下,保护式Guard1,...将被依次求值。如果一个保护式成立则对与之关联的序列求值。该序列的求值结果便是if结构的结果。if保护式与函数保护式形式相同。与case相同,一个保护式都不成立的话将引发一个错误。如果需要,可以增加保护式断言true作为垃圾箱:

if
    ...
    true ->
        true
end

Case 和 if 使用示例

使用caseif我们可以以多种方式来编写factorial

最简单的:

factorial(0) -> 1;
factorial(N) -> N * factorial(N - 1).

使用函数保护式:

factorial(0) -> 1;
factorial(N) when N > 0 -> N * factorial(N - 1).

使用if

factorial(N) ->
    if
        N == 0 -> 1;
        N > 0 -> N * factorial(N - 1)
    end.

使用case

factorial(N) ->
    case N of
        0 -> 1;
        N when N > 0 ->
            N * factorial(N - 1)
    end.

使用变量保持临时结果:

factorial(0) ->
    1;
factorial(N) when N > 0 ->
    N1 = N - 1,
    F1 = factorial(N1),
    N * F1.

以上所有定义都是正确且等价的[7]——如何进行选择完全是个美学问题[8]

算术表达式

算术表达式由以下运算符构成:

运算符 描述 类型 操作数类型 优先级
+ X + X 单目 混合 1
- X - X 单目 混合 1
X * Y X * Y 双目 混合 2
X / Y X / Y(浮点除法) 双目 混合 2
X div Y X整除Y 双目 整数 2
X rem Y X除以Y的余数 双目 整数 2
X band Y XY的位与 双目 整数 2
X + Y X + Y 双目 混合 3
X - Y X - Y 双目 混合 3
X bor Y XY位或 双目 整数 3
X bxor Y XY的位算数异或 双目 整数 3
X bsl N X算数左移N 双目 整数 3
X bsr N X右移N 双目 整数 3

单目运算符有一个参数,双目运算符有两个参数。混合意味着参数即可以是integer 也可以是float。单目运算符的返回值与其参数类型相同。

双目混合运算符(即*-+)在参数都是integer时返回类型为integer的对象,在参数至少包含一个float时返回一个float。浮点除法运算符/总是返回一个float

双目整数运算符(即banddivremborbxorbslbsr)的参数必须是整数,其返回值也是整数。

求值顺序取决于运算符的优先级:首先计算第1优先级的运算符,然后是第2优先级,以此类推。括号内的表达式优先求值。

优先级相同的运算符从左到右进行求值。比如:

A - B - C - D

其求值顺序与下面的表达式一致:

(((A - B) - C) - D)

变量作用域

子句中变量的生存期从它首次被绑定处开始,到子句中对该变量的最后一个引用处结束。变量的绑定只会在模式匹配中发生;可以将之认作是一个变量产生过程。后续对变量的所有引用都是对变量的值的使用表达式中的变量必须是经过绑定的。变量第一次出现时就被用在表达式中是非法的。比如:

1
2
3
4
f(X) ->
    Y = g(X),
    h(Y, X),
    p(Y).

第1行中,定义了变量X(它在进入函数时被绑定)。第2行中,使用了X,定义了Y(首次出现)。第3行中,使用了XY,然后在第4行中使用了Y

ifcasereceive的作用域规则

ifcasereceive原语中引入的变量会被隐式导出到原语主体之外。比方我们有:

f(X) ->
    case g(X) of
        true -> A = h(X);
        false -> A = k(X)
end,
...

变量A在其被定义的case原语之后仍然有效。从ifcasereceive原语中导出变量时应注意一些规则:

ifcasereceive原语的不同分支中引入的变量集合必须相同,除非缺少的变量在原语外不再被引用。

例如以下代码:

f(X) ->
    case g(X) of
        true -> A = h(X), B = A + 7;
        false -> B = 6
    end,
h(A).

这段代码就是非法的。因为在对true分支求值时定义了变量AB,而在对false分支求值时只定义了B。在case原语之后,又在调用h(A)中引用了A——如果是fase分支被求值,则A尚未被定义。注意如果调用的不是h(A)而是h(B)则这段代码就是合法的,因为Bcase原语的两个分支中都有定义。

脚注

[1]附录A给出了Erlang的形式语法。
[2]许多人认为破坏性赋值会导致难以理解和易错的不清晰的程序。
[3]标记VarValue表示变量Var的值为Value
[4]假设所有函数调用都结束。
[5]即是说函数的与调用上下文无关。
[6]有时被称为垃圾箱。
[7]好吧,几乎是——想想看factorial(-1)
[8]如果不知道选哪个,选最漂亮的那个!