Python学习笔记(11)-魔法方法、特性和迭代器

魔法方法

魔法方法(Magic methods)在Python中是一类特殊的方法,它们的形式就是前后有两个下划线,例如__init__这个方法,这个方法是我们最常见的魔法方法之一。

构造函数

构造函数(constructor)是Python中的一个魔法方法,例如我们在创建某个类的时候使用的初始化方法__init__构造函数不同的于普通方法的地方在于,对象创建后会自动调用魔法方法,例如常规的方法使用如下:

1
2
f = FooBar()
f.init()

但是构造函数的使用如下所示:

1
f = FooBar()

从上面可以看出,构造函数不用初始化,只要创建这个对象,它自己就自动初始化了,来看一个简单的案例:

1
2
3
4
5
6
class FooBar:
def __init__(self):
self.somevar = 42
f = FooBar()
f.somevar

运行结果如下所示:

1
2
3
4
5
6
class FooBar:
def __init__(self):
self.somevar = 42
f = FooBar()
f.somevar
Out[2]: 42

从上面的结果可以看出来,在创建了f这个对象后,我们并没有给f.somevar添加属性,但是在__init__中我们已经初始化了这个属性,因此不用再次定义属性。

再给这个构造函数添加几个参数,如下所示:

1
2
3
4
5
6
class FooBar:
def __init__(self, value = 24):
self.somevar = value
f = FooBar("This is a constructor argument")
f.somevar

运行结果如下所示:

1
2
3
4
5
6
7
8
In [1]: class FooBar:^M
...: def __init__(self, value = 24):^M
...: self.somevar = value^M
...: ^M
...: f = FooBar("This is a constructor argument")^M
...: f.somevar
...:
Out[1]: 'This is a constructor argument'

从这个案例中可以知道,如果指定了参数,那么最终属性显示的结果就是添加的指定的参数。

重写普通方法和特殊的构造函数

在面向对象的编程中,每个类都有一个或多个超类,并从它们那里继承行为,对类B的实例调用方法或访问其属性时,如果找不到该方法或属性,将在其超类A中查找,可以看下面的案例:

1
2
3
4
5
6
7
8
9
10
11
class A:
def hello(self):
print("Hello, I'm A.")
class B(A):
pass
a = A()
b = B()
a.hello()
b.hello()

运行结果如下所示:

1
2
Hello, I'm A.
Hello, I'm A.

在这个案例中,我们可以看到,由于类B自己没有定义方法hello,因此对其调用方法hello时,打印的消息是Hello, I'm A.。要在子类中添加功能,一种基本方法是添加方法,但是如果你想写超类的某些方法,以定制继承而来的行为。还以上面的案例为例说明一下,类B可以重写方法hello,如下述修改后的类B定义如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
class A:
def hello(self):
print("Hello, I'm A.")
class B(A):
def hello(self):
print("Hello, I'm B")
a = A()
b = B()
a.hello()
b.hello()

运行结果如下所示:

1
2
Hello, I'm A.
Hello, I'm B

重写是继承机制的一个重要方法,对构造函数来说万为重要,构造函数用于初始化新建对象的状态,而对于大多数子类来说,除超类的初始化代码外,还要有自己的初始化值代码。虽然所有方法的重写机制都相同,但与重写普通方法相比,重写构造函数时更有可能遇到一个特别的问题:重写构造函数时,必须调用超类(继承的类)的构建函数,否则无法正确地初始化对象,看下面的案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Bird:
def __init__(self):
self.hungry = True
def eat(self):
if self.hungry:
print("Aaaah ...")
self.hungry = False
else:
print("No, thanks!")
b = Bird()
b.eat()
b.eat()

运行结果如下所示:

1
2
Aaaah ...
No, thanks!

从这个案例中我们可以知道,初始情况下,鸟b处于饥饿状态(hungry = True),第一次b.eat()后,输出的是Aaaah ..., 此时并把hungry = True切换为hungry = False,当第二次b.eat()后,就输出了No, thanks !

再看下面的案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Bird:
def __init__(self):
self.hungry = True
def eat(self):
if self.hungry:
print("Aaaah ...")
self.hungry = False
else:
print("No, thanks!")
b = Bird()
b.eat()
b.eat()
class SongBird(Bird):
def __init__(self):
self.sound = "Squawk!"
def sing(self):
print(self.sound)
singbird = SongBird()
singbird.sing()
singbird.eat()

运行结果如下所示:

1
2
3
4
5
6
7
8
9
Aaaah ...
Traceback (most recent call last):
No, thanks!
Squawk!
File "D:/netdisk/bioinfo.notes/Python/python_bio/PythonBio/test.py", line 24, in <module>
singbird.eat()
File "D:/netdisk/bioinfo.notes/Python/python_bio/PythonBio/test.py", line 6, in eat
if self.hungry:
AttributeError: 'SongBird' object has no attribute 'hungry'

在这个案例中,SongBirdBird的子类,继承了方法est,但如果你要尝试调用它,就出现了问题。

异常提示,SongBird没有属性hungry,这是因为,在SongBird类中重写了构造函数,但新的构造函数中没有包含任何初始化属性hungry的代码。如果要消除这种错误,SongBird的构造函数必须调用其超类Bird的构造函数,以确保基本的初始化得以执行。

为此,有两种方法:

第一,调用未关联的超类构造函数;

第二,使用函数super

调用未关联的超类构建函数

还以上面的案例为例说明一下,如果要调用未关联的超类方法,就要添加一行Bird.__init__(self),完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Bird:
def __init__(self):
self.hungry = True
def eat(self):
if self.hungry:
print("Aaaah ...")
self.hungry = False
else:
print("No, thanks!")
class SongBird(Bird):
def __init__(self):
Bird.__init__(self)
self.sound = "Squawk!"
def sing(self):
print(self.sound)
singbird = SongBird()
singbird.sing()
singbird.eat()
singbird.eat()

运行结果如下所示:

1
2
3
Squawk!
Aaaah ...
No, thanks!

在常规的面向对象编程中,对实例调用方法时,方法的参数self将自动关联到实例(称为关联的方法)。

如果通过类调用方法(例如Bird.__init__),就没有实例与其相关联,在这种情况下,可以随便设置参数self,这样的方法称为未关联。参过将这个未关联方法的self参数设置为当前实例,将使用超类的构造函数来初始化singbird对象,这表明将设置其属性hungry

使用super()函数

这一种方法只适用Python3。调用super这个函数时,将当前类和当前实例作为参数。对其返回的对象调用方法时,调用的是将是超类(而不是当前类)的方法,因此在singbird的构造函数中,可以不使用Bird而是使用了super(singbird, self),另外,可以像常规调用方法那样(也就是调用关联的方法那样)调用方法__init__。在Python3中调用函数super,可以不提供任何参数,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Bird:
def __init__(self):
self.hungry = True
def eat(self):
if self.hungry:
print("Aaaah ...")
self.hungry = False
else:
print("No, thanks!")
class SongBird(Bird):
def __init__(self):
super().__init__()
self.sound = "Squawk!"
def sing(self):
print(self.sound)
singbird = SongBird()
singbird.sing()
singbird.eat()
singbird.eat()

运行结果如下所示:

1
2
3
Squawk!
Aaaah ...
No, thanks!

元素访问

这一部分是其他的魔法方法。

基本的序列和映射协议

序列和映射基本上是元素(item)的集合,要实现它们的基本行为(协议),不可变对象需要实现2个方法,而可变对象要实现4个:

  • __len__(self):这个方法返回集合包含的项数,对序列来说就是元素个数,对映射来说,就是键-值对。如果__len__(self)返回零(且没有实现覆盖这种行为的__nonzero__),对象在布尔上下文中将被视为假(就像空的列表、元组、字符串和字典一样)。
  • __getitem__(self, key):返回与指定键相关联的值。对序列来说,键应该是0~n-1的整数(也可以是负数),其中n为序列的长度,对映射来说,键可以是任何类型。如果键为负责,应从末尾往前数,也就是说x[-n]应与x[len(x)-n]等效。
  • __setitem__(self, key8, value):这个方法应以与键相关联的方式存储值,以便以后能够使用__getitem__来获取。
  • __delitem__(self, key):这个方法在对对象的组成部分使用__del__语句时被调用,应删除与key相关联的值,同样,仅当对象可变(且允许其值被删除)时,才需要实现这个方法。

另外,如果键的类型不合适(如对序列使用字符串键),可能引发TypeError异常。对于序列,如果索引的类型是正确的,但不在允许的范围内,会引发IndexError异常。

下面看一个案例,观察一下能否创建一个无穷序列,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# coding=utf8
def check_index(key):
"""
指定的键上是否是可接受的索引?
键必须是非负整数,才是可接受的;
如果不是整数,将引发TypeError异常;
如果是负数,将引发IndexError异常(因为这个序列是无穷的)
"""
if not isinstance(key, int): raise TypeError
if key < 0:raise IndexError
class ArithmeticSequence:
def __init__(self, start = 0, step = 1):
"""
初始化这个算术序列
start --- 序列中的第一个值
step --- 两个相邻的值的差
changed - 一个字典,包含用户修改后的值
"""
self.start = start #存储起始值
self.step = step
self.changed = {}
def __getitem__(self, key):
"""
从算术序列中获取一个元素
"""
check_index(key)
try: return self.changed[key]
except KeyError:
return self.start + key*self.step
def __setitem__(self, key, value):
"""
修改算术序列中的元素
"""
check_index(key)
self.changed[key] = value
s = ArithmeticSequence(1, 2)
s[4]
s[4] = 2
s[4]
s[5]
print(s[4])
print(s[5])

运行结果如下所示:

1
2
2
11

这些代码实现的是一个算术序列,其中任何两个相邻数字的差都相同,第一值是由构造函数的参数start(默认为0)指定的,而相邻值之间的差是由参数step(默认为1)指定的。允许允许修改某些元素,这是通过将不符合规则的值保存在字典changed中实现的。如果元素未被修改,就用公式self.start + key*self.step来计算它的值。

这段代码中禁止删除元素,因此没有实现__del__,如下所示:

1
2
3
4
del s[4]
File "D:/netdisk/bioinfo.notes/Python/python_bio/PythonBio/test.py", line 52, in <module>
del s[4]
AttributeError: __delitem__

另外这个类中没有方法__len__,因此其长度是无穷的,如果所使用考虑综的类型非法,将引发TypeError异常,如果索引的类型正确,但不在允许的范围内(即为负数),将引发IndexError异常,其中索引的检查是源代码中的check_index这个函数负责的,如果出现索引错误,就是如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
s["four"]
Traceback (most recent call last):
File "D:/netdisk/bioinfo.notes/Python/python_bio/PythonBio/test.py", line 49, in <module>
s["four"]
File "D:/netdisk/bioinfo.notes/Python/python_bio/PythonBio/test.py", line 31, in __getitem__
check_index(key)
File "D:/netdisk/bioinfo.notes/Python/python_bio/PythonBio/test.py", line 11, in check_index
if not isinstance(key, int): raise TypeError
TypeError
s[-42]
Traceback (most recent call last):
File "D:/netdisk/bioinfo.notes/Python/python_bio/PythonBio/test.py", line 49, in <module>
s[-42]
File "D:/netdisk/bioinfo.notes/Python/python_bio/PythonBio/test.py", line 31, in __getitem__
check_index(key)
File "D:/netdisk/bioinfo.notes/Python/python_bio/PythonBio/test.py", line 12, in check_index
if key < 0:raise IndexError
IndexError

从list、dict和str派生

关于序列/映射的一些方法可以还从已有方法中继承,在标准库collections这个模块中提供了抽象和具体的基类,用户也能继承内置类型。因此如果要实现一种行为类似于内置列表的序列类型,可以直接继承list,在下面这个案例中,来实现一个带访问计数器的列表,如下所示:

1
2
3
4
5
6
7
8
class CounterList(list):
def __init__(self, *args):
super().__init__(*args)
self.counter = 0
def __getitem__(self, index):
self.counter += 1
return super(CounterList, self).__getitem__(index)

在上面的这段代码中,CounterList类依赖于其超类List的行为。CounterList没有重写的方法(如appendextendindex等)都可以直接使用。在两个被重写的方法中,使用super来调用超类的相应方法,并添加了必要的行为:初始化属性counter(在__init__中)和更新属性counter(在__getitem__)。需要注意的是,重写__getitem__并不能保证一定会捕捉用户的访问操作,因为还有其他访问列表内容的方法,例如通过方法pop,下面来演示一下CounterList的可能用法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CounterList(list):
def __init__(self, *args):
super().__init__(*args)
self.counter = 0
def __getitem__(self, index):
self.counter += 1
return super(CounterList, self).__getitem__(index)
cl = CounterList(range(10))
print(cl)
cl.reverse()
print(cl)
del cl[3:6]
print(cl.counter)
print(cl[4] + cl[2])
print(cl.counter)

运行结果如下所示:

1
2
3
4
5
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
0
9
2

在上面的案例中,CounterList的行为在大多数方面的都类型于列表,但它有一个counter属性(其初始值为0),每当访问列表元素时,这个属性的值就都加1,执行加法运算cl[4] + cl[2]后,counter的值递增了2次,变成了2。

特性

存取方法是名称类似于getHeightsetHeight的方法,用于获取或设置属性,如果访问给定属性时,必须采取特定的措施,那么像这样的封装状态变量(属性)很重要,例如看下面的案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Rectangle:
def __init__(self):
self.width = 0
self.height = 0
def set_size(self, size):
self.width, self.height = size
def get_size(self):
return self.width, self.height
r = Rectangle()
r.width = 10
r.height = 5
print(r.get_size())
r.set_size((150, 100))
print(r.width)

运行结果如下所示:

1
2
3
4
print(r.get_size())
(10, 5)
print(r.width)
150

get_sizeset_size是假想属性size的存取方法,这个属性是一个由widthheight组成的元组,这些代码并非完全错误,但存在缺陷。使用这个类时,用户无需关心它是如何被封装的。如果用户想要修改实现,让size成为真正的属性,而width和height是动态计算出来的,就需要提供用于访问width和height的存取方法,使用这个类的程序也必须重写。应当客户代码能够以同样的方式对待所有的属性。

那么如何解决这个问题呢?给所有的属性都提供存取方法并不现实。而Python能够隐藏存取方法,让所有的属性看起来都一样,通过存取方法定义的属性通常称为特性(property)。在Python中有两种创建特性的机制,但Python中只用property函数。

property函数

以前面的Rectangle类为例说明一下,在使用property函数时,只需要添加一行代码,即size = property(get_size, set_size)即可,完整代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Rectangle:
def __init__(self):
self.width = 0
self.height = 0
def set_size(self, size):
self.width, self.height = size
def get_size(self):
return self.width, self.height
size = property(get_size, set_size)
r = Rectangle()
r.width = 10
r.height = 5
print(r.size)
r.size = 150, 100
print(r.width)

在上面的这段代码中,通过调用property函数,将存取方法作为参数(获取方法在前,设置方法在后)创建了一个特性,然后将名称size关联到这个特性。这样就能以同样的方式对待width、height和size了,而无需关心它们是如何实现的,如下所示:

1
2
3
4
print(r.size)
(10, 5)
print(r.width)
150

从运行结果中可以看到,属性size依然受制于get_sizeset_size执行的计算,但是看起来就像普通属性一样。

事实上,调用函数property时,还可以①不指定参数;②指定一个参数;③指定三个参数或四个参数。

如果没有指定任何参数,创建的特性将既不可读,也不可写。如果只指定一个参数(获取方法),创建的特性将是只读的。第三个参数是可选的,指定用于删除属性的方法(这个方法不接受任何参数)。第四个参数也是可选的,指定一个文档字符串,这些参数分别命名为fgetfsetfdeldoc。如果要创建一个只可写且带文档字符串的特性,可使用它们作为关键字参数。

property函数的工作原理

property其实并不一个函数,而是一个类,它的实例包含一些魔法方法,而所有的魔法都是由这些方法完成的。这些魔法方法为__get____set____delete__,它们一道定义了所谓的描述符协议。只要对象实现了这些方法中的任何一个,它就是一个描述符。描述符的独特之处在于其访问方式。例如,读取属性(具体来说,是在实例中访问类中定义的属性)时,如果它关联的是一个实现了__get__的对象,将不会返回这个对象,而是调用方法__get__并将其结果返回。实际上,这是隐藏在特性、关联的方法、静态方法和类方法。

静态方法和类方法

静态方法和类方法是这样创建的:将它们分别包装在staticmethodclassmethod类的对象中。静态方法的定义中没有参数self,可直接通过类来调用。类方法的定义中包含类似于self的参数,通常被命令为cls,对于类方法,也可通过对象直接调用,但参数cls将自动关联到类。

看下面的一个简单案例:

1
2
3
4
5
6
7
8
class MyClass:
def smeth():
print("This is a static method")
smeth = staticmethod(smeth)
def cmeth(cls):
print("This is a class method of",cls)
cmeth = classmethod(cmeth)

像这样手工包装和替换方法有点繁琐。在Python2中,引入了一种名为装饰器的新语法,可用于像这样包装方法(实际上,装饰器可用于包装任何可调用的对象,并且可用于方法和函数)。要指定一个或多个装饰器,为此可在方法(或函数)前面使用运算符@列出这些装饰器(指定了多少装饰器时,应用的顺序与列出的顺序相反),如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
class MyClass:
@staticmethod
def smeth():
print("This is a static method")
@classmethod
def cmeth(cls):
print("This is a class methods of ", cls)
MyClass.smeth()
MyClass.cmeth()

运行结果如下所示:

1
2
This is a static method
This is a class methods of <class '__main__.MyClass'>

__getattr____setattr__等方法

可以拦截对象属性的所有访问企图、其用途之一是在旧式类中实现特性(在旧类中,函数property的行为可能不符合预期)。要在属性被访问时执行一段代码,必须使用一些魔法方法,以下的4个魔法方法提供了你需要的所有功能:

  • __getattribute__(self, name):在属性被访问时自动调用;
  • __getattr__(self, name):在属性被访问而对象没有这样的属性时自动调用;
  • __setattr__(self, name, value):试图给属性赋值时自动调用;
  • __delattr__(self, name):试图删除属性时自动调用。

相比函数property,这些魔法方法使用起来比较麻烦。但它们很有用,因为用户可以在这些方法中编写处理多个特性的代码。然而在可能的情况下,最好还是首选property函数。

还看一下前面的Rectangle的案例,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Rectangle:
def __init__(self):
self.width = 0
self.height = 0
def __setattr__(self, name, value):
if name == "size":
self.width, self.height = value
else:
self.__dict__[name] = value
def __getattr__(self, name):
if name == "size":
return self.width, self.height
else:
raise AttributeError()

如上面代码所示,这个版本需要处理额外的管理细节,对于这段代码,需要注意以下两点:

  1. 即使涉及的属性不是size,也将调用方法__setattr__。因此这个方法必须考虑如下两种情形:如果涉及的属性为size,就执行与以前一样的操作;否则就使用魔法属性__dict____dict__属性是一个字典,其中包含所有的实例属性。使用它们而不是执行常规属性赋值,是因为旨在避免再次调用__setattr__,进而导致无限循环。
  2. 仅当没有找到指定的属性时,才会调用方法__getattr__。这意味着如果指定的名称不是size,这个方法将引发AttributeError异常。这在要让类能够正确地支持__hasattr____getattr__等内置函数时非常重要。如果指定的名称为size,就使用前一个实现中的表达式。

需要注意的是:编写方法__setattr__时需要避开无限循环陷阱,编写__getattribute__时也是如此。由于它拦截对所有属性的访问,因此将拦截对__dict__的访问。在__getattribute__中访问当前实例的属性时,唯一案例的方式是使用父类的方法__getattribute__(使用super)。

迭代器

有关迭代器的魔法方法是__iter__,它是迭代器协议的基础。

迭代器协议

迭代(iterate)意味着重复多次,就像循环那样。方法__iter__返回一个迭代器,它是包含方法__next__的对象,而调用这个方法时可不提供任何参数,当调用__next__方法时,迭代器应返回其下一个值。如果迭代器没有可供返回的值,应引发StopIteration异常。另外,还可以使用内置的函数next,在这种情况下next(it)it.__next__()等效。

这有什么意义呢,为什么不使用列表?

因为在很多情况下,使用列表都点大材小用。例如,现在有一个可逐个计算值的函数,你可能只想逐个地获取值,而不是使用列表一次性获取。这是因为如果有很多值,列表可以战用太多的内存。但还有其他原因:使用迭代器更通用与简便。

在下面的案例中就可以看到不能使用列表的案例,因为如果要使用列表,这个列表的长度必须是无穷大的,下面的这个案例是有关斐波那契数列的,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
class Fibs:
def __init__(self):
self.a = 0
self.b = 1
def __next__(self):
self.a, self.b = self.b, self.a + self.b
return self.a
def __iter__(self):
return self

这个迭代器实现了方法__iter__,而这个方法返回迭代器本身,在很多情况下,都在另一个对象中实现返回迭代器的方法__iter__,并在for循环中使用这个对象。在推荐在迭代器中也实现方法__iter__(并像刚才那样让它返回self),这样迭代器就可直接用于for循环中。

有关迭代更正规的定义是,实现了方法__iter__的对象是可迭代的,而实现了方法__next__的对象是迭代器

来看一下迭代器的for循环案例,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Fibs:
def __init__(self):
self.a = 0
self.b = 1
def __next__(self):
self.a, self.b = self.b, self.a + self.b
return self.a
def __iter__(self):
return self
fibs = Fibs()
for f in fibs:
if f > 1000:
print(f)
break

运行结果如下所示:

1
1597

这个循环会停止是因为其中包含break语句,否则这个for循环会一直执行下去。

通过对可迭代对象调用内置函数iter就可以获得一个迭代器,如下所示:

1
2
3
4
5
6
>>> it = iter([1, 2, 3])
>>> next(it)
1
>>>
>>> next(it)
2

从迭代器创建序列

除了对迭代器和可迭代对象进行迭代外,还可以将它们转换为序列。在可以使用序列的情况下,大多数也可以使用迭代器或可迭代对象(索引和切片这种操作除外)。

看下面的一个案例,使用构造函数list显式地将迭代器转换为列表:

1
2
3
4
5
6
7
8
9
10
11
12
class TestIterator:
value = 0
def __next__(self):
self.value += 1
if self.value > 10:raise StopIteration
return self.value
def __iter__(self):
return self
ti = TestIterator()
list(ti)

运行结果如下所示:

1
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

生成器

生成器是一种使用普通函数语法定义的迭代器。

创建生成器

现在创建一个将嵌套列表展开的函数,这个函数以类似于下面的列表作为参数:

1
nested = [[1,2], [3,4],[5]]

上面的这个参数其实是一个列表的列表,现在定义这样的函数:

1
2
3
4
def flatten(nested):
for sublist in nested:
for element in sublist:
yield element

这个函数首选迭代所提供嵌套列表中的所有子列表,然后按顺序迭代每个子列表的元素。

这个函数的最后一行含有yield语句,像含有yield语句的函数都被称为生成器。生成器与普通函数的区别在于,生成器不是使用return返回一个值,而是生成多个值,一次一个。每次使用yield生成一个值后,函数都将冻结,即在此停止执行,等待被重新唤醒,被唤醒后,函数将从停止的地方开始继续执行。

为使用所有的值,可对生成器进行迭代,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
nested = [[1,2], [3,4],[5]]
def flatten(nested):
for sublist in nested:
for element in sublist:
yield element
for num in flatten(nested):
print(num)
# 或者如下所示
list(flatten(nested))

运行结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
>>> for num in flatten(nested):
... print(num)
...
1
2
3
4
5
>>> # 或者如下所示
... list(flatten(nested))
[1, 2, 3, 4, 5]

生成器推导

生成器推导也叫生成器表达式,这是一个类似于列表推导的概念。其工作原理与列表推导相同,但不是创建一个列表,而是返回一个生成器,让你能够逐步执行计算,如下所示:

1
2
3
4
5
6
>>> g = ((i + 2 **2 for i in range(2, 27)))
>>> next(g)
6
>>> next(g)
7
>>>

在这段代码中,使用的是圆括号。像在这种简单的情形下,不如使用列表推导;但如果要包装可迭代对象(可能生成大量的值),使用列表推导将立即实例化一个列表,从而丧失迭代的优势。另一个好处是,直接在一对既有的圆括号内(如在函数中调用)使用生成器推导时,无需再添加一对圆括号。也就是可编写下面这样的代码:

1
2
>>> sum(i ** 2 for i in range(10))
285

递归生成器

前面的生成器是使用了2个for循环实现的,如果要处理更多层的嵌套列表,此时就需要使用递归的思路,如下所示:

1
2
3
4
5
6
7
def flatten(nested):
try:
for sublist in nested:
for element in flatten(sublist):
yield element
except TypeError:
yield nested

调用flatten时,有两种可能性:

第一,基线条件。在基线条件下,要求这个函数展开单个元素,例如一个数,在这种情况下,for循环将引发TypeError异常(因为用户此时试图迭代一个数),而这个生成器只生成一个元素。

第二,递归条件。如果要展开的是一个列表,就需要做这些工作:遍历所有的子列表,并对它们调用flatten,然后使用另一个for循环生成展开后的子列表中的所有元素,如下所示:

1
2
>>> list(flatten([[[1],2],3,4,[5,[6,7]],8]))
[1, 2, 3, 4, 5, 6, 7, 8]

但是这种方案存在一个问题,如果nested是字符串或类似于字符串的对象,它就属于序列,因此不会引发TypeError异常。要处理这种问题,必须要在生成器开头进行检查。要检查对象是否类似于字符串,最简单的方式就是尝试将对象与一个字符串拼接起来,并检查这是否会引发TypeError异常,添加后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# coding=utf8
def flatten(nested):
try:
# 不迭代类似于字符串的对象:
try:nested +""
except TypeError:pass
else:raise TypeError
for sublist in nested:
for element in flatten(sublist):
yield element
except TypeError:
yield nested
list(flatten(['foo', ['bar',['baz']]]))

运行结果如下所示:

1
2
>>> list(flatten(['foo', ['bar',['baz']]]))
['foo', 'bar', 'baz']

在这个案例中,如查表达式nested + ""引发了TypeError异常,就忽略这种异常;如果没有引发TypeError异常,内部try语句中的else语句就引发TypeError异常,这样将在外部的except中生成类似于字符串的对象。

需要注意的是,这段代码没有进行类型检查,也就是说没有检查nested是否为字符串,而是只检查了它的行为是否类似于字符串,即能否与字符串拼接。对于这种检查,更符合常规思路的方案是使用isinstance以及字符串和类似于字符串的对象的一些抽象父类,但是Python中没这样的标准类。

通用生成器

这里再描述一下生成器,生成器是含有yield的函数,但被调用时不会执行函数体内的代码,而是返回一个迭代器,每次请求值时,都将执行生成器的代码,直到遇到yieldreturnyield意味着应生成一个值,而return意味着生成器应停止执行(即不再生成值,仅当在生成器调用return时,才能不提供任何参数)。

换句话讲就是,生成器由两个单独的部分构成:①生成器的函数和②生成器的迭代器。生成器的函数是由def语句定义的,其中包含yield。生成器迭代器是这个函数返回的结果。用不太精准的话讲,生成器就是这两个东西的组合,看下面的代码:

1
2
3
4
5
6
7
8
9
>>> def simple_generator():
... yield 1
...
...
>>>
>>> simple_generator
<function simple_generator at 0x0000021C0C224840>
>>> simple_generator()
<generator object simple_generator at 0x0000021C0C203F68>

生成器的方法

在生成器开始运行后,可使用生成器和外部之间的通信渠道向它提供值,这个通信渠道包含如下两个商战:

  • 外部世界:外部世界可访问生成器的方法send,这个方法类似于next,但接受一个参数(要发挥的消息可以是任何对象)。
  • 生成器:在挂起的生成器内部,yield可能用作表达式而不是语句。也就是说,在生成器重新运行时,yield返回一个值,也就是通过send从外部世界发达的值。如果使用的是nextyield将返回None。需要注意的是,仅当生成器被挂起(也就是遇到第一个yield)后,使用send(而不是next)才有意义。要在此之前向生成器提供消息,可使用生成器的函数的参数,下面的一个简单的案例就说明了这个机制:
1
2
3
4
5
6
7
8
def repeater(value):
while True:
new = (yield value)
if new is not None:value = new
r = repeater(42)
next(r)
r.send("Hello, python")

运行结果如下所示:

1
2
3
4
5
>>> r = repeater(42)
>>> next(r)
42
>>> r.send("Hello, python")
'Hello, python'

在上面代码中,使用圆括号将yield表达式圈了起来。

生成器还有另外两种方法,分别是throwclose。其中:

throw方法用于在生成器中(yield表达式处)引发异常调用时可提供一个异常类型、一个可选值和一个traceback对象。

close方法用于停止生成器,调用时无需提供任何参数。方法close也是基于异常的。在yield处引发GeneratorExit异常。因此如果要在生成器中提供一些清理代码,可将yield放在一条try/finally语句中。如果愿意,也可以捕获GeneratorExit异常,但随后必须重新引发它(可能在清理后)、引发其他 异常或直接返回。对生成器调用close方法后,再试图从它那里获取值将导致RuntimeError异常。

生成器的使用案例——八皇后问题

现在使用生成器来解决一个经典的编程问题。

问题背景

对于逐步得到结果的复杂递归算法,非常适合使用生成器来实现。要在不使用生成器的情况下实现这些算法,通常必须通过额外的参数来传递部分结果,让递归调用能够接着往下计算。通过使用生成器,所有的递归调用进乞只需要生成其负责部分的结果。在前面的递归函数flatten就是这样做的,可以使用这种策略来遍历图结构树结构

然而在有些应用程序中,你不能马上得到答案。你必须尝试多次,且在每个递归层级中都如此。

这里讲一个简单的案例。假设你要去参加一个很重要的会议,你不知道会议在哪里召开,但前面有两扇门,而会议室就在其中一扇门的后面。你选择进入左边的那扇门后,又看到两扇门。你再次选择进入左边的那扇门,但发现走错了。因此你往回走,并进入右边那扇门,但发现也走错了。因此你继续往回走到起始,现在可以尝试进入右边那扇门了。

对于需要尝试所有组合直到找到答案的问题,这种回溯策略对其解决很有帮助。这种问题的解决方案类似于以下代码:

1
2
3
4
5
for each possibility at level 1:
for each possibility at level 2:
...
for each possibility at level n:
is it viable?

如果要直接使用for循环来实现,就要写许多层,此时就可以使用递归。

问题描述

将8个皇后(这个皇后指的是国际象棋中的皇后)放在棋盘上,条件是任何一个皇后都不能威胁其他皇后,即任何两个皇后都不能吃掉对方。

这里再讲一下国际象棋的部分规则。

第一,国际象棋的棋盘是8 x 8的方格,如下所示:

第二,皇后是国际象棋中的一个柜子,并且是棋局中实力最强的一种棋子,但同时也是最容易被吃掉的棋子。皇后可横着走,可以直着走,可以斜着走,且格数不限。

第三,因此如果不想让8个皇后中的任何1个被其他的皇后吃掉,那么要满足:①任意2个皇后不能处于同一条横线上;②任意2个皇不能处于同一条竖线上;③任意2个皇后不能处于同一条斜线上。

解决思路

八皇后问题是一个典型的回溯问题:

第一,在棋盘的第一行尝试为第一个皇后选择一个位置;

第二,在第二行尝试为第二个皇后选择一个位置,依次类推;

第三,当发现无法为一个皇后选择合适的位置后,回溯到前一个皇后,并尝试为它选择另外一个位置;

第四,最终要么要尝试完所有的可能性,要么找到答案。

代码实现

现在使用Python来解决一下这个问题。

状态表示

可以简单地使用元组或列表来表示可能的解,其中每个元素表示相应行中皇后所在的位置(即列)。因此如果state[0] = 3就说明第1行的皇后放在第4列。在特定的递归层级(特定的行),你只需要知道上面各皇后的位置即可,因此状态元组的长度是小于8(即皇后的总数)。

检测冲突

先来做些简单的抽象。要找到没有冲突,即任何一个皇后都无法吃掉其他皇后的位置组合,首选必须定义冲突是什么。此时我们就可以使用一个函数conflict来定义一下。

函数conflict接受现有皇后的位置,并确定下一个皇后的位置是否会导致冲突,如下所示:

1
2
3
4
5
6
def conflict(state, nextX):
nextY = len(state)
for i in range(nextY):
if abs(state[i] - nextX) in (0, nextY - i):
return True
return False

参数描述:

  1. nextX表示下一个皇后的水平位置,也就是x坐标;
  2. nextY表示下一个皇后的垂直位置,也就是y坐标;
  3. 这个函数对现有的每个皇后执行简单的检查:如果下一个皇后与当前皇后的x坐标相同或在同一条对角线上,将发生冲突,因此会返回True,如果没有发生冲突,就返回False
  4. abs(state[i] - nextX) in (0, nextY - i)表示:如果下一个皇后和当前皇后的水平距离为0,就表示他们同一列;或者是他们的垂直距离相等,这表示他们位于同一条对角线上,这个表达式就为真,否则为假。

基线条件

基线条件:最后一个皇后,对于这个皇后,代码如下所示:

1
2
3
4
5
def queens(num, state):
if len(state) == num - 1:
for pos in range(num):
if not conflict(state, pos):
yield pos

这段代码的意思是:

  1. 如果只剩下最后一个皇后没有放好,就遍历所有可能的位置,并返回那些不会引发冲突的位置;
  2. 参数num是皇后总数,而参数state是一个元组,包含已经放好的皇后位置,例如,假设总共有4个皇后,而前3个皇后的位置分别是1、3和0,如下所示:

从上图可知,每个皇后都占据一行,而皇后的位置是从0开始编号的,queens函数的代码运行如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> def conflict(state, nextX):
... nextY = len(state)
... for i in range(nextY):
... if abs(state[i] - nextX) in (0, nextY - i):
... return True
... return False
...
>>> def queens(num, state):
... if len(state) == num - 1:
... for pos in range(num):
... if not conflict(state, pos):
... yield pos
...
>>> list(queens(4, (1,3,0)))
[0, 2, 3]

使用list旨在让生成器生成所有的值,在这个案例中,只有一个位置符合条件,就是上面白色棋子的位置。

递归条件

处理好基线条件后,可在递归条件中假设来自更低层级(编号更大的皇后)的结果都是正确的,因此,只需要在函数queens的前述实现中给if语句添加一个else语句。最终我们想要的返回结果是当前行下面所有皇后的位置,假设位置是以元组的形式返回的,因此需要修改基线条件,使其返回一个(长度为1的)元组。

因此,对于递归调用,向它提供的是当前行上面的皇后位置组成的元组。对于当前皇后的每个合法位置,递归调用返回的是由下面的皇后位置组成的元组。为了让这个过程不断进行下去,只需将当前皇后的位置插入返回结果的开头即可,如下所示:

1
2
3
4
5
6
7
8
9
10
def queens(num=8, state=()):
if len(state) == num - 1:
for pos in range(num):
if not conflict(state, pos):
yield pos
else:
for pos in range(num):
if not conflict(state, pos):
for result in queens(num, state + (pos, )):
yield (pos, ) + result

这里的for pos和前面的if not conflict部分与前面相同,因此哦可以稍微简化一下代码,另外还可以给参数指定默认值,如下所示:

1
2
3
4
5
6
7
8
def queens(num = 8, state = ()):
for pos in range(num):
if not conflict(state, pos):
if len(state) == num - 1:
yield (pos,)
else:
for result in queens(num, state + (pos, )):
yield (pos, ) + result

扫尾工作

在代码结束部分,可以让输出更容易理解一些,如下所示:

1
2
3
4
5
def prettyprint(solution):
def line(pos, length=len(solution)):
return '. '*(pos) + 'X ' + '. ' *(length - pos - 1)
for pos in solution:
print(line(pos))

参考资料

  1. MagnusLieHetland. Python基础教程.第3版[M]. 人民邮电出版社, 2018.

参考资料

  1. A Guide to Python’s Magic Methods