VOL.II · 解剖CH 04监护 04/13

函数、模块与管道 |>

函数式语言里,函数就是全部剧情:没有类、没有对象、没有继承,只有「值进,值出」的变换器,和把变换器串起来的方法。本章先把函数的三种形态(命名、匿名、捕获)清点一遍,再看模块怎么收纳它们,最后请出 Elixir 最上镜的操作符——管道 |>,它能把四层嵌套的调用捋成一条从上游流到下游的蒸馏管线

name/arity捕获 &doctest|>

命名函数:名字 = 函数名 + 参数个数

命名函数必须住在模块里,用 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/2bmi/3——它们是两个完全独立的函数,不是重载的语法糖,文档、引用、追踪(第 12 章 :dbg 按 M/F/A 下钩子)都以 name/arity 为最小单位。单行函数写 , do:,多行用 do ... end 块,两者只是排版差别。

默认参数用 \\ 声明:def round_to(x, digits \\ 1) 会同时生成 round_to/1round_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, ...))。不是巧合,是整个生态为管道让路。你自己写函数也请遵守——「主语在前」,你的函数就能被别人的管道串起来。

▸ Android 类比

手感最像 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 章我们会把 dbgIO.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 一以贯之,是这门语言从骨子里的审美。

✚ 动手自测
  1. def f(a, b \\ 1, c \\ 2) 一共生成哪几个 arity?在 iex 里定义后用 h 验证。
  2. Enum.sum(Enum.map(String.split("1 2 3"), &String.to_integer/1)) 改写成管道。
  3. 给你写过的任意工具函数补一段带 iex> 示例的 @doc,感受「文档即测试」的写法。
  4. 说出 double(2)double.(2) 的区别,以及为什么语言要刻意区分。

流水线搭好了,而且你已经握着一枚随身听诊器(tap + dbg)。下一章解决一个悬了三章的问题:数据全都不可变,那「修改」到底是怎么发生的?顺便领取函数式的最大红利——Enum 与 Stream。