美文网首页
在 Python 中使用函数式编程的最佳实践!

在 Python 中使用函数式编程的最佳实践!

作者: 919b0c54458f | 来源:发表于2019-01-21 10:34 被阅读4次

    简介

    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 + b

    plus = add

    plus(3, 4) # returns 7

    Lambda

    通过

    Lambda 表达式的语法,可以用声明式的方式创建函数。关键字 lambda

    来自希腊字母,经常在正式的数学逻辑中用来描述函数和变量的虚拟绑定,即“lambda

    演算”,它的历史比函数式编程还要久远。这一概念的另一个术语叫做“匿名函数”,因为 lambda

    函数可以直接嵌入到行内使用,不需要事先指定名称。将匿名函数赋值给变量后,它的行为与正常函数完全一样。

    (lambda a, b: a + b)(3, 4) # returns 7

    addition = lambda a, b: a + b

    addition(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 name

    sorted(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 two

    list(map(lambda x: x * 2, val)) # [2, 4, 6, 8, 10, 12]

    # Take the factorial by multiplying the value so far to the next item

    reduce(lambda: x, y: x * y, val, 1) # 1 * 1 * 2 * 3 * 4 * 5 * 6

    还有许多高阶函数能用其他方式操作函数,其中最值得一提的就是 partial,它能锁定函数的一部分参数。这种方式也叫做“currying”,这个术语来自函数式编程的先驱者 Haskell Curry:

    def power(base, exp):

    return base ** exp

    cube = 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

    @retry

    def 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] = word

    def 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 result

    def test_pluralize():

    result = pluralize(dictionary)

    assert result == ['foxes', 'bosses', 'oranges', 'toeses', 'fairies', 'cups']

    注意这里并没有使用任何 FP 特有的概念,只是创建并返回了一个新的对象,而不是重用并修改已有的旧对象。这样输入的内容也会保持不变。

    虽然这个例子像个玩具,但想象一下,如果你传递并改变了某个复杂的对象,或者通过数据库连接进行了某些操作。当编写很多很多测试用例时就会发现,你必须非常小心地处理测试用例的顺序,或者花大量代价在每个测试用例之后清除并重新创建状态。这些工作应该是在

    e2e 集成测试阶段的活儿,不应该在比较小的单元测试阶段进行。

    理解(并避免)可修改性

    先来个调查,你认为哪些数据结构是可修改的?

    为什么这一点很重要?有些时候列表和元组可以互换使用,因此人们经常会在代码中随机使用两者之一。于是当你试图修改一个元组(比如给其中一个元素赋值)时就会出错。或者试图用列表作为字典的键,也会导致

    TypeError,因为列表是可修改的。元组和字符串可以作为字典的键使用,因为它们不可修改,可以得到确定的哈希值,而其他数据结构都不行,因为它们的对象标识即使保持不变,值也会改变。

    最重要的是,在传递字典、列表或集合时,它们可能会在其他上下文中被意料之外地改变。这种问题非常难以调试。可修改的默认参数就是个经典的例子:

    def add_bar(items=[]):

    items.append('bar')

    return items

    l = 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 namedtuple

    VerbTenses = namedtuple('VerbTenses', ['past', 'present', 'future'])

    # versus

    class 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中应用函数式编程可能很容易。为了保持优雅,特别是在共享的代码中保持优雅,我认为最好是使用纯粹的函数式思想,让代码更容易预测,从而更容易维护,并且具有幂等性。

    相关文章

      网友评论

          本文标题:在 Python 中使用函数式编程的最佳实践!

          本文链接:https://www.haomeiwen.com/subject/vhgqjqtx.html