Python学习笔记-07面向对象高级编程

该笔记记录的是学习廖雪峰Python3教程的过程,摘录了一些重点,重新编排内容,并加入了更丰富的代码示例和对学习过程中所遇到问题的理解。

本章内容安排如下:

前面一章介绍了OOP最基础的数据封装、继承和多态3个概念,还有一些类和实例的操作。而在Python中,OOP还有很多更高级的特性,这一章会讨论多重继承、定制类、元类等概念。

使用 __slots__

动态绑定属性

正常情况下,当我们定义了一个类,创建了一个类的实例后,我们可以给这个实例绑定任何属性和方法,这就是动态语言的灵活性。先定义类:

1
2
class Student(object):
pass

然后,创建实例并给这个实例绑定一个属性:

1
2
3
4
>>> s = Student()
>>> s.name = 'Michael' # 动态给实例绑定一个属性
>>> print(s.name)
Michael

动态绑定方法

还可以尝试给实例绑定一个方法:

1
2
3
4
5
6
7
8
>>> def set_age(self, age): # 定义一个函数
... self.age = age
...
>>> from types import MethodType
>>> s.set_age = MethodType(set_age, s) # 把函数绑定到实例上,变为实例的方法
>>> s.set_age(25) # 调用实例方法
>>> s.age # 测试结果
25

注意到这里使用types模块的 MethodType() 函数来给实例绑定方法,为什么要用 MethodType() 而不是直接用 s.set_age = set_age 直接绑定呢?这是因为我们采用后者绑定时,只是绑定了一个外部函数,它与实例本身没有任何关联,没法使用self变量,而使用 MethodType() 就会真正地为实例绑定一个方法,也因此绑定的函数的第一个参数要设置为self变量。做个对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> s.set_age = set_age # 直接绑定
>>> s.set_age(25) # 无法调用self变量
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: set_age() missing 1 required positional argument: 'age'
>>> s.set_age(s,25) # 必须显式地传入实例s自身
>>> s.age
25
>>> from types import MethodType
>>> s.set_age = MethodType(set_age, s) # 使用MethodType绑定
>>> s.set_age(30) # 可以调用self变量,只需传入一个参数
>>> s.age
30

但是,给一个实例绑定的方法,对另一个实例是不起作用的:

1
2
3
4
5
>>> s2 = Student() # 创建新的实例
>>> s2.set_age(25) # 尝试调用方法
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Student' object has no attribute 'set_age'

为了给所有实例都绑定方法,可以直接给类绑定方法:

1
2
3
4
>>> def set_score(self, score):
... self.score = score
...
>>> Student.set_score = set_score

给类绑定方法不需要使用 MethodType() 函数,并且所有实例均可调用绑定在类上的方法:

1
2
3
4
5
6
>>> s.set_score(100)
>>> s.score
100
>>> s2.set_score(99)
>>> s2.score
99

通常情况下,上面的 set_score 定义在类中,但动态绑定允许我们在程序运行的过程中动态给类加上功能,这在静态语言中很难实现。


限制可绑定的属性/方法

上面两个小节介绍了怎样绑定属性和方法,但是如果我们想要限制可以绑定到实例的属性/方法怎么办呢?比方说,只允许对Student类的实例绑定 nameage 属性。

为了达到限制的目的,Python允许在定义类的时候,定义一个特殊的 __slots__ 变量,来限制该类实例能添加的属性:

1
2
>>> class Student(object):
... `__slots__` = ('name', 'age') # 用tuple定义允许绑定的属性名称

然后,我们试试:

1
2
3
4
5
6
7
>>> s = Student() # 创建新的实例
>>> s.name = 'Michael' # 绑定属性'name'
>>> s.age = 25 # 绑定属性'age'
>>> s.score = 99 # 绑定属性'score'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Student' object has no attribute 'score'

由于属性 score 没有被放到 __slots__ 变量中,所以实例不能绑定 score 属性,试图绑定 score 将得到 AttributeError 错误。

使用 __slots__ 要注意,__slots__ 变量的属性限制仅对当前类的实例起作用,对继承的子类是不起作用的

1
2
3
4
5
>>> class GraduateStudent(Student):
... pass
...
>>> s1 = GraduateStudent()
>>> s1.score = 9999 # 可以绑定任何属性

但是!如果在子类中也定义 __slots__ ,则子类实例允许定义的属性就既包括自身的 __slots__ 也包括父类的 __slots__

1
2
3
4
5
6
7
8
9
10
11
>>> class GraduateStudent(Student):
... __slots__ = ('score')
...
>>> s2 = GraduateStudent()
>>> s2.name = 'Angela'
>>> s2.age = 17
>>> s2.score = 99
>>> s2.sex = 'Female' # 无法绑定父类__slots__和当前类__slots__都没有的属性
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'GraduateStudent' object has no attribute 'sex'


使用@property

为何需要@property

在绑定属性时,如果我们直接把属性暴露出去供使用者修改,虽然写起来很简单,但是没办法检查设置的属性值是否合理,可以把成绩随便改:

1
2
s = Student()
s.score = 9999 # 直接通过属性修改

这显然不合逻辑。为了限制 score 的范围,可以通过一个 set_score() 方法来设置成绩,再通过一个 get_score() 来获取成绩,这样,在 set_score() 方法里,就可以检查参数:

1
2
3
4
5
6
7
8
9
class Student(object):
def get_score(self):
return self._score
def set_score(self, value):
if not isinstance(value, int):
raise ValueError('score must be an integer!')
if value < 0 or value > 100:
raise ValueError('score must between 0 ~ 100!')
self._score = value

现在,对任意的Student实例进行操作,就不能随心所欲地设置score了:

1
2
3
4
5
6
7
8
>>> s = Student()
>>> s.set_score(60) # 通过类的方法修改
>>> s.get_score()
60
>>> s.set_score(9999)
Traceback (most recent call last):
...
ValueError: score must between 0 ~ 100!

但是,通过类的方法修改,调用者使用时比较麻烦,没有直接使用属性进行修改简单,而且对调用者是否自觉也有要求,如果调用者依然直接使用属性修改,就没法检查属性值了。

有没有既能检查属性值,又可以直接使用属性修改的办法呢?答案是有的!


如何实现@property

第四章-函数式编程中,我们学习到了装饰器(decorator),它可以给函数动态添加功能。事实上,不仅是对函数,装饰器对类的方法一样起作用。Python内置的 @property 装饰器就可以帮助我们实现前面的需求,把一个方法变成属性调用:

1
2
3
4
5
6
7
8
9
10
11
class Student(object):
@property
def score(self): # 对应getter方法,也即前面例子的get_score(self)
return self._score
@score.setter
def score(self, value): # 对应setter方法,也即前面例子的set_score(self, value)
if not isinstance(value, int):
raise ValueError('score must be an integer!')
if value < 0 or value > 100:
raise ValueError('score must between 0 ~ 100!')
self._score = value

@property 的实现比较复杂。准确地说,把一个getter方法变成属性,只需要加上 @property 装饰器就可以了,而把一个setter方法变成属性赋值,这要加上一个 @score.setter 装饰器,也即 @属性名.setter。注意!属性名和方法名一定要区分开,否则会出错!这里我们把 score 属性改为 _score 属性,所以对内部来说 _score 是属性,score 是方法对外部来说 score 是属性,_score 被封装起来了(因为我们使用了装饰器进行转换)。看看实际效果:

1
2
3
4
5
6
7
8
>>> s = Student()
>>> s.score = 60 # 实际转化为s.score(60)
>>> s.score # 实际转化为s.score()
60
>>> s.score = 9999 # 对外部来说可以直接使用属性赋值,同时也能检查属性值
Traceback (most recent call last):
...
ValueError: score must between 0 ~ 100!

还可以定义只读属性,只定义getter方法,不定义setter方法就是一个只读属性,只读属性只能获取属性值,无法设置属性值:

1
2
3
4
5
6
7
8
9
10
class Student(object):
@property
def birth(self):
return self._birth
@birth.setter
def birth(self, value):
self._birth = value
@property
def age(self): # 只读属性age,根据birth进行计算
return 2015 - self._birth

上面的birth是可读写属性,而age就是一个只读属性:。

1
2
3
4
5
6
7
8
9
10
>>> s = Student()
>>> s.birth = 2000 # 可读写属性birth可以进行赋值
>>> s.birth
2000
>>> s.age
16
>>> s.age = 17 # 只读属性age无法进行赋值
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

注意必须先对属性 birth 进行赋值,然后才可以访问 birthage,否则就会出现:

1
2
3
4
5
6
7
8
9
10
>>> s.birth
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 4, in birth
AttributeError: 'Student' object has no attribute '_birth'
>>> s.age
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 10, in age
AttributeError: 'Student' object has no attribute '_birth'

练习

请利用 @property 给一个 Screen 对象加上 widthheight 属性,以及一个只读属性 resolution

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Screen(object):
@property
def width(self):
return self._width
@width.setter
def width(self, value):
self._width = value
@property
def height(self):
return self._height
@height.setter
def height(self, value):
self._height = value
@property
def resolution(self):
return self._width * self._height

测试:

1
2
3
4
5
6
>>> s = Screen()
>>> s.width = 1024
>>> s.height = 768
>>> print(s.resolution)
786432
>>> assert s.resolution == 786432, '1024 * 768 = %d ?' % s.resolution

小结

@property 广泛应用在类的定义中,可以让调用者写出简短的代码,同时又保证了对属性值进行必要的检查,这样,程序运行时就减少了出错的可能性。



多重继承

为何需要多重继承

第六章-面向对象编程中,我们学习了面向对象编程的一个重要性质——继承。通过继承,子类可以获得父类的所有功能并进行进一步扩展

假设我们设计了一个 Animal 类,并要为以下4种动物设计四个新的类:

  • Dog - 狗狗;
  • Bat - 蝙蝠;
  • Parrot - 鹦鹉;
  • Ostrich - 鸵鸟。

如果把这些动物按照哺乳动物和鸟类分类,我们可以设计出这样的类的层次:

MixIn1

但是如果按照能跑的和能飞的来分类,则设计出这样的类的层次就变为:

MixIn2

但是,如果要把上面的两种分类方法都包含进来,我们就得设计更多的层次了。哺乳类要分为能跑的哺乳类和能飞的哺乳类,鸟类也要能跑的鸟类,能飞的鸟类。这么一来,类的层次变得很复杂了:

MixIn3

如果再增加更多的分类方式(例如:宠物和非宠物),那么类的数量会呈指数增长,这样设计就显得很不实用了。


使用多重继承

分析一下前面的设计方法,其实之所以会造成类的数量呈指数增长,是因为每个类只能继承一个类,这就造成了很多不必要的重复实现。解决方法是采用多重继承。比方说设计为:

MixIn4

因为能跑和能飞这两个类不受限于动物类,它们是独立的。我们单独实现这两个类,即使要再实现其他非动物的类,比如汽车和飞机,也能很轻松地继承它们的功能,而不需要再重复构造功能类似的新的类。而动物分类方面,假设我们加入宠物非宠物的分类,也不需再构造哺乳的能飞的宠物、鸟类的能飞的宠物等等类别,通过多重继承免去了很多麻烦。先进行动物分类的定义:

1
2
3
4
5
6
7
8
9
class Animal(object):
pass
# 大类:
class Mammal(Animal):
pass
class Bird(Animal):
pass

接下来定义好 RunnableFlyable 的类:

1
2
3
4
5
6
7
class Runnable(object):
def run(self):
print('Running...')
class Flyable(object):
def fly(self):
print('Flying...')

对于需要 Runnable 功能的动物,只需要多继承一个 Runnable,例如 Dog

1
2
class Dog(Mammal, Runnable):
pass

对于需要 Flyable 功能的动物,只需要多继承一个 Flyable,例如 Bat

1
2
class Bat(Mammal, Flyable):
pass

通过多重继承,一个子类可以同时获得多个父类的所有功能

MixIn

在设计类的继承关系时,通常主线都是单一继承下来的,例如,Ostrich 继承自 Bird。但是,如果需要混入额外的功能,通过多重继承就可以实现,比如,让 Ostrich 除了继承自 Bird 外,再同时继承 Runnable。种(利用多重继承混入额外的功能)这种设计方式通常称之为MixIn

为了更好地看出继承关系,我们通常把用于添加额外功能的类命名带上一个后缀MixIn,例如把 RunnableFlyable 改为 RunnableMixInFlyableMixInn 和 植食动物 HerbivoresMixIn,让某个动物同时拥有好几个MixIn:

1
2
class Dog(Mammal, RunnableMixIn, CarnivorousMixIn):
pass

MixIn的目的就是给一个类增加多个功能,这样,在设计类的时候,我们可以优先考虑通过多重继承来组合多个MixIn的功能,而不是设计多层次的复杂的继承关系

Python自带的很多库也使用了MixIn。举个例子,Python自带了 TCPServerUDPServer 这两类网络服务,而要同时服务多个用户就必须使用多进程或多线程模型,这两种模型由 ForkingMixInThreadingMixIn 提供。通过组合,我们就可以创造出合适的服务来。

比如,编写一个多进程模式的TCP服务,定义如下:

1
2
class MyTCPServer(TCPServer, ForkingMixIn):
pass

编写一个多线程模式的UDP服务,定义如下:

1
2
class MyUDPServer(UDPServer, ThreadingMixIn):
pass

如果你打算搞一个更先进的协程模型,可以编写一个 CoroutineMixIn

1
2
class MyTCPServer(TCPServer, CoroutineMixIn):
pass

这样一来,我们不需要复杂而庞大的继承链,只要选择组合不同的类的功能,就可以快速构造出所需的子类


小结

  • 由于Python允许使用多重继承,因此,MixIn就是一种常见的设计。

  • 只允许单一继承的语言(如Java)不能使用MixIn的设计。



定制类

在前面的章节中,我们知道了可以用 __slots__ 变量限制可绑定的属性,我们也知道了在构造类的时候,只要定义了 __len__() 方法,用户就能使用Python内置的 len() 函数获取该类实例的长度。我们知道形如 __xxx__ 的变量/方法都是有特殊用途的,那么Python中还有哪些特殊的变量/方法可以帮助我们更好地定制类呢?

__str__

我们先定义一个 Student 类,然后打印一个实例:

1
2
3
4
5
6
>>> class Student(object):
... def __init__(self, name):
... self.name = name
...
>>> print(Student('Michael'))
<__main__.Student object at 0x109afb190>

但是这样打印实例,我们只能知道它属于什么类以及在内存的位置,它的其他信息全都无法了解,所以对使用者来说并不友好。怎么才能定制打印的信息,使得打印实例时可以看到更多有用的信息呢?只需要定义好 __str__() 方法就可以了:

1
2
3
4
5
6
7
8
>>> class Student(object):
... def __init__(self, name):
... self.name = name
... def __str__(self):
... return 'Student object (name: %s)' % self.name
...
>>> print(Student('Michael'))
Student object (name: Michael)

这样打印实例就不但能知道实例所属的类,也能获得这个实例的属性信息了。

但是细心的朋友会发现直接敲变量不用 print 函数,打印出的实例依然是原来的样子:

1
2
3
>>> s = Student('Michael')
>>> s
<__main__.Student object at 0x109afb310>

这是因为直接显示变量调用的不是 __str__() 方法,而是 __repr__() 方法,两者的区别是 __str__() 方法返回用户看到的字符串,而 __repr__() 返回程序开发者看到的字符串,也就是说,__repr__() 是为调试服务的

解决办法是再定义一个 __repr__() 方法。但是通常 __str__()__repr__()代码都是一样的(当然,要写不同的也行),所以,有个偷懒的写法:

1
2
3
4
5
6
class Student(object):
def __init__(self, name):
self.name = name
def __str__(self):
return 'Student object (name=%s)' % self.name
__repr__ = __str__ # 直接令__repr__等于__str__

__iter__

如果我们希望用 for ... in 循环来遍历一个类的实例,像遍历 listtuple 那样,就必须实现一个 __iter__() 方法,该方法返回一个迭代对象,然后,Python的 for 循环就会不断调用该迭代对象的 __next__() 方法拿到循环的下一个值,直到遇到 StopIteration 错误时退出循环。

我们以斐波那契数列为例,写一个 Fib 类,可以作用于 for 循环:

1
2
3
4
5
6
7
8
9
10
11
12
class Fib(object):
def __init__(self):
self.a, self.b = 0, 1 # 初始化两个计数器a,b
def __iter__(self):
return self # 实例本身就是迭代对象,返回自己即可
def __next__(self):
self.a, self.b = self.b, self.a + self.b # 计算下一个值
if self.a > 100000: # 退出循环的条件
raise StopIteration();
return self.a # 返回下一个值

现在,试试把Fib类的实例作用于 for 循环,就能遍历斐波拉契数列了:

1
2
3
4
5
6
7
8
9
10
11
>>> for n in Fib():
... print(n)
...
1
1
2
3
5
...
46368
75025

__getitem__

Fib 类的实例虽然能作用于 for 循环,看起来和 list 有点像了,但是没有办法使用下标访问

1
2
3
4
>>> Fib()[5]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'Fib' object does not support indexing

要能像 list 那样按照下标访问元素,需要实现 __getitem__() 方法:

1
2
3
4
5
6
class Fib(object):
def __getitem__(self, n):
a, b = 1, 1
for x in range(n):
a, b = b, a + b
return a

现在,就可以按下标访问数列的任意一项了:

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> f = Fib()
>>> f[0]
1
>>> f[1]
1
>>> f[2]
2
>>> f[3]
3
>>> f[10]
89
>>> f[100]
573147844013817084101

但是 list 有个神奇的切片方法:

1
2
>>> list(range(100))[5:10]
[5, 6, 7, 8, 9]

对于 Fib 却报错。原因是 __getitem__() 传入的参数可能是一个 int,也可能是一个 slice(切片对象),所以要做判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Fib(object):
def __getitem__(self, n):
if isinstance(n, int): # n是索引
a, b = 1, 1
for x in range(n):
a, b = b, a + b
return a
if isinstance(n, slice): # n是切片
start = n.start
stop = n.stop
if start is None:
start = 0
a, b = 1, 1
L = []
for x in range(stop):
if x >= start:
L.append(a)
a, b = b, a + b
return L

现在再试试对 Fib 类的实例使用切片:

1
2
3
4
5
>>> f = Fib()
>>> f[0:5]
[1, 1, 2, 3, 5]
>>> f[:10]
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

但是没有对 step(步长)参数作处理

1
2
>>> f[:10:2]
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

也没有对负数作处理,所以,要正确实现一个完整的 __getitem__() 还是有很多工作要做的。

此外,如果把对象看成 dict,那么 __getitem__() 的参数也可能是一个可以作key的object,例如 str

__getitem__() 方法对应的是 __setitem__() 方法,把对象视作 listdict 来对一个/多个位置进行赋值。除此之外,还有 __delitem__() 方法,用于删除某个位置的元素。

总之,通过实现上面的方法,可以让我们自己定义的类表现得和Python自带的 listtupledict 没什么区别,这完全归功于动态语言的“鸭子类型”特点不需要强制继承某个接口就能实现该接口的部分功能


__getattr__

正常情况下,当我们调用类的方法或属性时,如果不存在,就会报错。比如定义Student类:

1
2
3
4
class Student(object):
def __init__(self):
self.name = 'Michael'

调用name属性,没问题,但是,调用不存在的score属性,就有问题了:

1
2
3
4
5
6
7
>>> s = Student()
>>> print(s.name)
Michael
>>> print(s.score)
Traceback (most recent call last):
...
AttributeError: 'Student' object has no attribute 'score'

错误信息很清楚地告诉我们,没有找到score这个attribute。

要避免这个错误,除了可以加上一个score属性外,Python还有另一个机制,那就是写一个 __getattr__() 方法,动态返回一个属性。修改如下:

1
2
3
4
5
6
7
8
class Student(object):
def __init__(self):
self.name = 'Michael'
def __getattr__(self, attr):
if attr=='score':
return 99

当调用不存在的属性时,Python解释器会试图调用 __getattr__(self, '属性名')来尝试获得属性,依然用 score 属性做例子,进行上述定义后,再次执行就变成了:

1
2
3
4
5
>>> s = Student()
>>> s.name
'Michael'
>>> s.score
99

动态返回函数也是完全可以的:

1
2
3
4
5
class Student(object):
def __getattr__(self, attr):
if attr=='age':
return lambda: 25

只是调用方式要变为:

1
2
>>> s.age()
25

注意,只有在没有找到属性的情况下,才调用 __getattr__,已有的属性,比如 name,不会在 __getattr__ 中查找。

此外,注意到此时调用其他任意属性,如 s.abc,返回的是 None,这是因为在 __getattr__ 中我们没有为这些属性定义返回值,那么默认返回就是 None。要让类只响应特定的几个属性,我们可以默认抛出 AttributeError 错误

1
2
3
4
5
6
class Student(object):
def __getattr__(self, attr):
if attr=='age':
return lambda: 25
raise AttributeError('\'Student\' object has no attribute \'%s\'' % attr)

这样就相当于把一个类的属性和方法调用都进行动态化处理了,不需要其他特殊手段。

这种完全动态调用的特性有什么实际作用呢?作用就是,可以针对完全动态的情况作调用。举个例子,现在很多网站都搞 REST API,比如新浪微博、豆瓣啥的,调用API的URL类似:

1
2
http://api.server/user/friends
http://api.server/user/timeline/list

如果要写SDK,为每个URL对应的API都写一个方法,那得累死,而且,API一旦改动,SDK也要改。

借助完全动态的 __getattr__ 方法,我们可以非常方便地实现链式调用

1
2
3
4
5
6
7
8
class Chain(object):
def __init__(self, path=''):
self._path = path
def __getattr__(self, path):
return Chain('%s/%s' % (self._path, path))
def __str__(self):
return self._path
__repr__ = __str__

试试:

1
2
3
4
5
6
7
>>> chain = Chain('http://api.server')
>>> API1 = chain.user.friends
>>> print(API1)
http://api.server/user/friends
>>> API2 = chain.user.timeline.list
>>> print(API2)
http://api.server/user/timeline/list

由于 __getattr__ 返回的也是一个 Chain 类的实例,所以后面继续接着使用点符访问属性也是可以的,这就是链式调用的本质。这样,无论想调用什么API,SDK都可以根据不同的URL进行完全动态的调用,不需要随API的增加而改变!相当方便!!

还有一些REST API会把参数放在URL中,比如GitHub的API:

1
GET /users/:user/repos

调用时,需要把 :user 替换为实际用户名。这时我们希望可以用这样的链式调用来获取API:

1
chain().users('michael').repos

尝试一下:

1
2
3
4
5
6
7
8
9
10
class Chain(object):
def __init__(self, path=''):
self._path = path
def __getattr__(self, path):
return Chain('%s/%s' % (self._path, path))
def users(self, username):
return Chain('%s/%s' % (self._path, username))
def __str__(self):
return self._path
__repr__ = __str__

运行结果:

1
2
3
>>> chain = Chain('/users')
>>> chain.users('michael').repos
/users/michael/repos

当然,除了实现一个 users 方法之外,直接在 getattr 方法里面使用正则也是可以的。


__call__

一个对象实例可以有自己的属性和方法,当我们调用实例方法时,我们用 实例名.方法名() 的方式来调用。能不能直接把实例本身当作一个方法调用呢?在Python中,答案是肯定的。

对任何类来说,只需要实现 __call__() 方法,就可以直接对该类的实例进行调用。比如:

1
2
3
4
5
6
class Student(object):
def __init__(self, name):
self.name = name
def __call__(self):
print('My name is %s.' % self.name)

调用方式如下:

1
2
3
>>> s = Student('Michael')
>>> s() # 调用实例本身,self参数不需要传入
My name is Michael.

和普通的函数和方法一样,我们还可以为 __call__() 方法定义其他参数。但有一点很特别,我们注意到类的实例都是运行期间动态创建出来的,而一般来说可调用对象(函数/方法)都是预先定义的,所以说当我们把实例本身变成可调用的方法时,实际上我们是动态创建了可调用对象

能被调用的对象就是一个 Callable 对象,要判断一个对象是否可调用可以使用Python内置的 callable 函数:

1
2
3
4
5
6
7
8
9
10
>>> callable(Student())
True
>>> callable(max)
True
>>> callable([1, 2, 3])
False
>>> callable(None)
False
>>> callable('str')
False

小结

Python中的类允许定义许多定制方法,可以让我们非常方便地生成特定的类。

本节介绍的是最常用的几个定制方法,还有很多可定制的方法,请参考Python的官方文档



使用枚举类

为何需要枚举类

当我们需要定义常量/枚举值时,一个比较常见的办法是用大写变量通过整数来定义,例如月份:

1
2
3
4
5
6
JAN = 1
FEB = 2
MAR = 3
...
NOV = 11
DEC = 12

这样做的好处是简单,缺点是把数据类型变为了 int 型,并且在Python中仍然是变量,因此可能会在使用者无法意识到的情况下被错误的操作改变值


如何使用枚举类

更好的方法是使用Python提供的枚举类 Enum把每一个枚举对象作为枚举类的一个属性

1
2
3
from enum import Enum
Month = Enum('Month', ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')) # 创建一个枚举类的实例

这样我们就获得了一个类型为 Month 的枚举类,可以直接使用 Month.Jan 来引用一个常量,或者枚举它的所有成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> for name, member in Month.__members__.items():
... print(name, '=>', member, ',', member.value)
...
Jan => Month.Jan , 1
Feb => Month.Feb , 2
Mar => Month.Mar , 3
Apr => Month.Apr , 4
May => Month.May , 5
Jun => Month.Jun , 6
Jul => Month.Jul , 7
Aug => Month.Aug , 8
Sep => Month.Sep , 9
Oct => Month.Oct , 10
Nov => Month.Nov , 11
Dec => Month.Dec , 12

特别地,枚举类中的每个成员会被分配一个 int 型的 value 属性,默认按初始化顺序从1开始计数


自定义枚举类

如果需要更精确地控制枚举类型,可以继承 Enum 类然后进行自定义:

1
2
3
4
5
6
7
8
9
10
11
from enum import Enum, unique
@unique
class Weekday(Enum):
Sun = 0 # Sun的value被设定为0
Mon = 1
Tue = 2
Wed = 3
Thu = 4
Fri = 5
Sat = 6

@unique 装饰器可以帮助我们检查枚举值是否存在重复,注意属性名字重复也会报错,但是与 @unique 装饰器无关。

自定义的枚举类使用方法和使用 Enum 构造的类似:

1
2
3
4
5
6
7
8
9
>>> day1 = Weekday.Mon # 按属性访问
>>> print(day1) # 打印枚举变量
Weekday.Mon
>>> day1 # 直接显示枚举变量
<Weekday.Mon: 1>
>>> day1.name # 获得枚举变量的名称
'Mon'
>>> day1.value # 获得枚举变量的值
1
1
2
3
4
5
6
>>> print(Weekday['Tue']) # 使用属性名作下标访问
Weekday.Tue
>>> print(day1 == Weekday.Mon) # 属性之间可以直接进行比较
True
>>> print(day1 == Weekday.Tue)
False
1
2
3
4
5
6
7
8
>>> print(Weekday(1)) # 把类作为一个方法调用,传入枚举值
Weekday.Mon
>>> print(day1 == Weekday(1))
True
>>> Weekday(7) # 找不到该枚举值对应的属性
Traceback (most recent call last):
...
ValueError: 7 is not a valid Weekday
1
2
3
4
5
6
7
8
9
10
>>> for name, member in Weekday.__members__.items(): # 遍历枚举类
... print(name, '=>', member)
...
Sun => Weekday.Sun
Mon => Weekday.Mon
Tue => Weekday.Tue
Wed => Weekday.Wed
Thu => Weekday.Thu
Fri => Weekday.Fri
Sat => Weekday.Sat

小结

使用枚举类可以把一组相关常量定义在一个类中,转化为该类的不同属性,该类不可变(属性都是只读的)且属性可以直接进行比较。



使用元类

type函数

动态语言和静态语言最大的不同,就是在动态语言中,函数和类的定义,不是编译时定义的,而是运行时动态创建的

比方说我们要定义一个 Hello 类,首先编写一个 hello.py 模块,里面的代码如下:

1
2
3
class Hello(object):
def hello(self, name='world'):
print('Hello, %s.' % name)

当Python解释器导入 hello 模块时,就会依次执行该模块的所有语句(与我们在交互环境下逐个语句输入来定义类一样),从而动态创建出一个类对象(注意这里说的是类对象而不是实例对象),测试如下:

1
2
3
4
5
6
7
8
9
10
11
12
>>> from hello import Hello # 这个语句创建了一个名为Hello的类对象
>>> h = Hello() # 创建一个Hello类的实例h
>>> h.hello()
Hello, world.
>>> print(type(Hello)) # Hello对象的类型为type
<class 'type'>
>>> print(type(h)) # 而Hello类实例的类型为hello.Hello
<class 'hello.Hello'>
>>> type(str) # str是一个类型
<class 'type'>
>>> type(int) # int也是一个类型
<class 'type'>

type() 函数可以用来查看一个变量的类型Hello 是一个类,它的类型就是 type,而 h 是一个实例,它的类型就是它所属的类。

前面说到,在Python中,类的定义是运行时动态创建的。而动态创建类使用的其实是 type()函数type() 函数既可以返回一个变量的类型,又可以创建出新的类型。依然举 Hello 类为例子,但我们这次使用 type() 函数来创建 Hello 类而不使用显式的 class Hello

1
2
3
4
5
6
7
8
9
10
11
>>> def fn(self, name='world'): # 先定义函数
... print('Hello, %s.' % name)
...
>>> Hello = type('Hello', (object,), dict(hello=fn)) # 创建Hello class
>>> h = Hello()
>>> h.hello()
Hello, world.
>>> print(type(Hello))
<class 'type'>
>>> print(type(h))
<class '__main__.Hello'>

使用 type() 函数创建一个类对象,需要依次传入以下3个参数:

  • 类名
  • 继承的父类集合:Python支持多重继承,所以这里用一个 tuple 来囊括继承的所有父类。注意只有一个父类时,要采用 tuple 的单元素写法,不要漏掉逗号。
  • 类的方法名与函数的绑定:在上面的例子中,我们把函数 fn 绑定到方法名 hello 上。也即类 Hello 的方法 hello 就是函数 fn,注意这和这章开头所说的动态绑定方法是不同的

通过 type() 函数创建的类和直接写类是完全一样的。事实上,Python解释器遇到类定义时,在扫描类定义的语法之后,就是调用 type() 函数来创建类的

正常情况下,我们都用 class 类名(父类1, 父类2, ...) 的方式来定义类,但是,type() 函数也允许我们动态创建类。

动态语言能够支持运行期间动态创建类,这和静态语言有非常大的不同。关于这两者的区别,感兴趣的话可以再查找其他资料。


什么是元类

除了使用 type() 函数动态创建类以外,要控制类的创建行为,还可以使用元类(metaclass)。

怎么理解什么是元类呢?简单地解释一下:

  • 当我们定义了类以后,以类为模版就可以创建出实例了。
  • 但如果我们要创建类呢?那就必须先定义元类,有了元类之后,以元类为模版就可以创建出类了。
  • 连起来就是:以元类为模版创建类,以类为模版创建该类的实例

也就是说,可以把类看成是元类创建出来的“实例”

metaclass

图片来源:What is a metaclass in Python?

在上一个小节中,我们了解到可以使用 type() 函数创建类,但 type 的本质是什么呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> help(type)
Help on class type in module builtins:
class type(object)
| type(object_or_name, bases, dict)
| type(object) -> the object's type
| type(name, bases, dict) -> a new type
|
| Methods defined here:
|
| __call__(self, /, *args, **kwargs)
| Call self as a function.
...

其实呀, type 本身就是一个类,调用 type() 创建类得到的其实就是 type 类的实例。所以所有类对象的类型都是 type。不难分析出,type 是一个元类,并且类都是默认以元类 type 为模版创建的


怎样使用元类

如果我们想要创建一个元类,并且想以这个元类为模版创建类,那么定义元类的时候,就应当让这个元类继承自 type

按照习惯,元类的类名应总是以Metaclass结尾,以便清楚地表示这是一个元类。下面举一个例子,定义元类 ListMetaclass

1
2
3
4
5
# metaclass是类的模板,所以必须从`type`类型派生:
class ListMetaclass(type):
def __new__(cls, name, bases, attrs):
attrs['add'] = lambda self, value: self.append(value)
return type.__new__(cls, name, bases, attrs)

元类的 __new__() 方法用于创建一个类对象,它接收四个参数,依次是:

  • 准备创建的类对象;
  • 准备创建的类的名字;
  • 准备创建的类继承的父类集合;
  • 准备创建的类的方法集合。

我们在 ListMetaclass__new__() 方法中加入了一句 attrs['add'] = lambda self, value: self.append(value),然后调用元类 type__new__() 方法创建类对象。这多出来的一句,实际上我们是给要创建的类提供了一个 add 方法,这个 add 方法接收实例本身和一个变量,并把这个变量拼接到实例的尾部。其实就是一个 append 方法。

定义好元类 ListMetaclass 之后,我们以它为模版创建类,注意传入关键字参数 metaclass

1
2
class MyList(list, metaclass=ListMetaclass):
pass

传入关键字参数 metaclass 后,Python解释器会在创建 MyList 类时,通过元类 ListMetaclass__new__() 方法来创建。因此虽然我们在类定义时没有为 MyList 类定义任何方法,但因为它是以元类 ListMetaclass 为模版创建的,所以拥有了 add 方法。另外,因为它继承了 list 类,所以我们相当于创建了一个拥有 add 方法的新的 list 类,测试一下:

1
2
3
4
5
6
7
>>> L = MyList()
>>> L.add(1) # 使用add方法在列表尾部添加元素
>> L
[1]
>>> L.add(2)
>>> L
[1, 2]

普通的 list 是没有 add() 方法的:

1
2
3
4
5
>>> L2 = list()
>>> L2.add(1)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'list' object has no attribute 'add'

但是,直接在 MyList 类的定义中写上 add() 方法不是更简单吗?是的,正常情况下我们应该直接在类定义中编写方法,而不是通过元类

但是,也有需要通过元类动态修改类定义的情况,ORM就是一个典型的例子。


编写ORM框架

ORM 全称 Object Relational Mapping(对象-关系映射),简单来说就是把关系型数据库中表格的每一行都映射为一个对象,而每一个表就是一个类。这样写代码更简单,不用直接操作SQL语句。

要编写一个 ORM 框架供不同的使用者使用,框架中的所有类都应该能动态定义,因为每位使用者的需求不同,需要根据具体的表结构来定义出不同的类。

举个例子,假如使用者想使用这个 ORM 框架定义一个 User 类来操作数据库中的表格 User,我们期望使用者可以写出这样简洁的形式:

1
2
3
4
5
6
class User(Model):
# 定义类的属性到表格中列的映射:
id = IntegerField('id')
name = StringField('username')
email = StringField('email')
password = StringField('password')
1
2
3
4
# 创建一个实例:
u = User(id=12345, name='Michael', email='test@orm.org', password='my-pwd')
# 插入到表格中:
u.insert()

也即,用户在使用这个 ORM 框架时,每个表格对应一个类,类定义只需要指定表格每列的字段类型即可,每一行数据都是该类的一个实例。而父类 Model 和数据类型 StringFieldIntegerField 等都由 ORM 框架负责提供。save() 之类的方法则全部由元类自动完成。虽然这样元类的编写会比较复杂,但 ORM 的使用者用起来却可以异常简单

想好了希望实现怎样的效果后,我们可以开始编写调用接口。

首先定义 Field 类,它是最底层的类,负责保存字段名(列名)和对应的字段类型:

1
2
3
4
5
6
7
class Field(object):
def __init__(self, name, column_type):
self.name = name
self.column_type = column_type
def __str__(self):
return '<%s:%s>' % (self.__class__.__name__, self.name)
__repr__ = __str__

Field 的基础上,我们可以进一步定义各种类型的 Field,比如 StringFieldIntegerField 等等:

1
2
3
4
5
6
7
class StringField(Field):
def __init__(self, name):
super(StringField, self).__init__(name, 'varchar(100)')
class IntegerField(Field):
def __init__(self, name):
super(IntegerField, self).__init__(name, 'bigint')

注意这里使用了 super 函数来获取父类的方法,并进行绑定,先看一看官方的解释:

1
2
3
4
5
6
7
8
9
10
super(type[, object-or-type])
Return the superclass of type. If the second argument is omitted the super object
returned is unbound. If the second argument is an object, isinstance(obj, type)
must be true. If the second argument is a type, issubclass(type2, type) must be
true. super() only works for new-style classes.
A typical use for calling a cooperative superclass method is:
class C(B):
def meth(self, arg):
super(C, self).meth(arg)
New in version 2.2.

所以这里实际上我们实例化 StringFieldIntegerField 时,是调用它们的父类,也即 Field 类的 __init__ 方法进行的,这两个类封装了 Field 的功能,使用者只需要传入字段名就可以了,不需要关心在数据库中类型的名字。上面的实现比较简单,不需要使用元类。

接下来先理一理整体的实现思路,我们编写 ORM 框架来实现底层的功能,用户使用该框架时,只需要根据自己的需求来为表格定义对应的类,比方说上面举的例子中定义 User 类那样。这个类的实例对应表格中的一行,定义一个新实例 u = User(id=12345, name='Michael', email='test@orm.org', password='my-pwd'),我们希望得到这个实例后可以通过 print(u['name']) 的方式读取字段值,通过 u['id']=23456 的方式来修改字段值,这就类似于Python中的 dict 的功能,所以我们实际上最底层的父类采用 dict 即可。

但是,我们除了 dict 的功能之外,肯定还需要实现一些其他功能,比如把新实例插入到数据库的表格中。这些功能我们可以在 Model 类中实现,Model 类继承 dict 类,这样我们就可以像前面说的那样进行读取和修改了。使用者为表格编写类时继承 Model 类即可,这样所有表格都能得到 Model 类中实现的操作表格的功能了。

但是,我们还注意到一点,我们希望用户定义类的时候,写法尽可能简单,只需要关注有哪些字段,然后每个字段作为一个属性,用 id = IntegerField('id') 的方式来定义,也即 属性名 = 字段类型('字段名'),字段类型的实现前面已经说过了。

这里我们需要关注另外一个很重要的点,在实例化得到表格的一行以后,我们希望使用者可以采用 实例名.属性名 = 值 的方式来修改这一行某个字段的值。但事实上,使用者定义类的时候,类属性表示的是以某个字段名为名的某字段类型的实例,属性的类型是 StringField 或者 IntegerField。而在读取或修改一个实例的属性值时,我们希望实例属性表示的是这一行数据在这个字段的值,属性的类型是 str 或者 int。这里说得比较绕,简单归纳来说就是用户定义类的方式和使用该类实例的方式不相符

我们希望使用者定义类的方式尽可能简单,同时也能用简单的方式修改字段值(实例的属性值),但由于类属性和实例属性同名时,对实例属性赋值会覆盖类属性,所以我们必须进行一些修改去避免这个问题。怎么实现呢?这时候我们就要用到元类了,虽然作为框架的编写者,我们要做的工作比较多,但这样使用者用起来就很方便了,他们依然可以很简单地定义类,但运行时类定义会被元类动态修改,我们可以把类属性该为其他名字,这样类定义中的类属性信息就可以保留下来了,而且不会被实例属性的赋值所覆盖。

另外,由于使用者不一定明白元类这么复杂的概念,所以我们把元类封装在 Model 类的定义中,指定 Model 类使用 ModelMetaclass 为模版。把前面所说的更换类属性名的操作封装在 ModelMetaclass 中,使用者为表格编写类的时候只需要继承 Model 类,那么运行时就会自动以 ModelMetaclass 为模版,得到 ModelMetaclass 的所有功能。但是要注意,Model 类本身不需要更换类属性名,所以在 ModelMetaclass 中我们要排除掉 Model 类。

接下来,直接上代码。元类 ModelMetaclass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ModelMetaclass(type):
# 四个参数,依次为:准备创建的类对象,类的名字,继承的父类集合,属性&方法集合
def __new__(cls, name, bases, attrs):
# Model类不需额外操作,先排除掉
if name=='Model':
return type.__new__(cls, name, bases, attrs)
# 其他类(对应具体的表格)则把类属性使用dict存好,绑定到__mappings__属性上
# 然后删除掉这些类属性
# 这里还动态地要创建的类添加了一个表名属性__table__,直接令表名等于类名
# 当然也可以作一些其他修改
print('Found model: %s' % name)
mappings = dict()
for k, v in attrs.items(): # 取出每个类属性
# 因为除了用户定义的类属性之外,还有一些继承自父类的属性等等
# 所以这里要先判断一下,属于字段类型的属性才需要考虑
if isinstance(v, Field):
print('Found mapping: %s ==> %s' % (k, v))
mappings[k] = v # 使用一个dict保存 类属性名-字段类型实例 的映射
for k in mappings.keys():
attrs.pop(k)
attrs['__mappings__'] = mappings # 把映射绑定到__mappings__属性上
attrs['__table__'] = name # 把表名绑定到__table__属性上
return type.__new__(cls, name, bases, attrs)

父类 Model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Model(dict, metaclass=ModelMetaclass):
def __init__(self, **kw):
super(Model, self).__init__(**kw) # 创建一个dict
def __getattr__(self, key): # 可以采用点符访问实例属性(字段值)
try:
return self[key]
except KeyError:
raise AttributeError(r"'Model' object has no attribute '%s'" % key)
def __setattr__(self, key, value): # 可以采用点符修改实例属性(字段值)
self[key] = value
def insert(self): # 将实例插入到数据库的对应表格中
fields = []
values = []
# 取得这一行数据的字段名及对应字段值
for k, v in self.__mappings__.items():
fields.append(v.name)
values.append(str(getattr(self, k, None)))
# 格式化SQL语句(MySQL语法)
sql = 'INSERT INTO %s (%s) values (%s)' % (self.__table__, ','.join(fields), ','.join(values))
print('SQL: %s' % sql) # 输出SQL语句,这里我们没有写真的插入数据库的操作,只是举例子

当用户为 User 表定义 User 类时,Python解释器首先在当前类 User 的定义中查找是否有metaclass关键字,如果没有找到,就继续在父类 Model 中查找metaclass关键字,因为父类 Model 定义了以元类 ModelMetaclass 为模版来创建,所以 User 类也会以元类 ModelMetaclass 为模版来创建。借助元类,我们可以在运行时动态地修改子类的定义,但使用者定义子类时却不需要显式地声明

使用者定义 User 类之后,会输出:

1
2
3
4
5
Found model: User
Found mapping: password ==> <StringField:password>
Found mapping: name ==> <StringField:username>
Found mapping: email ==> <StringField:email>
Found mapping: id ==> <IntegerField:id>

运行时,类定义被元类动态地修改了,使用者定义的四个类属性被集成到 __mappings__ 属性中,因此不会被实例属性覆盖,也就不会丢失字段名信息了。

创建实例,然后把这个实例(一行数据)插入到数据库:

1
2
3
4
5
>>> # 创建一个实例:
... u = User(id=12345, name='Michael', email='test@orm.org', password='my-pwd')
>>> # 插入到表格中:
... u.insert()
SQL: INSERT INTO User (email,username,password,id) values (test@orm.org,Michael,my-pwd,12345)

我们也可以看看 __mappings__ 属性怎么样:

1
2
3
4
>>> User.__mappings__
{'email': <StringField:email>, 'name': <StringField:username>, 'password': <StringField:password>, 'id': <IntegerField:id>}
>>> u.__mappings__
{'email': <StringField:email>, 'name': <StringField:username>, 'password': <StringField:password>, 'id': <IntegerField:id>}

正如我们定义那样,它是一个 dict,里面保存着各个字段的名字和它们的数据类型。

虽然我们没有真的实现插入数据库,但可以看到打印出的SQL语句是正确的,要实现完整的功能,只要再使用数据库模块的接口就可以了。通过这样短短不到100行的代码,我们就借助元类实现了一个精简的 ORM 框架。


小结

元类是Python中非常具有魔术性的对象,它可以改变类创建时的行为。这种强大的功能使用起来务必小心。

坚持技术分享,您的支持将鼓励我继续创作!