第6章 模块与命名函数
Elixir 模块与命名函数
一旦代码量增长到多行,就需要组织它们。Elixir 让这个工作变得很容易。你可以将代码分割成命名函数,然后再组织成模块。
:light_bulb: 核心规则:在 Elixir 中,命名函数必须编写在模块内部。
基础示例
打开你的工作目录,创建一个名为 times.exs 的 Elixir 源文件:
# mm/times.exs
defmodule Times do
def double(n) do
n * 2
end
end
- defmodule Times:定义了一个名为 Times 的模块。
- def double(n):在模块内定义了一个名为 double 的函数,它接收一个参数 n。
- 函数标识符 (double/1):由于该函数只有一个参数,而参数个数也是 Elixir 函数标识符的一部分,因此在 Elixir 中该函数会被写作 double/1。
Elixir 编译与加载模块
在 Elixir 中,有两种主要的方法来编译源文件并将其加载到交互式命令行 iex 中。
方法 1:启动 iex 时直接加载文件
如果你刚打开命令行提示符,可以在启动 iex 的同时传入文件名。iex 会在显示提示符之前编译并装载该文件,进入后可直接调用:
iex times.exs
Erlang/OTP 29 [erts-17.0.4] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit:ns]
Interactive Elixir (1.20.2) - press Ctrl+C to exit (type h() ENTER for help)
iex> Times.double(4)
8
方法 2:在 iex 内部使用 c 命令编译
如果你已经进入了 iex 会话,可以使用内建的 c 命令来编译文件,而不需要退出命令行:
iex> c "times.exs"
[Times]
iex> Times.double(4)
8
:light_bulb: 原理解析:c "times.exs" 编译了源文件,并将其装载到 iex 内。随后,我们可以通过 Times.double 反复调用 Times 模块的 double 函数。
错误处理与堆栈追踪
如果传入不合法的参数(例如将字符串传给需要数字的算术函数),会发生什么?
iex> Times.double("cat")
** (ArithmeticError) bad argument in arithmetic expression: "cat" * 2
:erlang.*("cat", 2)
times.exs:4: Times.double/1
iex:3: (file)
- 明确指出你正在尝试让字符串 "cat" 乘以数字 2,这在数学上是不可能的。
- 暴露底层 Erlang 调用 (
:erlang.*("cat", 2)), Elixir 最终会被编译成 Erlang 字节码并在 BEAM 虚拟机上运行。堆栈第一行显示,错误真正发生的地方是 Erlang 内部的乘法函数:erlang.*。 - times.exs:4: 错误起源于你的源文件 times.exs 的第 4 行(即
n * 2这一行) - iex:3: (file): 这个调用最初是由你在交互式命令行 iex 中输入的第 3 行指令触发的
看出来你使用的是更新版本的 Elixir(或 Erlang)!
新版本的 Elixir 在抛出异常时提供了更详细的上下文和更清晰的堆栈追踪(Stacktrace)。我们可以对比分析一下你发出的这段新版错误日志:
:magnifying_glass_tilted_left: 新版错误日志深度解析
iex> Times.double("cat")
** (ArithmeticError) bad argument in arithmetic expression: "cat" * 2
:erlang.*("cat", 2)
times.exs:4: Times.double/1
iex:3: (file)
- 精确的错误原因 (: "cat" * 2)
- 旧版只模糊地告诉你算术表达式有坏参数。
- 新版直接把作恶的底层代码复现给你看:"cat" * 2。它明确指出你正在尝试让字符串 "cat" 乘以数字 2,这在数学上是不可能的。
- 暴露底层 Erlang 调用 (:erlang.*("cat", 2))
- Elixir 最终会被编译成 Erlang 字节码并在 BEAM 虚拟机上运行。
- 堆栈第一行显示,错误真正发生的地方是 Erlang 内部的乘法函数 :erlang.*。
- 更完整的堆栈追踪 (Stacktrace)
- times.exs:4: 错误起源于你的源文件 times.exs 的第 4 行(即 n * 2 这一行)。
- iex:3: (file): 这是旧版没有的。它告诉你,这个调用最初是由你在交互式命令行 iex 中输入的第 3 行指令触发的。
:shield: 如何在 Elixir 中防止这种错误?
为了防止用户传入 "cat" 这样的非数字参数,Elixir 提供了两种优雅的解决方式:
方案 A:使用卫语句(Guards)—— 最推荐, 可以使用 when 关键字限制参数必须是数字(整数或浮点数):
defmodule Times do
# 只有当 n 是数字时,才会进入这个函数
def double(n) when is_number(n) do
n * 2
end
end
- 效果:如果此时你再输入 Times.double("cat"),它不会报算术错误,而是直接报 FunctionClauseError(函数子句不匹配错误),明确告诉你没有找到能接收字符串的 double 函数。
方案 B:使用模式匹配与多重函数子句,可以利用 Elixir 强大的模式匹配,专门写一个处理错误输入的子句:
defmodule Times do
# 正确的数字处理
def double(n) when is_number(n), do: n * 2
# 如果传了其他任何东西(比如字符串),返回友好的提示
def double(_invalid_input) do
"抱歉,我只能给数字做翻倍处理!"
end
end
Elixir 的函数元数 (Arity)
Elixir 命名函数的标识符是由函数名加上参数个数(它的 arity)共同决定的。
- double/1:因为这个 double 函数有一个参数,所以 Elixir 称它为 double/1。
- double/3:假如另外一个 double 函数有三个参数,它将被写为 double/3。
:warning: 重要设计哲学:
Elixir 会认为 double/1 和 double/3 是两个完全不同的函数。虽然从人类的角度来看,同名函数可能具有相关性,但在 Elixir 中它们完全独立。因此,不要用两个同名同姓但参数不同的函数去做完全不相关的事情。
Elixir 函数体与 do…end 的本质
在 Elixir 中,代码块 do…end 是一种组织表达式的方式。它允许我们将一段代码作为整体传递给模块定义、函数定义或控制结构。
日常编写的 do…end 形式其实只是一块语法糖。在编译期间,它会被转换为包含 do: 键的关键字列表(Keyword List)形式。
1. 单行语法
通常,在编写单行代码时,我们会直接使用底层的 do: 语法:
def double(n), do: n * 2
2. 多行语法
在编写多行代码时,底层的 do: 语法需要配合圆括号 () 来传递多条语句:
def greet(greeting, name), do: (
IO.puts greeting
IO.puts "How're you doing, #{name}?"
)
:light_bulb: 常规写法:为了可读性,多行代码在实际开发中一律使用 do…end 语法糖。
之前的 times.exs 文件改写为完全不使用 do…end 的形式。
示例 1:单行函数风格
# mm/times1.exs
defmodule Times do
def double(n), do: n * 2
end
示例 2:全关键字列表风格(极端情况),我们甚至可以将模块和函数全部压缩进一行:
defmodule Times, do: (def double(n), do: n * 2)
:warning: 警告:但是不建议这样做。 这种写法极大地破坏了代码的可读性,仅用于理解底层语法的本质。
习题
习题: 扩展Times模块,新增一个triple函数,计算传入参数的立方。
# mm/times1.exs
defmodule Times do
def double(n), do: n * 2
def triple(n), do: n * n * n
end
习 题 : 添加一个quadruple函数, 计算参数的4倍。(也许需要调用double函数。)
# mm/times1.exs
defmodule Times do
def double(n), do: n * 2
def triple(n), do: n * n * n
def quadruple(n), do: double(double(n))
end
在 Elixir 中,关键字列表的冒号后面必须加一个空格 do: double(double(n))。
函数调用与模式匹配
在 Elixir 中,命名函数同样支持通过模式匹配来绑定形参和实参。与匿名函数不同的是,同一个命名函数可以被书写多次,但每次的参数列表(模式)和函数体不同。虽然表面上看是在定义多个函数,但从纯粹主义的角度来看,它们其实只是同一个定义下的多个子句(Clauses)。
:gear: 函数的匹配机制
当调用一个命名函数时,Elixir 会采取自上而下的匹配策略:
-
- 尝试匹配第一个子句定义的参数列表。
-
- 如果不匹配,继续尝试下一个子句(注意:各个子句的形参数量(Arity)必须相同)。
-
- 测试是否匹配,直到匹配完所有候选选项为止。如果全部不匹配,则会抛出错误。
:abacus: 经典示例:计算阶乘 (Factorial)
数字 $n$ 的阶乘(写作 $n!$)表示从 $1$ 到 $n$ 的所有整数相乘。数学约定中,$0! = 1$。
在数学上,阶乘可以用递归定义:
- $factorial(0) \rightarrow 1$
- $factorial(n) \rightarrow n \times factorial(n-1)$
在 Elixir 中,我们可以用两个函数子句非常自然地表达这个数学定义:
# mm/factorial1.exs
defmodule Factorial do
def of(0), do: 1
def of(n), do: n * of(n - 1)
end
如果我们调用 Factorial.of(2),BEAM 虚拟机内部的执行流程如下:
-
- 第一次调用 Factorial.of(2):
- 尝试匹配第一个子句 of(0) :right_arrow: 失败(2 不等于 0)。
- 尝试匹配第二个子句 of(n) :right_arrow: 成功,变量 n 被绑定为 2。
- 执行函数体:2 * Factorial.of(1)。
- 第一次调用 Factorial.of(2):
-
- 第二次调用 Factorial.of(1):
- 尝试匹配第一个子句 of(0) :right_arrow: 失败(1 不等于 0)。
- 尝试匹配第二个子句 of(n) :right_arrow: 成功,变量 n 被绑定为 1。
- 执行函数体:1 * Factorial.of(0)。
- 第二次调用 Factorial.of(1):
-
- 第三次调用 Factorial.of(0):
- 尝试匹配第一个子句 of(0) :right_arrow: 成功!
- 直接返回结果 1,递归在此处结束。
- 第三次调用 Factorial.of(0):
-
- 弹栈并计算最终结果:
- 回溯乘法链:2 * (1 * 1) :right_arrow: 最终返回 2。
- 弹栈并计算最终结果:
函数子句的顺序与匹配警告
在 Elixir 中,多子句函数的编写顺序至关重要。Elixir 会严格按照自上而下的顺序进行模式匹配,并执行最先匹配成功的那一项。
如果把通配子句(能匹配任何内容的子句)放在具体子句的前面,代码就会产生逻辑错误。
# mm/factorial1-bad.exs
defmodule BadFactorial do
def of(n), do: n * of(n - 1)
def of(0), do: 1
end
-
- 第一个子句 def of(n) 中的变量 n 可以绑定任何传入的值。
-
- 即使传入 0,也会先被 of(n) 捕获,永远走不到底部的 of(0)。
-
- 这会导致无限递归,引发栈溢出错误(Stack Overflow)。
编译报错信息:
warning: this clause for of/1 cannot match because a previous clause at line 3 always matches
│
4 │ def of(0), do: 1
│ ~
│
└─ times.exs:4:7
warning: the following clause is redundant:
def of(0)
it has type:
integer()
previous clauses have already matched on the following types:
term()
│
4 │ def of(0), do: 1
│ ~
│
└─ times.exs:4:7: BadFactorial.of/1
[BadFactorial]
编译器:“你第 4 行准备匹配一个具体的整数 0,但在第 3 行,你已经用一个变量匹配了任何类型(term())。任何类型当然包含了整数,所以第 4 行是完全多余(redundant)的!”
:pushpin: 核心最佳实践
-
- 从最具体到最通用:编写多子句函数时,永远将特殊的、具体的边界条件(如 0、[]、{:ok, result})写在最上面,将通用的变量匹配(如 n、list)写在最下面。
-
- 同名函数写在一起:在源文件中,同一个模块内所有同名且参数个数相同(Arity 相同)的函数子句,必须紧挨着写在一起,不能在它们中间夹杂其他函数的定义。
习题
实现并测试一个sum(n)函数,其使用递归计算从1到n的整数和。你需要将函数写在模块里并存为单独的文件,然后装载进iex,编译并测试此函数。
defmodule MyTest do
def sum(1), do: 1
def sum(n), do: n+sum(n-1)
end
测试:
iex> MyTest.sum(1)
1
iex> MyTest.sum(50)
1275
iex> MyTest.sum(100)
5050
编写一个函数gcd(x,y),寻找两个非负整数的最大公约数。以代数方式描述为,如果y是零,则gcd(x,y)等于x;否则等于gcd(y,rem(x,y))
defmodule MyTest do
def gcd(x,0), do: x
def gcd(x,y), do: gcd(y,rem(x,y))
end
哨兵子句 (Guard Clauses)
模式匹配允许 Elixir 按照传入参数的结构或固定值来确定调用哪个函数。但如果需要基于类型进行判断,或者要对参数值做更复杂的条件测试,就需要使用哨兵子句(Guard Clauses)。
:gear: 什么是哨兵子句?
- 哨兵子句是一个或多个由 when 关键字紧接在函数定义之后的断言表达式。
- 执行机制:当进行模式匹配时,Elixir 首先执行普通的、基于参数结构的匹配。如果结构匹配成功,接着评估所有 when 断言。仅当至少有一条断言为真(true)时,才执行该函数体。
:laptop: 基础示例
创建一个名为 guard.exs 的源文件:
# mm/guard.exs
defmodule Guard do
# 检查参数是否为数字
def what_is(x) when is_number(x) do
IO.puts "#{x} is a number"
end
# 检查参数是否为列表
def what_is(x) when is_list(x) do
IO.puts "#{inspect(x)} is a list"
end
# 检查参数是否为原子 (Atom)
def what_is(x) when is_atom(x) do
IO.puts "#{x} is an atom"
end
end
inspect(x):在第二个子句中,由于直接通过字符串插值打印列表会尝试将其转换开,而 inspect/1 可以将列表以可读的代码文本形式(如 "[1, 2, 3]")打印出来。
测试:
iex> Guard.what_is(99)
99 is a number
iex> Guard.what_is(:cat)
cat is an atom
iex> Guard.what_is([1, 2, 3])
[1, 2, 3] is a list
之前例子修改:
# mm/factorial2.exs
defmodule Factorial do
def of(0), do: 1
# 使用 when 关键字添加哨兵子句
def of(n) when n > 0 do
n * of(n - 1)
end
end
测试:
iex> Factorial.of(10)
3628800
iex> Factorial.of(-10)
** (FunctionClauseError) no function clause matching in Factorial.of/1
The following arguments were given to Factorial.of/1:
# 1
-10
times.exs:3: Factorial.of/1
iex:7: (file)
哨兵子句的限制
哨兵子句(Guard Clauses)仅支持 Elixir 表达式的一个子集。以下是可在哨兵子句中使用的合法运算符和内置函数分类列表:
1. 支持的运算符
- 比较运算符
- ==、!=、===、!==、>、<、<=、>=
- 布尔和求逆运算符
- or、and、not、!
- :warning: 注意:哨兵子句不支持短路运算符 || 和 &&。
- 算术运算符
- +、-、*、/
- 连接运算符
- <> 和 ++
- :warning: 前提:只要运算符的左边是一个字面量便能使用。
- in 运算符
- in:判断成员是否在收集(collection)或者区间(range)之内。
2. 内置检查与操作函数
:test_tube: 类型检查函数
如果传入给定类型的参数,这些 Erlang 内置函数会返回 true:
| is_atom/1 | is_binary/1 | is_bitstring/1 |
| is_boolean/1 | is_exception/1 | is_float/1 |
| is_function/1 | is_integer/1 | is_list/1 |
| is_map/1 | is_number/1 | is_pid/1 |
| is_port/1 | is_record/1 | is_reference/1 |
| is_tuple/1 |
:gear: 其他允许的内置函数
这些内置函数在哨兵子句中调用时会返回相应的值(而非单纯的 true 或 false):
- 数值与数学计算:abs(number)、div(number, number)、rem(number, number)、round(number)、trunc(number)
- 大小与长度获取:bit_size(bitstring)、byte_size(bitstring)、length(list)、tuple_size(tuple)
- 集合与元组操作:elem(tuple, n)、hd(list)、tl(list)
- 系统与进程信息:node()、node(pid|ref|port)、self()
默认参数(Default Parameters)
在 Elixir 中定义命名函数时,可以使用 param \ value 的语法为任意参数指定默认值。
1. 参数匹配与覆盖机制
当调用带有默认参数的函数时,Elixir 会比较实际传入的实参数量和函数必需的形参数量:
- 实参少于必需形参:不匹配。
- 实参数量刚好相等:必需的形参获得传入的实参值,其余形参获得默认值。
- 实参多于必需形参:多出来的实参值会从左到右依次覆盖参数的默认值。
代码示例
defmodule Example do
# p1, p4 为必需参数;p2, p3 带有默认值
def func(p1, p2 \\ 2, p3 \\ 3, p4) do
IO.inspect [p1, p2, p3, p4]
end
end
测试调用:
Example.func("a", "b") # => ["a", 2, 3, "b"]
Example.func("a", "b", "c") # => ["a", "b", 3, "c"]
Example.func("a", "b", "c", "d") # => ["a", "b", "c", "d"]
2. 默认参数的冲突陷阱(CompileError)
由于默认参数会自动生成多个参数版本的函数重载,如果定义了其他同名且参数数量可重叠的函数,会导致编译冲突。
:cross_mark: 错误代码示例
# 第一个函数:可匹配 2 个、3 个或 4 个参数的调用
def func(p1, p2 \\ 2, p3 \\ 3, p4) do
IO.inspect [p1, p2, p3, p4]
end
# 第二个函数:精确匹配 2 个参数的调用
def func(p1, p2) do
IO.inspect [p1, p2]
end
:police_car_light: 编译报错信息
iex> c "times.exs"
error: def func/2 conflicts with defaults from func/4
│
8 │ def func(p1, p2) do
│ ^
│
└─ times.exs:8:7: Example.func/2
== Compilation error in file times.exs ==
** (CompileError) times.exs: cannot compile module Example (errors have been logged)
times.exs:8: (module)
** (CompileError) compile error
(iex 1.20.2) lib/iex/helpers.ex:246: IEx.Helpers.c/2
iex:10: (file)
\
- 原因分析:当调用 func(a, b) 时,Elixir 无法判断应该调用带有默认参数的 func/4(覆盖后实际接收2个参数)还是独立的 func/2。
多子句函数与默认参数的冲突
当一个函数有多个函数头(多子句/Multiple Clauses),且其中包含了默认参数时,直接编写会导致编译器无法区分,从而引发编译混乱。
1. 错误做法(导致编译失败)
在 mm/default_params1.exs 中,直接在其中一个具体实现子句中声明了默认参数。
# mm/default_params1.exs
defmodule DefaultParams1 do
# 错误:在具体有函数体的子句中声明默认参数
def func(p1, p2 \\ 123) do
IO.inspect [p1, p2]
end
def func(p1, 99) do
IO.puts "you said 99"
end
end
:police_car_light: 编译报错信息
iex> c "test.exs"
warning: variable "p1" is unused (if the variable is not meant to be used, prefix it with an underscore)
│
8 │ def func(p1, 99) do
│ ~~
│
└─ test.exs:8:12: DefaultParams1.func/2
warning: def func/2 has multiple clauses and also declares default values. In such cases, the default values should be defined in a header. Instead of:
def foo(:first_clause, b \\ :default) do ... end
def foo(:second_clause, b) do ... end
one should write:
def foo(a, b \\ :default)
def foo(:first_clause, b) do ... end
def foo(:second_clause, b) do ... end
the previous clause is defined on line 4
│
8 │ def func(p1, 99) do
│ ~
│
└─ test.exs:8:7
warning: this clause for func/2 cannot match because a previous clause at line 4 always matches
│
8 │ def func(p1, 99) do
│ ~
│
└─ test.exs:8:7
warning: the following clause is redundant:
def func(p1, 99)
it has type:
dynamic(), integer()
previous clauses have already matched on the following types:
term(), term()
│
8 │ def func(p1, 99) do
│ ~
│
└─ test.exs:8:7: DefaultParams1.func/2
- 原因分析:为了减少默认值造成的混淆,Elixir 禁止在包含多个子句的普通函数定义中直接写默认值。
2. 正确做法:使用“函数头部声明(Function Head)”
添加一个只包含默认参数、且没有函数体的空函数头。随后的具体实现子句中使用普通参数,默认值会自动应用到所有子句。
:laptop: 优化后的代码 mm/default_params2.exs
defmodule Params do
# 正确:独立的函数头,仅用于声明默认参数,无 do ... end 块
def func(p1, p2 \\ 123)
# 子句 1:使用普通参数 + 哨兵子句
def func(p1, p2) when is_list(p1) do
"You said #{p2} with a list"
end
# 子句 2:通用匹配
def func(p1, p2) do
"You passed in #{p1} and #{p2}"
end
end
:test_tube: 测试调用与输出结果
IO.puts Params.func(99) # => You passed in 99 and 123
IO.puts Params.func(99, "cat") # => You passed in 99 and cat
IO.puts Params.func([99]) # => You said 123 with a list
IO.puts Params.func([99], "dog") # => You said dog with a list
习题
任意选一个1到1000之间的数……
找到这个数的最有效的办法是猜测区间上下边界的一半。如果我们猜得过大,那么答案就落在下边界和我们猜测的数之间。如果我们猜得过小,那么答案就落在上边界和我们猜测的数之间。
调用API为guess (actual,range),此处 range 是Elixir 的区间类型。输出的结果类似于:
iex> Chop.guess (273, 1..1000)
Is it 500
Is it 250
Is it 375
Is it 312
Is it 281
Is it 265
Is it 273
273
提示:
- 你需要实现一个辅助函数,带一个额外的参数(当前猜测的数字)。
- div(a,b)函数支持整数除法。
- 哨兵子句会对你有帮助。
- 模式能匹配区间的上下边界(a..b=4..8)。
defmodule Chop do
def guess(actual,range) when actual>=range.first and actual<=range.last do
average_int = div((a+b),2)
IO.puts "Is it #{average_int}"
is_it(actual,average_int,range)
end
def is_it(actual,average_int,range) average_int==actual -> average_int
def is_it(actual,average_int,range) average_int>actual -> guess(actual,range.first..average_int)
def is_it(actual,average_int,range) average_int<actual -> guess(actual,average_int..range_last)
end
测试:
iex> c "test.exs"
[Chop]
iex> Chop.guess(273,1..1000)
Is it 500
Is it 250
Is it 375
Is it 312
Is it 281
Is it 265
Is it 273
273
私有函数(Private Functions)
在 Elixir 中,使用 defp 宏来定义私有函数。私有函数仅能在声明它的模块内部被调用,外部无法直接访问。
1. 多子句规则与限制
与普通函数(def)一样,私有函数(defp)也可以定义多个函数头(多子句匹配)。
:police_car_light: 核心限制:权限必须统一
你不能将同一个函数的某些子句定义为公有(def),而将其他子句定义为私有(defp)。所有同名同元数(Arity)的函数子句必须保持完全一致的可见性。
2. 错误做法(导致编译失败)
以下代码尝试混合使用 def 和 defp 来定义同一个 fun/1 函数,这是不合法的:
# :cross_mark: 错误代码示例
def fun(a) when is_list(a), do: true
defp fun(a), do: false
:police_car_light: 编译报错提示
若强行编译此类代码,Elixir 编译器会抛出类似以下的错误:
error: defp fun/1 already defined as def in test.exs:19
│
20 │ defp fun(a), do: false
│ ^
│
└─ test.exs:20:8
3. 正确做法
如果需要根据不同条件返回不同结果,且希望对外部隐藏内部逻辑,应当全部使用 defp 定义,并通过一个公有函数作为统一的对外接口:
# :laptop: 优化后的代码示例
defmodule MyModule do
# 统一的对外公开接口
def check(a), do: fun(a)
# 内部私有函数子句 1
defp fun(a) when is_list(a), do: true
# 内部私有函数子句 2
defp fun(_a), do: false
end
Elixir 管道运算符 (|>)
在编程中,传统的嵌套函数调用(如 f(g(h(x))))往往难以阅读,需要从内向外反向推导执行顺序。Elixir 语言提供了管道运算符 |>,它将左边表达式的结果,自动作为第一个参数传递给右边的函数调用。 这种管道式写法极大地提升了代码的可读性,让业务逻辑清晰得像一条流水线。
:pushpin: 传统写法 vs 管道写法对比
以一个模拟的业务处理流程为例(查询客户 → 寻找订单 → 计算税率 → 准备报税文件):
编写风格 代码示例 特点
传统分步式 people = DB.find_customers 产生了许多只使用一次的中间变量。
orders = Orders.for_customers(people)
tax = sales_tax(orders, 2013)
filing = prepare_filing(tax)
嵌套一行式 filing = prepare_filing(sales_tax( 极其难读。必须从里向外读才能理清顺序。
Orders.for_customers(
DB.find_customers), 2013))
管道流式 filing = DB.find_customers 清晰直观。代码从上往下读,
|> Orders.for_customers 完全符合业务实际完成的先后顺序。
|> sales_tax(2013)
|> prepare_filing
:gear: 管道运算符的运作机制
- 核心等价公式:
$ {val} {|>} f(a, b) \equiv { f(val, a, b)}$
**多参数传递规则: 在上面的例子中,orders 列表通过 |> 传递给 sales_tax(2013) 时,它会自动插到最前面,成为第一个参数。因此:
list
|> sales_tax(2013)
|> prepare_filing
实际上完全等同于:prepare_filing(sales_tax(list, 2013))。
:rocket: 高级技巧:单行连写与匿名函数
在 Elixir 中,管道运算符不仅可以换行写,完全合法的做法是串联写在同一行。
:light_bulb: 示例:计算 1 到 10 之间平方值小于 40 的数
iex> (1..10) |> Enum.map(&(&1 * &1)) |> Enum.filter(&(&1 < 40))
[1, 4, 9, 16, 25, 36]
:warning: 关键注意事项:
使用管道运算符调用带有多参数的函数或配合 & 快捷标记时,右侧的函数调用必须使用圆括号 ()。否则,& 快捷标记会与管道符号产生语意冲突,导致编译报错。
管道运算符 |> 的最大好处是让代码的格式更贴近人类的思维需求。 在编程中,编程的本质就是转换数据,而 |> 运算符能让这种数据转换的表达变得更直接、更自然。
:pushpin: 从现实需求到代码的直观转换
在日常生活中,当我们思考一个业务流程(例如计算营业税)时,通常会在纸上简略地写下以下步骤:
- 获得客户列表
- 生成他们的订单列表
- 计算订单的税
- 准备归档
在 Elixir 中,要将这页纸上的现实需求转换成代码,仅仅需要在每项之间加上 |>,并将每一项实现成一个函数:
DB.find_customers
|> Orders.for_customers
|> sales_tax(2013)
|> prepare_filing
这种流式写法不仅清晰美观,而且完全符合我们从上到下、从前到后的逻辑思考习惯。
:warning: 编写规范与核心原则
- 必须使用圆括号:在管道操作中,我们必须用圆括号将函数的参数列表括起来。这是 Elixir 语法中的一条核心铁律,可以避免编译器产生语意歧义。
- 参数的自动传递:管道运算符会自动将左侧表达式的“输出结果”,作为右侧函数调用的“第一个参数”隐式传入。例如上面代码中的 sales_tax(2013),其首位参数实际上是上一步生成的订单列表。
Elixir 模块与命名空间
在 Elixir 中,模块(Module)的主要功能是为代码定义的内容提供“命名空间(Namespace)”。它不仅可以用来组织和封装命名函数,还可以用来封装宏(Macro)、结构体(Struct)、协议(Protocol)以及其他嵌套模块。
:pushpin: 模块内外的函数调用规则
模块内外调用函数的最大区别在于是否需要指明模块名前缀(完全限定名):
- 同模块内调用(省略前缀):如果在代码里引用同一个模块里的东西,不需要加前缀,可以直接通过函数名调用。
- 跨模块调用(必须前缀):如果想在某个模块的外部引用该模块里的函数,则必须加上模块名作为前缀。
:laptop: 代码示例与行为解析
# 定义一个名为 Mod 的模块
defmodule Mod do
def func1 do
IO.puts "in func1"
end
def func2 do
func1() # :sparkles: 规则1:在同一个模块内,func2 可以直接调用 func1,无需加 Mod. 前缀
IO.puts "in func2"
end
end
# --------------------------------------------------
# 模块外部调用
Mod.func1() # :warning: 规则2:在模块外面,必须使用完全限定名「Mod.func1」
Mod.func2()
嵌套模块(Nested Modules)
为了进一步改进代码结构的可读性和复用性,Elixir 程序员经常会使用嵌套模块。在 Elixir 中,每个程序员都是工具库的创造者,合理组织模块结构至关重要。
:light_bulb: Elixir 嵌套模块的本质
在 Elixir 中,嵌套模块(Nested Modules)其实只是一个“语法糖(假象)”——实际上所有模块都定义在顶层。当我们在某个模块内部定义子模块时,Elixir 仅仅是在内部模块名前面加上外部模块名,并用点号(.)连接。这意味着模块 Mix 和 Mix.Tasks.Doctest 之间没有任何特别的继承或从属联系,它们在运行时是完全独立的顶层模块。
:pushpin: 嵌套模块的访问规则
由于嵌套只是命名空间的前缀拼接,在不同位置访问内部函数时,前缀的写法有所不同:
-
- 在外部访问:需要加上所有的模块名作为前缀(即完全限定名)。
-
- 在外部模块内部访问:要么使用完全限定名,要么省略外部模块名,仅使用内部模块名作为前缀。
场景一:通过真实嵌套定义模块
defmodule Outer do
# 在 Outer 内部定义 Inner,其实际编译后的全名为 Outer.Inner
defmodule Inner do
def inner_func do
IO.puts "in inner_func"
end
end
def outer_func do
# :sparkles: 规则 2:在 Outer 内部访问它,可以省略 Outer,直接写 Inner 前缀
Inner.inner_func()
# 也可以写全称:Outer.Inner.inner_func()
end
end
# --------------------------------------------------
# 模块外部调用
Outer.outer_func()
# :warning: 规则 1:在外部访问嵌套模块的函数,必须使用完整前缀
Outer.Inner.inner_func()
场景二:直接扁平化定义多级命名空间
既然嵌套只是前缀拼接,你完全可以跳过 defmodule Outer 的嵌套包壳,直接自己定义带点号的内部模块:
# 直接一步到位定义多级命名的模块
defmodule Mix.Tasks.Doctest do
def run do
IO.puts "Running doctest..."
end
end
# --------------------------------------------------
# 外部调用
Mix.Tasks.Doctest.run()
注:此时单独的 Mix 模块或 Mix.Tasks 模块甚至可以根本不存在,它们之间相互独立。
Elixir 模块指令与
Elixir 提供了三个指令来简化模块的使用。这些指令都在程序运行期执行,并且都在词法作用域内有效——以指令出现处为起点,直到当前作用域结束(如函数结束或模块结束)。
:pushpin: import 指令
import 指令将模块内的函数和/或宏引入到当前作用域。如果某个特殊模块在代码中被大量使用,import 能减少一遍遍重复书写模块名的麻烦,保持源文件的整洁。
例如,从 List 模块导入 flatten 函数后,可以直接调用 flatten 而无须指定模块名。
:magnifying_glass_tilted_left: 词法作用域示例: 指令的有效区域受限于它被编写的位置:
defmodule Example do
def func1 do
# :warning: 此时未导入 List,必须写完整的模块名
List.flatten([1, [2, 3], 4])
end
def func2 do
# :sparkles: 仅在 func2 函数内部导入 List 模块的 flatten 函数
import List, only: [flatten: 1]
# 此时可以直接使用函数名调用
flatten([5, [6, 7], 8])
end
def func3 do
# :warning: 越过了 func2 的作用域,这里直接调用 flatten 会报错
end
end
:hammer_and_wrench: import 的完整语法与最佳实践
import 的完整语法格式如下:
import Module [, only: ... | except: ... ]
为了避免命名冲突并提高代码可读性,尽可能地缩小 import 的作用域,并显式指定导入内容是一个好主意。
- 精确控制导入(推荐 only:)
通过 only: 或 except: 后跟一组 name:arity(函数名:参数个数)对来筛选:
# 仅导入 List 模块中的单参数 flatten 和双参数 duplicate 函数
import List, only: [flatten: 1, duplicate: 2]
- 按类型批量导入
也可以将原子类型 :functions 或 :macros 传给 only:,从而仅仅导入所有的函数或者所有的宏:
# 仅导入所有的函数,不导入宏
import List, only: :functions
:pushpin: alias 指令:创建模块别名
alias 指令用于为模块创建别名,其核心作用是减少代码中长模块名的输入。
defmodule Example do
def func do
# :sparkles: 为长模块名指定别名 Doctest
alias Mix.Tasks.Doctest, as: Doctest
# 之后便可直接使用简短的别名调用
doc = Doctest.setup()
doc.run(Doctest.defaults())
end
end
:light_bulb: 快捷简写(默认行为)
在绝大多数情况下,我们无须手动编写 as: 参数。Elixir 默认会将模块名字的最后一部分作为别名。
因此,上面的指令可以优雅地简写为:
alias Mix.Tasks.Doctest
# :backhand_index_pointing_up: 效果完全等同于 as: Doctest
:pushpin: require 指令:加载宏模块
当你想使用某个模块中定义的“宏(Macro)”时,必须先 require 那个模块。
- 核心机制:Elixir 的宏是在编译期执行的代码。require 指令能够确保在代码使用宏之前,就已经完整加载了定义这些宏的模块。
- 与 import 的区别:import 会直接把函数/宏引入到当前作用域(调用时不用加前缀);而 require 仅仅是告诉编译器“加载该模块以允许使用宏”,调用宏时仍然需要写出模块名。
Elixir 模块属性(Module Attributes)
在 Elixir 中,每个模块都可以包含与之关联的元数据(Metadata),元数据的每一项称为模块属性。
模块属性不是传统意义上的变量,它们通常有两个核心用途:
-
- 作为常量:类似于 Java 或 Ruby 程序员使用常量的场景。
-
- 作为配置与元数据:为模块、函数或文档提供配置(如 @doc、@moduledoc)。
:pushpin: 模块属性的读写规则与作用域
模块属性通过在名称前加 @ 符号 来访问和赋值:
| 操作 | 语法示例 | 作用域与限制 |
|---|---|---|
| 赋值 | @name value | :warning: 仅在模块的顶层有效。不能在函数内部设置(赋值)属性。 |
| 读取 | @name | :sparkles: 可以在模块顶层读取,也可以在函数内部读取。 |
# :laptop: 代码示例一:基础读写
defmodule Example do
@author "Dave Thomas" # :sparkles: 在顶层赋值
def get_author do
@author # :sparkles: 在函数内部读取
end
end
IO.puts "Example was written by #{Example.get_author()}"
# 输出 => Example was written by Dave Thomas
动态多次赋值与编译期求值
模块属性的一个独特之处在于:在同一个模块内,可以为属性多次赋值。
如果在模块内的命名函数内部访问该属性,其读取到的值事实上是定义该函数时属性的当前值(编译期求值)。
# :laptop: 代码示例二:多次赋值行为
defmodule Example do
@attr "one"
def first, do: @attr # 此时 @attr 的值是 "one"
@attr "two"
def second, do: @attr # 此时 @attr 的值已被更新为 "two"
end
IO.puts "#{Example.first()} #{Example.second()}"
# 输出 => one two
注:由于是在编译期直接将值注入到了函数中,运行期调用 first 和 second 会分别得到它们定义时的快照值。
Elixir 模块名与原子类型的本质关系
在 Elixir 中,我们经常写出首字母大写的模块名(如 String、PhotoAlbum 或 IO)。然而在系统内部,Elixir 的模块名本质上仅仅是原子类型(Atom)。当你写下一个大写字母开头的名字时,Elixir 会在后台自动将其转换为带有 Elixir. 前缀的原子。
:pushpin: 模块名的底层转换规则
- 自动加前缀:比如你写 IO,Elixir 内部实际将其转换为原子 :"Elixir.IO"。
- 等价性验证:在交互式命令行(IEx)中,可以非常直观地证实这一底层的微妙设计:
iex> is_atom IO
true # :sparkles: 验证:IO 模块名本质就是一个原子
iex> to_string IO
"Elixir.IO" # :sparkles: 验证:转换为字符串后露出了 "Elixir." 前缀的原形
iex> :"Elixir.IO" === IO
true # :sparkles: 验证:全称原子与大写模块名完全全等
:hammer_and_wrench: 模块函数的底层调用机制
既然模块名本质上是原子,那么调用模块内部的函数,事实上就是一个原子后面加一个点号(.),再加上函数名。
因为这种底层的一致性,以下两种调用方式在 Elixir 中是完全等价的:
# 方式 1:标准常规调用
iex> IO.puts 123
123
:ok
# 方式 2:使用底层的原子全称进行调用(完全合法)
iex> :"Elixir.IO".puts 123
123
:ok
:light_bulb: Elixir 调用 Erlang 库函数
Erlang 和 Elixir 运行在同一个虚拟机(BEAM)上,因此它们之间可以零开销地互相调用。
由于 Erlang 的命名约定与 Elixir 不同(Erlang 的变量以大写字母开头,而原子的名字都是小写字母),因此 Erlang 的模块在 Elixir 中全部表现为“小写字母开头的原子”。
:pushpin: 调用 Erlang 函数的语法规则
要在 Elixir 中引用 Erlang 的模块与函数,必须遵循以下格式:
:erlang_module_name.function_name(arguments)
:warning: 关键细节:模块名前面必须带有冒号 :,且模块名完全小写。
示例一:基础时间工具调用
- Erlang 模块名:timer
- Elixir 调用方式::timer.tc(…)
示例二:格式化输出浮点数
假设我们想以“三字符宽、一个小数位”的格式输出浮点数 5.678。这需要用到 Erlang 的 io 模块中的 format 函数:
# :sparkles: 在 Elixir 中直接调用 Erlang 的 io:format 函数
iex> :io.format("The number is ~3.1f~n", [5.678])
The number is 5.7
:ok
:magnifying_glass_tilted_left: 寻找函数库的递进策略
当你在开发中需要某个功能时,推荐按照以下顺序寻找现成的函数库:
- 第一步:浏览 Elixir 内置模块 ➔ 优先查阅 Elixir 官网文档。
- 第二步:检索三方生态 ➔ 在 hex.pm 和 GitHub 上搜索相关的 Elixir 开源库。
- 第三步:求助于 Erlang 内置函数库 ➔ 如果 Elixir 生态中没有,直接搜索并调用 Erlang 现有的实现。
:warning: 阅读 Erlang 文档的避坑指南:
Erlang 官方文档是严格按照 Erlang 语法约定的。当你在 Erlang 文档中看到小写字母开头的标识符(例如模块名 tomato),请自动在脑海中将其翻译为 Elixir 的原子语法(即 :tomato)。
:test_tube: Erlang 官方标准库与函数指南
- Erlang io:format/2 官方文档::backhand_index_pointing_right: https://erlang.org/doc/man/io.html#format-2 , 用途:查阅 Erlang 原生输入输出流的格式化控制符(如 ~3.1f、~n 等)。
- Erlang 官方中央文档库 :backhand_index_pointing_right: https://erlang.org/doc/ ,用途:当 Elixir 库不满足需求时,可在此检索强大的 Erlang 内置 OTP 库及底层的计算与网络工具。
:droplet: Elixir 官方标准库与快速入门
- Elixir 官方权威文档 :backhand_index_pointing_right: https://elixir-lang.org/docs/, 用途:包含通用标准库(如 Enum、Stream、Map 等)的最新 API 查阅与核心指南。
- 面向其他语言开发者的 Elixir 速成教程 :backhand_index_pointing_right: https://elixir-lang.org/crash-course.html , 用途:为拥有其他并发或函数式语言背景的开发者准备的快速语法映射指南。
习题
- 将浮点数转换为含两位小数的字符串(Erlang)
- 库函数::erlang.float_to_binary/2 或者是 :io_lib.format/2
- IEx 代码:
# 方法 A(最推荐,直接返回 Elixir 字符串):
iex> :erlang.float_to_binary(3.1415926, [decimals: 2])
"3.14"
# 方法 B(使用 Erlang 格式化列表并转换):
iex> :io_lib.format("~.2f", [3.1415926]) |> List.to_string()
"3.14"
- 获得操作系统环境变量的值(Elixir)
- 库函数:System.get_env/1
- IEx 代码:
# 获取名为 "PATH" 或 "USER" 的环境变量
iex> System.get_env("USER")
"your_username"
- 获取文件的扩展名(Elixir)
- 库函数:Path.extname/1
- IEx 代码:
iex> Path.extname("dave/test.exs")
".exs"
- 返回进程的工作目录(Elixir)
- 库函数:File.cwd/0 或 File.cwd!/0
- IEx 代码:
iex> File.cwd()
{:ok, "/Users/project/elixir_demo"}
# 或者直接返回路径字符串:
iex> File.cwd!()
"/Users/project/elixir_demo"
- 将 JSON 字符串转换成 Elixir 数据结构(仅查找)
- 主流第三方库:Jason(Elixir 现代标准推荐)或 Poison
- 实现函数:Jason.decode/1
- 伪代码演示(无须安装):
# 查找结论:生产环境中通常调用 Jason.decode/1
# Jason.decode!("{\"foo\": \"bar\"}") => %{"foo" => "bar"}
- 在操作系统 shell 中执行命令(Elixir)
- 库函数:System.cmd/2 或 System.cmd/3
- IEx 代码:
# 执行类 Unix 系统的 "whoami" 命令
iex> {result, exit_code} = System.cmd("whoami", [])
{"your_username\n", 0}