逻辑非常简单。然而,混淆很容易出现,因为:
- 导出不完全匹配面向对象的封装,尤其是公共方法;
- 几种常见的模式需要导出不打算由常规客户端调用的函数。
出口的真正作用
导出具有非常严格的含义:导出的函数是唯一可以通过其完全限定名称(即模块、函数名称和数量)引用的函数。
例如:
-module(m).
-export([f/0]).
f() -> foo.
f(_Arg) -> bar.
g() -> foobar.
您可以使用表达式调用第一个函数,例如,m:f()
但这不适用于其他两个函数。m:f(ok)
并将m:g()
因错误而失败。
出于这个原因,编译器会在上面的例子中警告 f/1 和 g/0 没有被调用,也不能被调用(它们是未使用的)。
函数总是可以从模块外部调用:函数是值,您可以引用本地函数(在模块内),并将此值传递给外部。例如,您可以使用非导出函数生成一个新进程,使用spawn/1
. 您可以按如下方式重写您的示例:
start() ->
spawn(fun loop/0).
这不需要导出循环。Joe Armstrong 在其他版本的Programming Erlang中明确建议按照上述方式转换代码以避免导出loop/0
.
需要导出的常见模式
因为导出是从模块外部通过名称引用函数的唯一方法,所以有两种常见模式需要导出函数,即使这些函数不是公共 API的一部分。
您提到的示例是每当您要调用采用 MFA 的库函数时,即模块、函数名称和参数列表。这些库函数将通过其完全限定名称引用该函数。除此之外spawn/3
,你可能会遇到timer:apply_after/4
。
同样,您可以编写接受 MFA 参数的函数,并使用apply/3
.
有时,这些库函数的变体直接采用 0 元函数值。如上所述,spawn 就是这种情况。apply/1 没有意义,因为您只需编写F()
.
另一种常见的情况是行为回调,尤其是OTP 行为。在这种情况下,您将需要导出当然是按名称引用的回调函数。
好的做法是为这些函数使用单独的导出属性,以明确这些函数不是模块常规接口的一部分。
导出和代码更改
在公共 API 之外使用导出还有第三种常见情况:代码更改。
想象一下你正在编写一个循环(例如一个服务器循环)。您通常会按如下方式实现:
-module(m).
-export([start/0]).
start() -> spawn(fun() -> loop(state) end).
loop(State) ->
NewState = receive ...
...
end,
loop(NewState). % not updatable !
此代码无法更新,因为循环永远不会退出模块。正确的方法是导出 loop/1 并执行完全限定的调用:
-module(m).
-export([start/0]).
-export([loop/1]).
start() -> spawn(fun() -> loop(state) end).
loop(State) ->
NewState = receive ...
...
end,
?MODULE:loop(NewState).
实际上,当您使用完全限定名称引用导出的函数时,总是针对最新版本的模块执行查找。所以这个技巧允许在循环的每次迭代中跳转到更新版本的代码。代码更新实际上相当复杂,而 OTP 及其行为适合您。它通常使用相同的构造。
相反,当您调用作为值传递的函数时,这始终来自创建该值的模块版本。乔·阿姆斯特朗(Joe Armstrong)在他的书的专门部分(8.10,MFA 的产生)中认为这是spawn/3
over的一个优势。spawn/1
他写:
我们编写的大多数程序都spawn(Fun)
用于创建新进程。如果我们不想动态升级我们的代码,这很好。有时我们想编写可以在运行时升级的代码。如果我们想确保我们的代码可以动态升级,那么我们必须使用不同形式的 spawn。
这是牵强的,因为当您生成一个新进程时,它会立即启动,并且在新进程开始和创建函数值之间不太可能发生更新。此外,Armstrong 的陈述部分不正确:为了确保代码可以动态升级,spawn/1
也能正常工作(参见上面的示例),诀窍不是使用spawn/3
,而是执行完全限定的调用(Joe Armstrong 在另一节中对此进行了描述)。spawn/3
有其他优势spawn/1
。
尽管如此,按值传递函数和按名称传递函数之间的区别解释了为什么没有按值传递函数的版本timer:apply_after/4
,因为存在延迟并且当计时器触发时按值传递函数可能是旧的。这种变体实际上是危险的,因为一个模块最多有两个版本:当前版本和旧版本。如果您多次重新加载一个模块,则尝试调用更旧版本代码的进程将被终止。因此,与函数值相比,您通常更喜欢 MFA 及其导出。