跳到主要内容

第 2 章 基础构建块(Building blocks)

本章涵盖内容

  • 使用交互式shell
  • 使用变量
  • 组织代码
  • 理解类型系统
  • 使用运算符
  • 理解运行时

是时候开始学习Elixir了。本章将介绍该语言的基本构建块,例如模块、函数和类型系统。这将是一次有些冗长且可能并不特别令人兴奋的语言特性之旅,但这里介绍的内容非常重要,因为它为探索更有趣、更高级的主题奠定了基础。

开始之前,请确保你已安装Elixir 1.15版本和Erlang 26版本。安装Elixir有几种方式,最好遵循官方Elixir网站上的说明:https://elixir-lang.org/install.html。

准备工作完成后,让我们开始探索Elixir。你首先需要了解的是交互式shell。

详细信息 本书不会提供任何语言或平台特性的详细参考手册。那样会占用太多篇幅,而且材料很快就会过时。你可以查阅以下其他参考资料:

2.1 交互式shell(The interactive shell)

实验和学习语言特性最简单的方法是通过交互式shell。你可以通过运行iex命令从命令行启动Elixir交互式shell:

$ iex
Erlang/OTP 26 [erts-14.0] [source] [64-bit] [smp:20:20] [ds:20:20:10]
Interactive Elixir (1.15.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>

运行iex会启动一个BEAM实例,然后在其中启动一个交互式Elixir shell。会打印运行时信息,例如Erlang和Elixir版本号,然后提供提示符,以便你可以输入Elixir表达式:

iex(1)> 1 + 2
3

输入表达式后,它会被解释并执行,然后将返回值打印到屏幕上。

注意 Elixir中的所有内容都是具有返回值的表达式。这不仅包括函数调用,还包括ifcase等结构。

提示 你将在本书中广泛使用iex,尤其是在前几章。表达式的结果通常不特别重要,为了减少干扰会被省略。但请记住,每个表达式都会返回一个结果,当你在shell中输入表达式时,其结果将会显示。

你几乎可以输入任何构成有效Elixir代码的内容,包括相对复杂的多行表达式:

iex(2)> 2 * (3 + 1 ) / 4
2.0

请注意,shell直到你在最后一行完成表达式后才对其进行求值。在Elixir中,你不需要特殊字符(例如分号)来表示表达式的结束。相反,如果表达式是完整的,则换行表示表达式的结束。否则,解析器会等待更多输入,直到表达式变得完整。如果你卡住了(例如,忘记了右括号),可以通过在新的一行输入#iex:break来中止整个表达式:

iex(3)> 1 + (2
...(3)> #iex:break
** (TokenMissingError) iex:1: incomplete expression
iex(3)>

退出shell最快的方法是连续按两次Ctrl-C。这样做会强制终止操作系统进程和所有正在执行的后台作业。由于shell主要用于实验,不应用于运行实际的生产系统,因此通常可以这种方式终止它。但如果你想以更优雅的方式停止系统,可以调用System.stop

注意 有多种方式可以启动Elixir和Erlang运行时以及运行Elixir程序。在本章结束时,你将对这些方式有所了解。在本书的第一部分,你将主要使用iex shell,因为它是实验语言的简单而有效的方式。

你可以用shell做很多事情,但最常用的是输入表达式并检查其结果。你可以自行研究在shell中还能做些什么。基本帮助可以通过h命令获取:

iex(3)> h

在shell中输入此命令将输出一整屏与iex相关的说明。你也可以查阅负责shell工作的IEx模块的文档:

iex(4)> h IEx

你可以在在线文档中找到相同的帮助信息:https://hexdocs.pm/iex。

现在你有了一个基本的实验工具,可以开始研究语言特性了。你将从变量开始。

2.2 使用变量(Working with variables)

Elixir是一门动态编程语言,这意味着你不需要显式声明变量或其类型。相反,变量的类型由它当前包含的数据决定。用Elixir的术语来说,赋值被称为绑定。当你用一个值初始化变量时,该变量就被绑定到那个值:

iex(1)> monthly_salary = 10000
10000

Elixir中的每个表达式都有一个结果。对于 = 操作符,结果就是操作符右侧的内容。表达式求值后,shell会将这个结果打印到屏幕上。

现在,你可以引用这个变量:

iex(2)> monthly_salary
10000

当然,变量可以用于复杂的表达式:

iex(3)> monthly_salary * 12
120000

在Elixir中,变量名总是以小写字母或下划线开头。之后,可以包含字母、数字和下划线的任意组合。通常的惯例是只使用小写ASCII字母、数字和下划线:

valid_variable_name
also_valid_1
validButNotRecommended
NotValid

变量名也可以以问号 ( ?) 或感叹号 ( ! ) 结尾:

valid_name?
also_ok!

变量可以重新绑定到不同的值:

iex(1)> monthly_salary = 10000 # 绑定变量
10000 # 最后一个表达式的结果

iex(2)> monthly_salary # 返回变量值的表达式
10000 # 变量的值

iex(3)> monthly_salary = 11000 # 重新绑定
11000

iex(4)> monthly_salary
11000

重新绑定并不会改变现有的内存位置。它会预留新的内存,并将符号名重新分配给新的位置。

注意 你应该始终记住数据是不可变的。一旦内存位置被数据占用,在释放之前就不能被修改。但是变量可以重新绑定,这使它们指向不同的内存位置。因此,变量是可变的,但它们所指向的数据是不可变的。

Elixir是一门垃圾回收的语言,这意味着你不必手动释放内存。当变量超出作用域时,相应的内存就有资格被垃圾回收,并将在未来垃圾回收器清理内存时被释放。

2.3 组织代码(Organizing your code)

作为一种函数式语言,Elixir 高度依赖函数。由于数据的不可变性,一个典型的 Elixir 程序由许多小型函数组成。在接下来的第 3 章和第 4 章中,当你开始使用一些典型的函数式惯用法时,就会亲眼见证这一点。多个函数可以进一步组织到模块中。

2.3.1 模块(Modules)

模块是函数的集合,有点像一个命名空间。每一个 Elixir 函数都必须在某个模块内部定义。

Elixir 自带一个标准库,提供了许多有用的模块。例如,IO 模块可用于完成各种输入/输出操作。IO 模块中的 puts 函数可以用来向屏幕打印消息:

iex(1)> IO.puts("Hello World!")
Hello World!
:ok

如示例所示,要调用模块中的函数,你需要使用语法 ModuleName.function_name(args)

要定义你自己的模块,你需要使用 defmodule 表达式。在模块内部,使用 def 表达式来定义函数。代码清单 2.1 演示了模块的定义。

代码清单 2.1 定义一个模块 (geometry.ex)

defmodule Geometry do
def rectangle_area(a, b) do
a * b
end
end

有两种方法可以使用这个模块。首先,你可以直接将这个定义复制并粘贴到 iex 中——如前所述,几乎任何内容都可以输入到 shell 中。第二种方法是在启动时告诉 iex 解释该文件:

$ iex geometry.ex

使用任何一种方法都会产生相同的效果。代码被编译,生成的模块被加载到运行时中,并可以在 shell 会话中使用。我们来试试看:

$ iex geometry.ex
iex(1)> Geometry.rectangle_area(6, 7)
42

很简单!你创建了一个 Geometry 模块,将其加载到 shell 会话中,并用它来计算矩形的面积。

注意 你可能已经注意到,文件名有 .ex 扩展名。这是 Elixir 源文件的常见约定。

在源代码中,一个模块必须在单个文件中定义。一个文件可以包含多个模块定义:

defmodule Module1 do
...
end

defmodule Module2 do
...
end

模块名称必须遵循某些规则。它以大写字母开头,通常采用驼峰式命名法。模块名称可以由字母数字字符、下划点和点 (.) 字符组成。点号通常用于以层次结构组织模块:

defmodule Geometry.Rectangle do
...
end

defmodule Geometry.Circle do
...
end

你也可以嵌套定义模块:

defmodule Geometry do
defmodule Rectangle do
...
end
...
end

内部模块可以通过 Geometry.Rectangle 来引用。

请注意,点号字符本身并没有什么特殊之处。它只是模块名称中允许使用的字符之一。编译后的版本不会记录模块之间的任何层次关系。

这通常用于将模块组织成有意义的层次结构,使阅读代码时更容易浏览。此外,这种非正式的命名空间划分可以消除可能的命名冲突。例如,考虑两个库,一个实现了 JSON 编码器,另一个实现了 XML 编码器。如果两个库都定义了一个名为 Encoder 的模块,那么你将无法在同一个项目中同时使用它们。然而,如果模块被命名为 Json.EncoderXml.Encoder,那么命名冲突就避免了。因此,通常会在项目中为所有模块名称添加一个共同的前缀。通常,应用程序或库的名称被用于此目的。

2.3.2 函数(Functions)

函数必须是模块的一部分。函数命名遵循与变量相同的规范:以小写字母或下划线开头,后跟字母、数字和下划线的组合。

与变量类似,函数名也可以以 ?! 字符结尾。? 字符通常用于表示返回 truefalse 的函数。在名称末尾添加 ! 字符表示可能引发运行时错误的函数。这两者都是约定而非硬性规则,但最好遵循它们并尊重社区风格。

函数可以使用 def 宏来定义:

defmodule Geometry do
def rectangle_area(a, b) do
...
end
end

定义以 def 表达式开始,后跟函数名、参数列表以及包含在 do…end 块中的函数体。由于你使用的是动态语言,因此参数没有类型声明。

注意 请注意,defmoduledef 并不被称为关键字。这是因为它们确实不是!相反,它们是 Elixir 宏的示例。你现在无需担心这是如何工作的;本章稍后会对此进行一些解释。如果有助于理解,你可以将 defdefmodule 视为关键字,但要知道这并不完全正确。

如果函数没有参数,可以省略括号:

defmodule Program do
def run do
...
end
end

那么返回值呢?回想一下,在 Elixir 中,一切有返回值的都是表达式。函数的返回值是其最后一个表达式的返回值。Elixir 中没有显式的 return 语句。

注意 既然没有显式的 return,你可能会想知道复杂的函数是如何工作的。这将在第 3 章详细讨论,届时你将学习分支和条件逻辑。通常的规则是保持函数简短明了,这样便于计算结果并通过最后一个表达式将其返回。

你在代码清单 2.1 中已经看到了返回值的示例,但让我们在此重复一下:

defmodule Geometry do
def rectangle_area(a, b) do
a * b
end
end

现在你可以验证这一点。再次启动 shell,然后尝试调用 rectangle_area 函数:

$ iex geometry.ex
iex(1)> Geometry.rectangle_area(3, 2)
6

如果函数体只包含一个表达式,可以使用简写形式,在一行内完成定义:

defmodule Geometry do
def rectangle_area(a, b), do: a * b
end

要调用定义在另一个模块中的函数,请使用模块名后跟函数名:

iex(1)> Geometry.rectangle_area(3, 2)
6

当然,你总是可以将函数结果存储到变量中:

iex(2)> area = Geometry.rectangle_area(3, 2)
6
iex(3)> area
6

在Elixir中,括号是可选的,因此你可以省略它们:

iex(4)> Geometry.rectangle_area 3, 2
6

就个人而言,我认为省略括号会使代码产生歧义,因此我的建议是在调用函数时始终加上括号。

使用代码格式化工具

自 1.6 版本起,Elixir 内置了一个代码格式化工具,你可以用它来使代码风格保持一致,无需担心诸如代码布局或括号使用等底层风格细节。

例如,将以下代码片段格式化后:

defmodule Client do
def run do
Geometry.rectangle_area 3,2
end
end

你会得到如下整洁美观的代码:

defmodule Client do
def run do
Geometry.rectangle_area(3, 2)
end
end

你可以通过 mix format 任务来格式化代码,也可以在你常用的编辑器中安装格式化插件。

如果要调用的函数位于同一个模块内,你可以省略模块前缀:

defmodule Geometry do
def rectangle_area(a, b) do
a * b
end

def square_area(a) do
rectangle_area(a, a)
end
end

鉴于Elixir是一门函数式语言,你经常需要组合函数,将一个函数的结果作为参数传递给下一个函数。Elixir内置了一个称为管道操作符|> 操作符,专门用于此目的:

iex(5)> -5 |> abs() |> Integer.to_string() |> IO.puts()
5

这段代码在编译时会被转换为以下形式:

iex(6)> IO.puts(Integer.to_string(abs(-5)))
5

更一般地说,管道操作符会将前一个调用的结果作为下一个调用的第一个参数。因此,以下代码:

prev(arg1, arg2) |> next(arg3, arg4)

在编译时会转换为:

next(prev(arg1, arg2), arg3, arg4)

可以说,管道版本更具可读性,因为执行顺序是从左到右阅读的。管道操作符在源文件中看起来特别优雅,因为你可以将管道布局在多行上:

-5
|> abs()
|> Integer.to_string()
|> IO.puts()

Shell中的多行管道 如果你将前面的管道链粘贴到iex会话中,你会注意到每个中间结果都会被打印到控制台:

iex(1)> -5
-5
iex(2)> |> abs()
5
iex(3)> |> Integer.to_string()
"5"
iex(4)> |> IO.puts()
5

回忆一下,iex会在Elixir表达式完整且有效时立即对其进行求值。在这个例子中,每一行都构成了一个有效的Elixir表达式,例如 -5-5 |> abs(),因此每个中间结果都被打印出来了。

2.3.3 函数元数(Function arity)

元数描述函数接收的参数数量。一个函数由其所在的模块、名称和元数唯一标识。请看以下函数:

defmodule Rectangle do
def area(a, b) do
...
end
end

函数 Rectangle.area 接收两个参数,因此可以说它是一个元数为 2 的函数。在 Elixir 领域,这个函数常被称为 Rectangle.area/2,其中 /2 表示函数的元数。

为什么这很重要?因为两个名称相同但元数不同的函数是两个不同的函数,如下例所示。

代码清单 2.2 同名但元数不同的函数 (arity_demo.ex)

defmodule Rectangle do
def area(a), do: area(a, a)
def area(a, b), do: a * b
end

将这个模块加载到 shell 中,然后尝试以下操作:

iex(1)> Rectangle.area(5)
25
iex(2)> Rectangle.area(5, 6)
30

如你所见,这两个函数的行为完全不同。名称可能被重载,但元数不同,因此我们将它们视为两个不同的函数,各自有自己的实现。

同名但实现完全不同的函数通常没有意义。更常见的情况是,元数较低的函数会委托给元数较高的函数,并提供一些默认参数。这就是代码清单 2.2 中的情况,Rectangle.area/1 委托给了 Rectangle.area/2。让我们看另一个例子。

代码清单 2.3 同名函数、不同元数与默认参数 (arity_calc.ex)

defmodule Calculator do
def add(a), do: add(a, 0)
def add(a, b), do: a + b
end

同样,一个元数较低的函数通过一个元数较高的函数来实现。这种模式非常常见,以至于 Elixir 允许你使用 \\ 操作符后跟参数的默认值来指定参数的默认值:

defmodule Calculator do
def add(a, b \\ 0), do: a + b
end

这个定义会生成两个函数,与代码清单 2.3 中完全一样。

你可以为任意参数组合设置默认值:

defmodule MyModule do
def fun(a, b \\ 1, c, d \\ 2) do
a + b + c + d
end
end

请始终记住,默认值会生成多个同名但元数不同的函数。前面的代码生成了三个函数:MyModule.fun/2MyModule.fun/3MyModule.fun/4,其实现如下:

def fun(a, c), do: fun(a, 1, c, 2)
def fun(a, b, c), do: fun(a, b, c, 2)
def fun(a, b, c, d), do: a + b + c + d

因为元数区分了同名的多个函数,所以不可能让一个函数接受可变数量的参数。Elixir 中没有 C 语言的 或 JavaScript 的 arguments 的对应物。

2.3.4 函数可见性(Function visibility)

使用 def 宏定义函数时,该函数是公开的——可以被其他任何代码调用。用 Elixir 的术语来说,这个函数被导出了。你也可以使用 defp 宏来定义私有函数。私有函数只能在其定义的模块内部使用。以下示例展示了这一点。

代码清单 2.4 包含公共函数和私有函数的模块 (private_fun.ex)

defmodule TestPrivate do
def double(a) do
sum(a, a)
end

defp sum(a, b) do
a + b
end
end

模块 TestPrivate 定义了两个函数。函数 double 是导出的,可以从外部调用。在内部,它依赖私有函数 sum 来完成工作。让我们在 shell 中尝试一下。加载该模块,然后执行以下操作:

iex(1)> TestPrivate.double(3)
6
iex(2)> TestPrivate.sum(3, 4)
** (UndefinedFunctionError) function TestPrivate.sum/2 ...

如你所见,私有函数不能在模块外部调用。

2.3.5 导入与别名(Imports and aliases)

调用其他模块的函数有时可能很繁琐,因为你需要引用模块名。如果你的模块经常调用另一个模块的函数,你可以将该模块导入到你的模块中。导入一个模块允许你在调用其公共函数时省略模块名前缀:

defmodule MyModule do
import IO

def my_function do
puts "Calling imported function."
end
end

当然,你可以导入多个模块。实际上,标准库的 Kernel 模块会自动导入到每个模块中。Kernel 包含了许多常用函数,自动导入使它们更易于访问。

注意 你可以通过查阅在线文档(https://hexdocs.pm/elixir/Kernel.html)来查看 Kernel 模块中有哪些可用的函数。

另一个表达式 alias,可以让你使用不同的名称来引用一个模块:

defmodule MyModule do
alias IO, as: MyIO

def my_function do
MyIO.puts("Calling imported function.")
end
end

当模块名称很长时,别名会很有用。例如,如果你的应用程序深度划分到多层模块层次结构中,使用完全限定名引用模块会很繁琐。别名可以帮助解决这个问题。例如,假设你有一个 Geometry.Rectangle 模块。你可以在客户端模块中为其设置别名并使用较短的名称:

defmodule MyModule do
alias Geometry.Rectangle, as: Rectangle

def my_function do
Rectangle.area(...)
end
end

在前面的例子中,Geometry.Rectangle 的别名是其名称的最后一部分。这是 alias 最常见的用法,因此 Elixir 允许你在这种情况下省略 as 选项:

defmodule MyModule do
alias Geometry.Rectangle

def my_function do
Rectangle.area(...)
end
end

别名可以帮助你减少一些干扰,尤其是当你需要多次调用一个长名称模块中的函数时。

2.3.6 模块属性(Module attributes)

模块属性具有双重用途:它们既可作为编译时常量使用,也可以注册任何属性,以便在运行时进行查询。让我们来看一个例子。

下面的模块提供了处理圆的基本函数:

iex(1)> defmodule Circle do
@pi 3.14159
def area(r), do: r*r*@pi
def circumference(r), do: 2*r*@pi
end
iex(2)> Circle.area(1)
3.14159
iex(3)> Circle.circumference(1)
6.28318

请注意你如何在 shell 中直接定义模块。这是允许的,使得实验成为可能,而无需在磁盘上存储任何文件。

关于常量 @pi 的重要一点是,它仅在模块编译期间存在,此时对它的引用会被内联。

此外,属性可以被注册,这意味着它将被存储在生成的二进制文件中,并可在运行时访问。Elixir 默认会注册一些模块属性。例如,属性 @moduledoc@doc 可用于为模块和函数提供文档:

defmodule Circle do
@moduledoc "Implements basic circle functions"
@pi 3.14159

@doc "Computes the area of a circle"
def area(r), do: r*r*@pi

@doc "Computes the circumference of a circle"
def circumference(r), do: 2*r*@pi
end

然而,要尝试这个,你需要生成一个编译文件。这里有一个快速的方法:将此代码保存到某处的 circle.ex 文件中,然后运行 elixirc circle.ex。这将生成文件 Elixir.Circle.beam。接下来,从同一文件夹启动 iex shell。

现在你可以在运行时检索该属性:

iex(1)> Code.fetch_docs(Circle)
{:docs_v1, 2, :elixir, "text/markdown",
%{"en" => "Implements basic circle functions"}, %{},
[
{{:function, :area, 1}, 5, ["area(r)"],
%{"en" => "Computes the area of a circle"}, %{}},
{{:function, :circumference, 1}, 8, ["circumference(r)"],
%{"en" => "Computes the circumference of a circle"}, %{}}
]}

值得注意的是,Elixir 生态系统中的其他工具知道如何处理这些属性。例如,你可以使用 iex 的帮助功能来查看模块的文档:

iex(2)> h Circle
Circle
Implements basic circle functions
iex(3)> h Circle.area
* def area(r)
Computes the area of a circle

此外,你可以使用 ex_doc 工具为你的项目生成 HTML 文档。这是生成 Elixir 文档的方式,如果你计划构建更复杂的项目,特别是那些将被许多不同客户端使用的东西,你应该考虑使用 @moduledoc@doc

其根本在于,注册属性可用于将元信息附加到模块,这些元信息随后可被其他 Elixir(甚至 Erlang)工具使用。还有许多其他预注册属性,你也可以注册自己的自定义属性。更多详细信息,请查看 Module 模块的文档。

类型规范 类型规范(通常称为 typespecs)是另一个基于属性的重要特性。它们允许你为函数提供类型信息,随后可以使用名为 dialyzer 的静态分析工具对这些信息进行分析。

以下是我们扩展 Circle 模块以包含类型规范的方法:

defmodule Circle do
@pi 3.14159

@spec area(number) :: number
def area(r), do: r*r*@pi

@spec circumference(number) :: number
def circumference(r), do: 2*r*@pi
end

在这里,你使用 @spec 属性来指示这两个函数都接受并返回一个数字。

类型规范提供了一种弥补缺乏静态类型系统的方式。结合 dialyzer 工具,这对于执行程序的静态分析非常有用。此外,类型规范可以让你更好地记录你的函数。请记住,Elixir 是一门动态语言,因此函数输入和输出不能通过查看函数签名轻易推断出来。类型规范在这方面可以提供显著帮助,我可以证明,当提供类型规范时,理解他人的代码会容易得多。

例如,看看 Elixir 函数 List.insert_at/3 的类型规范:

@spec insert_at(list, integer, any) :: list

即使不看代码或不阅读文档,你也能合理地猜出这个函数将任意类型的项(第三个参数)插入到列表(第一个参数)中的给定位置(第二个参数),并返回一个新列表。

本书中将不会使用类型规范,主要是为了使代码尽可能简短。但如果你计划构建更复杂的系统,我的建议是认真考虑使用类型规范。你可以在官方文档中找到详细参考。

2.3.7 注释(Comments)

Elixir 中的注释以 # 字符开头,表示该行的其余部分是注释:

# 这是一个注释
a = 3.14 # 这也是一个注释

不支持块注释。如果你需要注释多行,请为每一行添加 # 字符前缀。

至此,我们已经完成了函数和模块的基础知识。你现在已经了解了主要的代码组织技术。在此基础上,是时候来看看 Elixir 的类型系统了。

2.4 理解类型系统 (Understanding the type system)

Elixir 的核心采用 Erlang 的类型系统。因此,与 Erlang 库的集成通常很简单。该类型系统本身相当简单,但如果你来自传统的面向对象语言,你会发现它与你习惯的方式有很大不同。本节将介绍 Elixir 的基本类型,并讨论不可变性的一些影响。首先,我们来看数字。

2.4.1 数字 (Numbers)

数字可以是整数或浮点数,它们的使用方式基本符合预期:

iex(1)> 3
3
iex(2)> 0xFF
255
iex(3)> 3.14
3.14
iex(4)> 1.0e-2
0.01

支持标准的算术运算符:

iex(5)> 1 + 2 * 3
7

除法运算符 / 的工作方式可能与你的预期不同。它总是返回浮点值:

iex(6)> 4/2
2.0
iex(7)> 3/2
1.5

要执行整数除法或计算余数,可以使用自动导入的 Kernel 函数:

iex(8)> div(5,2)
2
iex(9)> rem(5,2)
1

为了增加语法糖,可以使用下划线字符作为视觉分隔符:

iex(10)> 1_000_000
1000000

整数的大小没有上限,可以使用任意大的数字:

iex(11)> 999999999999999999999999999999999999999999999999999999999999 999999999999999999999999999999999999999999999999999999999999

如果你担心内存占用问题,最好查阅 http://mng.bz/QREv 上的官方 Erlang 内存指南。整数占用容纳该数字所需的空间大小,而浮点数则占用 32 位或 64 位,具体取决于虚拟机的构建架构。浮点数内部采用 IEEE 754-1985(二进制精度)格式表示。

2.4.2 原子 (Atoms)

原子(Atoms)就是字面意义上的命名常量。它们类似于 Ruby 中的符号(symbols)或 C/C++ 中的枚举(enumerations)。原子常量以冒号开头,后跟字母数字和/或下划线的组合:

:an_atom
:another_atom

也可以使用以下语法在原子名称中包含空格:

:"an atom with spaces"

一个原子由两部分组成:文本和值。原子文本是你放在冒号后面的任何内容。在运行时,此文本保存在原子表中。值是放入变量中的数据,它仅仅是对原子表的一个引用。 这正是原子最适合用作命名常量的原因。它们在内存和性能方面都很高效。当你写下

variable = :some_atom

时,变量并不包含整个文本——只包含对原子表的引用。因此,内存消耗低,比较速度快,代码仍然可读。

别名(ALIASES) 原子常量还有另一种语法。你可以省略开头的冒号,而以大写字母开头:

AnAtom

这被称为别名(alias),在编译时,它会被转换为 :"Elixir.AnAtom"。我们可以在 shell 中轻松验证这一点:

iex(1)> AnAtom == :"Elixir.AnAtom"
true

当你使用别名时,编译器会隐式地将 Elixir. 前缀添加到其文本中并生成原子。但如果别名已经包含了 Elixir. 前缀,则不会再添加。 因此,以下操作也同样有效:

iex(2)> AnAtom == Elixir.AnAtom
true

你可能还记得前面提到过,也可以使用别名为模块提供替代名称:

iex(3)> alias IO, as: MyIO
iex(4)> MyIO.puts("Hello!")
Hello!

在这两种情况下都使用"别名"一词并非偶然。当你编写 alias IO, as: MyIO 时,你指示编译器将 MyIO 转换为 IO。进一步解析后,生成的二进制文件中发出的最终结果是 :Elixir.IO。因此,设置别名后,以下等式也成立:

iex(5)> MyIO == Elixir.IO
true

所有这些可能看起来有些奇怪,但它有一个重要的潜在目的。别名支持模块的正确解析。我们将在本章末尾重新讨论模块并查看它们在运行时如何加载时再详细讨论这一点。

原子作为布尔值(ATOMS AS BOOLEANS) Elixir 没有专门的布尔类型,这可能会让人感到惊讶。相反,它使用原子 :true:false。作为语法糖,Elixir 允许你在不带起始冒号的情况下引用这些原子:

iex(1)> :true == true
true
iex(2)> :false == false
true

在 Elixir 中,仍然使用"布尔"一词来表示值为 :true:false 的原子。标准逻辑运算符适用于布尔原子:

iex(1)> true and false
false
iex(2)> false or true
true
iex(3)> not false
true
iex(4)> not :an_atom_other_than_true_or_false
** (ArgumentError) argument error

请始终记住,布尔值就是值为 true 或 false 的原子。

Nil 与真值(NIL AND TRUTHY VALUES) 另一个特殊的原子是 :nil,它的工作方式与其他语言中的 null 有些类似。你可以不带冒号引用 nil:

iex(1)> nil == :nil
true

原子 nil 在 Elixir 对"真值"(truthiness)的额外支持中发挥作用,其工作方式类似于 C/C++ 和 Ruby 等主流语言中的用法。原子 nil 和 false 被视为假值(falsy values),而其他所有值都被视为真值(truthy value)。

此属性可与 Elixir 的短路运算符 ||、&& 和 ! 一起使用。|| 运算符返回第一个不为假值的表达式:

iex(1)> nil || false || 5 || true
5

因为 nil 和 false 都是假值表达式,所以返回数字 5。请注意,后续表达式根本不会被执行。如果所有表达式的结果都为假值,则返回最后一个表达式的结果。

&& 运算符返回第二个表达式,但仅在第一个表达式为真值时。否则,它返回第一个表达式,并且不会计算第二个表达式:

iex(1)> true && 5
5
iex(2)> false && 5
false
iex(3)> nil && 5
nil

短路操作可以用于优雅地链式操作。例如,如果你需要从缓存、本地磁盘或远程数据库中获取一个值,可以这样做:

read_cached() || read_from_disk() || read_from_database()

类似地,你可以使用 && 运算符来确保满足某些条件:

database_value = connection && read_data(connection)

在这两个例子中,短路运算符使得编写简洁的代码成为可能,而无需使用复杂的嵌套条件表达式。

2.4.3 元组 (Tuples)

元组有点像没有命名字段的结构或记录,最常用于把固定数量的元素组合在一起。下面的代码定义了一个包含姓名和年龄的元组:

iex(1)> person = {"Bob", 25}
{"Bob", 25}

要从元组中提取元素,可以使用 Kernel.elem/2 函数。它接收一个元组和从零开始的元素索引。由于 Kernel 模块会被自动导入,因此可以直接调用 elem

iex(2)> age = elem(person, 1)
25

要修改元组中的某个元素,可以使用 Kernel.put_elem/3。它接收一个元组、从零开始的索引,以及该位置的新值:

iex(3)> put_elem(person, 1, 26)
{"Bob", 26}

put_elem 并不会修改原元组,而是返回一个新版本,并保持旧版本不变。Elixir 中的数据是不可变的,因此无法在内存中直接修改某个值:

iex(4)> person
{"Bob", 25}

因此,你需要把 put_elem 的结果保存到另一个变量中:

iex(5)> older_person = put_elem(person, 1, 26)
{"Bob", 26}
iex(6)> older_person
{"Bob", 26}

也可以重新绑定到同一个变量名:

iex(7)> person = put_elem(person, 1, 26)
{"Bob", 26}

这样做实际上是让 person 变量绑定到新的内存位置。旧位置不再被任何变量引用,因此可以被垃圾回收。

注意 你可能会担心这种方式是否节省内存。在大多数情况下,数据复制很少,两个变量会尽可能共享内存。稍后讨论不可变性时会解释这一点。

元组最适合用来组合少量、固定数量的元素。如果需要动态大小的集合,可以使用列表。

2.4.4 列表 (Lists)

在 Erlang 中,列表用于管理动态的、大小可变的数据集合。它的语法看起来很像其他语言中的数组:

iex(1)> prime_numbers = [2, 3, 5, 7]
[2, 3, 5, 7]

列表看起来像数组,但实际工作方式类似单向链表。要对列表做某些操作,通常必须遍历它。因此,大多数列表操作都是 O(n) 复杂度,包括 Kernel.length/1,它会遍历整个列表来计算长度:

iex(2)> length(prime_numbers)
4

要获取列表中的某个元素,可以使用 Enum.at/2

iex(3)> Enum.at(prime_numbers, 3)
7

Enum.at 同样是 O(n) 操作:它从列表开头迭代到目标元素。只要需要直接随机访问,列表就不是好选择。此时应考虑元组、映射或更高层的数据结构。

列表工具函数

列表有很多可用操作,这里只介绍几个基础操作。更完整的参考可以查看 List 模块文档;Enum 模块中也有许多有用函数。

可以借助 in 运算符检查列表是否包含某个元素:

iex(4)> 5 in prime_numbers
true
iex(5)> 4 in prime_numbers
false

要操作列表,可以使用 List 模块中的函数。例如,List.replace_at/3 会修改某个位置的元素:

iex(6)> List.replace_at(prime_numbers, 0, 11)
[11, 3, 5, 7]

和元组一样,这种修改不会改变原变量,而是返回修改后的版本。你需要把它保存到另一个变量,或重新绑定到同一个变量:

iex(7)> new_primes = List.replace_at(prime_numbers, 0, 11)
[11, 3, 5, 7]
iex(8)> prime_numbers = List.replace_at(prime_numbers, 0, 11)
[11, 3, 5, 7]

可以用 List.insert_at/3 在指定位置插入新元素:

iex(9)> List.insert_at(prime_numbers, 3, 13)
[11, 3, 5, 13, 7]

如果想追加到末尾,可以使用负数作为插入位置:

iex(10)> List.insert_at(prime_numbers, -1, 13)
[11, 3, 5, 7, 13]

和大多数列表操作一样,修改任意元素的复杂度是 O(n)。特别是追加到末尾代价较高,因为它总是需要 n 步,其中 n 是列表长度。

此外,还可以使用专门的 ++ 运算符连接两个列表:

iex(11)> [1, 2, 3] ++ [4, 5]
[1, 2, 3, 4, 5]

它的复杂度也是 O(n),其中 n 是左侧列表的长度。一般来说,应避免向列表末尾添加元素。列表在把新元素推入头部或从头部弹出时最高效。要理解原因,需要看看列表的递归本质。

递归列表定义

看待列表的另一种方式是把它视为递归结构。一个列表可以表示为一对 (head, tail)head 是列表第一个元素,而 tail “指向”剩余元素形成的 (head, tail) 对。

如果你熟悉 Lisp,就会知道这就是 cons cell 的概念。在 Elixir 中,有专门语法支持递归列表定义:

a_list = [head | tail]

head 可以是任意类型的数据,而 tail 本身是一个列表。如果 tail 是空列表,就表示整个列表结束。

下面是一些例子:

iex(1)> [1 | []]
[1]
iex(2)> [1 | [2 | []]]
[1, 2]
iex(3)> [1 | [2]]
[1, 2]
iex(4)> [1 | [2, 3, 4]]
[1, 2, 3, 4]

这只是另一种定义列表的语法,但它展示了列表的本质:列表是包含两个值的对,一个头部和一个尾部,而尾部本身也是列表。

下面是列表的标准递归定义:

iex(1)> [1 | [2 | [3 | [4 | []]]]]
[1, 2, 3, 4]

当然,没有人愿意一直这样写表达式。但重要的是,你要始终意识到,在内部,列表就是 (head, tail) 对组成的递归结构。

可以用 hd 函数获取列表头部,用 tl 函数获取尾部:

iex(1)> hd([1, 2, 3, 4])
1
iex(2)> tl([1, 2, 3, 4])
[2, 3, 4]

这两个操作都是 O(1),因为它们只是从 (head, tail) 对中读取其中一个值。

注意 严格来说,尾部不一定必须是列表,也可以是任意类型。如果尾部不是列表,则称为不正规列表(improper list),大多数标准列表操作都无法处理它。不正规列表有一些特殊用途,但本书不会涉及。

一旦理解了列表的递归本质,就很容易明白为什么把新元素推到列表头部既简单又高效:

iex(1)> a_list = [5, :value, true]
[5, :value, true]
iex(2)> new_list = [:new_element | a_list]
[:new_element, 5, :value, true]

构造 new_listO(1) 操作,不会发生内存复制:new_list 的尾部就是 a_list

2.4.5 不可变性 (Immutability)

前面已经多次提到,Elixir 数据不能被修改。每个函数都会返回输入数据的新的、修改后的版本。你必须把新版本放入另一个变量,或者把同一个符号名重新绑定到它。无论哪种方式,结果都位于另一个内存位置。对输入的修改会导致一些数据复制,但通常旧版本和新版本会共享大部分内存。让我们更仔细地看一下。

修改元组

先看元组。修改后的元组始终是旧版本的完整浅拷贝。考虑下面的代码:

a_tuple = {a, b, c}
new_tuple = put_elem(a_tuple, 1, b2)

变量 new_tuple 会包含 a_tuple 的浅拷贝,只是第二个元素不同。两个元组仍然引用变量 ac,这些变量中的内容会在两个元组之间共享,而不会被复制。

如果重新绑定变量会怎样?重新绑定之后,变量 a_tuple 会引用另一个内存位置。旧位置不再可访问,因此可以被垃圾回收。请记住,元组总是会被复制,但这种复制是浅拷贝。列表则有不同性质。

修改列表

当你修改列表的第 n 个元素时,新版本会包含前 n - 1 个元素的浅拷贝,接着是修改后的元素。之后的尾部则完全共享。

这正是为什么向列表末尾添加元素代价高昂。要在尾部追加新元素,必须遍历并浅拷贝整个列表。相反,把元素推入列表头部不会复制任何内容,因此是最便宜的操作。

在 Elixir 程序中,迭代构建列表时经常利用这一点:最好把连续元素推入头部,然后在列表构造完成后,用一次遍历反转整个列表。

好处

不可变性可能看起来奇怪,你也可能会疑惑它的目的。不可变性有两个重要好处:无副作用函数和数据一致性。

由于数据不能被修改,你可以把大多数函数视为无副作用的转换。它们接收输入并返回结果。更复杂的程序通过组合简单转换编写:

def complex_transformation(data) do
data
|> transformation_1(...)
|> transformation_2(...)
...
|> transformation_n(...)
end

这段代码依赖前面介绍过的管道运算符,把前一次调用的结果作为下一次调用的第一个参数。

无副作用函数更容易分析、理解和测试。它们有明确的输入和输出。当你调用一个函数时,可以确信没有变量会被隐式修改。不管函数做了什么,你都必须接收它的结果并继续处理。

注意 Elixir 不是纯函数式语言,因此函数仍然可能产生副作用。例如,函数可以写文件、发起数据库或网络调用。但你可以确定,一个函数不会修改任何变量的值。

不可变数据的一个隐含结果是,程序可以同时持有一个数据结构的所有版本。这又让原子性的内存内操作成为可能。假设有一个执行一系列转换的函数:

def complex_transformation(original_data) do
original_data
|> transformation_1(...)
|> transformation_2(...)
...
end

这段代码从原始数据开始,并通过一系列转换传递它,每个转换都返回输入的新版本。如果出了问题,complex_transformation 函数可以返回 original_data,这实际上会回滚函数中执行的所有转换。这之所以可能,是因为没有任何转换会修改 original_data 占据的内存。

到这里,我们完成了对基础不可变性理论的了解。如何在更复杂程序中正确使用不可变数据,可能仍然不够清晰。第 4 章会在讨论更高层数据结构时重新回到这个主题。

2.4.6 映射 (Maps)

映射是键值存储,其中键和值都可以是任意项。映射在 Elixir 中有双重用途:既用于支持动态大小的键值结构,也用于管理简单记录,也就是把若干定义明确的命名字段组合在一起。我们分别看看这两种用法。

动态大小的映射

可以用 %{} 表达式创建空映射:

iex(1)> empty_map = %{}
%{}

包含一些值的映射可以这样创建:

iex(2)> squares = %{1 => 1, 2 => 4, 3 => 9}
%{1 => 1, 2 => 4, 3 => 9}

也可以用 Map.new/1 预先填充映射。该函数接收一个可枚举结构,其中每个元素都是大小为 2 的元组:

iex(3)> squares = Map.new([{1, 1}, {2, 4}, {3, 9}])
%{1 => 1, 2 => 4, 3 => 9}

要获取给定键对应的值,可以使用:

iex(4)> squares[2]
4
iex(5)> squares[4]
nil

第二个表达式返回 nil,因为没有值与该键关联。

类似结果也可以通过 Map.get/3 获得。表面上,它的行为类似 [],但 Map.get/3 允许指定默认值;如果找不到键,就返回该默认值。未提供默认值时返回 nil

iex(6)> Map.get(squares, 2)
4
iex(7)> Map.get(squares, 4)
nil
iex(8)> Map.get(squares, 4, :not_found)
:not_found

在最后一个表达式中,无法精确区分是键不存在,还是该键对应的值本身就是 :not_found。如果想精确区分这两种情况,可以使用 Map.fetch/2

iex(9)> Map.fetch(squares, 2)
{:ok, 4}
iex(10)> Map.fetch(squares, 4)
:error

成功时会得到形如 {:ok, value} 的值。这种格式让你能够精确检测键不存在的情况。

有时,你希望只有当键存在时才继续,否则抛出异常。可以使用 Map.fetch!/2

iex(11)> Map.fetch!(squares, 2)
4
iex(12)> Map.fetch!(squares, 4)
** (KeyError) key 4 not found in: %{1 => 1, 2 => 4, 3 => 9}

要向映射存储新元素,可以使用 Map.put/3

iex(13)> squares = Map.put(squares, 4, 16)
%{1 => 1, 2 => 4, 3 => 9, 4 => 16}
iex(14)> squares[4]
16

Map 模块中还有许多有用函数,例如 Map.update/4Map.delete/2。此外,映射也是可枚举结构,这意味着 Enum 模块中的函数也可以作用于映射。

结构化数据

映射是管理任意大小键值数据结构的常用类型。不过,它们也经常用于把几个字段组合成单一结构。这种用法与元组有些重叠,但优势是可以按名称访问字段。

下面创建一个表示单个人的映射:

iex(1)> bob = %{:name => "Bob", :age => 25, :works_at => "Initech"}
%{age: 25, name: "Bob", works_at: "Initech"}

如果键是原子,可以写得更短:

iex(2)> bob = %{name: "Bob", age: 25, works_at: "Initech"}
%{age: 25, name: "Bob", works_at: "Initech"}

要获取字段,可以使用 [] 运算符:

iex(3)> bob[:works_at]
"Initech"
iex(4)> bob[:non_existent_field]
nil

原子键还有特殊语法。下面的代码获取 :age 键下存储的值:

iex(5)> bob.age
25

如果用这种语法获取不存在的字段,会得到错误:

iex(6)> bob.non_existent_field
** (KeyError) key :non_existent_field not found

要改变字段值,可以使用以下语法:

iex(7)> next_years_bob = %{bob | age: 26}
%{age: 26, name: "Bob", works_at: "Initech"}

这种语法也可以同时修改多个属性:

iex(8)> %{bob | age: 26, works_at: "Initrode"}
%{age: 26, name: "Bob", works_at: "Initrode"}

不过,只能修改映射中已经存在的值。这让更新语法非常适合用于表示结构的映射。如果拼错字段名,会立刻得到运行时错误:

iex(9)> %{bob | works_in: "Initech"}
** (KeyError) key :works_in not found

在 Elixir 中,用映射保存结构化数据是一种常见模式。通常会在创建映射时提供所有字段,并使用原子作为键。如果某个字段的值暂时不可用,可以设为 nil。这样的映射始终拥有所有字段。随后可以用更新表达式修改映射,用 a_map.some_field 表达式获取字段。

当然,这样的数据仍然是映射,所以也可以使用 Map.put/3Map.fetch/2 之类的 Map 模块函数。不过,这些函数通常更适合映射被用作动态键值结构的场景。

2.4.7 二进制与位串 (Binaries and bitstrings)

二进制是一块字节。可以把字节序列放在 <<>> 运算符之间来创建二进制:

iex(1)> <<1, 2, 3>>
<<1, 2, 3>>

每个数字代表对应字节的值。如果提供的字节值大于 255,它会被截断为字节大小:

iex(2)> <<256>>
<<0>>
iex(3)> <<257>>
<<1>>
iex(4)> <<512>>
<<0>>

可以指定每个值的大小,从而告诉编译器该值使用多少位:

iex(5)> <<257::16>>
<<1, 1>>

这个表达式把数字 257 放入连续的 16 位内存空间。输出显示使用了 2 个字节,两个字节的值都是 1。这是因为 257 的二进制表示在 16 位形式下是 00000001 00000001

大小说明符以位为单位,不必是 8 的倍数。下面的代码通过组合两个 4 位值创建二进制:

iex(6)> <<1::4, 15::4>>
<<31>>

如果所有值的总大小不是 8 的倍数,这个二进制就称为位串,也就是位序列:

iex(7)> <<1::1, 0::1, 1::1>>
<<5::size(3)>>

还可以用 <> 运算符连接两个二进制或位串:

iex(8)> <<1, 2>> <> <<3, 4>>
<<1, 2, 3, 4>>

二进制还有许多更多用途,这里暂时先放下。此刻最重要的是知道:二进制是连续的字节序列。它们在字符串支持中扮演重要角色。

2.4.8 字符串 (Strings)

Elixir 没有专门的字符串类型,这可能会让人惊讶。字符串使用二进制或列表表示。

二进制字符串

最常见的字符串用法是熟悉的双引号语法:

iex(1)> "This is a string"
"This is a string"

结果会以字符串形式打印,但底层只是一个二进制,也就是连续字节序列。

Elixir 支持嵌入式字符串表达式。可以使用 #{} 把 Elixir 表达式放入字符串常量中。表达式会立即求值,其字符串表示会放到字符串中对应位置:

iex(2)> "Embedded expression: #{3 + 0.14}"
"Embedded expression: 3.14"

经典的反斜杠转义也可用:

iex(3)> "\r \n \" \\"

字符串也不必在同一行结束:

iex(4)> "
This is
a multiline string
"

Elixir 还提供了另一种声明字符串的语法,称为 sigil。在这种方式中,可以把字符串包在 ~s() 中:

iex(5)> ~s(This is also a string)
"This is also a string"

当字符串中需要包含引号时,sigil 很有用:

iex(6)> ~s("Do... or do not. There is no try." -Master Yoda)
"\"Do... or do not. There is no try.\" -Master Yoda"

还有一个大写版本 ~S,它不会处理插值或转义字符:

iex(7)> ~S(Not interpolated #{3 + 0.14})
"Not interpolated \#{3 + 0.14}"
iex(8)> ~S(Not escaped \n)
"Not escaped \\n"

最后,还有 heredoc 语法,它更适合格式化多行字符串。Heredoc 字符串以三个双引号开始,结束的三个双引号必须独占一行:

iex(9)> """
Heredoc must end on its own line """
"""
"Heredoc must end on its own line \"\"\"\n"

因为字符串是二进制,可以用 <> 运算符连接它们:

iex(10)> "String" <> " " <> "concatenation"
"String concatenation"

处理二进制字符串有许多辅助函数,大多数位于 String 模块。

字符列表

字符列表(charlist)是一种列表,其中每个元素都是相应字符的整数码点。例如,字母 ABC 可以表示为列表 [65, 66, 67]

iex(1)> IO.puts([65, 66, 67])
ABC

也可以用 ~c sigil 创建:

iex(2)> IO.puts(~c"ABC")
ABC

另一种选择是使用单引号:

iex(3)> IO.puts('ABC')
ABC

从 Elixir 1.15 开始,推荐方式是使用 ~c。Elixir formatter 会把单引号转换成等价的 sigil。此外,字符列表在 shell 中也会使用 ~c 语法打印:

iex(4)> ~c"ABC"
~c"ABC"
iex(5)> [65, 66, 67]
~c"ABC"
iex(6)> 'ABC'
~c"ABC"

字符列表与二进制字符串不兼容。String 模块中的大多数操作不适用于字符列表。一般来说,应优先使用二进制字符串。

偶尔会遇到只接受字符列表的函数,这主要发生在纯 Erlang 库中。此时,可以使用 String.to_charlist/1 把二进制字符串转换为字符列表:

iex(7)> String.to_charlist("ABC")
~c"ABC"

要把字符列表转换为二进制字符串,可以使用 List.to_string/1

iex(8)> List.to_string(~c"ABC")
"ABC"

总体来说,应尽可能优先使用二进制字符串,只有在第三方库(通常是纯 Erlang 库)要求时才使用字符列表。

2.4.9 一等函数 (First-class functions)

在 Elixir 中,函数是一等公民,这意味着函数可以被赋值给变量。这里,把函数赋值给变量并不是调用函数并把结果存入变量,而是把函数定义本身赋值给变量,随后可以通过变量调用该函数。

要创建函数变量,可以使用 fn 表达式:

iex(1)> square = fn x ->
...(1)> x * x
...(1)> end
#Function<...>

变量 square 现在包含一个计算数字平方的函数。由于该函数没有绑定到全局名称,它也被称为匿名函数或 lambda。

注意,参数列表没有放在括号中。技术上讲这里可以使用括号,但通行约定(也由 Elixir formatter 强制)是省略括号。相反,具名函数的参数列表应放在括号中。乍看起来这有点不一致,但这种约定有很好的原因,第 3 章会解释。

调用这个函数时,需要写变量名、一个点号和参数:

iex(2)> square.(5)
25

注意 这里为什么需要点号?它的作用是让代码更明确。当你在源码中看到 square.(5) 时,就知道正在调用匿名函数。相反,square(5) 调用的是模块中某处定义的具名函数。

由于函数可以存入变量,它们也可以作为参数传给其他函数。这常用于让调用者参数化通用逻辑。例如,Enum.each/2 实现通用迭代,它可以遍历任何可枚举结构,例如列表。Enum.each/2 接收两个参数:一个可枚举结构和一个一元 lambda。它会遍历可枚举结构,并对每个元素调用这个 lambda。

下面的代码使用 Enum.each 把列表中的每个值打印到屏幕:

iex(3)> print_element = fn x -> IO.puts(x) end
iex(4)> Enum.each(
...(4)> [1, 2, 3],
...(4)> print_element
...(4)> )
1
2
3
:ok

当然,不需要临时变量也能把 lambda 传给 Enum.each

iex(5)> Enum.each(
...(5)> [1, 2, 3],
...(5)> fn x -> IO.puts(x) end
...(5)> )
1
2
3

这个 lambda 只是把所有参数转发给 IO.puts,没有做其他有意义的工作。对于这种情况,Elixir 可以直接引用函数,写出更紧凑的 lambda。与其写 fn x -> IO.puts(x) end,可以写 &IO.puts/1

& 运算符也称为捕获运算符,它接收完整的函数限定符:模块名、函数名和元数,并把该函数转换成可赋给变量的 lambda。可以用它简化 Enum.each

iex(6)> Enum.each(
...(6)> [1, 2, 3],
...(6)> &IO.puts/1
...(6)> )

捕获运算符也可用于缩短 lambda 定义,使你可以省略显式参数名。例如,可以把下面的定义:

iex(7)> lambda = fn x, y, z -> x * y + z end

改写成更紧凑的形式:

iex(8)> lambda = &(&1 * &2 + &3)

这会创建一个三元 lambda。每个参数通过 &n 占位符引用,其中 n 表示函数的第几个参数:

iex(9)> lambda.(2, 3, 4)
10

闭包

lambda 可以引用外部作用域中的任何变量:

iex(1)> outside_var = 5
5
iex(2)> my_lambda = fn ->
...(2)> IO.puts(outside_var)
...(2)> end
iex(3)> my_lambda.()
5

只要持有对 my_lambda 的引用,变量 outside_var 也仍然可访问。这也称为闭包:持有 lambda 的引用,就间接持有了它使用的所有变量的引用,即使这些变量来自外部作用域。

闭包总是捕获特定的内存位置。重新绑定变量不会影响之前定义的、引用同一符号名的 lambda:

iex(1)> outside_var = 5
iex(2)> lambda = fn -> IO.puts(outside_var) end
iex(3)> outside_var = 6
iex(4)> lambda.()
5

这说明另一个重要点:通常,在你把 outside_var 重新绑定到值 6 之后,原始内存位置就可以被垃圾回收。但因为 lambda 捕获了原始位置(其中保存数字 5),而你仍在引用这个 lambda,所以原始位置不能被回收。

2.4.10 其他内置类型 (Other built-in types)

还有几个类型尚未介绍。我们不会深入讨论它们,但为了完整性值得一提:

  • 引用(reference) 是 BEAM 实例中几乎唯一的一段信息。它通过 Kernel.make_ref/0(或 make_ref)生成。根据 Elixir 文档,一个引用大约在 2^82 次调用之后才会再次出现。不过,如果重启 BEAM 实例,引用生成会从头开始,因此其唯一性只保证在该 BEAM 实例的生命周期内成立。
  • 进程标识符(PID) 用于标识 Erlang 进程。PID 在并发任务协作时很重要。第 5 章讨论 Erlang 进程时会学习它们。
  • 端口标识符(port identifier) 在使用端口时很重要。端口是 Erlang 用于与外部世界通信的机制。文件 I/O 和与外部程序通信都通过端口完成。端口超出了本书范围。

到这里,我们已经覆盖了所有基础数据类型。可以看到,Elixir 的类型系统很简单,只由少量数据类型组成。

当然,还有构建在这些基础类型之上的更高层类型,用于提供额外功能。下面看看 Elixir 附带的一些最重要类型。

2.4.11 更高层类型 (Higher-level types)

前面提到的内置类型继承自 Erlang 世界。毕竟 Elixir 代码运行在 BEAM 上,因此其类型系统深受 Erlang 基础影响。但在这些基础类型之上,Elixir 提供了一些更高层抽象。

最常用的包括 RangeKeywordMapSetDateTimeNaiveDateTimeDateTime。下面逐一看看。

Range

范围是一种表示数字范围的抽象。Elixir 甚至提供了定义范围的专门语法:

iex(1)> range = 1..2
1..2

可以用 in 运算符询问某个数字是否落在范围内:

iex(2)> 2 in range
true
iex(3)> -1 in range
false

范围是可枚举的,因此 Enum 模块中的函数知道如何处理它们。下面使用 Enum.each/2 打印前三个自然数:

iex(4)> Enum.each(
...(4)> 1..3,
...(4)> &IO.puts/1
...(4)> )
1
2
3

Range 并不是特殊类型。内部它表示为一个包含范围边界的映射。因此,无论代表多少元素,范围的内存占用都很小且恒定。包含一百万个数字的范围仍然只是一个小映射。

关键字列表

关键字列表是一种特殊列表,其中每个元素都是二元元组,且每个元组的第一个元素都是原子。第二个元素可以是任意类型。

iex(1)> days = [{:monday, 1}, {:tuesday, 2}, {:wednesday, 3}]
[monday: 1, tuesday: 2, wednesday: 3]

Elixir 支持稍短的语法来定义关键字列表:

iex(2)> days = [monday: 1, tuesday: 2, wednesday: 3]
[monday: 1, tuesday: 2, wednesday: 3]

两个表达式得到的都是同一个结果:一个由键值对组成的列表。关键字列表常用于小型键值结构,其中键是原子。可以使用 Keyword 模块中的函数,例如 Keyword.get/2

iex(3)> Keyword.get(days, :monday)
1
iex(4)> Keyword.get(days, :noday)
nil

也可以像映射一样用 [] 获取值:

iex(5)> days[:tuesday]
2

不过不要被迷惑:这里处理的是列表,因此查找复杂度是 O(n)

关键字列表最常见的用途是允许调用者传递任意数量的可选参数。例如,IO.inspect 会把某个项的字符串表示打印到控制台,其结果可以通过关键字列表提供额外选项来控制:

iex(6)> IO.inspect([100, 200, 300])
[100, 200, 300]
iex(7)> IO.inspect([100, 200, 300], [width: 3])
[100,
200,
300]

这种模式非常频繁,因此如果最后一个参数是关键字列表,Elixir 允许省略方括号:

iex(8)> IO.inspect([100, 200, 300], width: 3, limit: 1)
[100,
...]

在这个例子中,你仍然向 IO.inspect/2 发送两个参数:一个数字列表和一个包含两个元素的关键字列表。不过这个片段展示了如何模拟可选参数。可以把关键字列表作为函数最后一个参数,并让该参数默认等于空列表:

def my_fun(arg1, arg2, opts \\ []) do
...
end

客户端随后可以通过最后一个参数传递选项。当然,你需要检查 opts 参数中的内容,并根据调用方发送的内容执行条件逻辑。

你可能会想,用映射作为可选参数是否更好。关键字列表可以包含同一个键的多个值;此外,可以控制关键字列表元素的顺序,而映射不行。最后,Elixir 和 Erlang 标准库中的许多函数都以关键字列表形式接收选项。因此,最好遵循现有约定,用关键字列表接收可选参数。

MapSet

MapSet 是集合的实现:它保存唯一值,且值可以是任意类型。下面是一些例子:

iex(1)> days = MapSet.new([:monday, :tuesday, :wednesday])
MapSet.new([:monday, :tuesday, :wednesday])
iex(2)> MapSet.member?(days, :monday)
true
iex(3)> MapSet.member?(days, :noday)
false
iex(4)> days = MapSet.put(days, :thursday)
MapSet.new([:monday, :tuesday, :wednesday, :thursday])

可以使用 MapSet 模块中的函数操作集合。MapSet 也是可枚举结构,因此可以传给 Enum 模块中的函数:

iex(5)> Enum.each(days, &IO.puts/1)
monday
thursday
tuesday
wednesday

从输出可以看出,MapSet 不保持元素顺序。

时间与日期

Elixir 有几个用于处理日期和时间类型的模块:DateTimeDateTimeNaiveDateTime

可以用 ~D sigil 创建日期。下面创建表示 2023 年 1 月 31 日的日期:

iex(1)> date = ~D[2023-01-31]
~D[2023-01-31]

创建日期后,可以获取各个字段:

iex(2)> date.year
2023
iex(3)> date.month
1

类似地,可以用 ~T sigil 表示时间,提供小时、分钟、秒和微秒:

iex(1)> time = ~T[11:59:12.00007]
~T[11:59:12.00007]
iex(2)> time.hour
11
iex(3)> time.minute
59

除了这些类型,还可以用 NaiveDateTimeDateTime 模块处理日期时间。朴素版本可以用 ~N sigil 创建:

iex(1)> naive_datetime = ~N[2023-01-31 11:59:12.000007]
~N[2023-01-31 11:59:12.000007]
iex(2)> naive_datetime.year
2023
iex(3)> naive_datetime.hour
11

DateTime 模块可用于处理日期时间并支持时区。UTC 日期时间实例可以用 ~U sigil 创建:

iex(1)> datetime = ~U[2023-01-31 11:59:12.000007Z]
~U[2023-01-31 11:59:12.000007Z]
iex(2)> datetime.year
2023
iex(3)> datetime.hour
11
iex(4)> datetime.time_zone
"Etc/UTC"

2.4.12 IO 列表 (IO lists)

IO 列表是一种特殊列表,用于增量构建将被发送到 I/O 设备(例如网络或文件)的输出。IO 列表中的每个元素必须是以下之一:

  • 0 到 255 范围内的整数
  • 二进制
  • IO 列表

换句话说,IO 列表是深度嵌套结构,其中叶子元素是普通字节或二进制。例如,下面是用复杂 IO 列表表示的 "Hello, world!"

iex(1)> iolist = [[[~c"He"], "llo,"], " worl", "d!"]
[[[~c"He"], "llo,"], " worl", "d!"]

注意,可以把字符列表和二进制字符串组合到深度嵌套列表中。

许多 I/O 函数可以直接且高效地处理这种数据。例如,可以把这个结构打印到屏幕:

iex(2)> IO.puts(iolist)
Hello, world!
:ok

底层会把结构展平,你会看到人类可读的输出。把 IO 列表发送到文件或网络套接字时也有同样效果。

当需要增量构建字节流时,IO 列表非常有用。普通列表在这种情况下通常效率不高,因为向列表追加是 O(n) 操作。相比之下,向 IO 列表追加是 O(1),因为可以使用嵌套:

iex(3)> iolist = []
[]
iex(4)> iolist = [iolist, "This"]
[[], "This"]
iex(5)> iolist = [iolist, " is"]
[[[], "This"], " is"]
iex(6)> iolist = [iolist, " an"]
[[[[], "This"], " is"], " an"]
iex(7)> iolist = [iolist, " IO list."]
[[[[[], "This"], " is"], " an"], " IO list."]

这里通过创建一个包含两个元素的新列表来追加到 IO 列表:前一个 IO 列表版本和要追加的后缀。每次操作都是 O(1),因此性能很好。然后可以把该数据发送给 I/O 函数:

iex(8)> IO.puts(iolist)
This is an IO list.
:ok

至此,我们完成了对类型系统的初步游览。我们覆盖了大部分基础内容,之后会根据需要在全书中继续扩展。接下来,是时候了解一下 Elixir 运算符了。

2.5 运算符 (Operators)

本章中你已经使用了各种运算符,本节会系统地看看 Elixir 中最常用的那些。大多数运算符定义在 Kernel 模块中,可以参考该模块文档获取详细说明。

先从算术运算符开始。它们包括标准的 +-*/,工作方式基本符合预期。例外是除法运算符总是返回浮点数,这在前面讨论数字时已经提到。

比较运算符也与你熟悉的大致相同:

运算符描述
===, !==严格相等/不等
==, !=弱相等/不等
<, >, >=, <=小于、大于、大于等于、小于等于

这里唯一需要讨论的是严格相等和弱相等之间的区别。它只在比较整数和浮点数时相关:

iex(1)> 1 == 1.0
true
iex(2)> 1 === 1.0
false

逻辑运算符作用于布尔原子。前面讨论原子时已经见过它们:andornot

与逻辑运算符不同,短路运算符使用真值概念:原子 falsenil 被视为假值,其他所有值都被视为真值。&& 运算符在第一个表达式是假值时返回第一个表达式,否则返回第二个表达式。|| 运算符在第一个表达式是真值时返回第一个表达式,否则返回第二个表达式。一元运算符 ! 在值为真值时返回 false,否则返回 true

这里介绍的并不是所有可用运算符。例如,你已经见过管道运算符 |>。不过这些是最常见的一些,值得集中说明一次。

许多运算符都是函数

Elixir 中许多运算符实际上是函数。例如,除了调用 a + b,也可以调用 Kernel.+(a, b)。当然没人愿意这样写普通代码,但运算符函数在转换为匿名函数时很有用。例如,可以通过 &Kernel.+/2 或更短的 &+/2 创建一个二元 lambda,用于求两个数字之和。这样的 lambda 可与各种枚举和流函数配合使用,第 3 章会涉及。

我们几乎完成了对语言的初步游览。还剩一件事:Elixir 宏。

2.6 宏 (Macros)

宏是 Elixir 带来的最重要特性之一,而纯 Erlang 中没有这个特性。宏可以在编译期执行强大的代码转换,从而减少样板代码,并提供优雅的迷你 DSL 表达式。

宏是一个相当复杂的主题,要完整讲解需要一本小书。由于本书更关注运行时和 BEAM,而宏是应谨慎使用的相对高级特性,这里不会详细展开。不过,你应该对宏的工作方式有一个基本认识,因为许多 Elixir 特性都由宏驱动。

宏由可以改变输入代码语义的 Elixir 代码组成。宏总是在编译期被调用;它接收输入 Elixir 代码的解析表示,并有机会返回该代码的另一个版本。

用一个例子说明。unless(等价于 if not)是 Elixir 提供的一个简单宏:

unless some_expression do
block_1
else
block_2
end

unless 不是特殊关键字。它是一个宏,也就是说是一个 Elixir 函数,会把输入代码转换为类似下面的形式:

if some_expression do
block_2
else
block_1
end

这种转换无法用 C 风格宏完成,因为表达式代码可以任意复杂且深度嵌套。但在 Elixir 宏中,你处理的是已经解析过的源码表示,因此可以分别访问表达式和两个代码块。

最终效果是,Elixir 的许多部分都是借助宏用 Elixir 自身写成的。这包括 unlessif 表达式,以及 defmoduledef。其他语言通常把这些特性作为关键字,而 Elixir 则构建在更小的语言核心之上。

关键点是:宏是编译期代码转换器。每当我说明某个东西是宏时,其隐含意思就是它在编译期运行并生成替代代码。

到这里,我们完成了对 Elixir 语言的初步游览。不过在结束本章之前,还应讨论底层运行时的一些重要方面。

2.7 理解运行时 (Understanding the runtime)

如前所述,Elixir 运行时就是一个 BEAM 实例。一旦编译完成、系统启动,Erlang 就接管控制。熟悉虚拟机的一些细节很重要,因为这能帮助你理解系统如何工作。首先看看模块在运行时中的意义。

2.7.1 运行时中的模块和函数 (Modules and functions in the runtime)

无论以何种方式启动运行时,都会启动一个用于 BEAM 实例的操作系统进程,所有内容都运行在该进程内部。即使使用 iex shell 也是如此。如果需要找到这个操作系统进程,可以按名称 beam 查找。

系统启动后,你会运行一些代码,通常是调用模块中的函数。运行时如何访问代码?VM 会跟踪所有已经加载到内存中的模块。当你调用某个模块中的函数时,BEAM 首先检查该模块是否已加载。如果已加载,就执行对应函数的代码。否则,VM 会尝试在磁盘上找到已编译模块文件,也就是字节码,然后加载它并执行函数。

注意 前面的描述揭示了:每个已编译模块都位于单独文件中。已编译模块文件扩展名为 .beam。文件名对应模块名。

特殊形式

Elixir 编译器会用特殊方式处理一些语言构造。这些构造称为 special forms。一些例子包括捕获语法 &(...)for 推导式(第 3 章介绍)、receive 表达式(第 5 章)以及 try 块(第 8 章)。

模块名和原子

回忆一下模块是如何定义的:

defmodule Geometry do
...
end

也请回忆原子部分的讨论:Geometry 是一个别名,对应 :"Elixir.Geometry"

iex(1)> Geometry == :"Elixir.Geometry"
true

这并非巧合。当你编译包含 Geometry 模块的源文件时,磁盘上生成的文件名是 Elixir.Geometry.beam,无论输入源文件叫什么。事实上,如果一个源文件中定义了多个模块,编译器会为这些模块生成多个 .beam 文件。

可以从命令行调用 Elixir 编译器 elixirc 试试:

$ elixirc source.ex

如果 source.ex 定义了几个模块且没有语法错误,你会在磁盘上看到多个 .beam 文件。

在运行时,模块名是别名,而别名是原子。第一次调用某个模块的函数时,BEAM 会尝试在磁盘上找到对应文件。VM 会先在当前目录查找,然后在代码路径中查找。

当使用 Elixir 工具(例如 iex)启动 BEAM 时,会预定义一些代码路径。可以通过 -pa 开关添加额外代码路径:

$ iex -pa my/code/path -pa another/code/path

可以调用 Erlang 函数 :code.get_path 查看运行时使用了哪些代码路径。

如果模块已经加载,运行时就不会在磁盘上搜索它。这可以在启动 shell 时用于自动加载模块:

$ iex my_source.ex

该命令会编译源文件,并立即加载所有生成的模块。注意,这种情况下 .beam 文件不会保存到磁盘。iex 工具执行的是内存内编译。

同样,也可以在 shell 中定义模块:

iex(1)> defmodule MyModule do
...(1)> def my_fun, do: :ok
...(1)> end
iex(2)> MyModule.my_fun
:ok

这种情况下字节码同样不会保存到磁盘。

纯 Erlang 模块

前面已经见过如何调用纯 Erlang 模块中的函数。再看一下这种语法:

:code.get_path

在 Erlang 中,模块也对应原子。磁盘上某处有一个名为 code.beam 的文件,包含 :code 模块的编译代码。Erlang 使用简单文件名,这也是这种调用语法的原因。但规则与 Elixir 模块相同。事实上,Elixir 模块不过是名字更华丽的 Erlang 模块,例如 Elixir.MyModule

你可以在 Elixir 中创建简单名称的模块,虽然不推荐:

defmodule :my_module do
...
end

编译包含这种定义的源文件会在磁盘上生成 my_module.beam

这段讨论中需要记住的重要点是:运行时中,模块名是原子。磁盘上某处有一个 xyz.beam 文件,其中 xyz 是别名展开后的形式,例如模块名为 MyModule 时,对应 Elixir.MyModule

动态调用函数

与这个讨论相关的是在运行时动态调用函数的能力。可以借助 Kernel.apply/3 完成:

iex(1)> apply(IO, :puts, ["Dynamic function call."])
Dynamic function call.
:ok

Kernel.apply/3 接收三个参数:模块原子、函数原子,以及传给函数的参数列表。这三个参数通常称为 module、function、arguments(MFA),它们包含调用某个已导出(公开)函数所需的全部信息。Kernel.apply/3 在需要运行时决定调用哪个函数时很有用。

2.7.2 启动运行时 (Starting the runtime)

启动 BEAM 有几种方式。到目前为止,你一直在使用 iex,接下来一段时间也会继续使用它。不过,先快速看看所有可能的运行时启动方式。

交互式 shell

启动 shell 时,底层会启动 BEAM 实例,然后 Elixir shell 接管控制。shell 接收输入、解释输入并打印结果。

需要注意的是,输入是被解释的,这意味着它没有编译后代码那么高效。通常这没问题,因为 shell 只是用于实验语言。不过,不应直接在 iex 中测量性能。另一方面,模块总是会被编译。即使在 shell 中定义模块,它也会被编译并加载到内存中,因此不会有性能损失。

运行脚本

elixir 命令可以用于运行单个 Elixir 源文件。基本语法如下:

$ elixir my_source.ex

启动后会发生以下事情:

  1. BEAM 实例启动。
  2. 文件 my_source.ex 在内存中编译,生成的模块被加载到 VM 中。磁盘上不会生成 .beam 文件。
  3. 模块外部的代码会被解释。
  4. 所有内容完成后,BEAM 停止。

这通常适合运行脚本。实际上,推荐这类脚本使用 .exs 扩展名,末尾的 s 表示它是脚本。

下面是一个简单的 Elixir 脚本:

defmodule MyModule do
def run do
IO.puts("Called MyModule.run")
end
end

MyModule.run

可以从命令行执行这个脚本:

$ elixir script.exs

该调用首先在内存中编译 MyModule 模块,然后调用 MyModule.runMyModule.run 调用结束后,BEAM 实例停止。

如果不希望 BEAM 实例终止,可以提供 --no-halt 参数:

$ elixir --no-halt script.exs

如果主代码(模块外部代码)启动了执行所有工作的并发任务,这通常很有用。在这种情况下,主调用会在并发任务启动后立即结束,BEAM 也会立刻终止,导致没有工作真正完成。提供 --no-halt 选项可以让整个系统保持存活并继续运行。

Mix 工具

mix 工具用于管理由多个源文件组成的项目。只要需要构建生产级系统,mix 就是最佳选择。

要创建新的 Mix 项目,可以在命令行调用 mix new project_name

$ mix new my_project

这会创建一个名为 my_project 的新文件夹,包含若干子文件夹和文件。可以进入该文件夹并编译整个项目:

$ cd my_project
$ mix compile
Compiling 1 file (.ex)
Generated my_project app

编译会遍历 lib 文件夹中的所有文件,并把生成的 .beam 文件放入 ebin 文件夹。

可以在项目上执行各种 mix 命令。例如,生成器创建了带有单个 hello/0 函数的模块 MyProject。可以用 mix run 调用它:

$ mix run -e "IO.puts(MyProject.hello())"
world

生成器还会创建一些测试,可以用 mix test 执行:

$ mix test
..
Finished in 0.03 seconds
2 tests, 0 failures

无论如何启动 Mix 项目,它都会确保 ebin 文件夹(.beam 文件所在位置)位于加载路径中,因此 VM 能找到你的模块。

当开始创建更复杂系统时,你会大量使用 mix。目前不需要再深入更多细节。

总结

  • Elixir 代码被划分为模块和函数。
  • Elixir 是动态语言。变量的类型由它当前持有的值决定。
  • 数据是不可变的,不能被修改。函数可以返回输入的修改版本,该版本位于另一个内存位置,并尽可能与原数据共享内存。
  • 最重要的原始数据类型是数字、原子和二进制。
  • 没有布尔类型;使用原子 truefalse
  • 没有可空类型;可以用原子 nil 表示这一目的。
  • 没有字符串类型;可以使用二进制(推荐)或列表(必要时)。
  • 内置复杂类型包括元组、列表和映射。元组用于组合少量固定大小字段;列表用于管理可变大小集合;映射是键值数据结构。
  • Range、关键字列表、MapSetDateTimeNaiveDateTimeDateTime 是构建在现有内置类型之上的抽象。
  • 函数是一等公民。
  • 模块名是原子(或别名),对应磁盘上的 .beam 文件。
  • 启动程序有多种方式:iexelixirmix 工具。