通过函数式编程实现更简洁的代码
— 焉知非鱼Cleaner code with functional programming
函数式编程是一种编程风格,现代语言或多或少都支持这种风格。在这篇文章中,我想解释一下函数式编程如何为你提供强大的抽象,使你的代码更加简洁。我将用 Raku 和 Python 中的例子来说明这一点,我们将看到这两种语言都是函数式编程的优秀语言。
Raku: 简介 #
本文的代码示例是用 Python 和 Raku 编写的。我想大多数人都熟悉 Python,但 Raku 不太为人所知,所以我先解释一下基础知识。本文中的代码不是很习惯,所以如果你懂得其他编程语言,应该很容易理解。
Raku 与 Perl 最为相似。两种语言在语法上都与 C/C++、Java 和 JavaScript 相似:基于块,语句用分号隔开,块用大括号分界,参数列表放在括号中,用逗号隔开。将 Perl 和 Raku 与其他语言区分开来的主要特征是使用魔符(“有趣的字符”)来识别变量的类型:$
代表标量,@
代表数组,%
代表哈希(映射),&
代表子程序。变量也有关键字来标识它们的作用域,我只用 my
来标识变量的词法作用域。子程序是用 sub
关键字来声明的,子程序可以是命名的,也可以是匿名的。
sub square ($x) {
$x*$x;
}
# anonymous subroutine
my $anon_square = sub ($x) {
$x*$x;
}
在 Python 中,这将是:
def square(x):
return x*x
# anonymous subroutine
anon_square = lambda x: x*x
Raku 支持无符号变量,并使用 \
语法来声明它们。更多关于普通变量和无符号变量之间的区别,请参见 Raku 文档。例如(say
打印它的参数,后面加一个换行)。
my \x = 42; # sigilless
my $y = 43;
say x + $y;
在本文的代码中,我将尽可能地使用无符号变量。
Raku 有几种类型的序列数据结构。在下面的代码中,我将使用列表和数组以及范围。在 Raku 中,列表和数组的主要区别在于,列表是不可变的,这意味着一旦创建,就不能修改。所以它是一个只读的数据结构。要"更新"一个不可变的数据结构,你需要创建一个更新的副本。另一方面,数组是可变的,所以我们可以更新它们的元素,扩展它们,缩小它们等等。所有的更新都发生在原始数据的位置上。
Raku 的数组类似于 Python 的 list,Raku 的 list 类似于 Python 的 tuple,也是不可变的。除了语法之外,Raku 中的范围与 Python 中的范围相似,都是不可变的。
my @array1 = 1,2,3; #=> an array because of the '@' sigil
my \array2 = [1,2,3]; #= an array, because of the '[...]'
my \range1 = 1 .. 10; #=> a range 1 .. 10
my @array3 = 1 .. 10; #= an array from a range, because of the '@' sigil
my \list1 = 1,2,3; #=> a list
my $list2 = (1,2,3); #= also a list
my \list3 = |(1 .. 10); #=> an array from a range because of the '|' flattening operation
相应的 Python 代码为:
list1 = list((1,2,3)) #=> a list from a tuple
list2 = [1,2,3]; #=> a list, because of the '[...]'
range1 = range(1,11) #=> a range 1 .. 10
list3 = list(range(1,11)); #=> a list from a range
tuple1 = 1,2,3; #=> a tuple
tuple2 = tuple([1,2,3]) #=> a tuple from a list
tuple3 = tuple(range(1,11)) #=> creates a tuple from a range
其他具体的语法或功能将针对具体的例子进行解释。
其他任何名称的函数 - 作为值的函数 #
函数是函数式编程的精髓。正如我在“万物皆函数”一文中所解释的那样,在适当的函数式语言中,所有的结构都是由函数构建的。
所有现代编程语言都有函数、程序、子程序或方法的概念。它们是代码重用的重要机制。通常,我们认为函数是对一些输入值进行操作以产生一个或多个输出值的东西。输入值可以是全局声明的,也可以是一个类的属性,或者作为参数传递给函数。同样,输出值可以直接返回,到全局变量,作为类的属性或通过修改输入值。
要想从函数式编程中获益最多,最好是函数是纯粹的,这意味着对函数的调用总是对相同的输入产生相同的输出。在实践中,如果函数只接受输入作为参数,并直接返回输出,这一点比较容易实现,但这并不是必不可少的。
函数式编程的关键特征是,函数的输入值和输出值本身可以是函数。所以函数必须是你语言中的值。有时这被称为 “函数必须是一等公民”,一个接收和/或返回函数的函数有时被称为"高阶函数"。
如果函数是值,那么我们就可以将它们赋值给变量。特别是我们会将它们赋值给其他函数的参数。但我们也可以将它们赋值给普通的变量。
让我们考虑以下函数,choose
,它需要三个参数 t
,f
和 c
。
# Raku
sub choose (\t, \f, \d) {
if (d) {t} else {f}
}
# Python
def choose (t, f, d):
if d:
return t
else:
return f
首先让我们用字符串作为前两个参数的值来调用 choose
。
# Raku
my \tstr = "True!";
my \fstr = "False!";
my \res_str = choose(tstr, fstr, True);
say res_str; #=> says "True!"
# Python
tstr = "True!"
fstr = "False!"
res_str = choose(tstr,fstr,True)
print(res_str) #= says "True!"
现在让我们尝试用函数作为参数:
# Raku
sub tt (\s) { say "True {s}!" }
sub ff (\s) { say "False {s}!" }
my &res_f = choose(&tt, &ff, False);
say &res_f; #=> says &ff
res_f("rumour"); #= says "False rumour!"
# Python
def tt(s):
print( "True "+s+"!")
def ff(s):
print( "False"+s+"!")
res_f = choose(tt,ff,True)
print(res_f) #=> says <function tt at 0x7f829c3aa310>
res_f("rumour") #=> says "False rumour!"
因此,我们的函数 choose
接收两个函数作为它的前两个参数,并返回一个函数。在 Raku 中,我们需要在函数名上加上 &
符号,因为否则它们会被求值:像 tt
这样的裸函数名就等于调用没有参数的函数 tt()
。通过将这个函数赋值给一个变量(res_f
),我们现在可以将 res_f
作为一个函数来调用,它最终会根据选择来调用 tt
或 ff
。
函数不需要名字 #
现在,如果我们可以将函数赋值给变量,它们本身其实并不需要一个名字。所以我们的函数可以是匿名的。大多数语言都支持匿名函数,在函数式语言中,它们通常被称为 “lambda 函数”。在 Raku 中,我们有两种方法来创建匿名函数。
使用 sub (...)
语法:
my \tt = sub (\s) { say "True {s}!" };
或者使用‘尖号块’语法,这样更紧凑一些:
my \ff = -> \s { say "False {s}!" };
Python 使用 lambda
关键字:
tt = lambda s : print( "True "+s+"!" )
ff = lambda s : print( "False "+s+"!" )
所以现在我们可以说:
my &res_f = choose(tt, ff, True);
say &res_f; #=> says sub { }
res_f("story"); #= says "True story!"
当我们打印出函数所绑定的变量时,Raku 返回 sub { }
来表示该变量包含一个函数。
在 Python 中:
res_f = choose(tt, ff, True);
print( res_f) #=> says <function <lambda> at 0x7f829b298b80>
res_f("story") #=> says "True story!"
例子: map
、 grep
和 reduce
#
函数的功能有很多用途,我只想重点介绍三个在 Raku 中现成的例子:map
、reduce
和 grep
。Python 有 map
和 filter
,并通过 functools
模块提供 reduce
。这些函数的共同点是,它们提供了一种对列表进行 for
循环的替代方法。
map
: 对列表中的所有元素进行函数应用 #
map
有两个参数:一个函数和一个列表。它将函数按顺序应用于列表中的所有值,并返回结果,例如将列表中的所有值平方。
my \res = map -> \x {x*x} , 1 .. 10;
在 Python 中,我们需要显式地创建元组,但除了语法上的差异,结构是完全一样的。
res = tuple( map( lambda x : x*x , range(1,11)))
这是对传统 for
循环的功能替代。
# Raku
my \res = [];
for 1 .. 10 -> \x {
res.push(x*x);
}
# Python
res = []
for x in range(1,11):
res.append(x*x)
请注意,在 Raku 和 Python 中,我们需要为 for
循环版本使用一个可变的数据结构,而 map
版本则使用不可变的数据结构。
grep
: 过滤列表 #
grep
(在 Python 中称为 filter
)也接受参数,一个函数和一个列表,但它只返回函数返回真的列表中的值。
# Raku
my \res = grep -> \x { x % 5 == 0 }, 1 .. 30;
# Python
res = tuple(filter( lambda x : x % 5 == 0 ,range(1,31)))
当然我们也可以用 for
循环和 if
语句来写,但这又需要一个可变的数据结构。
# Raku
my \res = [];
for 1 .. 30 -> \x {
if (x % 5 == 0) {
res.push(x);
}
}
# Python
res = []
for x in range(1,31):
if (x % 5 == 0):
res.append(x)
map
和 grep
的好处是,你可以很容易地把它们链在一起。
# Raku
grep -> \x { x % 5 == 0 }, map -> \x {x*x}, 1..30
# Python
res = tuple(filter( lambda x : x % 5 == 0 ,map( lambda x : x*x ,range(1,31))))
这是因为 map
和 grep
接受一个列表并返回一个列表,所以只要你需要对一个列表进行操作,就可以通过链式调用来实现。
reduce
: 化整为零 #
reduce
也接受一个函数和一个 list,但它使用函数将 list 的所有元素合并成一个结果。所以函数必须接受两个参数。第二个参数是从列表中取出的元素,第一个参数作为状态变量来组合所有元素。例如,计算一个数字列表的和:
# Raku
my \sum = reduce sub (\acc,\elt) {acc+elt}, 1 .. 10;
say sum; #=> says 55
# Python
from functools import reduce
sum = reduce(lambda acc,elt: acc+elt, range(1,11))
print( sum); #= says 55
这里发生的情况是,首先将 acc
设置为列表中的第一个元素(1),然后加上第二个元素,所以 acc
变成 1+2=3;然后加上第三个元素(3),以此类推。其效果是将列表中的所有数字连续相加。
为了更清楚地说明这一点,我们来写一个我们自己的 reduce
版本。
编写你自己的 #
在许多函数式语言中,从左到右(从最低索引开始)和从右到左(从最高索引开始)的还原是有区别的。这一点很重要,因为根据做还原的函数,如果从左边或右边消耗列表,结果可能会不同。例如,假设我们的化简函数是
# Raku
-> \x,\y {x+y}
# Python
lambda x,y: x+y
那么我们从哪个方向遍历列表并不重要。但考虑以下函数:
# Raku
-> \x,\y { x < y ?? x+y !! x }
# Python
lambda x,y: x+y if x<y else x
( ... ?? ... !! ...
是条件操作符的 Raku 句法,在大多数其他语言中是 ... ? ... : ...
在 Python 中是 ... if ... else ...
)。
在这种情况下,如果列表从左或从右还原,结果会有所不同。在 Raku 和 Python 中,reduce
是一种从左到右的还原。
另外,reduce
函数可以不使用列表的第一个元素,而是取一个额外的参数,通常称为累加器。在函数式语言中,reduce
通常被称为 fold
,所以我们可以有一个左折和一个右折。让我们来看看如何实现这些。
Left fold #
实现左折的直接方法(所以和 reduce
一样)是在函数内部使用 for
循环。这意味着我们必须在循环的每次迭代上更新累加器的值。在 Raku 中,无符号变量是不可变的(我在这里简化了,完整的故事请看 Raku 文档),所以我们需要使用一个有符号的变量,$acc
。
# Raku
sub foldll (&f, \iacc, \lst) {
my $acc = iacc;
for lst -> \elt {
$acc = f($acc,elt);
}
$acc;
}
# Python
def foldll (f, iacc, lst):
acc = iacc
for elt in lst:
acc = f(acc,elt)
return acc
如果我们只想使用不可变的变量,我们可以使用递归。Raku 使这一点变得简单,因为它允许一个子程序有多个签名(multi sub
),并且它会调用与签名相匹配的变量。
我们的 foldl
将消耗输入列表 lst
,并使用 f
将其元素组合到累加器 acc
中,当列表被消耗后,计算结束,我们可以返回 acc
作为结果。所以我们的第一个变体说,如果输入列表是空的,我们应该返回 acc
。 第二个变体从列表中取出一个元素 elt
(关于 *
的细节请参见 Raku 文档),并将其与 acc
结合到 f(acc,elt)
中。然后用这个新的累加器和 list 的剩余部分 rest
再次调用 foldl
。
# When the list is empty, return the accumulator
multi sub foldl (&f, \acc, ()) { acc }
multi sub foldl (&f, \acc, \lst) {
# Raku's way of splitting a list in the first elt and the rest
# The '*' is a shorthand for the end of the list
my (\elt,\rest) = lst[0, 1 .. * ];
# The actual recursion
foldl( &f, f(acc, elt), rest);
}
Python 不允许这种模式匹配,所以我们需要使用条件来编写递归。
def foldl (f, acc, lst):
if lst == ():
return acc
else:
# Python's way of splitting a tuple in the first elt and the rest
# rest will be a list, not a tuple, but we'll let that pass
(elt,*rest) = lst
# The actual recursion
return foldl( f, f(acc, elt), rest)
在这个实现中,所有的变量都不会被更新。所以所有的变量都可以是不可变的。
Right fold #
右折与左折颇为相似。对于基于循环的版本,我们所做的只是将列表反转(reverse
)。
# Raku
sub foldrl (&f, \acc, \lst) {
my $res = acc;
for lst.reverse -> \elt {
$res = f($res,elt);
}
$res;
}
# Python
def foldlr (f, iacc, lst):
acc = iacc
for elt in lst.reverse():
acc = f(acc,elt)
return acc
在递归版本中,我们从列表中取最后一个元素而不是第一个元素。关于 ..^ * - 1
语法的细节,请参见 Raku 文档。
# Raku
multi sub foldr ( &f, \acc, ()) { acc }
multi sub foldr (&f, \acc, \lst) {
my (\rest,\elt) = lst[0..^*-1, * ];
foldr( &f, f(acc, elt), rest);
}
# Python
def foldr (f, acc, lst):
if lst == ():
return acc
else:
(*rest,elt) = lst
return foldr( f, f(acc, elt), rest)
map
and grep
are folds #
现在,map
和 grep
呢?我们当然可以用 for
循环来实现,但我们也可以用我们的 foldl
来实现它们。
# Raku
sub map (&f,\lst) {
foldl( sub (\acc,\elt) {
(|acc,f(elt))
}, (), lst);
}
# Python
def map (f,lst):
return foldl(
lambda acc,elt:(*acc, f(elt))
,()
,lst
)
因为函数 f
是可映射的,所以它只有一个参数。但是 foldl
需要一个有两个参数的函数,第一个参数为累加器。所以我们用两个参数的匿名函数调用 foldl
。累积器本身是一个空列表。虽然我们前面说过,还原将原来列表的所有元素合并成一个返回值,当然这个返回值可以是任何数据类型,所以也是一个列表。所以我们对原始列表中的每一个元素都调用 f
,并将其添加到累加器列表的末尾。(|
将列表扁平化,所以 (|acc,f(elt))
是一个由 acc
的元素和 f(elt)
的结果建立的新列表。)
类似地,我们也可以定义 grep
:
# Raku
sub grep (&f,\lst) {
foldl( sub (\acc,\elt) {
if (f(elt)) {
(|acc,elt)
} else {
acc
}
}, (), lst);
}
# Python
def filter (f,lst):
return foldl(
lambda acc,elt:
(*acc,elt) if f(elt) else acc
, (), lst)
就像在 map
实现中一样,我们用一个匿名函数调用 foldl
。在这个函数中,我们测试 lst
中的每个 elt
是否为 f(elt)
为真。如果是真,我们就从 acc
和 elt
创建一个新的列表,否则我们就只返回 acc
。 因为 map
和 grep
分别对列表中的每个元素进行操作,所以我们也可以使用右折来实现它们。
通过这些例子,我希望无论是对函数工作的概念,还是对函数可能的实现方式,都变得更加清晰。递归实现的优点是它允许我们使用不可变的数据结构。
为什么是不可变的数据结构? #
你可能会好奇为什么我关注这些不可变的数据结构。正如我们将看到的那样,函数式编程与不可改变的数据结构配合得非常好。而且它们有一个很大的优势:你永远不用担心是否不小心修改了你的数据,也不用担心是否应该做一个副本来确定。所以使用不可变数据结构可以使代码不易出错,更容易调试。它们还具有潜在的性能优势。而我们接下来会看到,在 Raku 中还有另一个优势。
返回函数的函数 #
函数也可以返回函数。如果我们想拥有一个可参数化的函数,这一点尤其有用。举个简单的例子,假设我们想要一系列以固定值递增一个数字的函数:add1
、add2
等。当然,我们可以分别写出每一个函数。
# Raku
sub add_1 (\x) {x+1}
sub add_2 (\x) {x+2}
sub add_3 (\x) {x+3}
sub add_4 (\x) {x+4}
sub add_5 (\x) {x+5}
say add_1(4); #=> says 5
# Python
def add_1 (x) : return x+1
def add_2 (x) : return x+2
def add_3 (x) : return x+3
def add_4 (x) : return x+4
def add_5 (x) : return x+5
print( add_1(4)) #= says 5
或者我们可以使用一个充满匿名函数的列表。
# Raku
my \add =
sub (\x) {x},
sub (\x) {x+1},
sub (\x) {x+2},
sub (\x) {x+3},
sub (\x) {x+4},
sub (\x) {x+5};
say add[0].(4); #=> says 5
# Python
add = (
lambda x : x+1,
lambda x : x+2,
lambda x : x+3,
lambda x : x+4,
lambda x : x+5
)
print( add[0](4)) #= says 5
我们可以做得更好,用一个循环来填充一个匿名函数的数组。
# Raku
my \add = [];
for 0 .. 5 -> \n {
add.push(sub (\x) {x+n});
}
say add[1].(4); #=> says 5
# Python
add = []
for n in range(0,6):
add.append(lambda x: x+n)
我们每次循环迭代都会创建一个新的匿名函数,并将其添加到数组中。但是,我们可以使用一个函数来创建这些匿名函数,然后我们可以使用 map
来代替循环,并使用一个不可改变的数据结构。
# Raku
sub gen_add(\n) {
sub (\x) {x+n}
}
my \add = map &gen_add, 0..5;
say add[1].(4); #=> says 5
# Python
def gen_add(n):
return lambda x : x+n
add = tuple(map( gen_add, range(0,6)))
print( add[1](4)) #= says 5
Laziness #
在 Raku 中,使用(不可改变的)范围有一个额外的好处:我们可以将范围的末端设置为无穷大,在 Raku 中可以写成 ∞
(unicode 221E)、*
或 Inf
。
# Raku
my \add = map &gen_add, 0 .. ∞;
say add[244].(7124); #=> says 7368
这是一个所谓的"懒惰求值"的例子,简称 laziness:Raku 不会尝试(和失败)处理这个无限的列表。相反,它将在我们实际使用该列表中的一个元素时进行处理。表达式的评估会延迟到需要结果的时候,所以当我们调用 add[244]
时,发生的情况是 gen_add(244)
被调用来生成该函数。请注意,这在 for
循环中是行不通的,因为要使用 for
循环,我们需要一个可变的数据结构,而惰性列表必须是不可变的。所以这是一个很好的例子,说明函数式编程风格如何让你从懒惰中获益。
这也是为什么我们递归地实现了 foldl
,然后用它来实现我们自己的 map
和 grep
:基于递归的版本不需要更新任何变量,所以它们可以与不可变的惰性数据结构一起工作。
函数组合 #
我们在上面看到,你可以把 map
和 grep
的调用链在一起。通常情况下,你只需要将 map
调用链在一起,例如
# Raku
map -> \x { x + 5 }, map -> \x {x*x}, 1..30;
# Python
map( lambda x : x + 5, map( lambda x : x*x, range(1,31)))
在这种情况下,我们可以做得更有效率一些:比起创建一个列表,然后在这个列表上调用 map
,我们可以通过组合函数一次完成两个计算。Raku 为此提供了一个特殊的操作符。
map -> \x { x + 5 } ∘ -> \x { x * x }, 1..30;
操作符 ∘
(“环形操作符”,unicode 2218,但你也可以用普通的 o
)是函数组成操作符,它的发音是 “after”,所以 f ∘ g
是 “f after g”。它的作用是将两个现有的函数组合起来,创建一个新的函数。
my &h = &f ∘ &g;
是下面的代码是一样的:
sub h (\x) {
f(g(x))
}
组成运算符的优点是,它可以适用于任何函数,包括匿名函数。但实际上,它只是另一个高阶函数。它只是下面函数的运算符形式。
# Raku
sub compose(&f,&g) {
sub (\x) { f(g(x)) }
}
Python 没有函数组成操作符,但你也可以很容易地在 Python 中拥有 compose
。
# Python
def compose(f,g):
return lambda x: f(g(x))
结论 #
在这篇文章中,我用 Raku 和 Python 的例子介绍了三种关键的函数式编程技术:对函数进行操作的函数、返回函数的函数和函数组成。我已经展示了你如何使用函数 map
、reduce
(折叠)和 grep
(过滤)来操作不可变的列表。我已经解释了哟(如何用递归和不递归实现这样的函数,以及递归实现的优势是什么。下面是《 Raku 与 Python》一文中的代码。
当然,函数式编程的内容还有很多,我也写了几篇更高级的文章。本文介绍的概念应该为理解那些更高级的主题打下良好的基础。如果你想了解更多关于函数式编程的知识,你可以考虑我的免费在线课程。
原文: https://wimvanderbauwhede.github.io/articles/decluttering-with-functional-programming/