当前位置: 首页 > 科技观察

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

时间:2023-03-14 09:30:37 科技观察

简介Python是一种功能丰富的高级编程语言。它有一个通用的标准库,支持多种编程语言范式,并且具有很大的内部透明度。如果你愿意,你也可以看看Python的底层并修改它,甚至可以在程序运行时直接修改runtime。我最近注意到一个有经验的Python程序员使用Python的新方法。与许多Python新手一样,当我第一次看到Python时,我就喜欢它用于基本循环、函数和类定义的简单语法。掌握了基本语法后,我对继承、生成器、元编程等高级特性产生了兴趣,但是却不知道如何使用它们,而且经常用在不合适的地方。有一段时间我写的代码很复杂,很难理解。后来,我反复修改,尤其是当我需要长时间处理同一段代码时,最终我会慢慢将大部分代码改回使用基本函数、循环和单例类。不过,这些高级功能一定是有原因的,它们一定是非常重要的工具。显然,“如何写出好的代码”是一个很宽泛的话题,连一个正确的答案都没有!相反,这篇文章针对一个特定主题:函数式编程在Python中的应用。我将讨论什么是函数,如何在Python中使用它,并根据我的经验描述使用它的最佳方法。什么是函数式编程?函数式编程,简称FP,是一种编程范式,其中最基本的元素是不可修改的值,以及不与其他函数共享状态的“纯函数”。纯函数总是为给定的输入返回相同的输出,而不修改任何数据或引起副作用。因此,纯函数常被比作数学运算。例如,3+4将始终等于7,无论同时执行任何其他数学运算,也无论之前执行了多少次加法。使用纯函数和不可修改的值,程序员可以创建逻辑结构。迭代可以用递归代替,因为递归是一种多次执行同一动作的“函数式”方式。一个函数用新的输入调用自己,直到参数满足某个终止条件。此外,还有将其他函数作为输入并返回另一个函数的高阶函数。我稍后会介绍这个概念。尽管函数式编程自1950年代就已出现,并已以多种语言实现,但它并不能完全描述一种语言。Clojure、CommonLisp、Haskell和OCaml都是主要的函数式语言,它们还结合了其他不同的编程语言概念,例如类型系统、严格或惰性求值等。大多数语言还以某种方式支持副作用,例如如写入文件、从文件读取等,通常会被仔细标记为“不纯”。功能性通常被认为是深奥的,它偏爱优雅和简单而不是实用性。大公司很少在大型项目中依赖函数式语言,即使有,范围也更小,远不如C++、Java、Python等其他语言受欢迎。然而,FP实际上只是一个框架,一种思考逻辑流的方式,它有自己的优点和缺点,也可以与其他编程范式一起使用。Python支持什么?尽管Python主要不是函数式语言,但支持函数式编程相对容易,因为Python中的一切都是对象。这意味着函数定义也可以分配给变量并传递。defadd(a,b):returna+bplus=addplus(3,4)#returns7Lambda通过Lambda表达式的语法,可以声明式的方式创建函数。关键字lambda来自希腊字母表,常用于形式数学逻辑中描述函数和变量的虚拟绑定,即“lambda演算”,它早于函数式编程。这个概念的另一个术语是“匿名函数”,因为lambda函数可以直接内联使用,而无需事先指定名称。将匿名函数分配给变量后,它的行为与普通函数完全一样。(lambdaa,b:a+b)(3,4)#returns7addition=lambdaa,b:a+badition(3,4)#returns7lambda函数最常见的用途是提供接受可调用对象作为参数的函数。“可调用对象”是任何可以通过括号调用的对象,特别是类、函数和方法。最常见的用法是在对数据结构进行排序时,通过参数的key指定排序的相对顺序。authors=['OctaviaButler','IsaacAsimov','NealStephenson','MargaretAtwood','UsulaKLeGuin','RayBradbury']排序(作者,key=len)#Returnslistorderedbylengthofauthornamesorted(作者,key=lambdaname:name.split()[-1])#Returnslistorderedalphabeticallybylastname。内联嵌入式lambda函数的缺点是它不会在堆栈跟踪中显示名称,这可能会给调试带来麻烦。Functools高阶函数是函数式编程的精髓,有的是Python直接提供的,有的是通过functools函数库提供的。在大规模分布式数据分析方面大家可能听说过map和reduce,但实际上它们也是最重要的两个高阶函数。map对给定序列的每个元素执行函数并返回结果序列;reduce使用函数收集序列中的每个元素并返回单个值。val=[1,2,3,4,5,6]#将每个项目乘以两个列表(map(lambdax:x*2,val))#[2,4,6,8,10,12]#通过乘以softothenexitemreduce(lambda:x)的值来取阶乘,y:x*y,val,1)#1*1*2*3*4*5*6有很多高阶函数可以用其他方式操作函数,最值得一提的是偏函数,它可以锁定函数的部分参数。这种方法也称为“currying”,这个术语来自函数式编程的先驱HaskellCurry:defpower(base,exp):returnbase**expcube=partial(power,exp=3)cube(5)#returns125关于PythonFor关于FP概念的具体介绍,以及如何优先考虑函数式编程,我推荐MaryRoseCook的这篇文章(https://maryrosecook.com/blog/post/a-practical-introduction-to-functional-programming)。这些函数可以将多行循环变成极其紧凑的单行代码。但是,普通程序员也很难看懂这些代码,尤其是Python与英语极为相似的语法流程。根据个人经验,我永远记不住参数的顺序,以及每个函数的作用,尽管我查了很多遍手册。但我强烈建议尝试这些函数以获得一些FP概念,有时我认为它们是正确的做法,如下一节中的示例所示。装饰器高阶函数也以装饰器的形式融入到日常的Python编程中。定义装饰器的方法体现了这一点,@符号其实只是一个语法糖,将装饰后的函数作为参数传递给装饰器。下面定义了一个简单的装饰器,它会重试给定代码三次,返回第一个成功的值,或者在三次尝试失败后放弃并抛出最终异常。defretry(func):defretried_function(*args,**kwargs):exc=Nonefor_inrange(3):try:returnfunc(*args,**kwargs)exceptExceptionaseexc:print("调用%swithargs时引发异常:%s,kwargs:%s.Retrying"%(func,args,kwargs).raiseexcreturnretried_function@retrydefdo_something_risky():...retried_function=retry(do_something_risky)#Noneedtouse`@`这个装饰器的输入输出完全一样的类型和值,但这不是必需的。装饰器可以添加或删除参数,并且可以改变参数的类型。它们也可以通过自己的参数进行配置。我想指出的是,装饰器本身不一定是“纯函数”,它们can(而且经常会)有副作用,恰好是使用高阶函数。像许多中级或高级Python技巧一样,这个技巧非常强大,但也很混乱。你必须用functools.wrap装饰器包装它,否则call的函数名会和函数na不一样我在堆栈跟踪中看到。我见过一些装饰器做一些非常复杂或非常重要的事情,比如解析jsonblob中的值,或者处理身份验证。我也见过同样的在一个函数或方法定义上多层装饰器,你必须知道应用装饰器的顺序才能正确理解。我认为通过使用内置装饰器(如“staticmethod”)或编写最简单的装饰器来避免大量样板代码可能有助于理解,但如果您希望您的代码符合类型检查,则尽量不要这样做修改输入或输出类型。我的建议函数式编程很有趣,在你的舒适区之外学习编程范式可以给你带来灵活性,也可以让你从另一个角度思考问题。但是,我不建议使用Python作为主要功能,尤其是在旧代码库中。除了我上面提到的坑,还有一个原因:你不需要懂FP就可以开始使用Python。这样做可能会使其他读者或您未来的自己感到困惑。你不能保证你所依赖的任何代码(通过pip安装的模块,或其他同事的代码)是功能性的和纯净的。您也不知道您自己的代码是否像您认为的那样纯净。与函数式语言不同,Python的语法或编译器不会帮助您加强纯度,也不会帮助您消除某些错误。混合副作用和高阶函数会导致巨大的混乱,因为您需要演示两种不同的复杂性,其难度是两者的乘积。使用带有类型注释的高阶函数是一种高级技术。类型签名通常是长而笨重的“Callable”嵌套。例如,返回输入函数的简单高阶装饰器定义为“F=TypeVar['F',bound=Callable[...,Any]]”,然后标记为“deftransparent(func:F)->F:返回函数”。也许你懒得研究签名的正确写法,直接用“Any”代替。那么,我们应该使用函数式编程的哪一部分呢?纯函数在可能和合理的情况下,尽可能保持函数“纯”,并仔细考虑应在何处保留已更改的状态,并仔细标记它们。这使得单元测试更容易,你不需要做太多的设置和拆卸,也不需要太多的模拟,测试用例会产生预期的结果,不管它们是什么顺序执行。这是一个非功能性示例。dictionary=['狐狸','boss','orange','toes','fairy','cup']defpuralize(words):foriinrange(len(words)):word=words[i]ifword.endswith('s')orword.endswith('x'):word+='es'ifword.endswith('y'):wordword=word[:-1]+'ies'else:word+='s'words[i]=worddeftest_pluralize():pluralize(dictionary)assertdictionary==['foxes','bosses','oranges','toeses','fairies','cups']第一次运行test_pluraize时测试可以通过,但是后来每次运行都失败,因为它重复添加“s”和“es”。为了让它成为一个纯函数,你可以这样写:dictionary=['fox','boss','orange','toes','fairy','cup']defpuralize(words):result=[]forwordinwords:word=words[i]ifword.endswith('s')orword.endswith('x'):plural=word+'es')ifword.endswith('y'):plural=word[:-1]+'ies'else:plural=+'s'result.append(plural)returnresultdeftest_pluralize():result=pluralize(dictionary)assertresult==['foxes','bosses','oranges','toeses','fairies','cups']请注意,这没有使用任何特定于FP的概念,只是创建并返回一个新对象,而不是重用和修改现有的旧对象。输入的内容也将保持不变。虽然这个例子只是一个玩具,但想象一下,如果您传递和改变了一些复杂的对象,或者通过数据库连接做了一些事情。当编写很多很多测试用例时,你会发现你必须非常小心测试用例的顺序,或者花很多钱在每个测试用例之后清除和重新创建状态。这些工作应该在端到端集成测试阶段完成,而不是在相对较小的单元测试阶段完成。要了解(并避免)可修改性,让我们首先进行调查。您认为哪些数据结构是可修改的?为什么这很重要?有时列表和元组可以互换使用,因此人们通常会在代码中随机使用两者之一。所以当你试图修改一个元组时(比如给其中一个元素赋值),就会发生错误。或者尝试将列表用作字典键也会导致TypeError,因为列表是可修改的。元组和字符串可以用作字典键,因为它们是不可变的并且可以获得确定性的哈希值,而其他数据结构则不能,因为即使值保持不变,它们的对象标识也可以改变。最重要的是,当传递字典、列表或集合时,它们可能会在其他上下文中意外更改。这种问题很难调试。可修改的默认参数是一个典型的例子:defadd_bar(items=[]):items.append('bar')returnitemsl=add_bar()#lis['bar']l.append('foo')add_bar()#returns['bar','foo','bar']字典、集合和列表功能强大、高效、非常Pythonic且非常有用。根本不使用它们就编写代码是不明智的。但即便如此,我总是使用元组或无(而不是空字典或空列表)代替默认参数,并避免在缺乏足够防御代码的情况下在不同的上下文中传递可修改的数据结构。减少类的使用类(及其实例)的可修改性是一把双刃剑。随着我编写越来越多的Python代码,我倾向于只在绝对必要时才使用类,而且我几乎从不使用可修改的类属性。这对于Java等高度面向对象的语言的程序员来说可能有些困难,但是很多其他语言在类级别完成的事情在Python中可以在模块级别完成。例如,如果您需要对函数或常量或命名空间进行分组,您可以将它们放在另一个.py文件中。我经常看到类的目的是保存几个命名变量的值,在这种情况下,一个命名元组(类型为typing.NamedTuple)就足够了,而且是不可变的。fromcollectionsimportnamedtupleVerbTenses=namedtuple('VerbTenses',['past','present','future'])#versusclassVerbTenses(object):def__init__(self,past,present,future):self.past=past,self.present=presenself.future=future如果你真的需要一个状态源,并且多个视图需要改变那个状态,那么这个类是一个很好的选择。此外,与静态方法相比,我更喜欢单例纯函数,因此它们可以在其他上下文中组合使用。可修改的类属性非常危险,因为它们属于类定义而不是类实例,因此可能会意外修改同一类的多个实例中的状态!classBus(object):passengers=set()defadd_passenger(self,person):self.passengers.add(person)bus1=Bus()bus2=Bus()bus1.add_passenger('abe')bus2.add_passenger('伯莎')bus1.passengers#returns['abe','bertha']bus2.passengers#also['abe','bertha']idempotence任何实际的大型复杂系统都可能失败,失败将不得不重试。矩阵代数中的“幂等”概念在API设计中也存在,但对于函数式编程,将之前的输出传递给一个幂等函数,将始终返回相同的值。因此,重做某事会收敛到相同的值。因此,上面的pluralize函数比较理想的写法是:先检查输入是否已经是复数,再考虑如何计算复数形式。关于使用lambda和高阶函数的注意事项我发现使用lambda在执行小操作时非常方便,例如获取排序键以供排序使用。但是如果lambda不止一行,使用普通的函数定义可能会更好。通常传递一个函数可以避免重复,但是我在使用的时候经常提醒自己,多余的结构会不会降低代码的清晰度。通常将它分解成更小的辅助函数会更简洁。在需要的地方使用生成器和高阶函数有时您会遇到可能返回巨大或无限序列的抽象生成器和迭代器。一个例子是范围。在Python3中,range默认是一个生成器(相当于Python2中的xrange),避免在迭代大数时出现内存不足的错误,例如range(10**10)。如果你想对一个可能很大的生成器的每个元素执行一些操作,使用map、filter等工具可能是最好的方法。同样,如果您不知道您新编写的迭代器可能返回多少结果,但很可能很大,您应该定义一个生成器。然而,并不是每个人都愿意使用生成器,他们可能更喜欢使用列表理解(listcomprehension),导致内存不足的错误,这是你首先要避免的。生成器是Python对流式编程的实现,不一定是函数式的,所以它也有其他Python编程方式的安全缺陷。结论通过浏览函数、库和内部代码来了解您选择的编程语言无疑会帮助您加快调试和阅读代码的速度。了解其他语言的思想或编程语言理论也很有趣,会让你成为一个更强大的无所不知的程序员。然而,成为一名高级Python程序员不仅意味着知道可以做什么,还意味着了解哪种方法最有效。在Python中应用函数式编程很容易。为了优雅,尤其是在共享代码中,我认为最好使用纯函数式思维,使代码更可预测,从而更易于维护,并且是幂等的。