基本要点
函数即对象
函数名的本质就是一个变量,保存了函数所在的内存地址。
1 | #!/usr/bin/env python3 |
函数对象可以被赋值给变量。
1 | #!/usr/bin/env python3 |
函数名可以作为另外一个函数的参数。
1 | #!/usr/bin/env python3 |
函数名可以作为另外一个函数的返回值。
1 | #!/usr/bin/env python3 |
函数名可以作为容器类型(例如列表、元组,字典)的元素。
1 | #!/usr/bin/env python3 |
函数嵌套
函数都有各自的作用域:
1 | def foo(): |
Python 允许创建嵌套函数,通过在函数内部 def
的关键字再声明一个函数即为嵌套:
1 | def outer(): |
闭包
认识闭包
在一些语言中,在函数中可以(嵌套)定义另一个函数时,如果内部的函数引用了外部的函数的变量,则可能产生闭包。运行时,一旦外部的函数被执行,一个闭包就形成了,闭包中包含了内部函数的代码,以及所需外部函数中的变量的引用。闭包可以用来在一个函数与一组“私有”变量之间创建关联关系。在给定函数被多次调用的过程中,这些私有变量能够保持其持久性。 —— 维基百科
闭包(closure)是函数式编程的重要的语法结构,在 Python 的嵌套函数中,如果内部函数中对在外部作用域(但不是在全局作用域)的变量进行了引用,并且这个内部函数名被当成对象返回,这就形成了一个闭包(closure)。
1 | def echo_info(name): # name 是外层函数的变量 |
在上述的例子中 echo
就是内部函数,并且在 echo
中引用了外部作用域的变量 name
,而变量 name
是在外部作用域 echo_info
里面的,并且不是全局的作用域,函数名 echo
又作为了外层函数的返回值,此时内部函数 echo
就形成了一个闭包 。
闭包 = 函数块 + 定义函数时的环境,echo
就是函数块, name
就是环境,当然这个环境可以有很多,不止一个简单的 name
。
闭包的用途
闭包的最大用处有两个:
内部函数可以引用外部作用域的变量。
让外部作用域的变量的值始终保持在内存中。
闭包存在有什么意义呢?为什么需要闭包?我们来看爬取网页源码的例子:
普通方式爬取
1 | from urllib.request import urlopen |
闭包的方式爬取
1 |
|
假设我们要对函数调用 100 次,第二种爬取方式要比第一种更节省资源。因为第一种每调用一次函数,就会在内存中创建一次对象引用 url = 'https://movie.douban.com'
,调用 100 次就创建了一百次对象引用。而第二种是个闭包函数,对象引用 url = 'https://movie.douban.com'
会一直在内存中存在,而不会被创建一百次。
原因就在于 geturl
是 inner_geturl
的父函数,而 inner_geturl
被传入了一个父级函数的变量 url
,这导致 inner_geturl
始终在内存中,而 inner_geturl
的存在依赖于 geturl
,因此 geturl
也始终在内存中,不会在调用结束后被垃圾回收机制回收。
如何判断是不是闭包
闭包函数相对与普通函数会多出一个 __closure__
的属性,里面定义了一个元组用于存放所有的 cell 对象,每个 cell 对象保存了这个闭包中所有的外部变量。使用它就可以判断函数是否形成了闭包。例如:
1 | def mkinfo(name, age, sex='F'): |
输出结果如下:
1 | (<cell at 0x7f099fdf67c8: int object at 0x7f099fcc5680>, <cell at 0x7f099fdf67f8: str object at 0x7f099fdbc180>, <cell at 0x7f099fdf6828: str object at 0x7f099fdddce0>) |
装饰器
装饰器前戏
假设有如下函数,被其他各种程序调用:
1 | #!/usr/bin/env python3 |
现在公司要进行绩效考核,考核标准为 Python 代码中每个函数所执行的时间。如果在每个函数中加入时间统计的功能,则会造成大量雷同的代码,为了解决这个问题,我们想到可以重新定义一个函数用来专门计算时间:
1 | #!/usr/bin/env python3 |
但是这样的话,基础平台的函数修改了名字,很容易被业务线的人投诉的,因为我们每次都要将一个函数作为参数传递给 spent_time
函数。而且这种方式已经破坏了原有的代码逻辑结构,之前执行业务逻辑时运行 dns_resolver()
、 os_release()
,但是现在不得不改成 spent_time(dns_resolver)
、spent_time(os_release)
,这在生产环境是很不切合实际的,使用装饰器就可以很好地解决这个问题。
在此之前,我们先认识一下 开放封闭原则。
开放封闭原则
软件开发中的 “开放-封闭” 原则 :
封闭:已实现需求的功能代码块不应该再被修改。
开放:对现有功能的扩展开放。
装饰器
装饰器(Decorator)本质上是一个返回函数对象的高阶函数,该函数用不需要修改源函数代码(函数体、调用方式、返回值)的前提下为其增加额外的功能,装饰器的返回值也是一个函数对象。
概括的讲,装饰器的作用就是为已经存在的对象添加额外的功能 ,它常用于有切面需求的场景,比如插入日志、性能测试、事务处理、缓存、权限校验等应用场景。
现在回到上面绩效的例子,假设我们已经在源代码的基础上单独添加了计算时间的函数:
1 | #!/usr/bin/env python3 |
在上面的代码中,函数 spent_time
就是一个装饰器, funcname
就是被装饰的对象。看起来像 dns_resolver
、os_release
被上下时间函数装饰了,并且没有改变原函数的调用方式,但是每次调用时都必须进行一次赋值操作。为了避免这种重复性赋值操作,Python 使用了 @
语法糖:
1 | #!/usr/bin/env python3 |
如上所示,装饰器直接省去赋值操作,这样就提高了程序的可重复利用性,并增加了程序的可读性。并且 dns_resolver = spent_time(dns_resolver)
实际上是把 inner
的对象引用给了 dns_resolver
,而 inner
中的变量 funcname
之所以可以用,是因为 inner
是一个闭包函数,它引用了外部作用域 spent_time
接收到的 funcname
。
带参数的装饰器
上面我们已经用装饰器解决了调用方式和重复赋值的问题。现在问题又来了,这个月的绩效考核完了,函数代码不需要统计花费时间了,下个月又要再次进行一次考核。
解决这个问题最笨的办法就是,如果不考核了我们把装饰器的使用给注释掉,如果开始考核再把注释取消。如果有一千个函数的话,就要每次都要操作一千次。虽然编辑器都有批量查找替换的功能,但是这种方法很显然并不符合实际。
那么,有没有更简单的办法呢?我们可以使用一个全局变量作为 flag
,再配合使用可以传入参数的装饰器即可:
1 | #!/usr/bin/env python3 |
上面的代码开发完之后,如果想用装饰器就将全局变量 flag
的值改为 True
,不想用就改为 False
,这样以来就使用带参数的装饰器灵活地解决是否启用装饰的问题。
对象属性复制
函数对象都有 __name__
等属性,在上面的例子中,看似已经把所有问题都解决掉了,但是当我们打印经过装饰器做了装饰之后的原函数,它们的 __name__
已经发生了变化:
1 | print("function dns_resolver's name :", dns_resolver.__name__) |
结果如下:
1 | function dns_resolver's name : inner |
本来 dns_resolver
在没有被装饰的时候,dns_resolver.__name__
就是 dns_resolver
,而装饰后却变成了 inner
,因为返回的那个 inner()
函数名字就是 inner
,所以需要把原始函数的 __name__
等属性复制到 inner()
函数中,否则有些依赖函数签名的代码执行就会出错。
不需要编写 inner.__name__ = func.__name__
这样的代码,Python 内置的 functools.wraps
就是干这个事的,所以一个完整的 decorator 的写法如下:
1 | #!/usr/bin/env python3 |
如果是带参数的 decorator,应该这样写:
1 | #!/usr/bin/env python3 |
其中 from functools import wraps
是导入 functools
模块的 wraps
功能,现在只需要记住在定义 inner()
的前面加上 @wraps(funcname)
即可。由此一来,属性也完成了复制:
1 | function dns_resolver's name : dns_resolver |
多层装饰器
多层装饰器有点类似于俄罗斯套娃,我们来看下面的代码:
1 | #!/usr/bin/env python3 |
多层装饰器在执行时,最先执行的是离原函数最近的一层装饰器,依次往外执行,下面用一张图理解一下上述代码中装饰器的执行过程: