引言
如果你来自 C、Java 或 Python 等命令式语言,可能会惊讶地发现:Erlang 没有 for、while 或 do-while 这样的传统循环语句。作为一门根植于并发与容错的函数式编程语言,Erlang 用完全不同的方式解决了"重复执行"这一问题。
在 Erlang 中,循环的核心机制是递归(recursion)——函数调用自身来重复执行任务。与此同时,Erlang 标准库 lists 模块提供了丰富的高阶函数,如 foreach、map、foldl、filter 等,它们封装了常见的遍历模式,让代码更加简洁和声明式。此外,列表推导式(list comprehension)则以一种优雅的语法糖形式,提供了从现有列表生成新列表的快捷方式。
本文将系统介绍 Erlang 中实现循环与迭代的各种方法,并对比递归与高阶函数的适用场景,帮助你写出地道、高效的 Erlang 代码。
目录
递归:Erlang 循环的基石
递归是 Erlang 中实现循环的最基本也最重要的方式。由于 Erlang 的变量是单赋值(single assignment)的,一旦绑定就不能更改,因此传统的"循环计数器递增"模式无法使用。递归通过函数调用自身来替代变量状态的变更,成为 Erlang 循环的核心范式。
基本递归
最简单的递归模式是:定义一个基础情形(base case)作为终止条件,以及一个递归情形(recursive case)不断缩小问题规模。
%% 计算列表中所有元素的和
sum_list([]) ->
0;
sum_list([H|T]) ->
H + sum_list(T).
在这个例子中,sum_list/1 通过模式匹配区分两种情况:空列表 [] 时返回 0(基础情形);非空列表 [H|T] 时将头元素 H 加上对尾部 T 的递归调用结果(递归情形)。
注意:上面的例子虽然简洁,但不是尾递归。每次递归调用都需要等待返回后才能完成加法运算,这意味着每次调用都要在调用栈上保留一帧。当列表非常长时,可能导致栈溢出。
尾递归优化
尾递归(tail recursion)是 Erlang 中编写高效递归的关键技术。当递归调用是函数体中的最后一个操作时,Erlang 虚拟机(BEAM)会进行尾调用优化(tail call optimization),将递归调用转换为类似"跳转"的操作,不会消耗额外的栈空间。
%% 尾递归版本的列表求和
sum_list(L) ->
sum_list(L, 0).
sum_list([], Acc) ->
Acc;
sum_list([H|T], Acc) ->
sum_list(T, Acc + H).
这里引入了一个累加器参数 Acc,将中间结果作为参数传递给下一次调用。递归调用 sum_list(T, Acc + H) 是函数体的最后一个操作,因此 BEAM 虚拟机会将其优化为常数栈空间。
最佳实践:在 Erlang 中编写递归函数时,应始终优先使用尾递归形式,尤其是在处理大规模数据或长时间运行的服务进程时。
下面是一个更贴近实际应用的尾递归示例——实现一个简易的消息循环计数器:
-module(counter).
-export([start/0]).
start() ->
loop(0).
loop(Count) ->
receive
stop ->
io:fwrite("Final count: ~p~n", [Count]),
ok;
increment ->
io:fwrite("Count: ~p~n", [Count]),
loop(Count + 1)
end.
这个 counter 模块展示了 Erlang 中最经典的循环模式:一个进程通过 receive 接收消息,然后递归调用自身来处理下一条消息。这正是 gen_server 等 OTP 行为模式底层的工作原理。loop/1 的递归调用位于 receive 分支的末尾,因此是尾递归,进程可以无限期运行而不会栈溢出。
列表推导式
列表推导式(list comprehension)是 Erlang 中一种简洁而强大的语法结构,用于从一个或多个列表生成新的列表。它的语法形式为 [Expression || Pattern <- List, Guard],读起来接近数学中的集合描述法。
%% 生成 1 到 N 的平方数列表
squares(N) ->
[X * X || X <- lists:seq(1, N)].
%% squares(5) => [1, 4, 9, 16, 25]
列表推导式还可以加入过滤条件(guard),只选择满足条件的元素:
%% 筛选出列表中的偶数并求平方
even_squares(L) ->
[X * X || X <- L, X rem 2 =:= 0].
%% even_squares([1,2,3,4,5,6]) => [4, 16, 36]
列表推导式还支持多个生成器(generator),实现类似嵌套循环的效果:
%% 生成所有 (X, Y) 组合,其中 X < Y
pairs(N) ->
[{X, Y} || X <- lists:seq(1, N), Y <- lists:seq(1, N), X < Y].
%% pairs(3) => [{1,2},{1,3},{2,3}]
提示:列表推导式在底层是通过递归实现的。对于简单的映射和过滤操作,列表推导式通常比手写递归更简洁易读。但如果需要复杂的累积逻辑,建议使用
lists:foldl/3等高阶函数。
高阶函数:lists 模块的循环利器
Erlang 标准库的 lists 模块提供了一系列高阶函数(higher-order function),它们接受一个函数(fun)作为参数,对列表中的每个元素执行操作。这些函数封装了最常见的遍历模式,是除递归外最常用的循环方式。
foreach:遍历并执行副作用
lists:foreach/2 用于对列表中的每个元素执行一个有副作用的操作(如打印、发送消息、写文件),它不返回有意义的值(返回 ok)。
%% 打印列表中的每个元素
print_list(L) ->
lists:foreach(fun(Elem) -> io:fwrite("~p~n", [Elem]) end, L).
foreach 保证从左到右依次执行,适合需要按顺序产生副作用的场景。
map:映射生成新列表
lists:map/2 对列表中的每个元素应用一个变换函数,并返回由变换结果组成的新列表。新列表的长度与原列表相同。
%% 将列表中的每个元素乘以 2
double_list(L) ->
lists:map(fun(Elem) -> Elem * 2 end, L).
%% double_list([1, 2, 3]) => [2, 4, 6]
map 是函数式编程中最核心的操作之一,它表达了"对集合中的每个元素做同样的变换"这一意图,代码比手写递归更加声明式和可读。
foldl / foldr:折叠累积
lists:foldl/3(左折叠)和 lists:foldr/3(右折叠)用于在遍历列表的同时累积一个结果,类似于其他语言中的 reduce。
%% 使用 foldl 计算列表的总和
sum_list(L) ->
lists:foldl(fun(Elem, Acc) -> Elem + Acc end, 0, L).
%% sum_list([1, 2, 3, 4]) => 10
foldl 从左到右遍历,每次将当前元素和累加器传入函数,返回新的累加器值。它实际上可以实现所有其他列表操作:
%% 用 foldr 实现 map
my_map(Fun, L) ->
lists:foldr(fun(Elem, Acc) -> [Fun(Elem) | Acc] end, [], L).
%% 用 foldr 实现 filter
my_filter(Pred, L) ->
lists:foldr(fun(Elem, Acc) ->
case Pred(Elem) of
true -> [Elem | Acc];
false -> Acc
end
end, [], L).
注意:
foldl是尾递归的,在处理大列表时内存效率更高;foldr不是尾递归的,但在某些场景(如构建列表时保持元素顺序)更自然。
filter、any、all:条件遍历
lists 模块还提供了几个常用的条件遍历函数:
%% 筛选出所有偶数
Evens = lists:filter(fun(X) -> X rem 2 =:= 0 end, [1,2,3,4,5,6]).
%% => [2, 4, 6]
%% 判断列表中是否存在大于 10 的元素
HasBig = lists:any(fun(X) -> X > 10 end, [1, 5, 8, 12, 3]).
%% => true
%% 判断列表中是否所有元素都是正数
AllPositive = lists:all(fun(X) -> X > 0 end, [1, 2, 3, 4]).
%% => true
这些函数让常见的列表查询操作变得一行搞定,无需编写额外的递归函数。
递归 vs 高阶函数:如何选择?
了解了 Erlang 中的各种循环方式后,一个常见的问题是:什么时候用递归,什么时候用高阶函数?
| 特性 | 手写递归 | 高阶函数(map/foldl/foreach 等) | 列表推导式 |
|---|---|---|---|
| 可读性 | 中,需要理解递归逻辑 | 高,意图明确 | 高,语法简洁 |
| 灵活性 | 最高,可实现任意逻辑 | 中,受限于函数签名 | 中,适合映射和过滤 |
| 性能 | 尾递归时最优 | 内部已优化,通常等同手写递归 | 与递归性能相当 |
| 栈安全 | 需手动确保尾递归 | 库函数已保证尾递归 | 安全 |
| 适用场景 | 复杂状态机、消息循环、自定义遍历 | 标准的映射、过滤、累积操作 | 简单的映射与过滤组合 |
一般原则:
- 优先使用高阶函数和列表推导式。它们代码更短、意图更清晰,且由标准库保证尾递归优化。
- 当遍历逻辑复杂(如需要同时处理多个列表、有条件跳过、提前返回等),或需要实现消息循环(如
gen_server模式)时,使用手写尾递归。 - 避免非尾递归处理大列表,以防栈溢出。如果写了非尾递归,考虑引入累加器参数将其改写为尾递归。
总结与最佳实践
Erlang 虽然没有传统意义上的循环语句,但通过递归和高阶函数提供了同样强大且更加优雅的循环机制。以下是本文的核心要点:
- 递归是 Erlang 循环的基石。掌握尾递归优化是编写安全、高效 Erlang 代码的基本功。
lists模块的高阶函数是日常首选。map、foldl、filter、foreach覆盖了绝大多数遍历需求,代码更声明式、更易维护。- 列表推导式是简洁的语法糖。适合简单的映射和过滤操作,一行代码完成其他语言需要多行循环才能实现的逻辑。
- 消息循环是 Erlang 递归的独特应用。
receive+ 尾递归调用的模式是构建长期运行服务进程的核心范式,也是 OTPgen_server的底层原理。
实践建议:
- 编写新的遍历逻辑时,先考虑
lists模块是否已有现成函数可用。 - 手写递归函数时,始终检查是否为尾递归——递归调用是否是函数体的最后一个操作?
- 对于长时间运行的进程循环(如 TCP 连接处理、消息分发),使用
receive+ 尾递归模式,并配合 OTP 行为模式(如gen_server)获得额外的容错和监控能力。 - 使用 Erlang shell(
erl)实际运行本文中的代码示例,加深对各方法行为的理解。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。