面向对象高级编程
前面一章介绍了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 | 100) s.set_score( |
通常情况下,上面的 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
和 FlyableMixIn
n 和 植食动物 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 | 'Michael') s = Student( |
这是因为直接显示变量调用的不是 __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 | 5] Fib()[ |
要能像 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 | 10:2] f[: |
也没有对负数作处理,所以,要正确实现一个完整的 __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 | 'http://api.server') chain = Chain( |
由于 __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 | '/users') chain = Chain( |
当然,除了实现一个 users
方法之外,直接在 getattr
方法里面使用正则也是可以的。
__call__
一个对象实例可以有自己的属性和方法,当我们调用实例方法时,我们用 实例名.方法名()
的方式来调用。能不能直接把实例本身当作一个方法调用呢?在Python中,答案是肯定的。
对任何类来说,只需要实现 __call__()
方法,就可以直接对该类的实例进行调用。比如:
1 | class Student(object): |
调用方式如下:
1 | 'Michael') s = Student( |
和普通的函数和方法一样,我们还可以为 __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 | list() L2 = |
但是,直接在 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中非常具有魔术性的对象,它可以改变类创建时的行为。这种强大的功能使用起来务必小心。