函数、模块与管道 |>
函数式语言里,函数就是全部剧情:没有类、没有对象、没有继承,只有「值进,值出」的变换器,和把变换器串起来的方法。本章先把函数的三种形态(命名、匿名、捕获)清点一遍,再看模块怎么收纳它们,最后请出 Elixir 最上镜的操作符——管道 |>,它能把四层嵌套的调用捋成一条从上游流到下游的蒸馏管线。
命名函数:名字 = 函数名 + 参数个数
命名函数必须住在模块里,用 def 定义,defp 定义私有版本(模块外不可见):
defmodule Vitals do @moduledoc "体征换算工具" def bmi(kg, m), do: Float.round(kg / (m * m), 1) def bmi(kg, cm, :cm), do: bmi(kg, cm / 100) defp validate(kg) when kg > 0, do: :ok end
Elixir 对函数的正式称呼永远带着参数个数(arity):上面定义的是 bmi/2 和 bmi/3——它们是两个完全独立的函数,不是重载的语法糖,文档、引用、追踪(第 12 章 :dbg 按 M/F/A 下钩子)都以 name/arity 为最小单位。单行函数写 , do:,多行用 do ... end 块,两者只是排版差别。
默认参数用 \\ 声明:def round_to(x, digits \\ 1) 会同时生成 round_to/1 和 round_to/2 两个 arity——再次强调,视角永远是「多个独立函数」。
还有一件事你可能已经注意到了:没有 return。函数的返回值就是函数体最后一个表达式的值,没有提前返回的语法。这不是刁难——第 2 章说过一切皆表达式,而「提前 return」的需求在这里由多子句和模式匹配接管了:与其在函数体里判断然后跳出,不如在函数头上就分成两个子句。习惯之后你会发现,没有 return 的函数天然只有一个出口,推理起来反而省心。
匿名函数与捕获:函数是一等公民
iex(1)> double = fn x -> x * 2 end #Function<42.39164016/1 in :erl_eval.expr/6> iex(2)> double.(21) 42 iex(3)> short = &(&1 * 2) iex(4)> short.(21) 42 iex(5)> Enum.map([60, 72, 88], &(&1 + 5)) [65, 77, 93]
fn 参数 -> 表达式 end 定义匿名函数,值可以存进变量、塞进 Map、当参数传——它就是一个普通的值。注意第 2 行那个刺眼的点:调用匿名函数必须写 .()。这不是设计失误,而是刻意的视觉区分:double(21) 是调用某个命名函数,double.(21) 是调用变量里装的函数值,读代码时一眼分清。
& 是捕获操作符,两种用法:①&(&1 * 2) 是匿名函数的速记,&1、&2 指第几个参数;②&Vitals.bmi/2 把一个命名函数捕获成值,好递给别人。第二种在标准库里无处不在:
iex(1)> Enum.map(["ok", "warn"], &String.upcase/1) ["OK", "WARN"]
匿名函数还有一项隐藏技能:它也支持多子句和 guard,语法和 case 一脉相承——第 6 章会用到。
模块:收纳函数的文件柜
模块没有实例、没有状态(状态是进程的事,第 7 章起),就是一摞函数的命名空间。名字用大写驼峰,通常一个文件一个模块。跨模块引用的三件套,用一张表说清:
| 指令 | 作用 | 典型场景 |
|---|---|---|
alias Ward.Triage | 起短名:之后写 Triage.check/1 | 最常用,90% 的场合 |
import Enum, only: [map: 2] | 把函数拉进当前作用域,直呼其名 | 克制使用,防止来源不明 |
use GenServer | 执行对方的宏,注入代码 | 框架入口(第 8 章大量出现) |
两条不成文的收纳规矩:模块名与路径对应(Ward.Triage 住在 lib/ward/triage.ex),这是 mix 项目的默认目录法;代码风格不用吵,官方格式化器 mix format 一键统一,CI 里挂上检查,团队从此没有缩进之争。
use 值得提前打个预防针:它不是导入,而是「请这个模块在编译期往我这里写代码」。看到 use 就该想到背后有宏(Elixir 的元编程系统)。日常你只需要会用框架提供的 use,不需要会写宏——本书也不写。
模块属性:编译期的常量与注记
模块里以 @ 开头的行叫模块属性,一物三用:
defmodule Vitals do @moduledoc "体征换算工具" # ① 文档 @hr_ceiling 100 # ② 常量(编译期展开) @spec bmi(number, number) :: float # ③ 类型标注 def tachy?(bpm), do: bpm > @hr_ceiling end
②是最常用的:@hr_ceiling 在编译期被替换成字面量 100,零运行时开销,改一处全模块生效——对应 Kotlin 的 const val。③的 @spec 是类型签名,1.20 之前它只是给文档和 Dialyzer 工具看的君子协议;如今渐进类型系统会真的拿它对账,第 13 章展开。注意属性只在模块编译时求值一次,别在里面放「当前时间」这类动态值,那是一个经典陷阱。
文档是一等公民,文档还能当测试
defmodule Vitals do
@doc """
体质指数,保留一位小数。
## 示例
iex> Vitals.bmi(70, 1.75)
22.9
"""
def bmi(kg, m), do: Float.round(kg / (m * m), 1)
end
@doc 里那段缩进的 iex> 示例不是装饰。在测试文件里写一行 doctest Vitals,测试框架就会把文档里的每个示例真的跑一遍,输出和文档写的不一致就算测试失败。这个机制叫 doctest,它把「文档过期」这个千古难题变成了 CI 红灯:示例错了,构建就不过。第 1 章说过文档编译进字节码、h 随处可查,现在再加一条——文档还被持续验证着。这是 Elixir 工程文化里我最建议你「偷」回自己团队的东西。
管道 |>:数据的蒸馏塔
主角登场。先看没有它的世界——需求:把一句化验备注切成词、去空白、转大写、去重:
Enum.uniq(Enum.map(Enum.map(String.split(note, ","), &String.trim/1), &String.upcase/1))
阅读顺序和执行顺序相反,括号配对靠数。同一段逻辑,换管道:
note
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.map(&String.upcase/1)
|> Enum.uniq()
|> 的规则简单到一句话:把左边的结果,塞进右边函数的第一个参数位。note |> String.split(",") 就是 String.split(note, ",")。于是代码变成一座蒸馏塔:原料从顶部进,每层做一次提纯,读代码的顺序、数据流动的顺序、执行的顺序,三者第一次完全一致。
这也解释了标准库的一个统一设计:被操作的数据永远放第一个参数(String.split(str, ...)、Enum.map(list, ...)、Map.put(map, ...))。不是巧合,是整个生态为管道让路。你自己写函数也请遵守——「主语在前」,你的函数就能被别人的管道串起来。
手感最像 Kotlin 的链式调用 note.split(",").map { it.trim() }.map { it.uppercase() }.distinct()。区别在机制:Kotlin 的链靠「方法属于对象」,想给别人的类续链要写扩展函数;|> 不属于任何类型,任何「第一参数是主语」的普通函数都能入链,包括你自己刚写的。链式是类的特权,管道是所有函数的公共设施。
管道的坑与查体口诀
三个新手常见病,提前打疫苗:
- 别硬凑管道。只有一步就直接调用;数据不是「一路向下变换」(比如中途要分叉判断)就改用 case 或 with(第 6 章)。管道是表达「流水线」的,不是缩进风格。
- 第一参数不合适就别上管道。极少数老函数主语不在第一位,硬接会得到莫名其妙的参数错位报错。
- 管道里调匿名函数要写全:
|> then(&my_fun.(&1))或|> then(my_fun),不能直接|> my_fun.()。
那管道中段出了错怎么查?Elixir 给了一个专门的听诊器——dbg(1.14+ 内置宏)。把它接到管道末尾,它会把每一段的中间结果全部打出来:
iex(1)> "a, b , a" |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.uniq() |> dbg()
[iex:1]
"a, b , a" #=> "a, b , a"
|> String.split(",") #=> ["a", " b ", " a"]
|> Enum.map(&String.trim/1) #=> ["a", "b", "a"]
|> Enum.uniq() #=> ["a", "b"]
["a", "b"]
一眼看清每层蒸馏塔的出料。这是本书第一个正经 Debug 工具,记住它的名字——第 11 章我们会把 dbg、IO.inspect 和更重型的活体检查工具排成一张梯度表。
then 与 tap:管道的两个补丁件
标准库给管道备了两枚通用接头,恰好补上刚才两个坑:
final_report |> then(&render(template, &1)) # then:结果不在第一参数位?手动摆位 |> tap(&IO.inspect(&1, label: "渲染后")) # tap:偷看一眼,原值继续往下流 |> deliver()
then/2 把左边的值交给一个匿名函数,放哪个参数位你说了算——专治「主语不在第一位」的老函数。tap/2 更妙:执行副作用(打日志、发监控)后把原值原样递下去,管道不断流。|> tap(&IO.inspect/1) 是比 dbg 更轻的「管道听诊器」,可以夹在任意两段之间,查完删掉这一行即可,上下游零改动。
管道还有一层不那么显眼的价值:每一段都是天然的断点和测试点。流水线的任何一截都可以单独摘出来跑、单独写 doctest;反观嵌套调用,中间结果没有名字,想验证只能拆代码。「让中间状态随时可观测」——你会发现这个主题从 |> 到 :sys.get_state 一以贯之,是这门语言从骨子里的审美。
def f(a, b \\ 1, c \\ 2)一共生成哪几个 arity?在 iex 里定义后用h验证。- 把
Enum.sum(Enum.map(String.split("1 2 3"), &String.to_integer/1))改写成管道。 - 给你写过的任意工具函数补一段带
iex>示例的@doc,感受「文档即测试」的写法。 - 说出
double(2)和double.(2)的区别,以及为什么语言要刻意区分。
流水线搭好了,而且你已经握着一枚随身听诊器(tap + dbg)。下一章解决一个悬了三章的问题:数据全都不可变,那「修改」到底是怎么发生的?顺便领取函数式的最大红利——Enum 与 Stream。