极客学院团队出品 · 更新于 2018-11-28 11:00:43

第 三 章 软件工程的原则

  • 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 努力降低模块间依赖性

如果模块需要调用很多不同模块中的函数,那么它就难以维护,相比之下,仅调用有限几个模块函数的模块能更轻松地得到维护。

这是因为,每次我们改变模块接口时,都要检查代码中所有调用该模块的位置。降低模块间的依赖性,可以使这些模块的维护变得简单。

减少给定模块所调用的不同模块数目,也可以简化系统结构。

同时也应注意,模块间调用依赖性结构最好呈现树状结构,而不要出现循环结构。例如下图所示的树状结构:

module-dep-ok

最好不要是这样的结构:

3.2_module_dep_bad

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 不是 normalall,函数就会崩溃,本该如此。调用者应负责提供正确的输入数据。

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