模块

什么是模块

A box with functions written on it

使用交互式 shell 通常被认为是使用动态编程语言的重要组成部分。它有助于测试各种代码和程序。Erlang 的大多数基本数据类型在使用时甚至不需要打开文本编辑器或保存文件。你可以放下键盘,出去玩球,然后说声再见,但如果你就此止步,你将成为一个糟糕的 Erlang 程序员。代码需要保存在某个地方才能使用!

这就是模块的用处。模块是一组函数,它们被归类在一个文件中,并使用一个名称。此外,Erlang 中的所有函数都必须在模块中定义。你可能已经在不知不觉中使用过模块。在上一章中提到的 BIF,如 hdtl,实际上属于 erlang 模块,所有算术、逻辑和布尔运算符也是如此。erlang 模块中的 BIF 与其他函数不同,因为它们在使用 Erlang 时会自动导入。你将使用到的任何其他模块中定义的函数,都需要以 Module:Function(Arguments) 的形式调用。

你可以自己看看

1> erlang:element(2, {a,b,c}).
b
2> element(2, {a,b,c}).
b
3> lists:seq(1,4).
[1,2,3,4]
4> seq(1,4).
** exception error: undefined shell command seq/2

在这里,seq 函数来自列表模块,没有自动导入,而 element 则自动导入了。错误“未定义的 shell 命令”来自于 shell 寻找类似于 f() 的 shell 命令,但找不到。erlang 模块中有一些函数没有自动导入,但它们并不常用。

从逻辑上讲,你应该将关于类似事物的函数放在同一个模块中。列表的常用操作保存在 lists 模块中,而用于进行输入和输出(例如写入终端或文件)的函数则被归类在 io 模块中。你将遇到的唯一不遵守这种模式的模块就是前面提到的 erlang 模块,它包含用于进行数学运算、转换、处理多进程、修改虚拟机设置等的函数。它们除了是内置函数之外,没有任何共同点。你应该避免创建像 erlang 这样的模块,而是专注于清晰的逻辑分离。

模块声明

A scroll with small text on it

在编写模块时,你可以声明两种类型的元素:函数属性。属性是描述模块本身的元数据,例如它的名称、应该对外部世界可见的函数、代码的作者等等。这种元数据很有用,因为它可以提示编译器如何完成它的工作,而且它还允许人们在不查看源代码的情况下从编译代码中获取有用的信息。

目前,世界各地的 Erlang 代码中使用了各种各样的模块属性;事实上,你甚至可以根据自己的需要声明自己的属性。在你的代码中,一些预定义的属性会比其他属性出现得更频繁。所有模块属性都遵循以下形式:-Name(Attribute).。其中只有一个对于模块的可编译性是必需的

-module(Name).
这始终是文件中的第一个属性(和语句),这是有充分理由的:它代表当前模块的名称,其中 Name 是一个 原子。这是你用来从其他模块调用函数的名称。调用使用 M:F(A) 的形式进行,其中 M 是模块名称,F 是函数,A 是参数。

现在就开始编写代码吧!我们的第一个模块将非常简单,毫无用处。打开你的文本编辑器,输入以下内容,然后将其保存在 useless.erl

-module(useless).

这一行文本是一个有效的模块。真的!当然,如果没有函数,它将毫无用处。首先让我们决定哪些函数将从我们的“无用”模块中导出。为此,我们将使用另一个属性

-export([Function1/Arity, Function2/Arity, ..., FunctionN/Arity]).
它用于定义模块的哪些函数可以被外部世界调用。它接受一个包含函数及其对应元数的列表。函数的元数是一个整数,代表可以传递给该函数的参数数量。这是一个关键信息,因为在一个模块中定义的不同函数如果仅当它们具有不同的元数时,才能共享相同的名称。因此,函数 add(X,Y)add(X,Y,Z) 将被认为是不同的,分别写成 add/2add/3 的形式。

注意:导出的函数代表模块的接口。重要的是定义一个接口,只公开使用它所必需的内容,而不过多地公开其他内容。这样做可以让您修改实现的所有其他 [隐藏] 细节,而不会破坏可能依赖于您的模块的代码。

我们的无用模块将首先导出一个名为“add”的有用函数,它将接受两个参数。以下 -export 属性可以添加到模块声明之后

-export([add/2]).

现在编写函数

add(A,B) ->
    A + B.

函数的语法遵循 Name(Args) -> Body. 的形式,其中 Name 必须是一个原子,Body 可以是一个或多个用逗号分隔的 Erlang 表达式。函数以句号结束。请注意,Erlang 不使用“return”关键字。“Return”是无用的!相反,函数要执行的最后一个逻辑表达式的值将自动返回给调用者,而无需你提及它。

添加以下函数(是的,每个教程都需要一个“Hello world”示例!即使是在第四章!),不要忘记将其添加到 -export 属性中。

%% Shows greetings.
%% io:format/1 is the standard function used to output text.
hello() ->
    io:format("Hello, world!~n").

从这个函数中,我们可以看到注释只能是单行注释,并且以 % 符号开头(使用 %% 纯粹是一个风格问题)。hello/0 函数还演示了如何在你的模块中调用来自外部模块的函数。在这种情况下,io:format/1 是标准的输出文本函数,如注释中所写。

最后一个函数将被添加到模块中,它同时使用 add/2hello/0 函数

greet_and_add_two(X) ->
	hello(),
	add(X,2).
A box being put in another one

不要忘记将 greet_and_add_two/1 添加到导出的函数列表中。对 hello/0add/2 的调用不需要在前面加上模块名称,因为它们是在模块本身中声明的。

如果你想以与 add/2 或模块中定义的任何其他函数相同的方式调用 io:format/1,你可以在文件开头添加以下模块属性:-import(io, [format/1]).。然后你可以直接调用 format("Hello, World!~n").。更一般地说,-import 属性遵循以下规则

-import(Module, [Function1/Arity, ..., FunctionN/Arity]).

导入函数只不过是程序员在编写代码时的快捷方式。Erlang 程序员经常被劝阻使用 -import 属性,因为有些人发现它会降低代码的可读性。在 io:format/2 的情况下,函数 io_lib:format/2 也存在。要找到使用的是哪一个,需要转到文件的开头,查看它是从哪个模块导入的。因此,保留模块名称被认为是良好的实践。通常,你看到的唯一导入的函数来自列表模块:它的函数的使用频率高于大多数其他模块的函数。

你的 useless 模块现在应该看起来像以下文件

-module(useless).
-export([add/2, hello/0, greet_and_add_two/1]).

add(A,B) ->
    A + B.

%% Shows greetings.
%% io:format/1 is the standard function used to output text.
hello() ->
    io:format("Hello, world!~n").

greet_and_add_two(X) ->
    hello(),
    add(X,2).

我们完成了“无用”模块。你可以将文件保存在名为 useless.erl 的目录下。文件名应该与 -module 属性中定义的模块名称相同,后面跟着“.erl”,这是标准的 Erlang 源代码扩展名。

在展示如何编译模块并最终尝试其所有激动人心的功能之前,我们将了解如何定义和使用宏。Erlang 宏与 C 的 #define 语句非常相似,主要用于定义简短的函数和常量。它们是简单的表达式,用文本表示,将在代码被编译到 VM 之前进行替换。此类宏主要用于避免在你的模块中出现魔法值。宏被定义为以下形式的模块属性:-define(MACRO, some_value).,并在模块中定义的任何函数中以 ?MACRO 的形式使用。一个“函数”宏可以写成 -define(sub(X,Y), X-Y).,并像 ?sub(23,47) 那样使用,之后由编译器替换为 23-47。有些人会使用更复杂的宏,但基本语法保持不变。

编译代码

Erlang 代码被编译成字节码,以便被虚拟机使用。你可以从多个地方调用编译器:在命令行中使用 $ erlc flags file.erl,在 shell 或模块中使用 compile:file(FileName),在 shell 中使用 c() 等等。

现在来编译我们的无用模块并尝试一下。打开 Erlang shell,输入

1> cd("/path/to/where/you/saved/the-module/").
"Path Name to the directory you are in"
ok

默认情况下,shell 只会查找它启动所在的目录和标准库中的文件:cd/1 是专门为 Erlang shell 定义的一个函数,它告诉 shell 将目录更改为一个新的目录,这样浏览我们的文件就不那么麻烦了。Windows 用户应该记住使用正斜杠。完成此操作后,执行以下操作

2> c(useless).
{ok,useless}

如果你收到其他消息,请确保文件名正确,你位于正确的目录中,并且你的 模块 没有错误。成功编译代码后,你会注意到在你的目录中,useless.erl 文件旁边添加了一个 useless.beam 文件。这就是编译后的模块。让我们尝试我们第一个函数

3> useless:add(7,2).
9
4> useless:hello().
Hello, world!
ok
5> useless:greet_and_add_two(-3).
Hello, world!
-1
6> useless:not_a_real_function().
** exception error: undefined function useless:not_a_real_function/0

这些函数按预期工作:add/2 添加数字,hello/0 输出“Hello, world!”,而 greet_and_add_two/1 同时执行这两项操作!当然,你可能会问为什么 hello/0 在输出文本后返回原子“ok”。这是因为 Erlang 函数和表达式必须始终返回某个值,即使它们在其他语言中不需要这样做。因此,io:format/1 返回“ok”表示正常状态,没有错误。

表达式 6 显示了一个错误被抛出,因为函数不存在。如果你忘记导出函数,当你尝试执行时,就会收到这种错误消息。

注意:如果你想知道,“.beam”代表Bogdan/Björn's Erlang Abstract Machine,也就是 VM 本身。Erlang 还存在其他虚拟机,但它们现在已经不再使用,属于历史:JAM(Joe's Abstract Machine,受 Prolog 的 WAM 和老式 BEAM 的启发,尝试将 Erlang 编译成 C 代码,然后编译成原生代码。基准测试表明这种做法几乎没有益处,因此放弃了这个概念。

存在大量编译标志,可以让你更好地控制模块的编译方式。你可以在 Erlang 文档 中获取所有标志的列表。最常用的标志是

-debug_info
Erlang 工具(如调试器、代码覆盖率和静态分析工具)将使用模块的调试信息来完成它们的工作。
-{outdir,Dir}
默认情况下,Erlang 编译器会在当前目录中创建“beam”文件。这将允许你选择将编译后的文件放在哪里。
-export_all
将会忽略 -export 模块属性,而是导出定义的所有函数。这主要在测试和开发新代码时有用,但不应该在生产环境中使用。
-{d,Macro} 或 {d,Macro,Value}
定义一个在模块中使用的宏,其中 Macro 是一个原子。这在处理单元测试时更常见,确保一个模块只有在显式需要时才会创建和导出其测试函数。默认情况下,如果 Value 未定义为元组的第三个元素,则其值为 'true'。

要使用一些标志编译我们的 useless 模块,我们可以执行以下操作之一

7> compile:file(useless, [debug_info, export_all]).
{ok,useless}
8> c(useless, [debug_info, export_all]).
{ok,useless}

你也可以偷偷摸摸地从模块内部定义编译标志,使用模块属性。要获得与表达式 7 和 8 相同的结果,可以将以下行添加到模块中

-compile([debug_info, export_all]).

然后只需编译,你将获得与手动传递标志相同的结果。现在我们能够编写函数、编译它们并执行它们,是时候看看我们能把它们带到多远了!

注意:另一个选择是将你的 Erlang 模块编译成原生代码。原生代码编译并不适用于所有平台和操作系统,但在支持它的平台上,它可以使你的程序运行得更快(根据轶事证据,大约快 20%)。要编译成原生代码,你需要使用 hipe 模块并以以下方式调用它:hipe:c(Module,OptionsList). 你也可以在 shell 中使用 c(Module,[native]). 来获得类似的结果。请注意,生成的 .beam 文件将包含原生和非原生代码,原生部分在不同平台之间不可移植。

关于模块的更多内容

在继续学习更多关于编写函数和几乎无用的代码片段之前,我还想讨论一些其他可能在将来对你有用的杂项信息。

第一个问题涉及关于模块的元数据。我在本章开头提到过,模块属性是描述模块本身的元数据。当我们没有访问源代码时,我们可以在哪里找到这些元数据呢?好吧,编译器很乐意帮助我们:在编译模块时,它会提取大多数模块属性并将它们(以及其他信息)存储在一个 module_info/0 函数中。你可以用以下方式查看 useless 模块的元数据

9> useless:module_info().
[{exports,[{add,2},
           {hello,0},
           {greet_and_add_two,1},
           {module_info,0},
           {module_info,1}]},
 {imports,[]},
 {attributes,[{vsn,[174839656007867314473085021121413256129]}]},
 {compile,[{options,[]},
           {version,"4.6.2"},
           {time,{2009,9,9,22,15,50}},
           {source,"/home/ferd/learn-you-some-erlang/useless.erl"}]}]
10> useless:module_info(attributes).
[{vsn,[174839656007867314473085021121413256129]}]

上面的代码片段还展示了一个额外的函数 module_info/1,它可以让你获取一个特定的信息。你可以查看导出的函数、导入的函数(本例中没有!)、属性(这是你自定义元数据所在的位置)以及编译选项和信息。如果你决定将 -author("An Erlang Champ"). 添加到你的模块中,它将出现在与 vsn 相同的部分。在生产环境中,模块属性的用途有限,但它们在做一些小技巧来帮助自己时会很方便:我在为这本书写的 测试脚本 中使用它们来标注可以改进单元测试的函数;脚本查找模块属性,找到标注的函数并显示有关它们的警告。

注意: vsn 是一个自动生成的唯一值,用于区分代码的每个版本,不包括注释。它用于代码热加载(在应用程序运行时升级应用程序,而无需停止它)以及一些与版本处理相关的工具。你也可以自己指定 vsn 值:只需将 -vsn(VersionNumber) 添加到你的模块中即可。

A small graph with three nodes: Mom, Dad and You. Mom and Dad are parents of You, and You is brother of Dad. Text under: 'If circular dependencies are digusting in real life, maybe they should be disgusting in your programs too'

另一个需要关注的点是关于一般的模块设计:避免循环依赖!一个模块 A 不应该调用也调用了模块 A 的模块 B。这种依赖关系通常会导致代码维护变得困难。事实上,即使不是循环依赖,依赖太多模块也会让维护变得更加困难。你最不希望的事情就是在半夜醒来,发现一个疯狂的软件工程师或计算机科学家试图挖掉你的眼睛,因为你写了糟糕的代码。

出于类似的原因(维护和对眼睛的恐惧),通常认为将具有类似角色的函数分组在一起是一个好习惯。启动和停止应用程序或在某个数据库中创建和删除记录就是这种情况的例子。

好吧,关于这些说教就到此为止。我们去探索一下 Erlang 吧?