Python 面向对象编程之类的特殊属性和方法
Python 类中,凡是以双下划线 __
开头和结尾命名的成员(属性和方法),都被称为类的特殊成员(特殊属性和特殊方法),例如类的 __init__(self)
构造方法。此外,Python 类中还包含很多其它的特殊成员,包括 __del__(self)
、__new__(self)
等等,这里会一一进行详解。
类常用特殊成员整理
我们知道,Python 类中的特殊成员,其特殊性类似 C++ 类的 private
私有成员,即不能在类的外部直接调用,但允许借助类中的普通方法调用甚至修改它们(封装)。如果需要,还可以对类的特殊方法进行重写,从而实现一些特殊的功能。
这一小节,将会详解 Python 顶级父类 object
中常用特殊成员的使用:
Method: __new__
__new__()
是一种用来创建类实例的静态方法,它无需使用 @staticmethod 装饰器修饰,且该方法会优先 __init__()
初始化方法被调用。
[1] >>>> __new__()
返回当前类实例
一般情况下,重写 __new__()
的实现需要使用合适的参数调用其超类的 super().__new__()
,并在返回之前修改实例。
实例如下:
1 | class demoClass: |
[2] >>>> __new__()
返回其它类实例
__new__()
通常会返回该类的一个实例,但有时也可能会返回其他类的实例,如果发生了这种情况,则会跳过对 __init__()
方法的调用。
而在某些情况下(比如需要修改不可变类实例(Python 的某些内置类型)的创建行为),利用这一点会事半功倍。比如:
1 | class nonZero(int): |
[3] >>>> 何时使用 __new__()
?
很简单,在 __init__()
不够用的时候。
例如,前面例子中对 Python 不可变的内置类型(如 int
、str
、float
等)进行了子类化,这是因为一旦创建了这样不可变的对象实例,就无法在 __init__()
方法中对其进行修改。
有些读者可能会认为,__new__()
对执行重要的对象初始化很有用,如果用户忘记使用 super()
,可能会漏掉这一初始化。虽然这听上去很合理,但有一个主要的缺点,即如果使用这样的方法,那么即便初始化过程已经是预期的行为,程序员明确跳过初始化步骤也会变得更加困难。不仅如此,它还破坏了 __init__() 中执行所有初始化工作
的潜规则。
并且,由于 __new__()
不限于返回同一个类的实例,所以很容易被滥用,不负责任地使用这种方法可能会对代码有害,所以要谨慎使用。
一般来说,对于特定问题,最好搜索其他可用的解决方案,最好不要影响对象的创建过程,使其违背程序员的预期。比如说,前面提到的覆写不可变类型初始化的例子,完全可以用工厂方法(一种设计模式)来替代。
Method: __repr__
我们经常会直接输出类的实例化对象,例如:
1 | class Language: |
通常情况下,直接输出某个实例化对象,本意往往是想了解该对象的基本信息,例如该对象有哪些属性,它们的值各是多少等等。但默认情况下,我们得到的信息只会是 class name + object at + <memory address>
,对我们了解该实例化对象帮助不大。
那么,有没有可能自定义输出实例化对象时的信息呢???答案是肯定,通过重写类的 __repr__()
方法即可。
重写 __repr__()
方法输出实例化对象时的信息 >>>>
事实上,当我们输出某个实例化对象时,其调用的就是该对象的 __repr__()
方法,输出的是该方法的返回值。
以本节开头的程序为例,执行 print(clangs)
等同于执行 print(clangs.__repr__())
,程序的输出结果是一样的(输出的内存地址可能不同)。
和 __init__(self)
的性质一样,Python 中的每个类都包含 __repr__()
方法,这是因为 object
类包含 __reper__()
方法,而 Python 中所有的类都直接或间接继承自 object 类。因此,你可以通过在类中重写这个方法,从而实现当输出实例化对象时,输出我们想要的信息。
实例演示:
1 | class Language: |
由此可见,__repr__()
方法是类的实例化对象用来做“自我介绍”的方法,默认情况下,它会返回当前对象的 class name + object at + <memory address>
,而如果对该方法进行重写,可以为其制作自定义的自我描述信息。
Method: __del__
我们知道,Python 中通过调用 __init__()
构造方法来创建当前类的实例化对象;__del__()
方法,功能正好和 __init__()
相反,其用来销毁实例化对象。
事实上在编写程序时,如果之前创建的类实例化对象后续不再使用,最好在适当位置手动将其销毁,释放其占用的内存空间(垃圾回收,GC)。
大多数情况下,Python 开发者不需要手动进行垃圾回收,因为 Python 有自动的垃圾回收机制,能自动将不需要使用的实例对象进行销毁。
但注意,无论是手动销毁,还是 Python 自动帮我们销毁,都会调用 __del__()
方法。实例演示:
1 | class Language: |
[1] >>>> 类的自动回收机制
千万不要误认为,只要为该实例对象调用 __del__()
方法,该对象所占用的内存空间就会被释放。举个例子:
1 | class Language: |
运行结果如下:
1 | 调用 __init__() 方法构造对象 |
可以看到,当程序中有其它变量(比如这里的 cl)引用该实例对象时,即便手动调用 __del__()
方法,该方法也不会立即执行,而是等待程序执行完成之后才会释放。这就和 Python 的垃圾回收机制有关。
以上面程序中的 langs
为例,实际上构建 langs
实例对象的过程分为两步:
- 先使用
Language()
调用该类中的__init__()
方法构造出一个该类的对象(将其称为 C,计数器为 0),并立即用langs
这个变量作为所建实例对象的引用( C 的计数器值 + 1)。 - 在此基础上,又有一个
cl
变量引用langs
(其实相当于引用Language()
,此时 C 的计数器再 +1 ),这时如果调用del langs
语句,只会导致 C 的计数器减 1(值变为 1),因为 C 的计数器值不为 0,因此 C 不会被销毁(不会执行__del__()
方法)。
[2] >>>> 重写时需显式调用父类 __del__()
方法
需要额外说明的是,如果我们重写子类的 __del__()
方法(父类为非 object 的类),则必须显式调用父类的 __del__()
方法,这样才能保证在回收子类对象时,其占用的资源(可能包含继承自父类的部分资源)能被彻底释放。
为了说明这一点,这里举一个反例:
1 | class Language: |
Method: __dir__
在 Python 内置函数中,提到过 dir()
函数,通过此函数可以某个对象拥有的所有的属性名和方法名,该函数会返回一个包含有所有属性名和方法名的有序列表。
实例如下:
1 | class Language: |
可以看到,不仅仅输出本类中新添加的属性名和方法(最后 3 个),还会输出从父类(这里为 object 类)继承得到的属性名和方法名。
值得一提的是,dir() 函数的内部实现,其实是在调用参数对象 __dir__()
方法的基础上,对该方法返回的属性名和方法名做了排序。
所以,你完全可以自行调用该对象具有的 __dir__()
方法来查看某个对象拥有的所有的属性名和方法名:
1 | class Language: |
显然,使用 __dir__()
方法和 dir()
函数输出的数据是相同,仅仅顺序不同。
Method: __dict__
事实上,在 Python 类的内部,无论是类属性还是实例属性,都是以字典的形式进行存储的,其中属性名作为键,而值作为该键对应的值。
为了方便用户查看类中包含哪些属性,Python 类提供了 __dict__
属性。
需要注意是,该属性可以用类名或者类的实例对象来调用:
- 使用类名直接调用
__dict__
,会输出该由类中所有类属性组成的字典; - 使用类的实例对象调用
__dict__
,会输出由类中所有实例属性组成的字典。
实例演示:
1 | class Language: |
不仅如此,对于具有继承关系的父类和子类来说,父类有自己的 __dict__
,同样子类也有自己的 __dict__
,它不会包含父类的 __dict__
。例如:
1 | class Language: |
可见,通过子类直接调用的 __dict__
中,并没有包含父类中的 a
和 b
类属性;同样,通过子类对象调用的 __dict__
,也没有包含父类对象拥有的 name
和 add
实例属性。
总结一下:
- 实例的
__dict__
仅存储与该实例相关的实例属性; - 类的
__dict__
存储所有实例共享的变量和函数(类属性,方法等),类的__dict__
并不包含其父类的属性。
[1] >>>> 修改类实例属性值
借助由类实例对象调用 __dict__
属性获取的字典,可以使用字典的方式对其中实例属性的值进行修改,例如:
1 | class Language: |
[2] >>>> __dict__
魔法操作
我们知道,__dict__
是用来存储对象属性的一个字典,其键为属性名,值为属性的值。
既然 __dict__
是个字典那么我们就可以用字典的属性了。我们通过使用 dir()
属性来看看 __dict__
都有哪些属性:
1 | ['__class__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'clear', 'copy', 'fromkeys', 'get', 'items', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values'] |
我们看一段代码内含注释:
1 | class A(): |
可以看到,__dict__
也提供了一个 update
方法,用于使用一个新的字典所包含的键值对来更新己有的字典。
这对于给对象的属性赋值的时候,非常 pythonic:
1 | class A(): |
想象一下如果我们传入的字典有 100 个键….如何还是这样一个一个赋值不敢想不敢想?!!
你可以使用如下方法:
1 | class A(): |
common Method
这一小节我们来看几个类以及对象相关的常用函数:
setattr && getattr && hasattr
[1] >>>> hasattr() 函数
hasattr() 函数用来判断某个类实例对象是否包含指定名称的属性或方法。该函数的语法格式如下:
1 | hasattr(obj, name) |
说明:obj
指的是某个类的实例对象,name
表示指定的属性名或方法名。同时,该函数会将判断的结果(True
or False
)作为返回值反馈回来。
实例演示:
1 | class Language: |
显然,无论是属性名还是方法名,都在 hasattr() 函数的匹配范围内。
需要注意的是,我们只能通过该函数判断实例对象是否包含该名称的属性或方法,但不能精确判断,该名称代表的是属性还是方法。
[2] >>>> getattr() 函数
getattr() 函数获取某个类实例对象中指定属性的值。和 hasattr() 函数不同,该函数只会从类对象包含的所有属性中进行查找。其语法格式如下:
1 | getattr(obj, name[, default]) |
其中,obj
表示指定的类实例对象;name
表示指定的属性名;而 default
是可选参数,用于设定该函数的默认返回值,即当函数查找失败时;如果不指定 default
参数,则程序将直接报 AttributeError
错误,反之该函数将返回 default
指定的值。
1 | class Language: |
可以看到,对于类中已有的属性,getattr()
会返回它们的值,而如果该名称为方法名,则返回该方法的状态信息;反之,如果该明白不为类对象所有,要么返回默认的参数,要么程序报 AttributeError
错误。
[3] >>>> setattr() 函数
setattr() 函数的功能相对比较复杂,它最基础的功能是修改类实例对象中的属性值。其次,它还可以实现为实例对象动态添加属性或者方法。其语法格式如下:
1 | setattr(obj, name, value) |
1)–> 修改类实例对象中的属性值
实例演示:
1 | class Language: |
2)–> 别名用法
setattr() 函数,还可以将类属性修改为一个类方法,同样也可以将类方法修改成一个类属性。
实例演示:
1 | def say(): |
显然,通过修改 name
属性的值为 say
(全局函数),原来的 name
属性就变成了一个 name()
方法。
3)–> 动态添加实例属性和方法
使用 setattr() 函数对实例对象中执行名称的属性或方法进行修改时,如果该名称查找失败,Python 解释器不会报错,而是会给该实例对象动态添加一个指定名称的属性或方法。
实例演示:
1 | def say(): |
可以看到,虽然 Language
为空类,但通过 setattr() 函数,我们为 langs 对象动态添加了一个 name
属性和一个 say()
方法。
issubclass && isinstance
Python 中提供了如下两个函数来进行类型检查:
- issubclass(cls, class_or_tuple):检查 cls 是否为后一个类或元组包含的多个类中任意类的子类;
- isinstance(obj, class_or_tuple):检查 obj 是否为后一个类或元组包含的多个类中任意类的对象。
通过使用上面两个函数,程序可以方便地先执行检查,然后才调用方法,这样可以保证程序不会出现意外情况。
类型检查实例:
1 | # 定义一个字符串 |
通过上面程序可以看出,issubclass()
和 isinstance()
两个函数的用法差不多,区别只是 issubclass() 的第一个参数是类名,而 isinstance() 的第一个参数是变量,这也与两个函数的意义对应:issubclass 用于判断是否为子类,而 isinstance() 用于判断是否为该类或子类的实例。
[1] >>>> 元组参数
issubclass() 和 isinstance() 两个函数的第二个参数都可使用元组。
实例演示:
1 | data = (20, 'fkit') |
[2] >>>> __bases__
属性参看直接父类
Python 为所有类都提供了一个 __bases__
属性,通过该属性可以查看该类的所有直接父类,该属性返回所有直接父类组成的元组。
实例演示:
1 | class A: |
可以看出,如果在定义类时没有显式指定它的父类,则这些类默认的父类是 object 类。
[3] >>>> __subclasses__()
方法参看直接父类
Python 还为所有类都提供了一个 __subclasses__()
方法,通过该方法可以查看该类的所有直接子类,该方法返回该类的所有子类组成的列表。
实例演示:
1 | class A: |
Method: __call__
__call__
是 Python 类中一个非常特殊的实例方法。功能类似于在类中重载 ()
运算符,使得类实例对象可以像调用普通函数那样,以 对象名()
的形式使用。
举个例子:
1 | class Language: |
可以看到,通过在 Language 类中实现 __call__()
方法,使的 langs 实例对象变为了可调用对象。
Python 中,凡是可以将
()
直接应用到自身并执行,都称为可调用对象。可调用对象包括自定义的函数、Python 内置函数以及本节所讲的类实例对象。
对于可调用对象,实际上 名称()
可以理解为是 名称.__call__()
的简写。如下:
1 | langs.__call__("Python", "Python is a OOP Programing.") |
再来看一个自定义函数的例子,例如:
1 | def say(): |
不仅如此,类中的实例方法也有以上 2 种调用方式,这里不再举例,有兴趣的读者可自行编写代码尝试。
用 __call__()
弥补 hasattr()
函数的短板 >>>>
前面我们提到,hasattr() 函数查找类的实例对象中是否包含指定名称的属性或者方法,但该函数有一个缺陷,即它无法判断该指定的名称,到底是类属性还是类方法。
要解决这个问题,我们可以借助可调用对象的概念。要知道,类实例对象包含的方法,其实也属于可调用对象,但类属性却不是。举个例子:
1 | class CLanguage: |
可以看到,由于 name
是类属性,它没有以 __call__
为名的 __call__()
方法;而 say
是类方法,它是可调用对象,因此它有 __call__()
方法。
Python 中的运算符重载
前面我们学习了各种序列类型,每个类型都有其独特的操作方法。例如列表类型支持直接做加法操作实现添加元素的功能,字符串类型支持直接做加法实现字符串的拼接功能。
也就是说,同样的运算符对于不同序列类型的意义是不一样的,这是怎么做到的呢?
事实上,每种序列类型都是 Python 的一个类,例如列表是 list
类,字典是 dict
类等,这些序列类的内部使用了一个叫作 重载运算符 的技术来实现不同运算符所对应的操作。
所谓重载运算符,是指通过重写 Python 内置运算符对应的方法来实现的。这样当类对象在进行运算符操作时,系统就会调用类中相应重写方法来处理。这些方法都是以双下划线开头和结尾的,类似于 __X__
的形式。
那么,Python 类支持对哪些内置方法进行重载呢?如下表列出来常用的可重载的运算符,以及各自的含义:
重载运算符 | 含义 |
__new__ |
创建类,在 __init__ 之前创建对象 |
__init__ |
类的构造函数,其功能是创建类对象时做初始化工作。 |
__del__ |
析构函数,其功能是销毁对象时进行回收资源的操作 |
__add__ |
加法运算符 +,当类对象 X 做例如 X+Y 或者 X+=Y 等操作,内部会调用此方法。但如果类中对 __iadd__ 方法进行了重载,则类对象 X 在做 X+=Y 类似操作时,会优先选择调用 __iadd__ 方法。 |
__radd__ |
当类对象 X 做类似 Y+X 的运算时,会调用此方法。 |
__iadd__ |
重载 += 运算符,也就是说,当类对象 X 做类似 X+=Y 的操作时,会调用此方法。 |
__or__ |
“或”运算符 |
__repr__,__str__ |
格式转换方法,分别对应函数 repr(X)、str(X) |
__call__ |
函数调用,类似于 X(*args, **kwargs) 语句 |
__getattr__ |
点号运算,用来获取类属性 |
__setattr__ |
属性赋值语句,类似于 X.any=value |
__delattr__ |
删除属性,类似于 del X.any |
__getattribute__ |
获取属性,类似于 X.any |
__getitem__ |
索引运算,类似于 X[key],X[i:j] |
__setitem__ |
索引赋值语句,类似于 X[key], X[i:j]=sequence |
__delitem__ |
索引和分片删除 |
__get__, __set__, __delete__ |
描述符属性,类似于 X.attr,X.attr=value,del X.attr |
__len__ |
计算长度,类似于 len(X) |
__lt__,__gt__,__le__,__ge__,__eq__,__ne__ |
比较,分别对应于 <、>、<=、>=、=、!= 运算符。 |
__iter__,__next__ |
迭代环境下,生成迭代器与取下一条,类似于 I=iter(X) 和 next() |
__contains__ |
成员关系测试,类似于 item in X |
__index__ |
整数值,类似于 hex(X),bin(X),oct(X) |
__enter__,__exit__ |
在对类对象执行类似 with obj as var 的操作之前,会先调用 __enter__ 方法,其结果会传给 var;在最终结束该操作之前,会调用 __exit__ 方法(常用于做一些清理、扫尾的工作) |
实例演示:
[1] >>>> 构造函数和析构函数:__init__
和 __del__
重载
主要作用是进行对象的创建和回收,当实例创建时,就会调用 __init__
构造方法。当实例对象被收回时,析构函数 __del__
会自动执行。
1 | class Human: |
[2] >>>> 加减运算: __add__
和 __sub__
重载
重载这两个方法就可以在普通的对象上添加+-运算符操作。下面的代码演示了如何使用 + && -
运算符:
1 | class Computer: |
如果将代码中的 __sub__
方法去掉,再调用减号运算符就会出错。
[3] >>>> 对象的字符串表达形式: __repr__
和 __str__
重载
都是用来表示对象的字符串表达形式:
- print()、str() 方法会调用到
__str__
方法 - print()、str() 和 repr() 方法会调用
__repr__
方法。
从下面的例子可以看出,当两个方法同时定义时,Python 会优先搜索并调用 __str__
方法。
1 | class Str: |
[4] >>>> 索引取值和赋值: __getitem__
和 __setitem__
重载
通过实现这两个方法,可以通过诸如 X[i]
的形式对对象进行取值和赋值,还可以对对象使用切片操作。
1 | class Indexer: |
[5] >>>> 设置和访问属性: __getattr__
和 __setattr__
重载
我们可以通过重载 __getattr__
和 __setattr__
来拦截对对象成员的访问。
__getattr__
在访问对象中不存在的成员时会自动调用。__setattr__
方法用于在初始化对象成员的时候调用,即在设置 __dict__
的 item
时就会调用 __setattr__
方法。具体例子如下:
1 | class Test: |
从结果可以看出,访问不存在的实例变量 c 时会调用 __getattr__
方法;当 __init__
被调用的时候,赋值运算也会调用 __setattr__
方法。
[6] >>>> 迭代器对象: __iter__
和 __next__
重载
前面我们知道,Python 中的迭代,可以直接通过重载 __getitem__
方法来实现,看下面的例子:
1 | class Indexer: |
通过上面的方法是可以实现迭代,但并不是最好的方式。
事实上,Python 中的迭代操作会优先尝试调用 __iter__
方法,再尝试 __getitem__
。迭代环境(for in)是通过 iter()
去尝试寻找 __iter__
方法来实现,而这种方法返回一个迭代器对象。如果 __iter__
方法已经提供,Python 会重复调用迭代器对象的 next()
方法,直到发生 StopIteration
异常;如果没有找到 __iter__
,Python 才会尝试使用 __getitem__
机制。下面看一下迭代器的例子:
1 | class Next(): |
可见实现了 __iter__
和 __next__
方法后,可以通过 for in
的方式迭代遍历对象,也可以通过 iter()
和 next()
方法迭代遍历对象。
[7] >>>> 自定义序列示例
基于前面介绍的方法重载,我们来实现一个自定义的序列类。
下面列出了和自定义序列类有关的几个特殊方法:
__len__(self) |
返回序列类中存储元素的个数。 |
__contains__(self, value) |
判断当前序列中是否包含 value 这个指定元素。 |
__getitem__(self, key) |
通过指定的 key(键),返回对应的 value(值)。 |
__setitem__(self, key, value) |
修改指定 key(键)对应的 value(值)。 |
__delitem__(self, key) |
删除指定键值对。 |
重写原则 >>>>
1)在上表中的这些特殊方法进行重写时,在实现其基础功能的基础上,还可以根据实际情况,对各个方法的具体实现进行适当调整。
以 __setitem__()
方法为例,当在序列中未找到指定 key
的情况下,该方法可以报错,当然也可以将此键值对添加到当前序列中。
2)在实现自定义序列类时,并不是必须重写表中全部的特殊方法。
例如如果该自定义序列是一个不可变序列(即序列中的元素不能做修改),则无需重写 __setitem__()
和 __delitem__()
方法;反之,如果该自定义序列是一个可变序列,可以重写以上五个特殊方法。
下面实现一个比较简单的序列类(字典类),其特点是只能存储 int
类型的元素:
1 | class IntDict: |
Python 中的迭代器
我们已经知道,字符串(str)、列表(list)、元组(tuple)、字典(dict)、集合(set)等序列容器有一个共同的特性:它们都支持使用 for in
循环遍历存储的元素。
可迭代对象(Iterable)
事实上,像字符串(str)、列表(list)、元组(tuple)、字典(dict)、集合(set)这些可以直接作用于 for in
循环的容器对象,都可以统称为可迭代对象(Iterable object)。
关于 Python 中的可迭代对象更多说明请参见:Python 中的可迭代对象(Iterable)。
我们可以通过 collections 模块中的 Iterable 类型来判断一个对象是否是可迭代对象,具体判断方法如下:
1 | from collections.abc import Iterable |
那么,可迭代对象(Iterable)和迭代器(Iterator)是否是一回事儿?答案肯定是不是的,不然废话这么多干嘛?!!
何为迭代器(Iterator)
从字面来理解,迭代器指的是:支持迭代的容器。这里的容器可以是列表、元组等 Python 内置的基础容器,也可以是用户自定义的容器类对象,只要该容器支持迭代即可。
你可能会问,既然都是支持迭代的容器,那么迭代器(Iterator)和可迭代对象(Iterable)的差别在哪里???
[1] >>>> 迭代器特性
更确切的说,迭代器(Iterator) 具有如下特性:
- 支持迭代的(for in 遍历),可以记住遍历位置的容器;
- 迭代器对象从容器的第一个元素开始访问,直到所有的元素被访问完结束,并且迭代器访问只能往前无法后退;
- 迭代器具有两个基本的方法:
iter()
&&next()
; - 迭代器都是可迭代对象。
上述列出的这些特性,都可以在下文给出的迭代器中表现出来。
[2] >>>> 判断对象是否属于迭代器
那么,如何判断一个对象是否属于 Iterator
呢?可以借助 connections 模块中的 Iterator:
1 | from collections.abc import Iterator |
可以看到,列表、字典、以及字符串等确实不是迭代器。并且 list
、dict
、str
虽然是 Iterable
,却不是 Iterator
(不存在 next()
函数)。
[3] >>>> next && iter 方法说明
1)–> iter()
iter()
函数,可以将 list
、dict
、str
等 Iterable
对象变为 Iterator
,从而创建迭代器:
1 | isinstance(iter([]), Iterator) |
2)–> next()
迭代器容器对象可以被 next()
函数调用,并不断返回下一个元素值。元素访问完后,继续调用 next()
函数会产生 StopIteration
Error。
代码演示:
1 | list=[1,2,3,4] |
[4] >>>> 迭代器对象打印
注意,直接打印迭代器对象是无法查看迭代器容器中元素值的,你需要根据容器数据结构使用 Python 内置的 list() && tuple() && dict() 等来将其转化成可打印的形式:
1 | list=[1,2,3,4] |
如何定义迭代器容器
这里给出两种迭代器的实现思路:
- 运算符重载方法:通过重写类的
__next__() && __iter__()
方法实现自定义迭代器类; - 内置函数方法:通过内置的 iter() 迭代器函数实现迭代器。
[1] >>>> 自定义迭代器类
如果要自定义实现一个迭代器,则类中必须实现如下两个方法:
__next__(self)
:返回容器的下一个元素;__iter__(self)
:该方法返回一个迭代器(iterator)对象,该对象实现了__next__()
方法并通过StopIteration
异常来标识迭代的完成。
事实上,在运算符重载中已经实现过一个简单的迭代器了,推荐返回重新认识一下。
例如,下面程序自定义了一个简易的列表容器迭代器,支持迭代访问(对照迭代器特性进行学习):
1 | class listDemo: |
StopIteration 说明 >>>>
迭代环境下,StopIteration 异常用于标识迭代的完成,防止出现无限循环的情况。
上面程序遍历(for in)时,会自动调用 __next__()
方法来返回迭代器容器中的下个元素直至发生 StopIteration,故需要在 __next__()
方法中设置在完成指定循环条件后触发 StopIteration 异常来结束迭代,这是必要的。
[2] >>>> 内置 iter() 迭代器函数
Python 内置的 iter()
函数可以将对象转化为一个迭代器(返回一个迭代器对象),该函数的语法格式如下:
1 | iter(obj[, sentinel]) |
其中,obj 必须是一个可迭代的容器对象,而 sentinel 作为可选参数,如果使用此参数,要求 obj 必须是一个可调用对象,具体功能后面会讲。
关于可调用对象,指的是该类的实例对象可以像函数那样,直接以
对象名()
的形式被使用。通过在类中添加__call__()
方法,就可以将该类的实例对象编程为可调用对象(参见博文第一章节相关内容)。
iter() 函数的两种使用形式 >>>>
1)–> iter(obj)
我们常用的是仅有第一个参数的 iter()
函数,即通过传入一个可迭代对象,iter()
函数会返回一个迭代器对象。
你可以通过调用该迭代器中的 __next__()
方法即可实现迭代(或者 for in)。例如:
1 | mylist = [1, 2, 3] |
可以看到,当迭代完存储的所有元素之后,如果继续迭代,则 __next__()
方法会抛出 StopIteration
异常。
另外,你也可以使用 next()
内置函数来迭代,即 next(myIter)
,和 __next__()
方法是完全一样的。
2)–> iter(obj[, sentinel])
如果同时使用 iter()
函数中的两个参数,则要求第一个 obj 参数必须传入可调用对象(可以不支持迭代)。
这样,当使用 iter()
函数返回的迭代器对象调用 __next__()
方法时,它会通过执行 obj() 调用 __call__()
方法来返回元素。
如果此时,__call__()
方法的返回值和第 2 个参数值相同,则输出 StopInteration
异常;反之,则输出 __call__()
方法的返回值。
例如,修改 listDemo
类如下所示:
1 | class listDemo: |
3)–> iter(obj[, sentinel]) 实用场景
iter(obj[, sentinel]) 形式常用来构建块读取器。
例如,从二进制数据库文件中读取固定宽度的块,直至到达文件的末尾:
1 | from functools import partial |
[3] >>>> 自定义字典迭代器 Demo
运算符重载小节最后,我们实现了一个自定义字典类序列,该字典是一个可迭代对象,但该字典对象并不支持良好的迭代(死循环),这里我们来将其升级为一个良好的迭代器。
尽管上面的自定义字典类序列中重写了
__getitem__
方法,但仍然是不支持良好迭代的,你可以尝试下~~~
这里,基于上述知识,我们来完善一下上一小节中的自定义字典序列:
1 | class IntDict: |
enumerate 迭代器
enumerate 方法可以用来实现下标循环 >>>>
很多情况下,我们想要对 list
或 tuple
实现类似 Java 那样的下标循环怎么办?
Python 中内置的 enumerate
函数可以把一个 list (tuple)变成 index-element
(索引-元素)对,这样就可以在for
循环中同时迭代索引和元素本身。
先来给出 enumerate
内置函数的语法格式:
1 | enumerate(iterable, start=0) |
其中,iterable
表示必须是可迭代的对象,start
表示下标开始的序号,该函数返回一个可迭代的 enumerate 对象(非枚举对象),index-element
以元组形式保存在迭代器中。
来看一个示例:
1 | seasons = ['Spring', 'Summer', 'Fall', 'Winter'] |
你无法直接查看可迭代的 enumerate 枚举对象中的 index-element
信息:
1 | print(seasons_enum) |
但我们可以通过使用 Python 内置的 list() && tuple() && dict() 来将其转化成可打印的形式:
1 | seasons = ['Spring', 'Summer', 'Fall', 'Winter'] |
这样,你就可以以 List && Tuple && Dict 的形式进行枚举对象数据的访问了。
深入解读迭代器
我们知道,迭代器的主要功能就是进行容器元素的迭代(遍历),有了上面的知识,我们就可以重新认识一下 for 循环了:
For 循环解读
事实上,Python 中的迭代操作会优先尝试调用 __iter__
方法,再尝试 __getitem__
。
迭代环境(for ... in ...
或者 next(Iterator)
)是通过 iter() 去尝试优先寻找 __iter__
方法来实现,而这种方法返回一个迭代器对象。
如果 __iter__
方法已经提供,Python 会重复调用迭代器对象的 next() 方法,直到发生 StopIteration
异常;如果没有找到 __iter__
,Python 才会尝试使用 __getitem__
机制。
示例演示:
1 | class listDemo: |
可以看到,使用 for ... in ...
循环方式进行遍历和在 while
循环内使用 next(Iterator)
是等价的。
因此,对于迭代器容器中的元素遍历,支持三种方式:
- for … in …;
- while 循环结构内使用 next(Iterator) 或者
Iterator.__next__()
,捕获StopIteration
异常后停止; - 借助 Python 内置的 list() && tuple() && dict() && set() 等转化为相应基本数据类型,然后进行遍历。
关于 __getitem__
的迭代(循环)样例这里就不重复给出了,你可以参见第一个版本的自定义字典序列 Demo,或者参见运算符重载部分关于迭代器的重载部分。
Iterator 数据流
你可能还对 list
、dict
、str
等序列容器不是 Iterator
感到很困惑。
事实上,Python 的 Iterator
对象表示的是一个数据流,Iterator 对象可以被 next()
函数调用并不断返回下一个数据,直到没有数据时抛出 StopIteration
错误。
这也就意味着,在 Iterator
数据流向前迭代过程中,可以看作是容器内数据流出的过程,表现为当前对象无法再通过迭代方式获取数据了(已经流完了)。
Iterator 数据流 可以看作是惰性的计算序列 >>>>
可以把这个数据流看做是一个有序序列,但我们却不能提前知道序列的长度,只能不断通过 next()
函数实现按需计算下一个数据,所以 Iterator
的计算是惰性的,只有在需要返回下一个数据时它才会计算。
Iterator
甚至可以表示一个无限大的数据流,例如全体自然数。而使用 list 是永远不可能存储全体自然数的。
Python 中的生成器
上一小节我们解读了什么是迭代器。事实上,生成器本质上也是迭代器,不过它比较特殊。
那么,相较于一般的迭代器,生成器特殊在哪里???
生成器(Generator)特性
参看前面我们创建的任意一个迭代器容器,你会发现,在使用该容器迭代一组数据时,必须事先将所有数据存储到容器中,才能开始迭代。而生成器(generator)却不同,它可以实现在迭代的同时生成元素(边迭代边计算元素)。
也就是说,对于某些特定的可迭代容器(如果其内部的元素是可以用某种算法推算得到的),将其编码成一个生成器对象,该生成器不会一次性生成容器中的所有元素,而是什么时候需要,才什么时候生成。
可能有同学就要问了,这样的机制什么用处呢???
想象一个容器内部元素可以用某种算法推算得到的场景 >>>>
例如前面通过列表推导式(解析式)直接创建一个列表,由于内存限制,列表容量肯定是有限的。假设要创建一个包含 10 万个元素的列表,这不仅占用很大的存储空间,如果程序中当前仅仅访问前面部分的元素,那后面绝大多数元素占用的空间都白白浪费了,这时生成器这种边迭代边推算的机制就会非常有用。
退一步来讲,就算容器中元素数量较少时,节省的内存有限,但使用生成器仍可以帮助我们获得一个方便操作的迭代器,这也是有益的。
使用生成器需要注意以下几点 >>>>
- 生成器适用于容器内部元素可以用某种算法推算得到的场景;
- 更适用于推算算法比较复杂时,用类似推导式(例如列表推导式)的
for
循环无法实现的场景; - 生成器是边迭代边计算元素的,元素什么时候需要,什么时候生成,用于节省内存空间;
- 生成器是一个特殊的迭代器,支持迭代器中的相关使用规则。
并且,除了具有如上特性,生成器的创建方式也比迭代器简单很多。
生成器创建
Python 中提供了两种生成器的创建方式:1)带 yield 关键字的生成器函数;2)元组推导式(… for … in …)。
yield 关键字
使用带 yield 关键字的生成器函数来创建生成器分为以下两步:
- 定义一个以 yield 关键字标识返回值 的函数;
- 调用刚刚创建的函数,即可创建一个生成器。
Get Start >>>>
比如,构建一个著名的斐波那契数列(Fibonacci)容器,除第一个和第二个元素外,任意一个数都可由前两个数相加得到。类似于:
1 | 1, 1, 2, 3, 5, 8, 13, 21, 34, ... |
斐波拉契数列用列表推导式写不出来(推导算法较复杂),但是,用函数把它打印出来却很容易:
1 | def fibonc(max): |
我们知道,斐波那契数列(Fibonacci)容器构建是符合生成器使用条件的,事实上上面的函数和 generator 仅一步之遥,即将 print(b)
打印函数改为 yield b
作为返回就可以了:
1 | def fibonc(max): |
这样我们就成功创建了一个 fi
生成器对象。显然,和普通函数不同,fibonc(max) 函数的返回值用的是 yield 关键字,而不是 return 关键字,此类函数又称为生成器函数(generator function)。
注意,即便调用生成器函数,Python 解释器也不会执行函数中的代码,它只会返回一个生成器(对象)。并且,和 return 作为返回值关键字相比,yield 除了可以返回相应的值,还有一个更重要的功能,即每当程序执行完该语句时,程序就会暂停执行。
[1] >>>> 生成器元素访问
生成器对象创建好之后,如何使用生成器中的元素数据呢?
即,要想使生成器函数得以执行,或者想使执行完 yield 语句立即暂停的程序得以继续执行,怎么办?
同理于迭代器,有以下三种方式:
- while 循环结构内使用 next(Iterator) 或者
Iterator.__next__()
遍历生成器,捕获StopIteration
异常后停止; - 通过 for 循环遍历生成器;
- 借助 Python 内置的 list() && tuple() && dict() && set() 等转化为相应基本数据类型,然后进行遍历。
示例如下:
1 | def fibonc(max): |
[2] >>>> 生成器执行方式
给出一个例子来看生成器内部程序的执行机制:
1 | def fibonc(max): |
1)首先,在创建有 fi
生成器的前提下,通过其调用 next()
内置函数,会使 Python 解释器开始执行 fibonc() 生成器函数中的代码,因此会输出 Begin to run
,程序会一直执行到 yield b
,而此时的 b==1,因此 Python 解释器输出 1
。由于受到 yield 的影响,程序会在此处暂停。
2)然后,我们使用 fi
生成器调用 __next__()
方法,该方法的作用和 next() 函数完全相同(事实上,next() 函数的底层执行的也是 __next__()
方法),它会使得程序继续执行,即输出 Continue to run
,程序又会执行到 yield b
,此时 b==1,因此输出 1
,然后程序暂停。
3)最后,我们使用 for 循环遍历 fi
生成器,之所以能这么做,是因为 for 循环底层会不断地调用 next() 函数,使暂停的程序继续执行,因此会输出后续的结果。
注意,在 Python 2.x 版本中不能使用
__next__()
方法,可以使用 next() 内置函数,另外生成器还有 next() 方法(即以 num.next() 的方式调用)。
元组推导式
另外对于不复杂的算法推算场景,即容器中元素可以使用推导式(解析式)生成。
Python 中支持一个很简单的 generator 创建方法就是,使用元组推导式,其返回一个生成器对象:
1 | gen = (x for x in range(4)) |
关于 gen 中元素遍历方式,请参见上一小节提供的三种方式。
素数示例
一个生成器函数中是可以包含多个 yield 关键字作为返回的,这和 return 关键字是类似的。我们将以实现一个以素数作为元素的生成器容器进行展示。
我们知道,素数就是质数,是一个大于 1 的自然数,且除了 1 和它本身外,不再有其他的因数。
素数都有哪些? >>>> 计算素数的一个方法是埃氏筛法,它的算法理解起来非常简单:
首先,列出从 2
开始的所有自然数,构造一个序列:
2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, …
取序列的第一个数 2
,它一定是素数,然后用 2
把序列的 2
的倍数筛掉:
3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, …
取新序列的第一个数 3
,它一定是素数,然后用 3
把序列的 3
的倍数筛掉:
5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, …
取新序列的第一个数5
,然后用5
把序列的5
的倍数筛掉:
7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, …
这样不断筛下去,就可以得到所有的素数。
Python 编程实现过程 >>>>
先来看三个核心的函数功能实现:
1)–> 先构造一个从 3
开始的奇数序列(注意这是一个生成器,并且是一个无限序列),作为初始序列:
1 | def __oddSeq_generator(self): |
2)–> 然后定义一个倍数筛查函数:
1 | def __not_divisible(self, n): |
3)–> 定义一个生成器,不断返回下一个素数:
1 | def __primes(self): |
类封装后,最终如下:
1 | class PrimesCalc: |
生成器高级用法
本节将在上述基础上,继续讲解生成器的一些高级用法,会涉及到:send() && close() && throw() 三种生成器方法。
send 方法
我们知道,通过 for 循环以及 next() 或者 __next__()
等方法,可以实现从外界控制生成器的执行。除此之外,通过 send() 方法,还可以向生成器中传值。
需要注意的是,使用 send() 方法可选择带一个参数,也可以不带任何参数(用 None 表示)。
[1] >>>> send(None)
当使用不带参数的 send(None) 方法时,它和 next() 函数的功能完全相同。例如:
1 | def intNum(): |
注意,虽然 send(None)
的功能是 next()
完全相同,但更推荐使用 next(),不推荐使用 send(None)。
[2] >>>> send(value)
send(value) 具备 next() 函数的部分功能,即将暂停在 yield 语句出的程序继续执行;但与此同时,该函数还会将 value 值作为 yield 语句返回值赋值给接收者。
样例演示:
1 | def foo(): |
哎?报错了:”你无法传入一个非 None 的值给一个刚启动的生成器”,什么意思? >>>>
事实上,带参数的 send(value) 无法启动执行生成器函数。必须先传递一个 None 进去或者调用一次 next() 方法(启动生成器),才能进行传值操作。修改程序如下:
1 | def foo(): |
分析一下程序的执行流程:
1)首先,构建生成器函数,并利用其创建生成器对象 f
。
2)使用 next() 函数启动生成器(其功能和 f.send(None) 函数完全相同),开始执行生成器函数,程序会执行到 yield "hello"
,而此时的值为 “hello”,因此 Python 解释器输出 hello
。这里注意,此时还未对 buff_a
进行赋值。
3)使用生成器 f 调用无参的 send(None) 函数重新启动生成器,它会使得程序继续执行(buff_a 赋值 -> 打印 buff_a),程序执行到第二个 yield "hello"
语句停止(未对 buff_b 进行赋值),此时 Python 解释器输出 hello
。同时,你会发现,在打印 buff_a 时,输出 buff_a --> None
,思考一下为什么???
事实上,3)在 buff_a 进行赋值时,由于使用的是 send(None) 函数启动的生成器,其没有返回值(或者就认为它也会将 None 赋值给当前 yield 语句的接收者),这就很合理了。这是不进一步意味着 next() 函数也是同理的,你可以将 send(None) 语句换为 next() 验证一下。
4)开始使用生成器 f 调用有参的 send(value) 函数重新启动生成器,它会使得程序继续执行(buff_b 赋值 -> 打印 buff_b),程序执行到 yield buff_b"
语句停止(未对 buff_c 进行赋值),此时 Python 解释器输出 Python
。这里,由于使用的是带 value 值的方式,故 buff_b 进行赋值时会拿到 "Python"
,故打印输出:buff_b --> Python
。
5)最后依然是使用生成器 f 调用有参的 send(value) 函数重新启动生成器,过程和 4)中同理,请结合理解一下。
close 方法
当程序在生成器函数中遇到 yield 语句暂停运行时,此时如果调用 close() 方法,会阻止生成器函数继续执行,该函数会在程序停止运行的位置抛出 GeneratorExit
异常。
举个例子:
1 | def foo(): |
需要注意的是,生成器函数一旦使用 close() 函数停止运行,后续将无法再调用 next() 函数或者 __next__()
方法启动执行,否则会抛出 StopIteration
异常。例如:
1 | def foo(): |
这就意味着,close 后仍想要执行的代码无法执行了,怎么办?
可以通过通过捕获 GeneratorExit
异常,可以在异常处理中继续执行生成器函数中仍然想执行的代码。但是注意这部分代码中不能再包含 yield 语句,否则程序会抛出 RuntimeError
异常。例如:
1 | def foo(): |
throw 方法
生成器 throw() 方法的功能是,在生成器函数执行暂停处,抛出一个指定的异常,之后程序会继续执行生成器函数中后续的代码,直到遇到下一个 yield 语句。
需要注意的是,如果到剩余代码执行完毕没有遇到下一个 yield 语句,则程序会抛出 StopIteration 异常。
示例如下:
1 | def foo(): |
显然,一开始生成器函数在 yield 1 处暂停执行,当执行 throw() 方法时,它会先抛出 ValueError 异常,然后继续执行后续代码找到下一个 yield 语句,该程序中由于后续不再有 yield 语句,因此程序执行到最后,会抛出一个 StopIteration 异常。
Python 中的@函数装饰器
所谓函数装饰器(Decorator),是指 通过装饰器函数,在不修改原函数的前提下,来对函数的功能进行合理的扩充。
@函数装饰器引入
在前面的章节,我们已经学习了三种 Python 内置的函数装饰器,分别是:@staticmethod、@classmethod 和 @property。它们分别是基于 Python 内置的装饰器函数 staticmethod()、classmethod() 和 property() 来实现的。
如下样例:
1 | class Test: |
也就是说,在不修改原函数 foo(cls) 的前提下(默认为类的实例方法),我们使用了一个 @classmethod
的装饰器,使得 foo(cls) 函数成为了一个类方法(功能扩展)。其中,@classmethod
函数装饰器,是基于装饰器函数 classmethod() 实现的。
那么,到底什么是函数装饰器?装饰器函数又是如何定义的???
你需要了解函数装饰器的工作原理 >>>>
@函数装饰器工作原理
假设用 funA() 装饰器函数所对应的函数装饰器 @funA
,去装饰 funB() 函数以实现扩展其功能。如下所示:
1 | # funA 作为装饰器函数 |
实际上,上面程序完全等价于下面的程序:
1 | def funA(fn): |
对比如上程序可以发现,,使用装饰器函数 funA() 去装饰另一个函数 funB(),其底层执行了如下 2 步操作:
- 将 funB 作为参数传给 funA() 函数(传递的是函数,即函数名,或函数引用);
- 将 funA() 函数执行完成的返回值反馈回 funB。
来看一个实例:
1 | # funA 作为装饰器函数 |
观察函数装饰器返回 >>>>
在此基础上,如果在程序末尾添加如下语句(打印观察返回的 funB 是什么?):
1 | print(funB) |
可见,funB 从一个函数引用,变为了一个普通的变量引用。显然,你可以理解被 “@函数”修饰的函数不再是原来的函数,而是被替换成一个新的东西(取决于装饰器的返回值):
- 如果装饰器函数的返回值为普通变量,那么被修饰的函数名就变成了变量名;
- 如果装饰器返回的是一个函数的名称,那么被修饰的函数名依然表示一个函数。
带参数的@函数装饰器###
分析@函数装饰器工作原理可以发现,即当被修饰的 funB() 函数无参数时,可以直接将 funB 作为 funA() 的参数传入。
思考一下,如果被修饰的函数本身带有参数 funB(arg),那应该如何传值呢?即如何向装饰器函数传递一个带参数的函数???
解决方法很简单 >>>>
就是在装饰器函数(funA())中嵌套一个函数,该函数带有的参数个数和被装饰器修饰的函数相同。
[1] >>>> 被修饰函数带有参数
1 | # funA 作为装饰器函数 |
等价于如下:
1 | # funA 作为装饰器函数 |
显然,此时 funB() 函数被装饰器 funA() 修饰,funB 就被赋值为 wrapper。这意味着,虽然我们在程序显式调用的是 funB() 函数,但其实执行的是装饰器嵌套的 wrapper() 函数。
[2] >>>> 多个(≥ 2)函数被同一个装饰器函数修饰,这些函数带有的参数个数并不相等
解决方法很简单 >>>>
用 *args
和 **kwargs
作为装饰器内部嵌套函数的参数,*args
和 **kwargs
表示接受任意数量和类型的参数。举个例子:
1 | # funA 作为装饰器函数 |
你应该可以想到,当只有一个被修饰函数,且被修饰函数参数列表有任意多个时,也可以采用上述方法。
上面我们提到的,都是被修饰函数中的参数场景。那你有没有考虑过一个问题,如果装饰器函数本身带有参数的话怎么办???
[3] >>>> 函数装饰器中含参数
如果装饰器函数(decorator)本身就需要传入参数怎么办???
借鉴上面的方法,可以在装饰器函数(funA)的外面套一个外部函数,通过外部函数传入参数,但需要保证外部函数返回一个装饰器(decorator),这是必要的。
为了方便理解,我们将 funA 改名为 decorator,示例如下:
1 | # externalFunc 作为外部函数 |
这种 3 层嵌套等价于如下:
1 | # externalFunc 作为外部函数 |
完整的@函数装饰器
先给出一个前面的,一般@函数装饰器的样例程序:
1 | # funA 作为装饰器函数 |
考虑一下,在以上基础上添加如下代码的输出效果:
1 | print(funB.__name__) |
可以看到,运行后函数名称输出的都是 wrapper
,这不是我们预期的输出啊,既然访问的是 funB/funC
,你应该输出相应的函数签名啊。
事实上,出现上述情况也不意外,因为装饰器函数返回的那个 wrapper()
函数名字就是 'wrapper'
, funB/funC
指向的实际上还是 wrapper()
函数的空间。所以需要把原始函数的 __name__
等属性复制到 wrapper()
函数中,否则有些依赖函数签名的代码执行就会出错。
当然了,不需要编写 wrapper.__name__ = func.__name__
这样的代码,Python functools 模块内置的 functools.wraps
就是干这个事的,所以,一个完整的 decorator 的写法如下:
1 | import functools |
或者针对带参数的 decorator:
1 | import functools |
@函数装饰器嵌套
上面都是使用一个装饰器的情况,但实际上,Python 中的函数也支持多个装饰器,比如:
1 |
|
上面程序的执行顺序是里到外,所以它等效于下面这行代码:
1 | fun = funA( funB( funC(fun) ) ) |
深入解读@函数装饰器
这一小节将通过实际工作中的几个例子,来加深对@函数装饰器的理解。
身份认证
首先是最常见的身份认证的应用。
这个很容易理解,举个最常见的例子,登录微信时,需要输入用户名密码,然后点击确认,这样服务器端便会查询你的用户名是否存在、是否和密码匹配等等。如果认证通过,就可以顺利登录;反之,则提示你登录失败。
再比如一些网站,你不登录也可以浏览内容,但如果你想要发布文章或留言,在点击发布时,服务器端便会查询你是否登录。如果没有登录,就不允许这项操作等等。
一个实现身份认证的简单示例:
1 | import functools |
上面这段代码中,定义了装饰器 authenticate,函数 post_comment() 则表示发表用户对某篇文章的评论,每次调用这个函数前,都会先检查用户是否处于登录状态,如果是登录状态,则允许这项操作;如果没有登录,则不允许。
日志记录
日志记录同样是很常见的一个案例。
在实际工作中,如果你怀疑某些函数的耗时过长,导致整个系统的延迟增加,想在线上测试某些函数的执行时间,那么,装饰器就是一种很常用的手段。
示例演示:
1 | import time |
这里,装饰器 log_execution_time 记录某个函数的运行时间,并返回其执行结果。如果你想计算任何函数的执行时间,在这个函数上方加上 @log_execution_time
即可。
输入合理性检查
在大型公司的机器学习框架中,调用机器集群进行模型训练前,往往会用装饰器对其输入(往往是很长的 json 文件)进行合理性检查。这样就可以大大避免输入不正确对机器造成的巨大开销。
示例演示:
1 | import functools |
很多情况下都会出现输入不合理的现象。因为我们调用的训练模型往往很复杂,输入的文件有成千上万行,很多时候确实也很难发现。
试想一下,如果没有输入的合理性检查,很容易出现“模型训练了好几个小时后,系统却报错说输入的一个参数不对,成果付之一炬”的现象。这样的“惨案”,大大减缓了开发效率,也对机器资源造成了巨大浪费。
缓存装饰器
关于缓存装饰器的用法,其实十分常见,这里以 Python 内置的 LRU cache 为例来说明。
LRU cache,在 Python 中的表示形式是 @lru_cache。@lru_cache 会缓存进程中的函数参数和结果,当缓存满了以后,会删除最近最久未使用的数据。
正确使用缓存装饰器,往往能极大地提高程序运行效率。举个例子,大型公司服务器端的代码中往往存在很多关于设备的检查,比如使用的设备是安卓还是 iPhone,版本号是多少。这其中的一个原因,就是一些新的功能,往往只在某些特定的手机系统或版本上才有(比如 Android v200+)。
这样一来,我们通常使用缓存装饰器来包裹这些检查函数,避免其被反复调用,进而提高程序运行效率,比如写成下面这样:
1 |
|
install_url
to use ShareThis. Please set it in _config.yml
.