为什么Elixir的Enum.uniq/uniq_by函数返回结果顺序总是出人意料?
在数据处理中,去重是常见需求。Elixir语言的Enum模块提供了uniq/1和uniq_by/2两个函数用于去除重复元素,但很多开发者会惊讶地发现:这两个函数返回的结果竟然保留了原始数据中首次出现元素的顺序!这与其他编程语言中常见的无序去重行为截然不同,也让不少刚接触Elixir的开发者踩过坑。本文将深入解析这一特性的实现原理、使用场景及注意事项。
一、眼见为实:一个反直觉的示例
先看一个简单示例,运行以下代码:
# 普通去重
iex> Enum.uniq([3, 1, 2, 2, 3, 4])
[3, 1, 2, 4]
# 按函数结果去重
iex> Enum.uniq_by([%{id: 1, name: "a"}, %{id: 2, name: "b"}, %{id: 1, name: "c"}], & &1.id)
[%{id: 1, name: "a"}, %{id: 2, name: "b"}]
你会发现输出结果不是排序后的[1,2,3,4],而是保留了元素首次出现的顺序。这种"稳定去重"特性在Python的dict.fromkeys()或JavaScript的Array.filter()+Set组合中也能实现,但在函数式编程语言中并不常见。
二、源码解析:如何实现"记住首次出现顺序"?
要理解这一特性,我们需要查看Elixir的源码实现。在lib/elixir/lib/enum.ex文件中(源码路径),uniq/1函数的核心代码如下:
def uniq(enumerable) do
reduce(enumerable, {[], %{}}, fn element, {acc, map} ->
if Map.has_key?(map, element) do
{acc, map}
else
{[element | acc], Map.put(map, element, true)}
end
end)
|> elem(0)
|> reverse()
end
这段代码使用了一个累加器元组{acc, map}:
map(映射)用于记录已出现的元素(键为元素值,值为true)acc(列表)用于收集首次出现的元素(注意是从头部插入)- 最后通过
reverse/1函数将结果反转,恢复原始顺序
关键实现细节:
- 遍历过程:按原始顺序遍历 enumerable
- 去重逻辑:用
Map.has_key?/2检查元素是否已出现 - 顺序保证:首次出现的元素会被添加到
acc头部 - 结果反转:最终通过
reverse/1恢复正确顺序
uniq_by/2函数的实现类似,只是将元素本身替换为函数计算结果作为Map的键:
def uniq_by(enumerable, fun) do
reduce(enumerable, {[], %{}}, fn element, {acc, map} ->
key = fun.(element)
if Map.has_key?(map, key) do
{acc, map}
else
{[element | acc], Map.put(map, key, true)}
end
end)
|> elem(0)
|> reverse()
end
三、为什么选择"保留首次出现顺序"的设计?
Elixir团队选择这种实现并非偶然,背后有三个关键原因:
1. 函数式编程的"引用透明性"要求
Elixir作为函数式语言,强调"引用透明性"(Referential Transparency),即函数的输出仅由输入决定,不受外部状态影响。稳定的输出顺序有助于开发者推理代码行为,特别是在处理流式数据或无限序列时。
2. 与其他Enum函数保持一致性
Enum模块的大多数函数(如map/2、filter/2)都保持输入顺序,uniq/1遵循同样的设计原则,让API行为更加一致。这种一致性降低了开发者的认知负担。
3. 性能与功能的平衡
使用Map(哈希表)实现去重,时间复杂度为O(n),空间复杂度为O(n),兼顾了性能和功能。如果要实现无序去重,Elixir可以直接使用MapSet,但那样会丢失顺序信息。
四、实用场景:何时需要稳定去重?
这种"保留首次出现顺序"的特性在以下场景特别有用:
1. 处理带优先级的数据
当数据中存在重复项但首次出现的元素具有更高优先级时:
# 配置合并场景:保留先出现的配置
configs = [
[debug: false, port: 8080],
[debug: true, port: 3000], # 重复的port键
[log: true]
]
# 合并并去重配置项,保留首次出现的值
Enum.uniq_by(Enum.concat(configs), fn {k, _v} -> k end)
# 结果: [debug: false, port: 8080, log: true]
2. 实现"最近使用"(LRU)缓存
利用uniq_by/2可以轻松实现一个简化版LRU缓存:
def lru_cache(entries, max_size) do
entries
|> Enum.reverse() # 最新的放在前面
|> Enum.uniq_by(& &1.key) # 保留最新的重复项
|> Enum.take(max_size) # 限制大小
|> Enum.reverse() # 恢复顺序
end
3. 数据清洗中的顺序敏感场景
在日志分析、用户行为追踪等场景中,事件发生的顺序至关重要。使用uniq/1可以去除重复记录同时保持时间线完整。
五、注意事项:这些坑你可能会踩
虽然稳定去重很有用,但也有需要注意的地方:
1. 与MapSet.to_list/1的行为差异
不要混淆Enum.uniq/1和MapSet的去重行为:
# 稳定去重(保留顺序)
iex> Enum.uniq([3, 1, 2, 2])
[3, 1, 2]
# 无序去重(不保证顺序)
iex> MapSet.new([3, 1, 2, 2]) |> MapSet.to_list()
[1, 2, 3] # 顺序可能因环境而异
2. 性能考量:大列表去重的内存占用
由于实现中需要维护一个Map来记录已出现元素,当处理超大列表时,可能会消耗较多内存。此时可以考虑使用Stream.uniq/1进行流式处理,避免一次性加载所有数据到内存:
# 处理大文件时使用流式去重
File.stream!("large_dataset.txt")
|> Stream.map(&String.trim/1)
|> Stream.uniq()
|> Enum.into(File.stream!("deduplicated.txt"))
3. 自定义数据结构的比较问题
uniq/1使用===/2运算符比较元素,对于自定义结构体,需要确保正确实现了相等性比较:
defmodule User do
defstruct [:id, :name]
# 自定义相等性比较(仅比较id)
defimpl String.Chars do
def to_string(user), do: "User(#{user.id})"
end
end
# 注意:这样不会按id去重!
iex> Enum.uniq([%User{id: 1, name: "a"}, %User{id: 1, name: "b"}])
[%User{id: 1, name: "a"}, %User{id: 1, name: "b"}]
# 应该使用uniq_by/2
iex> Enum.uniq_by([%User{id: 1, name: "a"}, %User{id: 1, name: "b"}], & &1.id)
[%User{id: 1, name: "a"}]
六、总结:理解并善用"稳定去重"
Elixir的Enum.uniq/1和uniq_by/2函数通过记录元素首次出现顺序实现了稳定去重,这一设计既符合函数式编程的一致性原则,又在实际开发中提供了独特价值。掌握这一特性,可以让你在数据处理、配置合并、缓存实现等场景中写出更简洁、更符合直觉的代码。
记住:当你需要保留原始顺序去重时,uniq/1和uniq_by/2是最佳选择;若追求纯粹的无序去重且不关心性能,可使用MapSet;处理大数据时,优先考虑Stream版本。
最后留给大家一个思考题:如何利用uniq_by/2实现一个简单的网页爬虫URL去重器?欢迎在评论区分享你的实现!
本文所有示例基于Elixir 1.15.4版本测试通过,源码可参考Elixir官方仓库中的
lib/elixir/lib/enum.ex文件。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



