高阶函数
让我们开始函数式编程
所有函数式编程语言的重要组成部分之一是能够将您定义的函数作为参数传递给另一个函数。这反过来将该函数参数绑定到一个变量,该变量可以在函数中像任何其他变量一样使用。可以接受以这种方式传递的其他函数的函数被称为高阶函数。高阶函数是抽象的强大手段,也是 Erlang 中掌握的最佳工具之一。
同样,这是一个源于数学的概念,主要是lambda 演算。我不会详细介绍 lambda 演算,因为有些人很难理解它,而且它有点超出范围。但是,我将简要地将其定义为一个系统,其中一切都是函数,即使是数字。因为一切都是函数,所以函数必须接受其他函数作为参数,并且可以使用更多函数对它们进行操作!
好吧,这可能有点奇怪,所以让我们从一个例子开始
-module(hhfuns). -compile(export_all). one() -> 1. two() -> 2. add(X,Y) -> X() + Y().
现在打开 Erlang shell,编译模块并开始操作
1> c(hhfuns). {ok, hhfuns} 2> hhfuns:add(one,two). ** exception error: bad function one in function hhfuns:add/2 3> hhfuns:add(1,2). ** exception error: bad function 1 in function hhfuns:add/2 4> hhfuns:add(fun hhfuns:one/0, fun hhfuns:two/0). 3
令人困惑吗?一旦您了解了它的工作原理(情况不总是这样吗?),就不那么令人困惑了。在命令 2 中,原子 one
和 two
被传递给 add/2
,然后 add/2
使用这两个原子作为函数名(X() + Y()
)。如果函数名在没有参数列表的情况下编写,那么这些名称将被解释为原子,而原子不能是函数,因此调用失败。这就是表达式 3 也失败的原因:值 1 和 2 也不能作为函数调用,而我们需要的正是函数!
这就是为什么需要在语言中添加新的符号,以便您可以从模块外部传递函数。这就是 fun Module:Function/Arity
的含义:它告诉 VM 使用该特定函数,然后将其绑定到一个变量。
那么以这种方式使用函数有什么好处呢?为了理解这一点,可能需要一个小例子。我们将向 hhfuns
添加一些函数,这些函数递归地遍历列表以在列表的每个整数中加或减一
increment([]) -> []; increment([H|T]) -> [H+1|increment(T)]. decrement([]) -> []; decrement([H|T]) -> [H-1|decrement(T)].
看看这些函数有多相似?它们基本上做同样的事情:它们循环遍历列表,对每个元素(+
或 -
)应用一个函数,然后再次调用自身。这段代码中几乎没有变化:只有应用的函数和递归调用不同。在列表上进行递归调用的核心始终是相同的。我们将所有类似的部分抽象到一个单一函数(map/2
)中,该函数将接受另一个函数作为参数
map(_, []) -> []; map(F, [H|T]) -> [F(H)|map(F,T)]. incr(X) -> X + 1. decr(X) -> X - 1.
然后可以在 shell 中测试它
1> c(hhfuns). {ok, hhfuns} 2> L = [1,2,3,4,5]. [1,2,3,4,5] 3> hhfuns:increment(L). [2,3,4,5,6] 4> hhfuns:decrement(L). [0,1,2,3,4] 5> hhfuns:map(fun hhfuns:incr/1, L). [2,3,4,5,6] 6> hhfuns:map(fun hhfuns:decr/1, L). [0,1,2,3,4]
这里的结果相同,但您刚刚创建了一个非常智能的抽象!每次您想要将函数应用于列表的每个元素时,您只需使用您的函数作为参数调用 map/2
即可。但是,将我们想要作为参数传递给 map/2
的每个函数都放在模块中、命名、导出、然后编译等等有点烦人。事实上,这很不切实际。我们需要的是可以动态声明的函数...
匿名函数
匿名函数,或funs,通过允许您在没有命名的情况下内联声明一种特殊的函数来解决这个问题。它们几乎可以做所有普通函数可以做的事情,除了递归调用自身(如果它们是匿名的,它们怎么能做到呢?)。它们的语法是
fun(Args1) -> Expression1, Exp2, ..., ExpN; (Args2) -> Expression1, Exp2, ..., ExpN; (Args3) -> Expression1, Exp2, ..., ExpN end
并且可以使用以下方式
7> Fn = fun() -> a end. #Fun<erl_eval.20.67289768> 8> Fn(). a 9> hhfuns:map(fun(X) -> X + 1 end, L). [2,3,4,5,6] 10> hhfuns:map(fun(X) -> X - 1 end, L). [0,1,2,3,4]
现在您看到了让人们如此喜欢函数式编程的原因之一:能够在非常低的代码级别进行抽象。因此,可以忽略循环等基本概念,让您专注于完成的任务而不是如何完成它。
匿名函数对于这样的抽象已经非常棒了,但它们仍然拥有更多隐藏的力量
11> PrepareAlarm = fun(Room) -> 11> io:format("Alarm set in ~s.~n",[Room]), 11> fun() -> io:format("Alarm tripped in ~s! Call Batman!~n",[Room]) end 11> end. #Fun<erl_eval.20.67289768> 12> AlarmReady = PrepareAlarm("bathroom"). Alarm set in bathroom. #Fun<erl_eval.6.13229925> 13> AlarmReady(). Alarm tripped in bathroom! Call Batman! ok
等等,蝙蝠侠!这里发生了什么?首先,我们声明了一个分配给 PrepareAlarm 的匿名函数。此函数尚未运行:它仅在调用 PrepareAlarm("bathroom").
时执行。 在那时,对 io:format/2
的调用被评估,并且输出“已设置警报”文本。第二个表达式(另一个匿名函数)被返回给调用者,然后分配给 AlarmReady。请注意,在此函数中,变量 Room 的值取自“父”函数(PrepareAlarm)。这与称为闭包的概念有关。
要理解闭包,首先必须理解作用域。函数的作用域可以被认为是存储所有变量及其值的地方。在函数 base(A) -> B = A + 1.
中,A 和 B 都被定义为 base/1
作用域的一部分。这意味着在 base/1
内部的任何地方,您可以引用 A 和 B 并期望一个值绑定到它们。当我说“任何地方”时,我不是在开玩笑,孩子;这包括匿名函数
base(A) -> B = A + 1, F = fun() -> A * B end, F().
B 和 A 仍然绑定到 base/1
的作用域,因此函数 F 仍然可以访问它们。这是因为 F 继承了 base/1
的作用域。就像大多数类型的现实生活继承一样,父母无法获得孩子拥有的东西
base(A) -> B = A + 1, F = fun() -> C = A * B end, F(), C.
在此版本的函数中,B 仍然等于 A + 1
,并且 F 将继续正常执行。但是,变量 C 仅位于 F 中的匿名函数的作用域内。当 base/1
尝试访问 C 的值时,它只找到一个未绑定的变量。事实上,如果您尝试编译此函数,编译器会大发雷霆。继承只是一种方式。
需要注意的是,继承的作用域会跟随匿名函数走到哪里,即使它被传递到另一个函数
a() -> Secret = "pony", fun() -> Secret end. b(F) -> "a/0's password is "++F().
然后,如果我们编译它
14> c(hhfuns). {ok, hhfuns} 15> hhfuns:b(hhfuns:a()). "a/0's password is pony"
谁告诉了 a/0
的密码?好吧,是 a/0
自己告诉的。虽然匿名函数在 a/0
中声明时具有 a/0
的作用域,但它仍然可以像上面解释的那样在 b/1
中执行时携带它。这非常有用,因为它允许我们携带参数和内容超出其原始上下文,而整个上下文本身不再需要(就像我们在之前的示例中对蝙蝠侠所做的那样)。
当您定义了接受多个参数的函数,但您有一个常量参数时,您最有可能使用匿名函数来传递状态
16> math:pow(5,2). 25.0 17> Base = 2. 2 18> PowerOfTwo = fun(X) -> math:pow(Base,X) end. #Fun<erl_eval.6.13229925> 17> hhfuns:map(PowerOfTwo, [1,2,3,4]). [2.0,4.0,8.0,16.0]
通过将对 math:pow/2
的调用包装在一个匿名函数中,并在其作用域内绑定 Base 变量,我们使 hhfuns:map/2
中对 PowerOfTwo 的每个调用都能够使用列表中的整数作为我们基数的指数。
在编写匿名函数时,您可能会陷入的一个小陷阱是当您尝试重新定义作用域时
base() -> A = 1, (fun() -> A = 2 end)().
这将声明一个匿名函数,然后运行它。由于匿名函数继承了 base/0
的作用域,因此尝试使用 =
运算符将 2 与变量 A(绑定到 1)进行比较。这肯定会失败。但是,如果在嵌套函数的头部完成,则可以重新定义变量
base() -> A = 1, (fun(A) -> A = 2 end)(2).
这可以工作。如果您尝试编译它,您将收到有关阴影(“警告:变量 'A' 在 'fun' 中被遮蔽”)的警告。阴影用于描述定义一个新变量的行为,该变量与父作用域中的一个变量具有相同的名称。这是为了防止一些错误(通常是正确的),因此在这些情况下,您可能希望考虑重命名您的变量。
更新
从 17.0 版本开始,语言支持使用具有内部名称的匿名函数。没错,匿名但有名的函数。
诀窍是该名称仅在函数的作用域内可见,而不在其外部可见。这样做的主要优点是它使定义匿名递归函数成为可能。例如,我们可以创建一个匿名函数,它会永远大声喧哗
18> f(PrepareAlarm), f(AlarmReady). ok 19> PrepareAlarm = fun(Room) -> 19> io:format("Alarm set in ~s.~n",[Room]), 19> fun Loop() -> 19> io:format("Alarm tripped in ~s! Call Batman!~n",[Room]), 19> timer:sleep(500), 19> Loop() 19> end 19> end. #Fun<erl_eval.6.71889879> 20> AlarmReady = PrepareAlarm("bathroom"). Alarm set in bathroom. #Fun<erl_eval.44.71889879> 21> AlarmReady(). Alarm tripped in bathroom! Call Batman! Alarm tripped in bathroom! Call Batman! Alarm tripped in bathroom! Call Batman! ...
变量 Loop 指向匿名函数本身,在该作用域内,它可以用作指向匿名函数的任何其他类似变量。这通常会让 shell 中许多操作在向前发展过程中变得不那么痛苦。
我们将把匿名函数理论放在一边,我们将探索更多常见的抽象,以避免不得不编写更多递归函数,就像我在上一章末尾承诺的那样。
映射、筛选、折叠等等
在本节开始时,我简要地展示了如何抽象出两个类似的函数来获得 map/2
函数。我还确认,此类函数可用于我们想要对每个元素执行操作的任何列表。该函数如下所示
map(_, []) -> []; map(F, [H|T]) -> [F(H)|map(F,T)].
但是,还有许多其他类似的抽象可以从常见的递归函数中构建。首先让我们看一下这两个函数
%% only keep even numbers even(L) -> lists:reverse(even(L,[])). even([], Acc) -> Acc; even([H|T], Acc) when H rem 2 == 0 -> even(T, [H|Acc]); even([_|T], Acc) -> even(T, Acc). %% only keep men older than 60 old_men(L) -> lists:reverse(old_men(L,[])). old_men([], Acc) -> Acc; old_men([Person = {male, Age}|People], Acc) when Age > 60 -> old_men(People, [Person|Acc]); old_men([_|People], Acc) -> old_men(People, Acc).
第一个函数接受一个数字列表,只返回偶数。第二个函数遍历一个形如 {Gender, Age}
的人列表,只保留年龄超过 60 岁的男性。这里很难找到相似之处,但我们有一些共同点。这两个函数都对列表进行操作,并且具有相同的目标,即保留通过某些测试(也称为谓词)的元素,然后丢弃其他元素。从这种泛化中,我们可以提取所有有用的信息,并将它们抽象出来
filter(Pred, L) -> lists:reverse(filter(Pred, L,[])). filter(_, [], Acc) -> Acc; filter(Pred, [H|T], Acc) -> case Pred(H) of true -> filter(Pred, T, [H|Acc]); false -> filter(Pred, T, Acc) end.
要使用筛选函数,我们现在只需要从函数外部获取测试。编译 hhfuns
模块并尝试它
1> c(hhfuns). {ok, hhfuns} 2> Numbers = lists:seq(1,10). [1,2,3,4,5,6,7,8,9,10] 3> hhfuns:filter(fun(X) -> X rem 2 == 0 end, Numbers). [2,4,6,8,10] 4> People = [{male,45},{female,67},{male,66},{female,12},{unknown,174},{male,74}]. [{male,45},{female,67},{male,66},{female,12},{unknown,174},{male,74}] 5> hhfuns:filter(fun({Gender,Age}) -> Gender == male andalso Age > 60 end, People). [{male,66},{male,74}]
这两个例子表明,通过使用 filter/2
函数,程序员只需要关心生成谓词和列表。循环遍历列表以丢弃不需要的项目的行为不再需要考虑。这是关于抽象函数代码的一件重要的事情:尝试摆脱始终相同的部分,让程序员提供更改的部分。
在上一节中,我们对列表应用的另一种递归操作是逐个查看列表的每个元素,并将它们简化为单个答案。这被称为折叠,可以用于以下函数
%% find the maximum of a list max([H|T]) -> max2(T, H). max2([], Max) -> Max; max2([H|T], Max) when H > Max -> max2(T, H); max2([_|T], Max) -> max2(T, Max). %% find the minimum of a list min([H|T]) -> min2(T,H). min2([], Min) -> Min; min2([H|T], Min) when H < Min -> min2(T,H); min2([_|T], Min) -> min2(T, Min). %% sum of all the elements of a list sum(L) -> sum(L,0). sum([], Sum) -> Sum; sum([H|T], Sum) -> sum(T, H+Sum).
要找到折叠应该如何表现,我们必须找到这些操作的所有共同点,然后找出不同的点。如上所述,我们始终有一个从列表到单个值的简化。因此,我们的折叠应该只考虑在保持单个项目的同时进行迭代,不需要构建列表。然后我们需要忽略保护条件,因为它们并不总是存在:这些需要在用户的函数中。在这方面,我们的折叠函数可能看起来很像 sum。
所有三个函数中一个尚未提及的微妙之处在于,每个函数都需要一个初始值来开始计数。在sum/2
的情况下,我们使用 0 作为加法运算,因为给定X = X + 0
,该值是中性的,并且我们不会从这里开始弄乱计算。如果我们要进行乘法运算,我们会使用 1,因为给定X = X * 1
。函数min/1
和 max/1
不能有一个默认的起始值:如果列表只有负数,并且我们从 0 开始,答案将是错误的。因此,我们需要使用列表的第一个元素作为起点。不幸的是,我们不能总是这样决定,所以我们将把这个决定留给程序员。通过考虑所有这些元素,我们可以构建以下抽象
fold(_, Start, []) -> Start; fold(F, Start, [H|T]) -> fold(F, F(H,Start), T).
当尝试时
6> c(hhfuns). {ok, hhfuns} 7> [H|T] = [1,7,3,5,9,0,2,3]. [1,7,3,5,9,0,2,3] 8> hhfuns:fold(fun(A,B) when A > B -> A; (_,B) -> B end, H, T). 9 9> hhfuns:fold(fun(A,B) when A < B -> A; (_,B) -> B end, H, T). 0 10> hhfuns:fold(fun(A,B) -> A + B end, 0, lists:seq(1,6)). 21
几乎所有你能想到的将列表缩减为 1 个元素的函数都可以表示为一个 fold。
有趣的是,你可以将累加器表示为单个元素(或单个变量),而累加器可以是列表。因此,我们可以使用 fold 来构建列表。这意味着 fold 是通用的,从某种意义上说,你可以使用 fold 实现几乎任何其他列表上的递归函数,甚至 map 和 filter
reverse(L) -> fold(fun(X,Acc) -> [X|Acc] end, [], L). map2(F,L) -> reverse(fold(fun(X,Acc) -> [F(X)|Acc] end, [], L)). filter2(Pred, L) -> F = fun(X,Acc) -> case Pred(X) of true -> [X|Acc]; false -> Acc end end, reverse(fold(F, [], L)).
它们都与之前手写的一样工作。强大的抽象怎么样?
Map、filter 和 fold 只是 Erlang 标准库提供的许多列表抽象之一(请参阅lists:map/2
,lists:filter/2
,lists:foldl/3
和 lists:foldr/3
)。其他函数包括all/2
和 any/2
,它们都接受一个谓词并分别测试所有元素是否返回 true 或至少有一个元素是否返回 true。然后你有dropwhile/2
,它将忽略列表中的元素,直到找到满足特定谓词的元素,其反面,takewhile/2
,它将保留所有元素,直到出现一个元素不返回谓词的 true 值。与前面两个函数互补的函数是partition/2
,它将接受一个列表并返回两个列表:一个包含满足给定谓词的项,另一个包含其他项。其他经常使用的列表函数包括flatten/1
,flatlength/1
,flatmap/2
,merge/1
,nth/2
,nthtail/2
,split/2
和许多其他函数。
你还会发现其他函数,例如拉链(如上一章所示)、解拉链、map 和 fold 的组合等。我鼓励你阅读关于列表的文档,看看可以做什么。你会发现自己很少需要通过使用那些已经被聪明人抽象掉的东西来编写递归函数。