第5章 匿名函数
- 函数是一等公民:作为函数式语言,函数是 Elixir 的一种基本类型。就像使用双括号包裹字符串一样,你可以用 fn…end 来包裹并返回一个函数值。
- 灵活传递:函数值可以像普通变量一样,作为参数传递给其他函数,或者被直接调用。
匿名函数语法结构
匿名函数使用 fn 关键字开始,以 end 关键字结束。它可以包含单个或多个匹配分支:
fn
parameter-list -> body
parameter-list -> body ...
end
最简单的情况是一个参数列表和一个函数体,两者之间通过 -> 箭头 隔开。
如何创建和调用匿名函数:
iex> sum = fn (a, b) -> a + b end
#Function<12.17052888 in :erl_eval.expr/5>
iex> sum.(1, 2)
3
- 定义与绑定:第一行创建了一个接收两个参数(a 和 b)的匿名函数,函数体将两个参数相加。该函数被赋值并绑定到变量 sum 中。
- 点号调用(关键区别):第二行通过 sum.(1, 2) 调用了该函数。 在 Elixir 中,调用匿名函数必须在变量名后加一个点号(.)。这被专门用来在视觉上区分“匿名函数”与“具名函数(命名函数)”
Elixir 匿名函数中圆括号的使用规则
在 Elixir 中,匿名函数的定义和调用对圆括号有不同的自由度要求:
1. 函数调用:无参也必须带括号
即使匿名函数不接受任何参数,在调用它时也必须使用圆括号 .,否则 Elixir 无法正确识别这是一个函数调用。
- 正确示例:
iex> greet = fn -> IO.puts "Hello" end
iex> greet.() # 必须带 .()
Hello
:ok
2. 函数定义:参数括号可省略
在定义匿名函数时,无论是有参还是无参,参数列表的圆括号都是可以省略的。
- 有参省略括号:
iex> f1 = fn a, b -> a * b end # a, b 两侧无需加括号
iex> f1.(5, 6)
30
- 无参省略括号:
iex> f2 = fn -> 99 end # fn 后面直接接箭头 ->
iex> f2.()
99
函数参数的本质是模式匹配
- 没有传统赋值:在 Elixir 中没有传统的“赋值”概念,所有的 = 操作和参数传递都是将值与模式进行匹配。
- 参数绑定机制:当你调用 sum.(2, 3) 时,并不是简单地把 2 赋值给 a,把 3 赋值给 b。其底层逻辑与执行 {a, b} = {2, 3} 完全相同,Elixir 会通过模式匹配将实参的值绑定到形参变量上。
在参数中进行复杂的模式匹配(解构)
由于参数传递基于模式匹配,你可以直接在函数的参数列表中对复杂数据结构(如元组、列表等)进行解构提取。
代码示例:交换元组中的元素,定义一个接收包含两个元素的元组并交换其顺序的匿名函数:
# 定义一个匿名函数 swap,其参数直接匹配一个两元素元组 {a, b}
iex> swap = fn { a, b } -> { b, a } end
#Function<12.17052888 in :erl_eval.expr/5>
# 调用函数,传入一个元组 {6, 8}
iex> swap.( { 6, 8 } )
{8, 6}
- 执行流程:当执行 swap.( { 6, 8 } ) 时,形参 { a, b } 与实参 { 6, 8 } 进行模式匹配,使得 a 被绑定为 6,b 被绑定为 8。随后函数体利用这两个变量组合并返回了新元组 {8, 6}。
这种在参数中直接匹配的能力,是实现 “一个函数,多个函数体(多重实现/多分支匹配)” 的核心基石。
习题
iex> list_concat = fn a,b->a++b end
iex> list_concat.([:a,:b],[:c,:d])
[:a, :b, :c, :d]
这里定义fn时,参数最好不要定义[a], 直接定义变量a,在绝大多数情况下,只需要直接写变量名 a 和 b,直接定义变量 fn a, b -> … end 意思是:“无论你传给我什么东西,我都把它装进变量 a 和 b 里。” 这种写法没有限制,可以接收任意长度的列表(比如 [:a, :b])。 写成模式匹配 fn [a], [b] -> … end 意思是:“你传给我的列表,必须有且仅有一个元素。如果是,我就把这唯一的一个元素提取出来命名为 a。”
iex> sum=fn list -> Enum.sum(list) end
#Function<42.130099583/1 in :erl_eval.expr/6>
iex> sum=fn (a,b,c) -> a+b+c end
#Function<40.130099583/3 in :erl_eval.expr/6>
iex> sum.(1,2,3)
6
不能使用Enum.sum(list), 因为对应的参数个数为1.
iex> pair_tuple_to_list = fn {a,b} -> [a,b] end
#Function<42.130099583/1 in :erl_eval.expr/6>
iex> pair_tuple_to_list.({1234,5678})
[1234, 5678]
一个函数多个函数体
Elixir 语言中一个匿名函数可以包含多个子句(多分支匹配),即可以为单个匿名函数定义多个实现(子句)。
- 核心规则:函数在被调用时,会依据传入的参数从上到下依次进行模式匹配,并执行第一个匹配成功的子句。
- 参数限制:同一个匿名函数的所有子句,其参数个数(Arity/元数)必须完全相同。不能根据参数数量的不同来进行选择。
代码示例解析:处理文件读取结果,经典的 handle_open 匿名函数,用来处理 File.open 返回的两种常见元组结果:
handle_open = fn
{:ok, file} -> "Read data: #{IO.read(file, :line)}"
{_, error} -> "Error: #{:file.format_error(error)}"
end
- 子句 1(成功分支):匹配模式为 {:ok, file}。当文件成功打开时,File.open 返回 {:ok, pid},匹配此分支。函数体内通过 IO.read 读取文件的第一行,并使用 #{…} 进行字符串插值输出。
- 子句 2(失败分支):匹配模式为 {_, error}。当文件不存在或打开失败时,会返回类似 {:error, :enoent} 的结构。这里使用下划线 _ 忽略首项,直接捕获错误原因 error,并通过 Erlang 底层的 :file.format_error/1 函数将错误码转换为可读的字符串。
底层生态提示:Erlang 与 Elixir,代码中调用的 :file.format_error(error) 揭示了 Elixir 的底层设计:
- 模块归属:以冒号开头的 :file 是一个 Erlang 原子(Atom)。它代表直接调用 Erlang 底层的 file 模块。
- 命名冲突规避:Elixir 自身拥有内建的 File 模块(首字母大写)。这种命名方式能清晰地将 Elixir 原生函数 与 Erlang 底层函数 区分开来。
工程实践:从 iex 到 .exs 脚本
当代码变长时,直接在 iex 交互式命令行中输入会变得难以维护。最佳实践是将其写入文件:
- 创建脚本:新建一个名为 handle_open.exs 的文件,将定义和调用代码写入其中。
- 编译执行的两种方式:
- 在 iex 内部运行:通过快捷函数 c "handle_open.exs" 来编译并运行。
- 在终端命令行运行:直接在系统终端执行 $ elixir handle_open.exs。
- 扩展名规范:
- .exs (Script):用于直接执行、不需要在生产环境中预先打包编译的脚本文件。
- .ex:用于最终需要编译成字节码、供后续重复使用的正式源码文件。
习题
编写一个接受3个参数的函数。如果前两个参数为零,则返回“FizzBuzz”。如果第一个参数为零,则返回“Fizz”。如果第2个参数为零,则返回“Buzz”。其他情况返回第3个参数。不要使用还没讲到的语言特性。
iex> fnTest = fn
0,0,_ -> "FizzBuzz"
0,_,_ -> "Fizz"
_,0,_ -> "Buzz"
_,_,a -> a
end
#Function<40.130099583/3 in :erl_eval.expr/6>
iex> fnTest.(0,0,1)
"FizzBuzz"
iex> fnTest.(0,1,1)
"Fizz"
iex> fnTest.(1,0,1)
"Buzz"
iex> fnTest.(1,1,1)
1
习题
运算符rem(a,b)返回a除以b的余数。编写接受一个整数参数(n)的函数,并调用上面函数,给它传入rem(n,3),rem(n,5)和n。传入参数10、11、12、13、14、15、16,并依此类推。你应该会得到“Buzz,11,Fizz,13,14,FizzBuzz,16”。(对,这就是没有条件逻辑的FizzBuzz的实现方法)
- 传入rem(n,3),rem(n,5)和n 作为参数时,对应位置可能同时被3、5整除得零,也可能仅被一个整除,也可能都有余数,可以覆盖上一个函数的各种情形
- 新函数只负责算余数并传参调用上一个函数
- 新函数没有任何一个 if 或 其他条件语句,仅仅靠模式匹配自动决定走哪条路线,消除了条件逻辑
iex> fizz_buzz_n =fn n -> fnTest.(rem(n,3),rem(n,5),n)
end
#Function<42.130099583/1 in :erl_eval.expr/6>
iex> fizz_buzz_n.(10)
"Buzz"
iex> fizz_buzz_n.(11)
11
iex> fizz_buzz_n.(12)
"Fizz"
iex> fizz_buzz_n.(13)
13
iex> fizz_buzz_n.(14)
14
iex> fizz_buzz_n.(15)
"FizzBuzz"
iex> fizz_buzz_n.(16)
16
高阶函数(能返回函数的函数)
在 Elixir 中,函数是“一等公民”(First-class citizens)。这意味着一个函数可以像普通变量一样,作为另一个函数的返回值。这种机制也被称为闭包(Closure)或高阶函数。高阶函数是函数式编程的基石。
1. 核心代码与结构展开
如下展示了一个不接受参数、但返回另一个函数的外部函数。
:laptop: 一行流写法:
iex> fun1 = fn -> fn -> "Hello" end end
#Function<12.17052888 in :erl_eval.expr/5>
:magnifying_glass_tilted_left: 展开结构分析:为了更清晰地理解它的嵌套关系,我们可以将它展开写成如下结构:
fun1 = fn -> # 1. 外部函数(不接受参数)
fn -> # 2. 内部函数(不接受参数),它是外部函数的返回值
"Hello" # 3. 内部函数的函数体,返回字符串
end # 结束内部函数
end # 结束外部函数
2. 调用方式与执行原理
这种嵌套函数有两种常见的调用与使用方式:
方式 A:连续调用(直接获取最终值)
通过在函数名后连续使用两组括号 .().(),第一组调用外部函数,第二组调用内部函数。
iex> fun1.().()
"Hello"
- 执行步骤:
-
- fun1.() 执行外部函数,返回了里面的那个匿名函数 #Function<…>。
-
- 紧接着的 .() 立即执行了这个被返回的内部函数,最终得到字符串 "Hello"。
方式 B:分步绑定(生产环境中更常用的推荐做法)
在实际开发中,我们通常不会写 fun1.().(),而是先将外部函数返回的内部函数绑定到一个新变量上,再单独调用它。
# 1. 调用外部函数,将返回的【内部函数】存入变量 other
iex> other = fun1.()
#Function<12.17052888 in :erl_eval.expr/5>
# 2. 在需要的时候,随时调用这个新函数
iex> other.()
"Hello"
3. 进阶小贴士 (Tips)
为了让代码的嵌套关系更具可读性,你也可以在定义时使用圆括号将内部函数包裹起来,使其层次更分明:
fun1 = fn -> (fn -> "Hello" end) end
:light_bulb: Elixir 核心概念:闭包(Closure)与环境记忆
在 Elixir 中,内部函数不仅能被返回,它还能记住并继承它被定义时的外部环境(作用域)里的变量绑定。即使外部函数已经执行完毕并返回,内部函数依然可以访问那些本该“销毁”的变量。这种特性被称为闭包(Closure)。
1. 核心代码示例
# 1. 定义一个可以生成“定制问候函数”的函数
iex> greeter = fn name -> (fn -> "Hello #{name}" end) end
#Function<12.17052888 in :erl_eval.expr/5>
# 2. 传入参数 "Dave" 调用外部函数,得到一个专属于 Dave 的问候函数
iex> dave_greeter = greeter.("Dave")
#Function<12.17052888 in :erl_eval.expr/5>
# 3. 随时调用这个新生成的内部函数
iex> dave_greeter.()
"Hello Dave"
2. 闭包的底层原理解析
如果按照传统命令式语言的思维,这段代码的执行逻辑会让人产生困惑:
- 疑问:当我们执行 greeter.("Dave") 时,外部函数已经执行结束并返回了,参数 name 的生命周期按理说应该结束了。为什么之后调用 dave_greeter.() 时,它还能正确打印出 "Dave"?
- 答案(闭包机制):
- Elixir 的函数在定义时,会连同它所在作用域的变量绑定一起打包。
- 内部函数继承了外部函数的作用域,并将 name = "Dave" 的绑定关系封闭(Closes over)在自己内部。
- 这种被打包带走的环境,让内部函数拥有了“记忆力”,可以随时在未来被调用时使用这些值。
3. 闭包的实际应用价值
利用闭包,我们可以编写“函数工厂”(用一个函数来批量生产功能相似但参数不同的其他函数)。
例如,你可以用同一个 greeter 工厂,快速生产出各种专属的问候工具:
iex> alice_greeter = greeter.("Alice")
iex> bob_greeter = greeter.("Bob")
iex> alice_greeter.()
"Hello Alice"
iex> bob_greeter.()
"Hello Bob"
:light_bulb: Elixir 核心概念:参数化函数(多参数闭包)
在前一个例子中,只有外部函数接受参数。而本节展示了外部函数和内部函数同时接受参数的进阶闭包用法。这在函数式编程中被称为柯里化(Currying)的雏形,即把一个接受多个参数的函数,转变为一系列只接受一个参数的嵌套函数。
1. 核心代码示例:高阶加法器
:laptop: 代码实现
# 1. 定义一个“加法器工厂”:外部函数接受 n,内部函数接受 other,最终计算 n + other
iex> add_n = fn n -> (fn other -> n + other end) end
#Function<12.17052888 in :erl_eval.expr/5>
# 2. 生产一个“加 2”的专属函数(此时 n 被锁定为 2)
iex> add_two = add_n.(2)
#Function<12.17052888 in :erl_eval.expr/5>
# 3. 生产一个“加 5”的专属函数(此时 n 被锁定为 5)
iex> add_five = add_n.(5)
#Function<12.17052888 in :erl_eval.expr/5>
# 4. 调用“加 2”函数,传入 other = 3
iex> add_two.(3)
5
# 5. 调用“加 5”函数,传入 other = 7
iex> add_five.(7)
12
2. 核心原理解析
- 动态绑定:内部函数不仅记住了外部环境,还能将外部环境的变量 n 与自己接收到的新参数 other 进行动态运算(n + other)。
- 独立作用域:add_two 和 add_five 虽然都是由 add_n 生产出来的,但它们各自拥有独立的闭包环境。一个包里的 n 是 2,另一个包里的 n 是 5,它们互不干扰。
3. 一步到位的调用方式(管道与连续调用)
除了先绑定到变量(如 add_two)再调用,你也可以直接一行流连续传参:
iex> add_n.(2).(3)
5
第一个 .(2) 传给 n 并返回内部函数,第二个 .(3) 紧接着传给 other。
练习
编写函数prefix,接受一个字符串参数。它需要返回一个新函数,这个新函数会接受第二个字符串。当这个新函数被调用的时候,会返回一个字符串,该字符串由第一个字符串、一个空格,以及第二个字符串组成。
iex> mrs = prefix.("Mrs")
#Function<erl_eval.6.82930912>
iex> mrs.("Smith")
"Mrs Smith"
iex> prefix. ("Elixir").("Rocks")
"Elixir Rocks"
代码:
iex> prefix = fn first -> (fn second -> first <> " " <> second end) end
#Function<42.130099583/1 in :erl_eval.expr/6>
iex> mrs = prefix.("Mrs")
#Function<42.130099583/1 in :erl_eval.expr/6>
iex> mrs.("Smith")
"Mrs Smith"
iex> prefix. ("Elixir").("Rocks")
"Elixir Rocks"
外部函数捕获 first 变量并闭包锁定,内部函数接收 second 并完成最终的拼接
:light_bulb: Elixir 核心概念:高阶函数(函数作为参数传递)
在 Elixir 中,“函数就是值”。这意味着你不仅可以把函数赋值给变量,还可以把一个函数作为参数,传递给另一个函数。这是实现代码复用和函数式转换的灵魂所在。
1. 基础示例
# 1. 定义一个简单的翻倍函数
iex> times_2 = fn n -> n * 2 end
# 2. 定义一个执行器函数,它接受一个函数(fun)和一个值(value)作为参数
iex> apply = fn (fun, value) -> fun.(value) end
# 3. 将 times_2 函数作为参数传给 apply
iex> apply.(times_2, 6)
12
2. 核心应用:内置的 Enum.map 模块
在实际开发中,最常高频使用此特性的地方是内置的 Enum 模块。Enum.map/2 接受两个参数:一个集合 和 一个函数。它会遍历集合中的每个元素,让其通过该函数进行转换,最后返回包含新元素的列表。
iex> list = [1, 3, 5, 7, 9]
# 示例 A:将集合中的每个元素乘以 2
iex> Enum.map list, fn elem -> elem * 2 end
[2, 6, 10, 14, 18]
# 示例 B:求每个元素的平方值(自己乘自己)
iex> Enum.map list, fn elem -> elem * elem end
[1, 9, 25, 49, 81]
# 示例 C:判断每个元素是否大于 6(返回布尔值列表)
iex> Enum.map list, fn elem -> elem > 6 end
[false, false, false, true, true]
3.核心思维
- 传统循环(如 for 循环):你需要命令程序“如何去遍历,如何去修改每一个值”。
- 函数式转换(如 Enum.map):你只需要关注“映射规则是什么”,然后把这个规则(即函数)直接丢给处理工具即可。
Elixir 中的 & 运算符(捕获运算符)
在 Elixir 中,创建简短辅助函数的场景非常普遍。& 运算符提供了一种快捷方式,可以将紧随其后的表达式转换成匿名函数。
1. 基础用法与占位符
在表达式内部,使用 &1、&2、&3 等符号作为占位符,分别对应函数的第一个、第二个和接下来的参数。
* 单参数示例:
# 表达式形式
add_one = &(&1 + 1)
# 等价于传统的匿名函数:
# add_one = fn (n) -> n + 1 end
add_one.(44) # 输出: 45
* 乘方示例:
square = &(&1 * &1)
square.(8) # 输出: 64
* 多参数示例:
# 表达式形式
sum = &(&1 + &2)
# 等价于:
# sum = fn p1, p2 -> p1 + p2 end
2. 进阶:将函数作为参数传递与编译器优化
当使用 & 运算符去包装一个已有的命名函数时,Elixir 会进行底层优化。
- 代码示例:
iex> speak = &(IO.puts(&1))
&IO.puts/1
iex> speak.("Hello")
Hello
:ok
:brain: 编译器的优化逻辑
-
- 直觉解析: 通常情况下,Elixir 会把 &(IO.puts(&1)) 生成为一个标准的匿名函数:fn x -> IO.puts(x) end。
-
- 智能优化: 但 Elixir 发现该匿名函数的内部仅仅是调用了一个命名函数(即 IO 模块的 puts 函数),且参数也是完全按正确顺序排列的(匿名函数的第一个参数就是命名函数的第一个参数)。
-
- 最终结果: 此时 Elixir 会直接把这个匿名函数优化掉,直接将其替换为对已有函数的引用:&IO.puts/1(表示 IO 模块中接收 1 个参数的 puts 函数),从而提高了运行效率。
Elixir 中的 & 运算符(进阶与特殊用法)
1. 自动优化与参数顺序的要求
只有当占位符参数的顺序与内部函数调用完全一致时,Elixir 编译器才会将其优化为直接的函数引用。
- 顺序一致(触发优化):
iex> rnd = &(Float.round(&1, &2))
&Float.round/2 # 成功优化为直接引用
- 顺序不一致(无法优化):如果改变了参数的位置(例如交换了 &1 和 &2),Elixir 无法直接引用,而是会保留并生成一个完整的匿名函数。
iex> rnd = &(Float.round(&2, &1))
#Function<12.17052888 in :erl_eval.expr/5> # 保持为匿名函数
2. 映射到底层 Erlang 库
由于 Elixir 运行在 Erlang 虚拟机(BEAM)上,许多内置的核心函数在经过 & 捕获后,会直接显露出它们对底层 Erlang 库的映射。
# 捕获绝对值函数 abs
iex> &abs(&1)
&:erlang.abs/1 # 直接映射到了 Erlang 的内置函数
# 捕获求长度函数 length
iex> l = &length/1
&:erlang.length/1
iex> l.([1, 3, 5, 7])
4
3. 字面量列表 [] 和元组 {} 的函数转换
在 Elixir 中,方括号 [] 和花括号 {} 在底层也被视为运算符。因此,你可以使用 & 运算符,直接通过字面量的列表或元组形式来快捷创建函数。
- 示例:返回包含商和余数的元组
iex> divrem = &{ div(&1, &2), rem(&1, &2) }
#Function<12.17052888 in :erl_eval.expr/5>
iex> divrem.(13, 5)
{2, 3} # 返回了一个元组
4. 命名函数捕获形式:&函数名/形参数量
& 运算符还有一种最直接的形式:&function_name/arity(arity 指参数个数)。通过这种方式,你可以直接将一个已存在的命名函数封装并赋值给变量,以便像匿名函数一样进行传递或调用。
- 语法格式: &模块名.函数名/参数个数 或 &内置函数名/参数个数
- 代码示例:
# 捕获 Enum 模块中的 count 函数(接收1个参数)
iex> len = &Enum.count/1
&Enum.count/1
# 通过变量加点号 (.) 的形式调用它
iex> len.([1, 2, 3, 4])
4
除了内置的全局函数外,Elixir 核心模块(如 Kernel)中的命名函数同样可以使用 & 运算符进行捕获。
- 代码示例:
# 捕获 Kernel 模块中计算最小值的 min 函数(接收 2 个参数)
iex> m = &Kernel.min/2
&:erlang.min/2 # 提示:这同样是 Erlang 函数的一个别名
# 调用捕获后的函数
iex> m.(99, 88)
88
- 结论: 这种 &模块名.函数名/参数个数 的语法不仅适用于标准库,也完全适用于开发者后续自己编写的命名函数。
& 快捷方式最核心、最常用的场景,就是将一个函数作为参数,传递给其他高阶函数(如 Enum.map/2)。它可以极大简化遍历和过滤数据的代码。
以下是 Enum.map 与 & 运算符结合的经典示例:
- 示例 A:批量加 1(数值转换)
iex> Enum.map, &(&1 + 1)
[2, 3, 4, 5]
- 示例 B:批量求平方(数值计算)
iex> Enum.map, &(&1 * &1)
[1, 4, 9, 16]
- 示例 C:批量条件判断(生成布尔列表)
iex> Enum.map, &(&1 < 3)
[true, true, false, false]
练习
使用&运算符来重写下面的代码
- Enum.map [1,2,3,4], fn x -> x + 2 end
- Enum.each [1,2,3,4], fn x -> I0.inspect x end
Enum.map,&(&1+2)
Enum.each, &IO.inspect/1
这里使用了命名函数捕获形式。因为匿名函数内部仅仅是以相同顺序调用了 IO.inspect 且只有一个参数,Elixir 编译器会自动将其优化为直接引用 &IO.inspect/1。当然,写成 Enum.each, &(IO.inspect(&1)) 也是完全正确的。
Elixir 核心理念:数据转换与函数基础
1. 编程的核心本质
- 转换数据: 编程的基础和本质在于对数据进行转换。
- 小引擎角色: 函数是实施这些数据转换的“小引擎”,在 Elixir 语言中占据着至关重要的核心位置。
2. 阶段性总结与过渡
- 已学内容(匿名函数): 到目前为止,内容均围绕“匿名函数”展开。这类函数虽然可以被绑定到某个变量上,但其函数实体本身是没有名字的。
- 后续方向: 接下来本书将正式告别匿名函数,开始讨论如何在模块(Module)中定义拥有自己名字的“命名函数”。