前言

在文章开始之前,我假设你没有太多的函数式编程经历。为了让你直观了解什么是函数式编程,我将在本系列文章中介绍一些相当常见的概念,这些概念被视为函数式编程的定义。其中的部分概念将在本系列后续的文章中进行深入介绍。

我们约定,在代码片段中以>>>符号开头的行是 Python 解释器的输出行,如果输出存在多行,则以..|符号表示续行。效果如下:

>>> 这是单行输出

>>> 这是第1行输出
..| 这是第2行输出
..| 这是第3行输出

第1部分 - 纯度

纯函数本质上是接受一些输入并产生一些输出而不改变给定输入的函数(即不改变外部作用域中的任何东西),因此,纯函数与外部作用域交互的唯一方式是返回值。 此外,纯函数的输出应该只取决于给定的输入,没有函数作用域以外的外部值影响结果。我们还可以基于一个更加抽象的设定,即在给定相同输入的情况下,函数将返回相同的值,这个设定的描述暗示了它与数学中函数定义的一些相似之处。

举个栗子 (。・∀・)ノ° ,假设我们要实现一个函数,它接受一个整数平方并返回下一个整数平方。

def next_square(x):
    x **= 0.5
    x += 1
    x **= 2
    print(x, end=', ')
    return x


print(next_square(25))
>>> 36.0, 36.0
from math import sqrt

def increase(x):
    return x + 1

def next_square(x):
    print(x, end=", ")
    return pow(increase(sqrt(x)), 2)


print(next_square(25))
>>> 25, 36.0

第1段代码是一种直接的命令式实现:我们将输入视为具有特定状态(即输入的数值)的变量,然后我们指示函数如何对变量进行状态转换(计算并修改其数值),并返回变量最终的状态(值)作为整个函数的计算结果。

第2段代码体现了纯函数思想。函数输入不再是变量,而是一个具有固定值的常量,甚至可以将整个函数视为一个数学意义上的映射。函数的返回值正是这样一个纯粹映射的应用结果。

需要注意:

  1. 在第2段代码实现中,没有需要变更的值或转换的状态。
  2. 第1段代码实现更多的是给出指令序列,即考虑如何实现结果(函数是一系列状态变更的有序集合)。第2段代码实现解释了『结果是什么』,以及它如何用输入进行数学表达。

所以第2种实现更多的是根据需要的计算结果来思考。这种区别就是所谓的命令式编程(也就是我们常说的面向过程)和声明式编程之间的区别。

第1种方式是以命令的形式指示函数如何完成每一步计算,而第2种方式是向函数声明结果的定义。但是,以这种清晰、连续、逐行的指导性方式编程似乎更直观。为什么我们会关心函数的纯度,这似乎更像是一种不必要的复杂化,而不是一种优越的改进。函数式思想怎么就比命令式更好呢?

额……这其实有很多原因。

首先,它能够减少潜在错误的数量。如果我们不以任何方式提供任何需要改变的状态,如果我们不依赖变量值从程序中的其他地方改变的可能性,那么发生错误的空间通常会更小。

在这里考虑这个简单的例子:在 Python 程序中经常会遇到这样的情况:一些函数根据某个外部变量的状态计算其结果。

from infix import and_infix as infix

flag = True


def times(x, y):
    global flag
    flag = not flag
    return x * y if flag else 1


times = infix(times)
print(8 &times& 7 &times& 5, 8 &times& 5 &times& 7)
>>> 5 7

尽管函数本身没有任何错误或异常,但正如你所看到的,这种乘法过程并不遵循一个简单的数学规则:乘法交换律(因为上面函数的结果会因为乘法项顺序的改变而不同)。如果任何函数可能在运行过程中更改了外部作用域中的某些内容,或是以某种不立即表现出影响的方式更改其结果,我们将难以彻底理解程序中发生的事情。

其次,如果我们所有的函数都是纯函数,那么这些函数之间是相互独立的。也就是说,由于变量读写碰撞或相互干预的空间变小了,计算更容易并行化。

此外,如果我们以这种函数式思想进行编程,我们倾向于以更模块化的方式编写代码,从许多更小、更通用的部分构建特定问题的解决方案。因此,如果您愿意,我们会编写可复用性更高的代码,构建一组可插拔性更强的小型可组合工具集。

此外,当以声明式的形式编程时,我们倾向于首先考虑问题。我们先明确结果是什么(通常会使用一些尚未定义的术语),然后对那些未定义的术语重复先前的定义过程。这种颠倒的解决方案构建模式能够帮助我们生成更加模块化、复用性更高的代码。

不过,我们应该注意到,使用没有副作用的绝对纯函数(副作用基本上是函数与外部世界进行的任何交互,除了接受输入和返回输出)将意味着让程序毫无实用意义,因为诸如输入输出函数是专门为它们的副作用而调用的。任何程序都不可避免地包含非纯函数,但这并不意味着我们不应该在可能的情况下避免产生更多的副作用。

第2部分 - 函数,一等实体

基本上,如果我们可以将这样的对象作为结果从函数返回或作为参数提供给函数,则我们称它们为一等实体。 Python 允许我们以这种方式操作函数,从而为我们设计问题的解决方案提供了强大的工具。

这里只是一个在 Python 中使用函数的这个属性的简单例子。假设我们想要一个函数来添加一对元素(特指长度为2的元组)。写这样的函数简直就是小菜一碟,但是假设我们希望有许多其他函数以这种方式适应多个参数。 编写一个函数似乎是更具可扩展性的解决方案,这个函数给定一个具有一定数量参数的函数,返回一个功能完全相同但采用唯一参数——元组的函数,并返回将原始函数应用到元组各元素上的结果。这是一个可能的实现,它利用了我们可以将函数作为参数并返回它的事实。

def toTfunc(func):
    def res(tuple):
        return func(*tuple)

    return res


def multiply(a, b):
    return a * b


print(multiply((5, 6)))
>>> 30

乘法(multiply)是我能想到的最好的名字了 ╮(╯▽╰)╭

第3部分 - 不变性

您可能知道某些数据类型的成员是不可变的——也就是说,它们不能从程序的任何地方直接更改,而是只能返回更改后的副本,但也有一些数据类型在某种程度上不是那么安全。让我们看看这个简单的例子。

def listfunc(list):
    list2 = list
    list2[0] = 'C'
    return list2


somelist = ['T', 'E', 'N', 'S', 'O', 'R']
somelist2 = listfunc(somelist)
print(somelist, somelist2, sep='\n')

>>> ['C', 'E', 'N', 'S', 'O', 'R']
..| ['C', 'E', 'N', 'S', 'O', 'R']

您可能知道,应用listfunc()函数的结果不只是对somelist2进行了赋值,同时还导致了原始列表(实参)的改变,其中的后者可能不是我们所希望的结果。这似乎没什么大不了的,因为我们应该只跟踪我们在做什么,确保复制我们想要保持原样的数据;实际上,这个问题非常严重,如果我们有犯错的可能性,这种可能性会随着项目的增长而增加,这样的错误(对形参的修改影响到了实参)将更难被发现与修复。如果我们为了代码的安全性和清洁性而注意保持函数的纯度,形参影响实参并不是我们希望数据结构发生的行为方式。当我们以函数式编写源代码时,我们不会询问自己某个特定的动作是否已经改变了某个对象,因为我们早已知道答案:不可能改变。

那么,我们如何在 Python 中实现类似的功能呢?其实大多数流行的自定义数据类型都有其不可变的类似物,比如众所周知的元组(tuple),也许还有不太知名的『冻结集合』(frozen set)。因此,一种可能的解决方案是创建一个继承自原始可变类(在我们的示例中为列表和集合类)的子类,并添加一个方法来构造一个类似于原始可变对象的方法,比如这个例子:

class switchlist(list):
    def freeze(self):
        return tuple(self)


sl = switchlist([3, 1, 4])
print(sl.freeze())

>>> (3, 1, 4)
somelist3 = listfunc(sl.freeze())

>>> TypeError: 'tuple' object does not support item assignment
class switchset(set):
    def freeze(self):
        return frozenset(self)


ss = switchset([2, 7, 1])
print(ss.freeze())

>>> frozenset({1, 2, 7})

通过这种方式,我们可以在将对象传递给某些函数之前『冻结』我们想要防止发生变化的对象。 我们也可以将新方法附加到现有类,并向元组(tuple)和frozenset类添加诸如unfreeze()之类的方法,以返回当前对象的可变版本(如果我们出于某种原因希望有这种可能性的话)。尽管这种方法很有效,但我们仍然应该记住在必要时进行可变性转换,而且我们习惯了使用列表(list,它默认是可变的)。此外,这种方式不适用于字典(dict),因为根本不存在某种类似于 Python 中字典的内置不可变数据结构。所以要做到这一点,我们需要定义我们自己的类,该类将从字典类继承,然后我们手动禁止调用像set_item()这样的方法。

因此,这可能不是一个非常令人满意的解决方案。为了使某些东西不可变且实际可用,我们可能需要定义自己的类,这些类具有原始列表(list)、集合(set)和字典(dict)所具有的所有熟悉的方法,但要重新调整它们,以便这些方法不返回None并改变原始对象(而是返回一个变更后的副本,保持原始对象不变)。这可能会奏效,毕竟我们已经习惯了一些类似的字符串函数机制。对我们来说幸运的是,有一个名为pyrsistent的模块,其中包含了为我们实现的这些类。回顾一下:

from pyrsistent import pvector, pset, pmap, freeze, thaw


vector1 = freeze([0, 5, 7, 7, 2, 1, 5])
vector2 = vector1.append(6)
vector3 = vector1.remove(1)
vector4 = vector1.set(0, 1)
lst1 = thaw(vector1)
print(vector1, vector2, vector3, vector4, lst1, sep='\n')

>>> pvector([0, 5, 7, 7, 2, 1, 5])
..| pvector([0, 5, 7, 7, 2, 1, 5, 6])
..| pvector([0, 5, 7, 7, 2, 5])
..| pvector([1, 5, 7, 7, 2, 1, 5])
..| [0, 5, 7, 7, 2, 1, 5]

pvectorpsetpmap是构造具有相同名称的类的对象的函数。pvector类似于Python的列表,pset类似于集合,pmap类似于字典。 这些p*对象是不可变的,它们的行为与原始类非常相似,但是当我们使用相应原始 Python 类的一些熟悉的方法时,对象本身保持不变,只会将一个应用了相应更改的副本作为函数返回值。 此外,我们可以使用freeze()函数将 Python 原生泛型容器转换为p*类,也可以使用thaw()函数从p*对象中获取泛型 Python 容器。上面的代码正是一个典例,首先我们从列表中构造一个pvector,然后我们调用一些非常常见的方法,但很少将它们的结果分配给其他变量。如您所见,初始pvector保持不变。

这是保持不变性的一种非常方便的方法,因此我强烈建议您使用pyrsistent并访问 GitHub页面 以进一步探索它的可能性。还有我没有介绍的precord类和许多用于创建不可变的自定义类的数据结构。

第4部分 - 惰性求值

本质上,惰性求值(Lazy Evaluation)是一种计算策略,与称为迫切策略(Eager Evaluation)的常用求值方式有些相反。

模糊地说,惰性求值器会延迟任何现在不是立即需要的计算,并且仅在被迫时才执行求值。比如这个例子:

def someFunc(x, y):
    return x ** y + 2 * y


"""
迫切求值过程:
   someFunc(8 - 6, 25 / 5)
-> someFunc(2, 25 / 5)
-> someFunc(2, 5)
-> 2 ** 5 + 2 * 5
-> 32 + 2 * 5
-> 32 + 10
-> 42

惰性求值过程:
   someFunc(8 - 6, 25 / 5)
-> (8 - 6) ** (25 / 5) + 2 * (25 / 5)
-> 2 ** (25 / 5) + 2 * (25 / 5)
-> 2 ** 5 + 2 * 5
-> 32 + 2 * 5
-> 32 + 10
-> 42
"""

我们有somefunc()函数,我们传递给它的参数是表达式(8 - 6)(25 / 5)。如果我们遵循迫切原则,我们会首先计算给定的参数,然后将它们替换为函数体中的参数并计算结果。但遵循惰性策略,我们首先插入给定的参数,无论它们具有什么形式,然后再在函数体内逐步计算。 请注意,这里的惰性策略可能引入重复的表达式计算。如果我们的编译器(比如 Scala。当然,Python 使用的是解释器)足够聪明,这些表达式实际上不会被插入,而是会有许多指向延迟计算的指针,所以我们实际上不会多次处理相同的表达式。

无论如何,这种『我稍后再做』的方法有什么意义?

我们可以设想这种场景:给定的参数计算量很大,但我们实际上不需要它们来产生结果。例如,假设我们的函数将某种指标作为其参数之一,在某些情况下(取决于指标的值)我们会直接进入return语句,对所有其他参数不做任何处理。在这种情况下,使用惰性策略可以避免不必要的计算。

实际上有一些非常常见的函数使用了惰性策略,比如下面这个例子,一个函数bad()

def bad():
    while True:
        print()


print(True or bad(), False and bad())

>>> True False

尝试计算函数结果将导致无限循环,但是orand关键字早已被设计成『短路求值』(若二元运算符左侧子表达式的结果决定了整个表达式的结果,则右侧子表达式将被忽略,跳过其内部一切计算)。 还有其他方法(比如yield关键字也和惰性求值有点关系)可以在 Python 中实现这种方法,结果证明它非常有用。后续的课程将会更加深入地回顾这些可能性。

第5部分 - 命令式 VS 声明式

我想回到命令式和声明式、过程式和函数式风格之间的区别。正如我已经提到的,函数式方法更有声明性、逻辑更高级、思想更抽象,但在底层逻辑中通常效率较低,Lisp 、Haskell 等语言编写的程序通常不如使用 C、C++ 等语言编写的快。就代码运行的速度和内存处理的安排程度而言,这是高级别、高表现力与高运行效率之间的权衡,但我们都知道,Python 并不追求高运行效率,而是要表现力强、层次高,它是为了更快地编写、更好地阅读、更方便地更改代码,而不是更快地运行它。因此在这种权衡中,Python 更倾向于抽象方面,有时为了更好、更优雅地表达您的想法而放弃一些效率并不是问题。

这是一个经典的例子——快速排序算法的两种实现:

def partition(seq, start, end):
    pivot = seq[start]
    left = start + 1
    right = end
    done = False
    while not done:
        while left <= right and seq[left] <= pivot:
            left += 1
        while right >= left and seq[right] >= pivot:
            right -= 1
        if right < left:
            done = True
        else:
            seq[left], seq[right] = seq[right], seq[left]
    seq[start], seq[right] = seq[right], seq[start]
    return right


def qsort(seq, start=0, end=0):
    if not end:
        end = len(seq) - 1
    if end <= start:
        return seq
    if start < end:
        pivot = partition(seq, start, end)
        qsort(seq, start, pivot-1)
        qsort(seq, pivot+1, end)
    return seq

第1种是经典的命令式方法。首先,我们编写一条关于如何进行分区的指令,然后我们将其用作关于如何快速排序序列的指令的子指令。

def qsorted(list):
    if not list:
        return []
    else:
      return (
          qsorted([x for x in list if x < list[0]]) +
          [x for x in list if x == list[0]] +
          qsorted([x for x in list if x > list[0]])
      )

以下是众所周知的 Haskell 改编版本。您可以在此处看到列表推导式(它们正是从 Haskell 借鉴而来的),它们在 Python 中被大量使用,被认为非常方便。无论如何,在第2个示例中,我们定义了什么是『排序列表』。空列表的『排序列表』是它自身;如果它不为空,则它由三个后续部分组成——首先是一个小于某个固定元素的排序列表,然后是所有等于该固定元素的元素,然后是一个大于某个固定元素的排序列表。

您可能会注意到,使用此函数式实现能够以更简短的方式解释算法背后的思想。您可能还注意到,要了解代码的意图,您必须完整阅读第一个实现,而第二个实现具有良好的自解释性。最后,您可能会注意到第2种实现并不是真正意义上的快速排序,因为它没有就地完成并且需要一些额外的内存。

这是一个通用模式:在使用函数式风格时,你倾向于编写运行效率较低但简短、简洁、抽象、表现力强的代码。我认为这也是一种符合Python哲学的方法——美丽和优雅总比丑陋好(来自 Tim Peters 的《Python 之禅》首句)。

后记

然而,Python 确实在引导你使用面向对象编程的思想,但它仍然是一种多范式语言。如果需要的话,当它提供相当多的优势时,以功能方式编写代码的一些元素是可能的。在其他课程中,我们将了解Python 其他一些有用的元素,这些元素可让您以更实用的风格进行编程。

总结

在这一课中,我们讲解了:

  1. 函数的『纯度』,并提出了『纯函数』的概念,同时举了不少例子说明纯函数的意义。我们还对比了面向过程的命令式思想和函数式编程的声明式思想,展示了声明式思想的在编程实践中的优势及其带来的价值。
  2. 函数作为『一等实体』的身份,并给出示例代码、解释了其具体表现。
  3. 函数式编程思想中的『不变性』理念,讲解了『不变性』在函数式编程中的重要性、给出了自行实现的方式。最后提供了部分示例、介绍了 Python 中已有的部分第三方实现(PyPI 第三方包)。
  4. 讲解了『惰性求值』的概念,并以实际例子说明其计算逻辑。
  5. 对比了命令式编程和声明式编程的区别,展示了声明式编程思想的优越性。