简介
Python 是一种功能丰富的高级编程语言。它有通用的标准库,支持多种编程语言范式,还有许多内部的透明度。如果你愿意,还可以查看 Python 的底层并修改,甚至能在程序运行的时候直接修改运行时。
我最近注意到一个有经验的 Python 程序员使用 Python 的新方法。就像许多 Python 新手一样,我在第一次看到 Python 时喜欢它的简单易懂的基本循环、函数和类定义的语法。在掌握了基础语法之后,我开始对高级功能感兴趣,如继承、生成器、元编程等。但是,我不太清楚它们的使用方法,经常会在不恰当的地方使用。有一段时间里我写的代码复杂又难理解。后来我反复修改,特别是需要长期在同一段代码上工作时,最终会将大部分代码慢慢改回使用基本的函数、循环、单例类。
尽管如此,那些高级功能一定有其存在的理由,它们也一定是非常重要的工具。很明显,“怎样编写优秀的代码”是个非常广泛的话题,甚至没有唯一的正确答案!相反,这篇文章的目标是一个特定的话题:Python 中函数式编程的应用。我将讨论函数式是什么,怎样在 Python 中使用,并根据我的经验介绍最佳使用方法。
什么是函数式编程?
函数式编程(简称 FP)是一种编程范式,其中最基本的元素是不可修改的值,以及不与其他函数共享状态的“纯函数”。纯函数对于给定的输入永远返回同样的输出,而且不会修改任何数据,也不会造成副作用。因此,纯函数经常与数学运算比较。例如,3+4 永远等于 7,不管同时进行了其他任何数学运算,也不管之前进行了多少次加法运算。
有了纯函数和不可修改的值,程序员就可以创建逻辑结构了。迭代可以用递归代替,因为递归才是让同一个动作多次执行的“函数式”做法。函数使用新的输入调用自己,直到参数满足某个终止条件。此外,还有高阶函数,它的输入是其他函数,返回另一个函数。我稍后会介绍这个概念。
进群:960410445 即可获取书十套PDF!
尽管函数式编程从上世纪五十年代就出现了,而且许多语言也都实现了它,但它并没有完全地描述一门语言。Clojure、Common Lisp、Haskell 和 OCaml 都是以函数式为主的语言,也都融合了其他不同的编程语言概念,如类型系统、严格或懒惰求值等。大多数语言还用某种方法支持副作用,如写入文件、读取文件等,通常这些副作用都被仔细地标记为“不纯净”。
人们通常都认为函数式很深奥,而且与可实践性相比,它更看重优雅和简洁。大公司很少会在大规模项目上依赖于函数式为主的语言,即使要用也是在较小的范围内,远远不如其他 C++、Java、Python 等语言流行。但是,FP 实际上只是一种框架,一种考虑逻辑流的方式,它本身也有优点和缺点,而且也能与其他编程范式配合使用。
Python 支持什么?
尽管Python并不是以函数式为主的语言,但对它来说支持函数式编程也相对比较容易,因为Python中的一切都是对象。这意味着函数定义也可以赋给变量并传递。
def add(a, b): return a + bplus = addplus(3, 4) # returns 7
Lambda
通过 Lambda 表达式的语法,可以用声明式的方式创建函数。关键字 lambda 来自希腊字母,经常在正式的数学逻辑中用来描述函数和变量的虚拟绑定,即“lambda 演算”,它的历史比函数式编程还要久远。这一概念的另一个术语叫做“匿名函数”,因为 lambda 函数可以直接嵌入到行内使用,不需要事先指定名称。将匿名函数赋值给变量后,它的行为与正常函数完全一样。
(lambda a, b: a + b)(3, 4) # returns 7addition = lambda a, b: a + baddition(3, 4) # returns 7
lambda 函数最常见的用法就是提供给那些接受可调用对象作为参数的函数。“可调用对象”是任何能够通过括号调用的东西,具体来说有类、函数和方法。其中最常见的用法就是在对数据结构进行排序时,通过参数的键指定排序的相对顺序。
authors = ['Octavia Butler', 'Isaac Asimov', 'Neal Stephenson', 'Margaret Atwood', 'Usula K Le Guin', 'Ray Bradbury']sorted(authors, key=len) # Returns list ordered by length of author namesorted(authors, key=lambda name: name.split()[-1]) # Returns list ordered alphabetically by last name.
行内嵌入式 lambda 函数的缺点在于它不会在栈跟踪中显示名称,可能会给调试带来麻烦。
Functools
高阶函数是函数式编程的精华,部分由 Python 直接提供,部分通过 functools 函数库提供。你可能在大规模分布式数据分析方面听说过 map 和 reduce,但实际上它们也是最重要的两个高阶函数。map 在给定序列的每个元素上执行函数,然后返回结果的序列;reduce 使用一个函数收集序列中的每个元素,然后返回单个值。
val = [1, 2, 3, 4, 5, 6]# Multiply every item by twolist(map(lambda x: x * 2, val)) # [2, 4, 6, 8, 10, 12]# Take the factorial by multiplying the value so far to the next itemreduce(lambda: x, y: x * y, val, 1) # 1 * 1 * 2 * 3 * 4 * 5 * 6
还有许多高阶函数能用其他方式操作函数,其中最值得一提的就是 partial,它能锁定函数的一部分参数。这种方式也叫做“currying”,这个术语来自函数式编程的先驱者 Haskell Curry:
def power(base, exp): return base ** expcube = partial(power, exp=3)cube(5) # returns 125
关于 Python 中的 FP 概念的具体介绍,以及怎样优先使用函数式进行编程,我推荐 Mary Rose Cook 的这篇文章(https://maryrosecook.com/blog/post/a-practical-introduction-to-functional-programming)。
这些函数可以将许多行的循环转变成极其精简的一行代码。但是,一般的程序员也很难理解这些代码,特别是 Python 原本与英语十分类似的语法流。个人经验,我永远都记不住参数的顺序,以及每个函数的功能,尽管我查了这么多次手册。但我强烈建议尝试一下这些函数,以了解一些 FP 的概念,而且有时候我认为它们才是正确的选择,如下一节的例子所示。
修饰器
高阶函数也以修饰器的形式融入了日常的 Python 编程中。定义修饰器的方法就反映了这一点,而@符号实际上只是个语法糖,将被修饰的函数传递给修饰器作为参数。下面就定义了一个简单的修饰器,它会将给定的代码重试三次,返回第一个成功的值,或者在三次尝试都失败之后放弃并抛出最后的异常。
def retry(func): def retried_function(*args, **kwargs): exc = None for _ in range(3): try: return func(*args, **kwargs) except Exception as exc: print("Exception raised while calling %s with args:%s, kwargs: %s. Retrying" % (func, args, kwargs). raise exc return retried_function@retrydef do_something_risky(): ...retried_function = retry(do_something_risky) # No need to use `@`
这个修饰器的输入和输出的类型和值完全一样,但这并不是必须的。修饰器可以添加或减少参数,也可以改变参数的类型。它们也可以通过本身的参数进行配置。我想指出的是,修饰器本身不一定是“纯函数”,它们可以(而且经常会)有副作用,只不过是恰巧使用了高阶函数而已。
就像许多中级或高级 Python 技巧一样,这个功能非常强大,但也很容易造成混乱。你必须使用 functools.wrap 修饰器进行修饰,否则调用的函数和栈跟踪中看到的函数名字会不一样。我见过一些修饰器会做一些非常复杂或非常重要的事情,如解析 json blob 中的值,或者处理认证。我还见过同一个函数或方法定义上的多层修饰器,必须掌握修饰器的应用次序才能正确理解。我认为通过利用内置的修饰器如“staticmethod”可以帮助理解,或者编写最简单的修饰器来避免大量样板代码,但如果你想让你的代码符合类型检查的话,那么尽量不要去修改输入或输出的类型。
我的建议
函数式编程很有趣,而且学习舒适区之外的编程范式能够为你带来灵活性,而且也可以让你从另一个角度考虑问题。但是,我不推荐使用 Python 时以函数式为主,特别是在旧的代码库中不要这么做。除了上面我提到的那些坑之外,还有下面的理由:
- 开始使用 Python 不需要理解 FP。这样做很可能会迷惑其他阅读者,或者迷惑未来的自己。
- 你无法保证任何你依赖的代码(通过 pip 安装的模块,或其他同事的代码)是函数式的,是纯净的。你也不知道你自己的代码是否像你想象的那么纯净。与函数式为主的语言不同,Python 的语法或编译器不会帮你强制纯净,也不会帮你消灭某些 Bug。将副作用和高阶函数混合在一起回导致巨大的混乱,因为你需要论证两种不同的复杂性,其难度是两者的乘积。
- 使用带有类型注释的高阶函数是高级技巧。类型签名通常是又长又笨拙的“Callable”的嵌套。例如,一个简单的返回输入函数的高阶修饰器,其定义是“F = TypeVar[‘F’, bound=Callable[..., Any]]”,然后标注是“def transparent(func: F) -> F: return func”。也许你懒得研究正确的签名的写法,而直接使用“Any”代替了。
那么,我们应该使用函数式编程的哪部分呢?
纯函数
只要可能并且合理,就应该尽量保持函数“纯净”,并仔细考虑应当在何处保持改变了的状态,并仔细地标记好。这样能让单元测试变得更容易,你不需要做太多 set-up 和 tear-down,也不需要太多 mocking,而且测试用例不论执行顺序如何,都会产生预期中的结果。
下面是个非函数式的例子。
dictionary = ['fox', 'boss', 'orange', 'toes', 'fairy', 'cup']def puralize(words): for i in range(len(words)): word = words[i] if word.endswith('s') or word.endswith('x'): word += 'es' if word.endswith('y'): word = word[:-1] + 'ies' else: word += 's' words[i] = worddef test_pluralize(): pluralize(dictionary) assert dictionary == ['foxes', 'bosses', 'oranges', 'toeses', 'fairies', 'cups']
第一次运行 test_pluraize 时该测试能够通过,但以后每次运行都会失败,因为它会反复添加“s”和“es”。为了让它变成纯函数, 可以这样写:
dictionary = ['fox', 'boss', 'orange', 'toes', 'fairy', 'cup']def puralize(words): result = [] for word in words: word = words[i] if word.endswith('s') or word.endswith('x'): plural = word + 'es') if word.endswith('y'): plural = word[:-1] + 'ies' else: plural = + 's' result.append(plural) return resultdef test_pluralize(): result = pluralize(dictionary) assert result == ['foxes', 'bosses', 'oranges', 'toeses', 'fairies', 'cups']
注意这里并没有使用任何 FP 特有的概念,只是创建并返回了一个新的对象,而不是重用并修改已有的旧对象。这样输入的内容也会保持不变。
虽然这个例子像个玩具,但想象一下,如果你传递并改变了某个复杂的对象,或者通过数据库连接进行了某些操作。当编写很多很多测试用例时就会发现,你必须非常小心地处理测试用例的顺序,或者花大量代价在每个测试用例之后清除并重新创建状态。这些工作应该是在 e2e 集成测试阶段的活儿,不应该在比较小的单元测试阶段进行。
理解(并避免)可修改性
先来个调查,你认为哪些数据结构是可修改的?
为什么这一点很重要?有些时候列表和元组可以互换使用,因此人们经常会在代码中随机使用两者之一。于是当你试图修改一个元组(比如给其中一个元素赋值)时就会出错。或者试图用列表作为字典的键,也会导致 TypeError,因为列表是可修改的。元组和字符串可以作为字典的键使用,因为它们不可修改,可以得到确定的哈希值,而其他数据结构都不行,因为它们的对象标识即使保持不变,值也会改变。
最重要的是,在传递字典、列表或集合时,它们可能会在其他上下文中被意料之外地改变。这种问题非常难以调试。可修改的默认参数就是个经典的例子:
def add_bar(items=[]): items.append('bar') return itemsl = add_bar() # l is ['bar']l.append('foo')add_bar() # returns ['bar', 'foo', 'bar']
字典、集合和列表很强大、效率很高、非常 Python,而且非常有用。写代码时完全不使用它们是不明智的。但即使如此,我永远会在默认参数的位置使用元组或 None(代替空字典或空列表),并且在缺乏足够的防御代码的情况下,避免将可修改的数据结构在不同的上下文中传递。
减少类的使用
类(及其实例)的可修改性是把双刃剑。随着写的 Python 代码越来越多,我开始倾向于仅在绝对必要时才使用类,而且我几乎从不使用可修改的类属性。对于那些高度面向对象的语言(如 Java)的程序员来说这一点可能很难做到,但许多其他语言中在类层面完成的东西,在 Python 可以在模块层面完成。例如,如果需要将函数或常量或命名空间分组,那么可以把它们一起放到另一个 .py 文件中。
我经常看到一些类的目的是保存几个命名变量的值,这种情况下 namedtuple(其类型是 typing.NamedTuple)就足够,而且还是不可改变的。
from collections import namedtupleVerbTenses = namedtuple('VerbTenses', ['past', 'present', 'future'])# versusclass VerbTenses(object): def __init__(self, past, present, future): self.past = past, self.present = present self.future = future
如果确实需要状态的来源,而且多个视图都需要改变该状态,那么类是绝佳的选择。此外,与静态方法相比,我更倾向于单例纯函数,这样它们能在其他上下文中组合使用。
可修改的类属性非常危险,因为它们属于类定义而不是类实例,因此可能会不小心修改到同一个类的多个实例中的状态!
class Bus(object): passengers = set() def add_passenger(self, person): self.passengers.add(person)bus1 = Bus()bus2 = Bus()bus1.add_passenger('abe')bus2.add_passenger('bertha')bus1.passengers # returns ['abe', 'bertha']bus2.passengers # also ['abe', 'bertha']
幂等性
任何实际的大规模复杂系统都可能会失败,而失败就要重试。矩阵代数中的“幂等性”的概念也存在于 API 设计中,但对于函数式编程来说,传递之前的输出给幂等函数,永远会返回相同的值。因此,重做某件事情会收敛到相同的值。因此,上述 pluralize 函数更理想的写法为:,首先检查输入是否已是复数,再考虑怎样计算出复数形式。
lambda 和高阶函数使用上的注意点
我发现,在进行短小的操作(如获取排序的键供 sort 使用)时使用 lambda 非常方便。但如果 lambda 超过一行,那么使用普通的函数定义可能更好。通常传递函数可以避免重复,但我在使用时经常提醒自己,额外的结构是否会让代码清晰度下降。通常,将其分解成更小的辅助函数会更清晰。
在需要时使用生成器和高阶函数
有时候你会遇到抽象的生成器和迭代器,它们可能会返回巨大或者无限的序列。一个例子就是 range。在 Python 3 中,range 默认是生成器(相当于Python 2 中的 xrange),避免在迭代大数字时出现内存不足的错误,如range(10 ** 10)。如果要在一个可能很大的生成器的每个元素上执行某个操作,那么使用 map、filter 之类的工具可能是最好的选择。
与此相似,如果不知道你新写的迭代器可能会返回多少结果,但可能会很大,那就应该定义一个生成器。但是,并不是每个人都愿意去使用生成器,他们可能更希望使用列表解析式(list comprehension),从而导致你一开始想要避免的内存不足错误。生成器是 Python 对于流式编程的实现,它也不一定是函数式的,所以它也有其他 Python 编程方式拥有的安全性缺陷。
结论
通过浏览功能、库和内部代码来理解自己选择的编程语言,毫无疑问能帮你在调试和阅读代码方面提高速度。理解其他语言或编程语言理论方面的思想也很有意思,而且能让你成为更强大、无所不通的程序员。但是,成为Python的高级程序员意味着你不仅要知道能做什么,更要理解哪种才是最有效的方式。在Python中应用函数式编程可能很容易。为了保持优雅,特别是在共享的代码中保持优雅,我认为最好是使用纯粹的函数式思想,让代码更容易预测,从而更容易维护,并且具有幂等性。