查看源代码 常见注意事项

本节列出了一些需要注意的构造。

运算符 ++

++ 运算符会复制其左侧的操作数。如果我们用 Erlang 实现自己的版本,这一点会很明显

my_plus_plus([H|T], Tail) ->
    [H|my_plus_plus(T, Tail)];
my_plus_plus([], Tail) ->
    Tail.

我们必须小心在循环中如何使用 ++。首先是不应该如何使用它

不要这样做

naive_reverse([H|T]) ->
    naive_reverse(T) ++ [H];
naive_reverse([]) ->
    [].

由于 ++ 运算符会复制其左侧的操作数,因此不断增长的结果会被重复复制,导致二次复杂度。

另一方面,像这样在循环中使用 ++ 是完全可以的

可以这样做

naive_but_ok_reverse(List) ->
    naive_but_ok_reverse(List, []).

naive_but_ok_reverse([H|T], Acc) ->
    naive_but_ok_reverse(T, [H] ++ Acc);
naive_but_ok_reverse([], Acc) ->
    Acc.

每个列表元素只复制一次。不断增长的结果 Acc 是右侧的操作数,它不会被复制。

有经验的 Erlang 程序员可能会这样写

这样做

vanilla_reverse([H|T], Acc) ->
    vanilla_reverse(T, [H|Acc]);
vanilla_reverse([], Acc) ->
    Acc.

原则上,这稍微更有效率,因为列表元素 [H] 在被复制和丢弃之前不会被构建。实际上,编译器会将 [H] ++ Acc 重写为 [H|Acc]

定时器模块

使用 erlang:send_after/3erlang:start_timer/3 创建定时器比使用 STDLIB 中的 timer 模块提供的定时器更有效率。

timer 模块使用单独的进程来管理定时器。在 Erlang/OTP 25 之前,这种管理开销很大,并且随着定时器数量的增加而增加,尤其是在它们是短期的时,因此定时器服务器进程很容易变得过载且无响应。在 Erlang/OTP 25 中,定时器模块得到了改进,消除了大部分管理开销和由此产生的性能损失。尽管如此,定时器服务器仍然是一个单进程,它在某些时候可能会成为应用程序的瓶颈。

timer 模块中不管理定时器的函数(例如 timer:tc/3timer:sleep/1)不会调用定时器服务器进程,因此是无害的。

意外复制和共享丢失

当使用 fun 生成新进程时,可能会意外地将比预期更多的数据复制到该进程。例如

不要这样做

accidental1(State) ->
    spawn(fun() ->
                  io:format("~p\n", [State#state.info])
          end).

fun 中的代码将从记录中提取一个元素并打印它。记录的其余部分 state 没有被使用。但是,当执行 spawn/1 函数时,整个记录会被复制到新创建的进程中。

使用 map 时也会发生同样的问题

不要这样做

accidental2(State) ->
    spawn(fun() ->
                  io:format("~p\n", [map_get(info, State)])
          end).

在以下示例中(实现 gen_server 行为的模块的一部分),创建的 fun 被发送到另一个进程

不要这样做

handle_call(give_me_a_fun, _From, State) ->
    Fun = fun() -> State#state.size =:= 42 end,
    {reply, Fun, State}.

这种不必要的复制有多糟糕取决于记录或 map 的内容。

例如,如果 state 记录像这样初始化

init1() ->
    #state{data=lists:seq(1, 10000)}.

一个包含 10000 个元素的列表(或大约 20000 个堆字)将被复制到新创建的进程中。

不必要地复制 10000 个元素的列表已经够糟糕了,但如果 state 记录包含*共享子项*,情况可能会更糟。这是一个带有共享子项的简单示例

{SubTerm, SubTerm}

当一个 term 被复制到另一个进程时,子项的共享将丢失,复制后的 term 可能比原始 term 大很多倍。例如

init2() ->
    SharedSubTerms = lists:foldl(fun(_, A) -> [A|A] end, [0], lists:seq(1, 15)),
    #state{data=Shared}.

在调用 init2/0 的进程中,state 记录中 data 字段的大小将为 32 个堆字。当记录被复制到新创建的进程时,共享将丢失,复制后的 data 字段的大小将为 131070 个堆字。有关共享丢失的更多详细信息,请参阅后面的部分。

为了避免这个问题,在 fun 之外仅提取实际使用的记录字段

这样做

fixed_accidental1(State) ->
    Info = State#state.info,
    spawn(fun() ->
                  io:format("~p\n", [Info])
          end).

类似地,在 fun 之外仅提取实际使用的 map 元素

这样做

fixed_accidental2(State) ->
    Info = map_get(info, State),
    spawn(fun() ->
                  io:format("~p\n", [Info])
          end).

list_to_atom/1

原子不会被垃圾回收。一旦创建了一个原子,它就永远不会被删除。如果达到原子数量的限制(默认情况下为 1,048,576),则模拟器会终止。

因此,在连续运行的系统中,将任意输入字符串转换为原子可能是危险的。如果只允许某些定义明确的原子作为输入,则可以使用 list_to_existing_atom/1binary_to_existing_atom/1 来防止拒绝服务攻击。(所有允许的原子必须事先创建,例如,通过在模块中使用所有原子并加载该模块。)

使用 list_to_atom/1 构建传递给 apply/3 的原子非常昂贵。

不要这样做

apply(list_to_atom("some_prefix"++Var), foo, Args)

length/1

计算列表长度的时间与列表长度成正比,而不是 tuple_size/1byte_size/1bit_size/1,它们都在恒定时间内执行。

通常,无需担心 length/1 的速度,因为它是在 C 中高效实现的。在时间紧迫的代码中,如果输入列表可能非常长,您可能需要避免使用它。

length/1 的某些用法可以用匹配替换。例如,以下代码

foo(L) when length(L) >= 3 ->
    ...

可以重写为

foo([_,_,_|_]=L) ->
   ...

一个细微的差别是,如果 L 是一个不正确的列表,则 length(L) 将失败,而第二个代码片段中的模式接受不正确的列表。

setelement/3

setelement/3 会复制它修改的元组。因此,在循环中使用 setelement/3 更新元组每次都会创建元组的新副本。

元组被复制的规则有一个例外。如果编译器清楚地看到破坏性地更新元组会给出与复制元组相同的结果,则对 setelement/3 的调用将替换为特殊的破坏性 setelement 指令。在以下代码序列中,第一个 setelement/3 调用会复制元组并修改第九个元素

multiple_setelement(T0) when tuple_size(T0) =:= 9 ->
    T1 = setelement(9, T0, bar),
    T2 = setelement(7, T1, foobar),
    setelement(5, T2, new_value).

以下两个 setelement/3 调用会就地修改元组。

要应用优化,所有以下条件必须为真

  • 已知元组参数是具有已知大小的元组。
  • 索引必须是整数字面量,而不是变量或表达式。
  • 索引必须按降序给出。
  • 在调用 setelement/3 之间不得有对另一个函数的调用。
  • 从一个 setelement/3 调用返回的元组必须仅在后续的 setelement/3 调用中使用。

如果代码不能像 multiple_setelement/1 示例中那样构造,则修改大型元组中多个元素的最佳方法是将元组转换为列表,修改列表,然后再将其转换回元组。

size/1

size/1 返回元组和二进制文件的大小。

使用 BIF tuple_size/1byte_size/1 为编译器和运行时系统提供了更多优化机会。另一个优点是这些 BIF 为 Dialyzer 提供了更多类型信息。

使用 NIF

将 Erlang 代码重写为 NIF 以使其更快应被视为最后的手段。

在每个 NIF 调用中执行过多的工作会降低 VM 的响应能力。执行过少的工作可能意味着 NIF 中更快处理的收益被调用 NIF 和检查参数的开销所抵消。

在编写 NIF 之前,请务必阅读有关长时间运行的 NIF 的内容。