面向对象高级编程
前面一章介绍了OOP最基础的数据封装、继承和多态3个概念,还有一些类和实例的操作。而在Python中,OOP还有很多更高级的特性,这一章会讨论多重继承、定制类、元类等概念。
使用 __slots__
动态绑定属性
正常情况下,当我们定义了一个类,创建了一个类的实例后,我们可以给这个实例绑定任何属性和方法,这就是动态语言的灵活性。先定义类:
1 | class Student(object): |
然后,创建实例并给这个实例绑定一个属性:
1 | s = Student() |
动态绑定方法
还可以尝试给实例绑定一个方法:
1 | def set_age(self, age): # 定义一个函数 |
注意到这里使用types模块的 MethodType() 函数来给实例绑定方法,为什么要用 MethodType() 而不是直接用 s.set_age = set_age 直接绑定呢?这是因为我们采用后者绑定时,只是绑定了一个外部函数,它与实例本身没有任何关联,没法使用self变量,而使用 MethodType() 就会真正地为实例绑定一个方法,也因此绑定的函数的第一个参数要设置为self变量。做个对比:
1 | s.set_age = set_age # 直接绑定 |
但是,给一个实例绑定的方法,对另一个实例是不起作用的:
1 | s2 = Student() # 创建新的实例 |
为了给所有实例都绑定方法,可以直接给类绑定方法:
1 | def set_score(self, score): |
给类绑定方法不需要使用 MethodType() 函数,并且所有实例均可调用绑定在类上的方法:
1 | s.set_score(100) |
通常情况下,上面的 set_score 定义在类中,但动态绑定允许我们在程序运行的过程中动态给类加上功能,这在静态语言中很难实现。
限制可绑定的属性/方法
上面两个小节介绍了怎样绑定属性和方法,但是如果我们想要限制可以绑定到实例的属性/方法怎么办呢?比方说,只允许对Student类的实例绑定 name 和 age 属性。
为了达到限制的目的,Python允许在定义类的时候,定义一个特殊的 __slots__ 变量,来限制该类实例能添加的属性:
1 | class Student(object): |
然后,我们试试:
1 | s = Student() # 创建新的实例 |
由于属性 score 没有被放到 __slots__ 变量中,所以实例不能绑定 score 属性,试图绑定 score 将得到 AttributeError 错误。
使用 __slots__ 要注意,__slots__ 变量的属性限制仅对当前类的实例起作用,对继承的子类是不起作用的:
1 | class GraduateStudent(Student): |
但是!如果在子类中也定义 __slots__ ,则子类实例允许定义的属性就既包括自身的 __slots__ 也包括父类的 __slots__ :
1 | class GraduateStudent(Student): |
使用@property
为何需要@property
在绑定属性时,如果我们直接把属性暴露出去供使用者修改,虽然写起来很简单,但是没办法检查设置的属性值是否合理,可以把成绩随便改:
1 | s = Student() |
这显然不合逻辑。为了限制 score 的范围,可以通过一个 set_score() 方法来设置成绩,再通过一个 get_score() 来获取成绩,这样,在 set_score() 方法里,就可以检查参数:
1 | class Student(object): |
现在,对任意的Student实例进行操作,就不能随心所欲地设置score了:
1 | s = Student() |
但是,通过类的方法修改,调用者使用时比较麻烦,没有直接使用属性进行修改简单,而且对调用者是否自觉也有要求,如果调用者依然直接使用属性修改,就没法检查属性值了。
有没有既能检查属性值,又可以直接使用属性修改的办法呢?答案是有的!
如何实现@property
在第四章-函数式编程中,我们学习到了装饰器(decorator),它可以给函数动态添加功能。事实上,不仅是对函数,装饰器对类的方法一样起作用。Python内置的 @property 装饰器就可以帮助我们实现前面的需求,把一个方法变成属性调用:
1 | class Student(object): |
@property 的实现比较复杂。准确地说,把一个getter方法变成属性,只需要加上 @property 装饰器就可以了,而把一个setter方法变成属性赋值,这要加上一个 @score.setter 装饰器,也即 @属性名.setter。注意!属性名和方法名一定要区分开,否则会出错!这里我们把 score 属性改为 _score 属性,所以对内部来说 _score 是属性,score 是方法,对外部来说 score 是属性,_score 被封装起来了(因为我们使用了装饰器进行转换)。看看实际效果:
1 | s = Student() |
还可以定义只读属性,只定义getter方法,不定义setter方法就是一个只读属性,只读属性只能获取属性值,无法设置属性值:
1 | class Student(object): |
上面的birth是可读写属性,而age就是一个只读属性:。
1 | s = Student() |
注意必须先对属性 birth 进行赋值,然后才可以访问 birth 和 age,否则就会出现:
1 | s.birth |
练习
请利用 @property 给一个 Screen 对象加上 width 和 height 属性,以及一个只读属性 resolution:
1 | class Screen(object): |
测试:
1 | s = Screen() |
小结
@property 广泛应用在类的定义中,可以让调用者写出简短的代码,同时又保证了对属性值进行必要的检查,这样,程序运行时就减少了出错的可能性。
多重继承
为何需要多重继承
在第六章-面向对象编程中,我们学习了面向对象编程的一个重要性质——继承。通过继承,子类可以获得父类的所有功能并进行进一步扩展。
假设我们设计了一个 Animal 类,并要为以下4种动物设计四个新的类:
- Dog - 狗狗;
- Bat - 蝙蝠;
- Parrot - 鹦鹉;
- Ostrich - 鸵鸟。
如果把这些动物按照哺乳动物和鸟类分类,我们可以设计出这样的类的层次:

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

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

如果再增加更多的分类方式(例如:宠物和非宠物),那么类的数量会呈指数增长,这样设计就显得很不实用了。
使用多重继承
分析一下前面的设计方法,其实之所以会造成类的数量呈指数增长,是因为每个类只能继承一个类,这就造成了很多不必要的重复实现。解决方法是采用多重继承。比方说设计为:

因为能跑和能飞这两个类不受限于动物类,它们是独立的。我们单独实现这两个类,即使要再实现其他非动物的类,比如汽车和飞机,也能很轻松地继承它们的功能,而不需要再重复构造功能类似的新的类。而动物分类方面,假设我们加入宠物非宠物的分类,也不需再构造哺乳的能飞的宠物、鸟类的能飞的宠物等等类别,通过多重继承免去了很多麻烦。先进行动物分类的定义:
1 | class Animal(object): |
接下来定义好 Runnable 和 Flyable 的类:
1 | class Runnable(object): |
对于需要 Runnable 功能的动物,只需要多继承一个 Runnable,例如 Dog
1 | class Dog(Mammal, Runnable): |
对于需要 Flyable 功能的动物,只需要多继承一个 Flyable,例如 Bat:
1 | class Bat(Mammal, Flyable): |
通过多重继承,一个子类可以同时获得多个父类的所有功能。
MixIn
在设计类的继承关系时,通常主线都是单一继承下来的,例如,Ostrich 继承自 Bird。但是,如果需要混入额外的功能,通过多重继承就可以实现,比如,让 Ostrich 除了继承自 Bird 外,再同时继承 Runnable。种(利用多重继承混入额外的功能)这种设计方式通常称之为MixIn。
为了更好地看出继承关系,我们通常把用于添加额外功能的类命名带上一个后缀MixIn,例如把 Runnable 和 Flyable 改为 RunnableMixIn 和 FlyableMixInn 和 植食动物 HerbivoresMixIn,让某个动物同时拥有好几个MixIn:
1 | class Dog(Mammal, RunnableMixIn, CarnivorousMixIn): |
MixIn的目的就是给一个类增加多个功能,这样,在设计类的时候,我们可以优先考虑通过多重继承来组合多个MixIn的功能,而不是设计多层次的复杂的继承关系。
Python自带的很多库也使用了MixIn。举个例子,Python自带了 TCPServer 和 UDPServer 这两类网络服务,而要同时服务多个用户就必须使用多进程或多线程模型,这两种模型由 ForkingMixIn 和 ThreadingMixIn 提供。通过组合,我们就可以创造出合适的服务来。
比如,编写一个多进程模式的TCP服务,定义如下:
1 | class MyTCPServer(TCPServer, ForkingMixIn): |
编写一个多线程模式的UDP服务,定义如下:
1 | class MyUDPServer(UDPServer, ThreadingMixIn): |
如果你打算搞一个更先进的协程模型,可以编写一个 CoroutineMixIn:
1 | class MyTCPServer(TCPServer, CoroutineMixIn): |
这样一来,我们不需要复杂而庞大的继承链,只要选择组合不同的类的功能,就可以快速构造出所需的子类。
小结
由于Python允许使用多重继承,因此,MixIn就是一种常见的设计。
只允许单一继承的语言(如Java)不能使用MixIn的设计。
定制类
在前面的章节中,我们知道了可以用 __slots__ 变量限制可绑定的属性,我们也知道了在构造类的时候,只要定义了 __len__() 方法,用户就能使用Python内置的 len() 函数获取该类实例的长度。我们知道形如 __xxx__ 的变量/方法都是有特殊用途的,那么Python中还有哪些特殊的变量/方法可以帮助我们更好地定制类呢?
__str__
我们先定义一个 Student 类,然后打印一个实例:
1 | class Student(object): |
但是这样打印实例,我们只能知道它属于什么类以及在内存的位置,它的其他信息全都无法了解,所以对使用者来说并不友好。怎么才能定制打印的信息,使得打印实例时可以看到更多有用的信息呢?只需要定义好 __str__() 方法就可以了:
1 | class Student(object): |
这样打印实例就不但能知道实例所属的类,也能获得这个实例的属性信息了。
但是细心的朋友会发现直接敲变量不用 print 函数,打印出的实例依然是原来的样子:
1 | s = Student('Michael') |
这是因为直接显示变量调用的不是 __str__() 方法,而是 __repr__() 方法,两者的区别是 __str__() 方法返回用户看到的字符串,而 __repr__() 返回程序开发者看到的字符串,也就是说,__repr__() 是为调试服务的。
解决办法是再定义一个 __repr__() 方法。但是通常 __str__() 和 __repr__()代码都是一样的(当然,要写不同的也行),所以,有个偷懒的写法:
1 | class Student(object): |
__iter__
如果我们希望用 for ... in 循环来遍历一个类的实例,像遍历 list 或 tuple 那样,就必须实现一个 __iter__() 方法,该方法返回一个迭代对象,然后,Python的 for 循环就会不断调用该迭代对象的 __next__() 方法拿到循环的下一个值,直到遇到 StopIteration 错误时退出循环。
我们以斐波那契数列为例,写一个 Fib 类,可以作用于 for 循环:
1 | class Fib(object): |
现在,试试把Fib类的实例作用于 for 循环,就能遍历斐波拉契数列了:
1 | for n in Fib(): |
__getitem__
Fib 类的实例虽然能作用于 for 循环,看起来和 list 有点像了,但是没有办法使用下标访问:
1 | Fib()[5] |
要能像 list 那样按照下标访问元素,需要实现 __getitem__() 方法:
1 | class Fib(object): |
现在,就可以按下标访问数列的任意一项了:
1 | f = Fib() |
但是 list 有个神奇的切片方法:
1 | list(range(100))[5:10] |
对于 Fib 却报错。原因是 __getitem__() 传入的参数可能是一个 int,也可能是一个 slice(切片对象),所以要做判断:
1 | class Fib(object): |
现在再试试对 Fib 类的实例使用切片:
1 | f = Fib() |
但是没有对 step(步长)参数作处理:
1 | f[:10:2] |
也没有对负数作处理,所以,要正确实现一个完整的 __getitem__() 还是有很多工作要做的。
此外,如果把对象看成 dict,那么 __getitem__() 的参数也可能是一个可以作key的object,例如 str。
与 __getitem__() 方法对应的是 __setitem__() 方法,把对象视作 list 或 dict 来对一个/多个位置进行赋值。除此之外,还有 __delitem__() 方法,用于删除某个位置的元素。
总之,通过实现上面的方法,可以让我们自己定义的类表现得和Python自带的 list、tuple、dict 没什么区别,这完全归功于动态语言的“鸭子类型”特点,不需要强制继承某个接口就能实现该接口的部分功能。
__getattr__
正常情况下,当我们调用类的方法或属性时,如果不存在,就会报错。比如定义Student类:
1 | class Student(object): |
调用name属性,没问题,但是,调用不存在的score属性,就有问题了:
1 | s = Student() |
错误信息很清楚地告诉我们,没有找到score这个attribute。
要避免这个错误,除了可以加上一个score属性外,Python还有另一个机制,那就是写一个 __getattr__() 方法,动态返回一个属性。修改如下:
1 | class Student(object): |
当调用不存在的属性时,Python解释器会试图调用 __getattr__(self, '属性名')来尝试获得属性,依然用 score 属性做例子,进行上述定义后,再次执行就变成了:
1 | s = Student() |
动态返回函数也是完全可以的:
1 | class Student(object): |
只是调用方式要变为:
1 | s.age() |
注意,只有在没有找到属性的情况下,才调用 __getattr__,已有的属性,比如 name,不会在 __getattr__ 中查找。
此外,注意到此时调用其他任意属性,如 s.abc,返回的是 None,这是因为在 __getattr__ 中我们没有为这些属性定义返回值,那么默认返回就是 None。要让类只响应特定的几个属性,我们可以默认抛出 AttributeError 错误:
1 | class Student(object): |
这样就相当于把一个类的属性和方法调用都进行动态化处理了,不需要其他特殊手段。
这种完全动态调用的特性有什么实际作用呢?作用就是,可以针对完全动态的情况作调用。举个例子,现在很多网站都搞 REST API,比如新浪微博、豆瓣啥的,调用API的URL类似:
1 | http://api.server/user/friends |
如果要写SDK,为每个URL对应的API都写一个方法,那得累死,而且,API一旦改动,SDK也要改。
借助完全动态的 __getattr__ 方法,我们可以非常方便地实现链式调用:
1 | class Chain(object): |
试试:
1 | chain = Chain('http://api.server') |
由于 __getattr__ 返回的也是一个 Chain 类的实例,所以后面继续接着使用点符访问属性也是可以的,这就是链式调用的本质。这样,无论想调用什么API,SDK都可以根据不同的URL进行完全动态的调用,不需要随API的增加而改变!相当方便!!
还有一些REST API会把参数放在URL中,比如GitHub的API:
1 | GET /users/:user/repos |
调用时,需要把 :user 替换为实际用户名。这时我们希望可以用这样的链式调用来获取API:
1 | chain().users('michael').repos |
尝试一下:
1 | class Chain(object): |
运行结果:
1 | chain = Chain('/users') |
当然,除了实现一个 users 方法之外,直接在 getattr 方法里面使用正则也是可以的。
__call__
一个对象实例可以有自己的属性和方法,当我们调用实例方法时,我们用 实例名.方法名() 的方式来调用。能不能直接把实例本身当作一个方法调用呢?在Python中,答案是肯定的。
对任何类来说,只需要实现 __call__() 方法,就可以直接对该类的实例进行调用。比如:
1 | class Student(object): |
调用方式如下:
1 | s = Student('Michael') |
和普通的函数和方法一样,我们还可以为 __call__() 方法定义其他参数。但有一点很特别,我们注意到类的实例都是运行期间动态创建出来的,而一般来说可调用对象(函数/方法)都是预先定义的,所以说当我们把实例本身变成可调用的方法时,实际上我们是动态创建了可调用对象。
能被调用的对象就是一个 Callable 对象,要判断一个对象是否可调用可以使用Python内置的 callable 函数:
1 | callable(Student()) |
小结
Python中的类允许定义许多定制方法,可以让我们非常方便地生成特定的类。
本节介绍的是最常用的几个定制方法,还有很多可定制的方法,请参考Python的官方文档。
使用枚举类
为何需要枚举类
当我们需要定义常量/枚举值时,一个比较常见的办法是用大写变量通过整数来定义,例如月份:
1 | JAN = 1 |
这样做的好处是简单,缺点是把数据类型变为了 int 型,并且在Python中仍然是变量,因此可能会在使用者无法意识到的情况下被错误的操作改变值。
如何使用枚举类
更好的方法是使用Python提供的枚举类 Enum,把每一个枚举对象作为枚举类的一个属性:
1 | from enum import Enum |
这样我们就获得了一个类型为 Month 的枚举类,可以直接使用 Month.Jan 来引用一个常量,或者枚举它的所有成员:
1 | for name, member in Month.__members__.items(): |
特别地,枚举类中的每个成员会被分配一个 int 型的 value 属性,默认按初始化顺序从1开始计数。
自定义枚举类
如果需要更精确地控制枚举类型,可以继承 Enum 类然后进行自定义:
1 | from enum import Enum, unique |
@unique 装饰器可以帮助我们检查枚举值是否存在重复,注意属性名字重复也会报错,但是与 @unique 装饰器无关。
自定义的枚举类使用方法和使用 Enum 构造的类似:
1 | day1 = Weekday.Mon # 按属性访问 |
1 | print(Weekday['Tue']) # 使用属性名作下标访问 |
1 | print(Weekday(1)) # 把类作为一个方法调用,传入枚举值 |
1 | for name, member in Weekday.__members__.items(): # 遍历枚举类 |
小结
使用枚举类可以把一组相关常量定义在一个类中,转化为该类的不同属性,该类不可变(属性都是只读的)且属性可以直接进行比较。
使用元类
type函数
动态语言和静态语言最大的不同,就是在动态语言中,函数和类的定义,不是编译时定义的,而是运行时动态创建的。
比方说我们要定义一个 Hello 类,首先编写一个 hello.py 模块,里面的代码如下:
1 | class Hello(object): |
当Python解释器导入 hello 模块时,就会依次执行该模块的所有语句(与我们在交互环境下逐个语句输入来定义类一样),从而动态创建出一个类对象(注意这里说的是类对象而不是实例对象),测试如下:
1 | from hello import Hello # 这个语句创建了一个名为Hello的类对象 |
type() 函数可以用来查看一个变量的类型,Hello 是一个类,它的类型就是 type,而 h 是一个实例,它的类型就是它所属的类。
前面说到,在Python中,类的定义是运行时动态创建的。而动态创建类使用的其实是 type()函数。type() 函数既可以返回一个变量的类型,又可以创建出新的类型。依然举 Hello 类为例子,但我们这次使用 type() 函数来创建 Hello 类而不使用显式的 class Hello:
1 | def fn(self, name='world'): # 先定义函数 |
使用 type() 函数创建一个类对象,需要依次传入以下3个参数:
- 类名
- 继承的父类集合:Python支持多重继承,所以这里用一个
tuple来囊括继承的所有父类。注意只有一个父类时,要采用tuple的单元素写法,不要漏掉逗号。 - 类的方法名与函数的绑定:在上面的例子中,我们把函数
fn绑定到方法名hello上。也即类Hello的方法hello就是函数fn,注意这和这章开头所说的动态绑定方法是不同的。
通过 type() 函数创建的类和直接写类是完全一样的。事实上,Python解释器遇到类定义时,在扫描类定义的语法之后,就是调用 type() 函数来创建类的。
正常情况下,我们都用 class 类名(父类1, 父类2, ...) 的方式来定义类,但是,type() 函数也允许我们动态创建类。
动态语言能够支持运行期间动态创建类,这和静态语言有非常大的不同。关于这两者的区别,感兴趣的话可以再查找其他资料。
什么是元类
除了使用 type() 函数动态创建类以外,要控制类的创建行为,还可以使用元类(metaclass)。
怎么理解什么是元类呢?简单地解释一下:
- 当我们定义了类以后,以类为模版就可以创建出实例了。
- 但如果我们要创建类呢?那就必须先定义元类,有了元类之后,以元类为模版就可以创建出类了。
- 连起来就是:以元类为模版创建类,以类为模版创建该类的实例。
也就是说,可以把类看成是元类创建出来的“实例”。

图片来源:What is a metaclass in Python?
在上一个小节中,我们了解到可以使用 type() 函数创建类,但 type 的本质是什么呢?
1 | help(type) |
其实呀, type 本身就是一个类,调用 type() 创建类得到的其实就是 type 类的实例。所以所有类对象的类型都是 type。不难分析出,type 是一个元类,并且类都是默认以元类 type 为模版创建的。
怎样使用元类
如果我们想要创建一个元类,并且想以这个元类为模版创建类,那么定义元类的时候,就应当让这个元类继承自 type 类。
按照习惯,元类的类名应总是以Metaclass结尾,以便清楚地表示这是一个元类。下面举一个例子,定义元类 ListMetaclass:
1 | # metaclass是类的模板,所以必须从`type`类型派生: |
元类的 __new__() 方法用于创建一个类对象,它接收四个参数,依次是:
- 准备创建的类对象;
- 准备创建的类的名字;
- 准备创建的类继承的父类集合;
- 准备创建的类的方法集合。
我们在 ListMetaclass 的 __new__() 方法中加入了一句 attrs['add'] = lambda self, value: self.append(value),然后调用元类 type 的 __new__() 方法创建类对象。这多出来的一句,实际上我们是给要创建的类提供了一个 add 方法,这个 add 方法接收实例本身和一个变量,并把这个变量拼接到实例的尾部。其实就是一个 append 方法。
定义好元类 ListMetaclass 之后,我们以它为模版创建类,注意传入关键字参数 metaclass:
1 | class MyList(list, metaclass=ListMetaclass): |
传入关键字参数 metaclass 后,Python解释器会在创建 MyList 类时,通过元类 ListMetaclass 的 __new__() 方法来创建。因此虽然我们在类定义时没有为 MyList 类定义任何方法,但因为它是以元类 ListMetaclass 为模版创建的,所以拥有了 add 方法。另外,因为它继承了 list 类,所以我们相当于创建了一个拥有 add 方法的新的 list 类,测试一下:
1 | L = MyList() |
普通的 list 是没有 add() 方法的:
1 | L2 = list() |
但是,直接在 MyList 类的定义中写上 add() 方法不是更简单吗?是的,正常情况下我们应该直接在类定义中编写方法,而不是通过元类。
但是,也有需要通过元类动态修改类定义的情况,ORM就是一个典型的例子。
编写ORM框架
ORM 全称 Object Relational Mapping(对象-关系映射),简单来说就是把关系型数据库中表格的每一行都映射为一个对象,而每一个表就是一个类。这样写代码更简单,不用直接操作SQL语句。
要编写一个 ORM 框架供不同的使用者使用,框架中的所有类都应该能动态定义,因为每位使用者的需求不同,需要根据具体的表结构来定义出不同的类。
举个例子,假如使用者想使用这个 ORM 框架定义一个 User 类来操作数据库中的表格 User,我们期望使用者可以写出这样简洁的形式:
1 | class User(Model): |
1 | # 创建一个实例: |
也即,用户在使用这个 ORM 框架时,每个表格对应一个类,类定义只需要指定表格每列的字段类型即可,每一行数据都是该类的一个实例。而父类 Model 和数据类型 StringField、IntegerField 等都由 ORM 框架负责提供。save() 之类的方法则全部由元类自动完成。虽然这样元类的编写会比较复杂,但 ORM 的使用者用起来却可以异常简单。
想好了希望实现怎样的效果后,我们可以开始编写调用接口。
首先定义 Field 类,它是最底层的类,负责保存字段名(列名)和对应的字段类型:
1 | class Field(object): |
在 Field 的基础上,我们可以进一步定义各种类型的 Field,比如 StringField,IntegerField 等等:
1 | class StringField(Field): |
注意这里使用了 super 函数来获取父类的方法,并进行绑定,先看一看官方的解释:
1 | super(type[, object-or-type]) |
所以这里实际上我们实例化 StringField 和 IntegerField 时,是调用它们的父类,也即 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 | class ModelMetaclass(type): |
父类 Model:
1 | class Model(dict, metaclass=ModelMetaclass): |
当用户为 User 表定义 User 类时,Python解释器首先在当前类 User 的定义中查找是否有metaclass关键字,如果没有找到,就继续在父类 Model 中查找metaclass关键字,因为父类 Model 定义了以元类 ModelMetaclass 为模版来创建,所以 User 类也会以元类 ModelMetaclass 为模版来创建。借助元类,我们可以在运行时动态地修改子类的定义,但使用者定义子类时却不需要显式地声明。
使用者定义 User 类之后,会输出:
1 | Found model: User |
运行时,类定义被元类动态地修改了,使用者定义的四个类属性被集成到 __mappings__ 属性中,因此不会被实例属性覆盖,也就不会丢失字段名信息了。
创建实例,然后把这个实例(一行数据)插入到数据库:
1 | # 创建一个实例: |
我们也可以看看 __mappings__ 属性怎么样:
1 | User.__mappings__ |
正如我们定义那样,它是一个 dict,里面保存着各个字段的名字和它们的数据类型。
虽然我们没有真的实现插入数据库,但可以看到打印出的SQL语句是正确的,要实现完整的功能,只要再使用数据库模块的接口就可以了。通过这样短短的不到100行代码,我们就借助元类实现了一个精简的 ORM 框架。
小结
元类是Python中非常具有魔术性的对象,它可以改变类创建时的行为。这种强大的功能使用起来务必小心。

