Erlang 编程规则
Erlang 是一种通用的面向并发的编程语言,它由瑞典电信设备制造商爱立信所辖的 CS-Lab 开发,目的是创造一种可以应对大规模并发活动的编程语言和运行环境
使用 Erlang 编程开发——编程规则及规范
概述
关于如何使用 Erlang 编写软件系统,本文档给出了一些编程规则及建议。
注意:本文档仅是一个草案,还未完成。
使用 EBC 的“基本系统”的必备条件没有记录在此。但如果将要用到“基本系统”,那么在最早期的设计阶段中,则必须遵循它们。这些必备条件都记录在 1/10268-AND 10406 Uen "MAP - Start and Error Recovery" 中。
第 一 章 文档目的
本文档列出了使用 Erlang 进行编程时应注意的一些方面,但并没有对 Erlang 之外的常见规范和设计行为进行完整描述。
第 二 章 结构和 Erlang 术语
Erlang 系统由各种 模块 (module)组成。模块则由 函数 (function)和 属性 (attribute)构成。函数或者只能在某个模块范围内中被调用,或者是可以 被导出 (exported)的函数,例如可以被其他模块中的其他函数所调用。属性名称必须带有 -
前缀,且放置于模块的开头。
在利用 Erlang 设计的系统中,任务是通过 进程 (process)来完成的。进程是一种能够在很多模块中使用函数的任务。进程间通过 传递消息 (sending message)来进行通信。进程不仅能够 接收 (receive)发送给它们的消息,而且还能确定准备接收何种消息。当接收进程还未准备好接收消息时,消息会自动进入排队等候状态。
进程能够通过创建一个链接来监控其他进程是否存在。当某一进程终止时,它会自动向其所链接的进程发送 退出信号 (exit signal)。默认情况下,当进程接收到退出信号时,就会终止并将该信号传播给它所链接的进程。通过 捕获退出信号 (trapping exits),进程可以改变这一默认行为,从而导致所有发送给一个进程的退出信号都转变成消息。
纯函数 (pure function):当参数相同时,函数返回值也相同,与函数调用上下文无关。这一特点与数学上的函数比较一致。不纯的函数,在 Erlang 中被称之为具有 副作用 。
副作用通常发生在以下这些情况中:函数(a)发送消息;(b)接收消息;(c)调用 exit
命令;(d)调用 BIF1 从而改变进程环境或操作模式(比如: get/1
, put/2
, erase/1
, process_flag/2
,等等)
1. Erlang 内建函数,Built-In Function 的缩写。
警告 :本文档中的一些范例代码效率并不理想,希望你能加以改进。
第 三 章 软件工程的原则
- 3.1 从模块中尽量少导出函数
- 3.2 努力降低模块间依赖性
- 3.3 将常用的代码放入库中
- 3.4 将“棘手的”或“脏乱的”代码分别放入不同的模块中
- 3.5 不要假设调用者会如何处理函数结果
- 3.6 将代码或行为的通用模式抽象出来
- 3.7 采用“由上至下”的编程方式
- 3.8 不要优化代码
- 3.9 遵循“惊讶最少”原则
- 3.10 终止副作用
- 3.11 不要“泄露”模块内的私有数据结构
- 3.12 尽量明确代码的行为
- 3.13 不要在编程中采取“防范”措施
- 3.14 利用设备驱动来隔离硬件接口
- 3.15 利用同一个函数来实现相反的两种行为
3.1 从模块中尽量少导出函数
模块是 Erlang 中的基本代码结构体。模块可以包含大量的函数,但只有模块导出列表中的函数才能从模块外部调用。
从模块外部来看,模块的复杂性跟模块可导出的函数数量有关。只导出一两个函数的模块通常要比那些能导出几十个函数的模块更易于人们理解。
对于使用者来说,可导出/非导出函数的比率较低的模块是比较易于接受的,因为他们只需理解模块可导出函数的功能即可。
另外,模块代码的作者或者维护人员还可以采取任何适当的方式,在保持外部接口不变的前提下改变模块的内部结构。
3.2 努力降低模块间依赖性
如果模块需要调用很多不同模块中的函数,那么它就难以维护,相比之下,仅调用有限几个模块函数的模块能更轻松地得到维护。
这是因为,每次我们改变模块接口时,都要检查代码中所有调用该模块的位置。降低模块间的依赖性,可以使这些模块的维护变得简单。
减少给定模块所调用的不同模块数目,也可以简化系统结构。
同时也应注意,模块间调用依赖性结构最好呈现树状结构,而不要出现循环结构。例如下图所示的树状结构:
最好不要是这样的结构:
3.3 将常用的代码放入库中
应将常用代码放入库中。库应该是相关函数的集合。应该努力确保库包含同样类型的函数。比如,若 lists
库只包含操纵列表的函数,那么这就是一种非常不错的设计;而如果 lists_and_maths
库中既含有操纵列表的函数,又含有用于数学运算的函数,那么就是一种非常糟糕的设计。
库函数应最好没有副作用。库中若包含带有副作用的函数,则会限制它的可重用性。
3.4 将“棘手的”或“脏乱的”代码分别放入不同的模块中
在解决某个问题时,往往需要结合使用整洁与脏乱的代码。最好将整洁的代码与脏乱代码分别放入单独的模块中。
脏乱代码是指那些做“脏活”的代码。比如说:
- 使用进程字典。
- 将
erlang:process_info/1
用于特殊目的。 - 做一些没想去做但又必须去做的事。
应该努力增加整洁代码,减少混乱代码。隔离混乱代码与清晰注释,或将代码中存在的所有副作用和问题记录下来。
3.5 不要假设调用者会如何处理函数结果
不要事先假设函数为何被调用,或者调用者希望如何处理结果。
例如,假设我们调用一个例程,它的某些参数可能是无效的。在实现该例程时,不需要知道当参数无效时,函数调用者会希望采用的行为。
因此我们不应该这样写函数:
do_something(Args) ->
case check_args(Args) of
ok ->
{ok, do_it(Args)};
{error, What} ->
String = format_the_error(What),
io:format("* error:~s\n", [String]), %% Don't do this
error
end.
而应该这样写函数:
do_something(Args) ->
case check_args(Args) of
ok ->
{ok, do_it(Args)};
{error, What} ->
{error, What}
end.
error_report({error, What}) ->
format_the_error(What).
在第一段代码中,错误字符串经常打印在标准输出中;而第二段代码则为程序返回一个错误描述符,程序可以决定如何处理错误描述符。
通过调用 error_report/1
,函数可以将错误描述转化为一个可输出的字符串并在需要时将其打印出来。但这可能并非是预期行为——无论如何,对结果的处理决策应由调用方来决定。
3.6 将代码或行为的通用模式抽象出来
如果在代码的两个或多个位置处出现了同样模式的代码,则最好将这种代码单独编写为一个常用的函数,然后通过调用该函数来解决问题,而不要让同样模式的代码散布在多个位置。维护复制的代码会需要付出更大的精力。
如果代码的两个或多个位置处具有相似模式的代码(比如,功能基本相同),那么就值得稍微研究一下,想一想是否不用怎么改变问题本身,就能使代码适用于不同的情况,然后还可以编写少量的额外代码来描述并应对不同情况之间的差别。
总之,尽量避免使用“复制”或“粘贴”来编程,要记得使用函数!
3.7 采用“由上至下”的编程方式
采用“由上至下”的方式来编写程序,而不要采用“由下到上”的方式(一开始就处理细节)。采用由上至下的方式,方便随后逐步实现细节,并能最终优化原始函数。代码将独立于表示形式之外,因为在设计较高层次的代码时,是不知道表示形式的。
3.8 不要优化代码
不要一开始就试图优化代码。首先要保证代码的正确性,而后(如果需要的情况下)再追求代码的执行效率(在保证正确性的前提下)。
3.9 遵循“惊讶最少”原则
系统的反应方式应该以让用户感到“惊讶最少”为宜,比如,当用户在执行一定行为时,应该能预知发生的结果,而不应该为实际结果而感到惊讶。
这一点跟一致性有关。在具有一致性的系统中,多个模块的执行方式应该保持一致,易于理解;而在有些不一致的系统中,每个模块都各行其是。
如果某个函数的执行方式让你感到惊讶,或者是该函数解决的是另一个问题,或者是函数名起错了。
3.10 终止副作用
Erlang 的有些原语具有一定的副作用。使用这些原语的函数将无法轻易地重用,因为这些原语会永久改变函数的环境,所以在调用这种例程前,要清楚了解进程的确切状态。
尽量利用无副作用的代码来编程。
尽量编写纯净的函数。
收集具有副作用的函数,清晰地注释它们的所有副作用。
只需稍加留心,绝大多数代码都可以用无副作用的方式来编写,从而使系统的维护、测试变得非常容易,其他人也更容易理解系统。
3.11 不要“泄露”模块内的私有数据结构
以下这个小例子会更容易阐述这一点。在下例中,为了实现队列,定义了一个叫做 queue
的小模块:
-module(queue).
-export([add/2, fetch/1]).
add(Item, Q) ->
lists:append(Q, [Item]).
fetch([H|T]) ->
{ok, H, T};
fetch([]) ->
empty.
上述代码将队列实现为列表的形式。不过遗憾的是,用户在使用该模块时必须知道队列已经被表现为列表形式。通常用到该模块的程序可能含有以下代码段:
NewQ = [], % 不要这样做
Queue1 = queue:add(joe, NewQ),
Queue2 = queue:add(mike, Queue1), ....
这很糟糕,因为用户(a)需要知道队列被表现为列表,而且(b)实现者无法改变队列的内部表现(从而使他们以后可能想编写一个更好的模块)。
所以,最好像下面这样:
-module(queue).
-export([new/0, add/2, fetch/1]).
new() ->
[].
add(Item, Q) ->
lists:append(Q, [Item]).
fetch([H|T]) ->
{ok, H, T};
fetch([]) ->
empty.
现在,我们就能像下面这样来调用该模块了:
NewQ = queue:new(),
Queue1 = queue:add(joe, NewQ),
Queue2 = queue:add(mike, Queue1), ...
这样做,不仅改正了前面谈到的问题,而且效率更好。假设用户想知道队列长度,那么他们很可能会忍不住像下面这样来调用模块:
Len = length(Queue) % 不要这样做
因为他们知道队列被表现为列表的形式。所以再次说明,这是一种非常丑陋的编程实践,会让代码变得难以维护和理解。如果用户想知道队列长度,那就必须给模块加入一个长度函数,如下所示:
-module(queue).
-export([new/0, add/2, fetch/1, len/1]).
new() -> [].
add(Item, Q) ->
lists:append(Q, [Item]).
fetch([H|T]) ->
{ok, H, T};
fetch([]) ->
empty.
len(Q) ->
length(Q).
现在用户可以安全地调用 queue:len(Queue)
了。
现在我们可以认为已经将队列的所有细节都抽象出来了(队列实际上被称为“抽象数据结构”)。
那我们还干嘛那么麻烦?通过对实现的内部细节予以抽象处理这条编程实践,对于那些会调用改变模块中函数的模块,我们完全可以在不改变它们代码的前提下改变实现。因此,关于队列这个例子,还有一个更好的实现方式,如下所示:
-module(queue).
-export([new/0, add/2, fetch/1, len/1]).
new() ->
{[],[]}.
add(Item, {X,Y}) -> % 加速元素的添加
{[Item|X], Y}.
fetch({X, [H|T]}) ->
{ok, H, {X,T}};
fetch({[], []) ->
empty;
fetch({X, []) ->
% 只在有时才执行这种复杂繁重的运算
fetch({[],lists:reverse(X)}).
len({X,Y}) ->
length(X) + length(Y).
3.12 尽量明确代码的行为
确定性程序(deterministic program)指的是,不管运行多少次,行为都能保持一致的程序。非确定性程序有时会产生不同的运行结果。从调试的角度来看,也应尽量保持程序的确定性,因为错误可以重现出来,有助于调试。
例如,某个进程必须开启 5 个并行的进程,然后检查这些进程是否正确开启。另外,无需考虑这 5 个进程开启的顺序。
我们当然可以并行开启 5 个进程,然后检查它们是否正确开启。但是,最好能同时开启它们,然后再检查某一进程是否能在下一进程开启之前正确开启。
3.13 不要在编程中采取“防范”措施
防范型程序是指那种开发者不信任输入到系统中的数据的程序。总之,开发人员不应该测试函数输入数据的正确性。系统中的绝大多数代码应该信任输入数据。只有少量的一部分代码才应该执行数据检查,而这通常是发生在数据首次被输入到“系统”中的时候,一旦数据进入系统,就应该认定该数据是正确的。
比如:
%% Args: Option is all|normal
get_server_usage_info(Option, AsciiPid) ->
Pid = list_to_pid(AsciiPid),
case Option of
all -> get_all_info(Pid);
normal -> get_normal_info(Pid)
end.
如果 Option
不是 normal
或 all
,函数就会崩溃,本该如此。调用者应负责提供正确的输入数据。
3.14 利用设备驱动来隔离硬件接口
应该通过使用设备驱动将硬件从系统中隔离出来。设备驱动应该实现硬件接口,使得硬件看起来像是 Erlang 的进程。应让硬件的外在特征和行为像是普通的 Erlang 进程。硬件应该能够接受并发送普通的 Erlang 消息,并在出现错误时采用通常可理解的方式予以回应。
3.15 利用同一个函数来实现相反的两种行为
假设有一个程序,功能是打开文件,对文件执行一些操作,以及关闭文件。编码如下:
do_something_with(File) ->
case file:open(File, read) of,
{ok, Stream} ->
doit(Stream),
file:close(Stream) % The correct solution
Error -> Error
end.
请注意在同一个例程中,打开文件( file:open
)与关闭文件( file:close
)的对称性。下面的解决方案就比较难以实行,让人搞不懂究竟关闭哪个文件。所以不要像这样编程。
do_something_with(File) ->
case file:open(File, read) of,
{ok, Stream} ->
doit(Stream)
Error -> Error
end.
doit(Stream) ->
....,
func234(...,Stream,...).
...
func234(..., Stream, ...) ->
...,
file:close(Stream) %% Don't do this
第 4 章 错误处理机制
- 4.1 隔离通常情况处理代码与错误处理代码
- 4.2 确定错误内核
4.1 隔离通常情况处理代码与错误处理代码
不要将处理“通常情况”的代码和处理异常的代码混杂在一起。尽量只编写处理通常情况的代码。如果通常情况的处理代码失效,进程应该报告错误,并尽可能快地崩溃。不要试图修复错误以继续运行进程。错误应该在另外一个进程中进行处理(参看 5.5 节内容)
将错误恢复代码和通常情况处理代码予以清晰的隔离,可以极大简化系统设计。
软硬件出错时生成的错误日志将用于后续阶段对错误的诊断和纠正。应该永久地保留该进程中的有益信息。
4.2 确定错误内核
系统设计的一个基本要素是确定系统中必须正确与不必正确的部分。
在传统的操作系统设计中,系统内核被假设为(而且被认为必须)是正确的,然而,用户应用程序没有必要都完全正确。如果用户应用程序失败,那么应该只会涉及到相应应用,不应该影响到系统的总体完整性。
在系统设计的第一阶段中,必须要搞清楚必须正确的部分。这一部分被称之为 错误内核 (error kernel)。通常,错误内核都有一些实时的内存驻留数据库,用来保存硬件状态。
第 5 章 进程、服务及消息
5.1 在一个模块中实现进程
实现单一进程的代码应该包含在一个模块中。进程可以调用任何库例程中的函数,但是进程的“顶层循环”代码应该包含在单独的一个模块中。进程顶层循环的代码不能分散在几个模块中——这样做会使控制流程复杂化,变得极难理解。这并不意味着不应该使用通用服务库,这些库有助于构建控制流。
相反,应该用单独的一个模块实现一种(不能再多了)进程。含有不同进程代码的模块会变得非常难于理解。每个独立进程的代码都应位于各自独立的一个模块中。
5.2 使用进程来构建系统
进程是基本的系统构建元素。但当可以使用函数调用时,就不要再使用进程和消息传递机制了。
5.3 注册进程
注册进程的注册名必须和模块名保持相同,从而易于查找进程代码。
只有注册进程才应该留存较长时间。
5.4 将一个并行进程赋予系统中的每个真正的并发行为
在确定是否使用顺序进程或并行进程来实现时,考虑问题的本质结构无疑能使结论变得清晰。主要原则如下:
“使用一个并行进程来对真实案例中的每个真正并发行为进行建模。”
如果在实际案例中,并行处理器与真正并行的行为之间能够建立起一对一的数量映射关系,程序就将变得易于理解。
5.5 Each process should only have one "role" 每个进程都应该只担当一个“角色”
进程扮演着系统的不同角色,下面以客户端-服务器模型为例。
一个进程应该尽量只担当一个角色,比如,它可以是服务器,也可以是客户端,但不能将两者混合起来。
Other roles which process might have are:
进程可能具有的其他角色包括:
Supervisor(监督者):查看其他进程,如果它们失败,则负责重启这些进程。
Worker(工作者):一种常见的工作进程(有可能会出现错误)。
Trusted Worker(可信工作者):不允许出现错误。
5.6 对于服务器和协议处理器,无论在什么情况下,都要尽量使用通用函数
在很多情况下,使用通用服务器程序都是一种非常好的方案,比如用标准库实现的 generic
服务器。使用通用服务器会极大简化整体的系统结构。
这一点也适用于系统中绝大多数协议处理软件。
5.7 Tag messages
所有的消息都应该加上标记。这样能使接收语句的顺序变得不那么重要,新消息的实现也容易了很多。
不要这样编程:
loop(State) ->
receive
...
{Mod, Funcs, Args} -> % Don't do this
apply(Mod, Funcs, Args},
loop(State);
...
end.
新消息 {get_status_info, From, Option}
如果被放在 {Mod, Func, Args}
消息后面,就会引发冲突。
如果消息同步,返回的消息将用一个新的原子进行标记,目的是为了标记这是返回消息。例如:假如传入消息的标记为 get_status_info
,则返回消息标记为 status_info
。另外,方便调试也是选择不同标记的一个理由。
下面这个方法就很不错:
loop(State) ->
receive
...
{execute, Mod, Funcs, Args} -> % Use a tagged message.
apply(Mod, Funcs, Args},
loop(State);
{get_status_info, From, Option} ->
From ! {status_info, get_status_info(Option, State)},
loop(State);
...
end.
5.8 清空未知消息
每个服务器都应该在至少一个 receive
语句中保存一个 Other
的替代方案,这能避免消息队列堵塞。范例如下:
main_loop() ->
receive
{msg1, Msg1} ->
...,
main_loop();
{msg2, Msg2} ->
...,
main_loop();
Other -> % 清空消息队列
error_logger:error_msg(
"Error: Process ~w got unknown msg ~w~n.",
[self(), Other]),
main_loop()
end.
5.9 编写尾部递归的服务器
所有的服务器 必须 实现尾部递归,否则服务器就将不断消耗系统内存,直至用光它们。
不要像这样编程:
loop() ->
receive
{msg1, Msg1} ->
...,
loop();
stop ->
true;
Other ->
error_logger:log({error, {process_got_other, self(), Other}}),
loop()
end,
io:format("Server going down"). % 不要这么做
% This is NOT tail-recursive
下面的方法才是正确的:
loop() ->
receive
{msg1, Msg1} ->
...,
loop();
stop ->
io:format("Server going down");
Other ->
error_logger:log({error, {process_got_other, self(), Other}}),
loop()
end. % This is tail-recursive
如果使用一些服务器库,比如说 generic
,就不会犯下这种错误。
5.10 接口函数
尽量在接口中利用函数,而不要直接发送消息,并且要封装传入接口函数的消息。这其中有几种例外情况。
消息协议是内部消息,对其他模块来说,它应该是不透明的。
下面是接口函数的一个范例:
-module(fileserver).
-export([start/0, stop/0, open_file/1, ...]).
open_file(FileName) ->
fileserver ! {open_file_request, FileName},
receive
{open_file_response, Result} -> Result
end.
...<code>...
5.11 超时
Be careful when using after in receive statements. Make sure that you handle the case when the message arrives later (See "Flush unknown messages" on page 16.).
receive
语句中使用 after
时要格外小心。一定要确保当消息到达时再处理案例(参看 5.8 节内容)。
5.12 捕获退出
尽量减少捕获退出信号的进程数目。进程要么捕获退出,要么就根本不捕获。在实际编码时,让进程对是否捕获退出进行“切换”,是非常糟糕的一种实践。
6 几种 Erlang 的特殊惯例
6.1 用记录作为主要的数据结构
把记录作为主要的数据结构。记录是 Erlang 4.3 时才引入的一种带标记的元组(参看 EPK/NP 95:034)。它类似于 C 语言中的 struct
或 Pascal 中的 record
。
如果记录将用于多个模块,它的定义应放在这些模块所包括的一个头文件中(带有 .hrl
后缀)。如果记录只用在一个模块中,则它的定义应位于定义模块的文件的最前面位置处。
Erlang 的记录特性可以保证数据结构跨模块的一致性,因此在模块间传递数据结构时,接口函数应使用记录。
6.2 使用选择器和构建函数
使用记录特性所提供的选择器(selector)和构造函数(constructor)来管理记录实例。不要使用明显假定记录是一个元组的匹配。范例如下:
demo() ->
P = #person{name = "Joe", age = 29},
#person{name = Name1} = P,% 使用匹配,或者......
Name2 = P#person.name. % 像这样使用 selector
不要像下面这样编程:
demo() ->
P = #person{name = "Joe", age = 29},
{person, Name, _Age, _Phone, _Misc} = P. % 不要这样做
6.3 使用带标记的返回值
使用带标记的返回值。
不要像下面这样编程:
keysearch(Key, [{Key, Value}|_Tail]) ->
Value; %% Don't return untagged values!
keysearch(Key, [{_WrongKey, _WrongValue} | Tail]) ->
keysearch(Key, Tail);
keysearch(Key, []) ->
false.
{Key, Value}
不含有 false 值。下面是正确的方法。
keysearch(Key, [{Key, Value}|_Tail]) ->
{value, Value}; %% Correct. Return a tagged value.
keysearch(Key, [{_WrongKey, _WrongValue} | Tail]) ->
keysearch(Key, Tail);
keysearch(Key, []) ->
false.
6.4 慎重使用 catch
和 throw
千万不要在搞不清用法的情况下使用 catch
和 throw
!要尽可能少地使用它们。
复杂与非信任输入(从外部环境中所输入的内容,而非来自于可信的程序时)可能会导致代码很多深层次位置的错误,当程序处理这些类型的输入时, catch
与 throw
就显得非常有用了。关于这一点,典型的例子就是编译器。
6.5 慎重使用进程字典
千万不要在搞不清用法的情况下使用 get
和 put
等函数!尽量少用它们。
引入一个新的参数,就可以重写使用进程字典的函数。
范例如下:
不要像下面这样编程:
tokenize([H|T]) ->
...;
tokenize([]) ->
case get_characters_from_device(get(device)) of % Don't use get/1!
eof -> [];
{value, Chars} ->
tokenize(Chars)
end.
正确的方法是:
tokenize(_Device, [H|T]) ->
...;
tokenize(Device, []) ->
case get_characters_from_device(Device) of % This is better
eof -> [];
{value, Chars} ->
tokenize(Device, Chars)
end.
使用 get
和 put
会容易导致,在同样输入下函数的结果却随情况不同而改变。这使得代码难以阅读,因为代码已经失去了确定性。调试也变得很复杂,因为使用 get
和 put
的函数不仅是自身参数的函数,而且还是进程字典的函数。Erlang 中的很多运行时错误(比如 bad_match
)就包括函数的参数,但从来不包括进程字典。
6.6 不要使用导入
不要使用 -import
,使用它会让代码变得难以阅读,这是因为无法弄清函数定义所在的模块。使用 exref
(交叉引用工具)查找模块依赖。
6.7 导出函数
一定要搞清楚某个函数之所以导出的原因。这种原因可能包括以下几种:
- 该函数是模块的用户接口;
- 该函数是面向其他模块的接口函数;
- 该函数从
apply
、spawn
调用,但只限于本模块内部调用。
使用不同的 -export
进行分组,并相应对它们进行注释。范例如下:
%% 用户接口
-export([help/0, start/0, stop/0, info/1]).
%% 模块间导出
-export([make_pid/1, make_pid/3]).
-export([process_abbrevs/0, print_info/5]).
%% 导出,只限于模块内部使用
-export([init/1, info_log_impl/1]).
7 特殊的语法规范
7.1 代码不要深度嵌套
嵌套代码表现为在 case/if/receive
语句中又包含着 case/if/receive
语句。这种深度嵌套的编程风格非常糟糕,代码很容易跨越一个屏幕页面,变得难以阅读。应该把大多数代码限定到最多两级缩进的程度。将代码分割为较短的函数可以解决这个需求。
7.2 模块不宜过大
超过 400 行代码的模块实在是太大了。最好用一系列小模块来代替大型模块。
7.3 函数不宜过长
不要让函数代码超过 15 到 20 行。应将大型函数分割为一些小型函数,也不要写把代码行写得过长。
7.4 每一行代码不要写得过长
过长的代码行是不可取的。每行代码最多不宜超过 80 个字符(一张 A4 纸就能容纳得下)。
自 Erlang 4.3 起,字符串常量可以自动连接。比如:
io:format("Name: ~s, Age: ~w, Phone: ~w ~n"
"Dictionary: ~w.~n", [Name, Age, Phone, Dict])
7.5 变量的取名
选择有意义的变量名——尽管这一点很难办到。
如果变量名包含一些单词,使用 _
或某个大写字母来分隔它们。比如: My_variable
或 MyVariable
。
对于不会用到的变量(don't care variable),则不要在变量名 中间 使用 _
,而应在变量名 前 加上 _
。比如说 _Name
。如果将来需要该变量值,则只需去掉变量名前面的下划线即可。这样就不会有确定应替换具体哪个下划线的麻烦,而且代码也易于阅读。
7.6 函数的取名
函数名必须要和函数的功能相符。函数名应能反映出函数所返回的参数类型,不能让阅读代码的人感到诧异。另外,对于常见的函数,应采用常见的名称(比如 start
、 stop
、 init
, main_loop
等)。
对于不同模块中的用来解决相同问题的函数,其函数名应相同。比如, Module:module_info()
。
糟糕的函数名是最常见的编程错误之一,从而也反映出取一个合适的名称是多么难!
在编写大量不同的函数时,有些命名规范非常有用。比如,名称前缀 is_
用来标识函数返回原子 true 或 false。
is_...() -> true | false
check_...() -> {ok, ...} | {error, ...}
7.7 模块的取名
Erlang 具有扁平的模块结构(不会出现模块嵌套的情况)。然而,我们往往希望能够模拟层级式模块结构的效果。通过带有相同模块前缀的相关模块集合可以实现这种结构。
比如,假设有一个 ISDN 处理程序,它是通过 5 个不同且相关的模块实现的,这些模块应该按照如下方式来取名:
isdn_init
isdn_partb
isdn_...
7.8 编程风格要一致
编程风格应该保持一致,这样有助于你和其他人日后理解这些代码。不同的开发人员都有不同编程风格,比如代码缩进、空格的使用,等等。
比如,在写元组时,你可能会用一个单独的逗号来分隔两个元素:
{12,23,45}
而其他人可能会在逗号后面加上一个空格:
{12, 23, 45}
总之,一旦你认准了一种风格,就要坚持使用它。
在一个较大的项目中,所有代码部分的编程风格应保持统一。
8 文档
8.1 标注代码的属性
必须在模块头部正确标注代码的属性。声明模块的所有思路来源。如果代码来源于其他代码,要注明来源及作者。
Never steal code - stealing code is taking code from some other module editing it and forgetting to say who wrote the original.
永远不要偷窃代码(从别的模块剽窃得来而又不注明原作者)。
以下是一些有用的属性范例:
-revision('Revision: 1.14 ').
-created('Date: 1995/01/01 11:21:11 ').
-created_by('eklas@erlang').
-modified('Date: 1995/01/05 13:04:07 ').
-modified_by('mbj@erlang').
8.2 要在代码中提供关于规范的引用
在代码中提供一些指向用于理解代码的文档的交叉引用。比如,如果代码实现了一些通信协议或硬件接口,则要提供用于编写代码的文档所在的页码,或者是一份确切的引用。
8.3 记录所有错误
必须将所有的错误保存在一份独立的文档中,并记录下错误的含义和原因(参见 10.4 节内容)。
这里所说的错误是系统已经检测出来的错误。
有时,可能会在程序中用错误日志记录函数来侦测逻辑错误:
error_logger:error_msg(Format, {Descriptor, Arg1, Arg2, ....})
这时要确保将 {Descriptor, Arg1,...}
添加到错误消息文档中。
8.4 将消息中的首要数据结构记录下来
在系统不同部分间传递消息时,使用标记元组作为首要数据结构。
Erlang 的记录特性(自 Erlang 4.3 版起引入)可用于确保模块数据结构的一致性。
应该记录下数据结构的文本描述(参见 10.2 节内容)。
8.5 注释
注释应该清晰准确,避免不必要的废话,而且要保证注释与代码保持一致更新。注释可以增进对代码的理解。注释应该用文本来编写。
关于模块的注释不应有缩进,开始前应带有三个百分号字符( %%%
),参见 8.10 节内容。
关于函数的注释不应带有缩进,开始前应带有 2 个百分号字符( %%
),参见 8.6 节内容。
Erlang 代码中的注释应带有一个百分号字符( %
)。如果一个代码行只包含一个注释,它会被认定为 Erlang 代码而进行缩进。这种注释应放置在它所针对的语句前。如果注释可以和语句放在同一行中,则优先考虑这种方式。
%% 关于函数的注释
some_useful_functions(UsefulArgugument) ->
another_functions(UsefulArgugument), % 代码行尾的注释
% 关于 complicated_stmnt 的注释,缩进级别等同
complicated_stmnt,
......
8.6 注释每一个函数
文档应重点记录的信息如下所示:
- 函数的用意;
- 函数有效输入范围。具体来说,即是函数参数的数据结构及其含义。
- 函数输出范围。具体是指返回值所有可能的数据结构及其具体含义。
- 如果函数实现了一个复杂的算法,则要对它进行描述。
- 可能由
exit/1
,throw/1
或任何不明显的运行时错误所产生的失败和退出信号的可能原因。 - 函数可能导出的任何副作用。
范例:
%%----------------------------------------------------------------------
%% Function: get_server_statistics/2
%% Purpose: Get various information from a process.
%% Args: Option is normal|all.
%% Returns: A list of {Key, Value}
%% or {error, Reason} (if the process is dead)
%%----------------------------------------------------------------------
get_server_statistics(Option, Pid) when pid(Pid) ->
......
8.7 数据结构
定义记录时,应该加入简明的文字描述。范例如下:
%% File: my_data_structures.h
%%---------------------------------------------------------------------
%% Data Type: person
%% where:
%% name: A string (default is undefined).
%% age: An integer (default is undefined).
%% phone: A list of integers (default is []).
%% dict: A dictionary containing various information about the person.
%% A {Key, Value} list (default is the empty list).
%%----------------------------------------------------------------------
-record(person, {name, age, phone = [], dict = []}).
8.8 文件头部,版权声明
每个源代码文件必须都要写明版权信息。示例如下:
%%%---------------------------------------------------------------------
%%% Copyright Ericsson Telecom AB 1996
%%%
%%% All rights reserved. No part of this computer programs(s) may be
%%% used, reproduced,stored in any retrieval system, or transmitted,
%%% in any form or by any means, electronic, mechanical, photocopying,
%%% recording, or otherwise without prior written permission of
%%% Ericsson Telecom AB.
%%%---------------------------------------------------------------------
8.9 File headers, revision history 文件头部,修订历史
每个源代码文件都应记录下代码的修订历史,其中要清晰地标注上修改文件的人及其修改内容。
%%%---------------------------------------------------------------------
%%% Revision History
%%%---------------------------------------------------------------------
%%% Rev PA1 Date 960230 Author Fred Bloggs (ETXXXXX)
%%% Intitial pre release. Functions for adding and deleting foobars
%%% are incomplete
%%%---------------------------------------------------------------------
%%% Rev A Date 960230 Author Johanna Johansson (ETXYYY)
%%% Added functions for adding and deleting foobars and changed
%%% data structures of foobars to allow for the needs of the Baz
%%% signalling system
%%%---------------------------------------------------------------------
8.10 文件头部,描述信息
每个源代码文件头部必须带有一个关于该文件所包含模块的简单描述信息,以及所有导出函数的简要概述。
%%%---------------------------------------------------------------------
%%% Description module foobar_data_manipulation
%%%---------------------------------------------------------------------
%%% Foobars are the basic elements in the Baz signalling system. The
%%% functions below are for manipulating that data of foobars and for
%%% etc etc etc
%%%---------------------------------------------------------------------
%%% Exports
%%%---------------------------------------------------------------------
%%% create_foobar(Parent, Type)
%%% returns a new foobar object
%%% etc etc etc
%%%---------------------------------------------------------------------
如果存在任何缺陷、Bug,或者重点测试的特性,则要将它们记录在一个特殊的注释里,不要隐藏这些信息。如果模块中的某些部分还未完成,则要添加一个特殊的注释。 另外,为了便于将来对模块进行维护,可以把任何相关信息加以注释记录。假设已完成的产品中包含你所编写的模块,你应保证在十年内,其他人(有可能你根本不认识)仍能变更或改善它们。
8.11 对于陈旧代码,不要注释掉,直接移除它们
关于陈旧代码的用意,可以在修订历史中加入一条注释。别忘了,源代码控制系统能帮你。
8.12 使用源代码控制系统
所有非项目都必须使用源代码控制系统(如 RCS、CVS 或 Clearcase),他们能保存所有模块的更改信息。
9 最常见的错误
- 函数跨页太多(参见 7.3 节)。
- 函数带有深度嵌套的
if
、receive
、case
等语句(参见 7.1 节)。 - 糟糕的函数类型(参见 6.3 节)
- 函数名不能反映函数的用途(参见 7.6 节)
- 变量名无意义(参见 7.5 节)
- 滥用进程。(参见 5.4 节)
- 选错数据结构(从而造成糟糕的表现形式)。
- 糟糕的注释(往往表现为注释参数和返回值)或根本没有注释。
- 代码根本不缩进。
- 滥用
put
或get
(参见 6.5 节)。 - 无法控制消息队列(参见 5.8 节和 5.11 节)。
10 必备文档
本节内容描述了一些系统级文档,它们对于设计并维护 Erlang 编写的系统来说都是很关键的。
10.1 模块描述
One chapter per module. Contains description of each module, and all exported functions as follows:
每个函数都有的描述,其中包含了每个模块以及所有的导出函数的描述信息,如下所示:
- 函数参数的含义和数据结构。
- 返回值的含义和数据结构。
- 函数的功能。
- 有关失败以及可能由于显式调用
exit/1
所产生的退出信号的可能原因。
文档格式的定义稍后详述。
10.2 消息描述
除了定义在模块中的消息之外,所有的进程间消息的格式。
文档格式的定义稍后详述。
10.3 进程
有关系统中所有注册服务器以及它们的接口和用途的描述。
动态进程及其接口的描述。
文档格式的定义稍后详述。
10.4 错误消息
有关错误消息的描述。
文档格式的定义稍后详述。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论