Python3入门到精通——元类编程

作者: Daniel Meng

GitHub: LibertyDream

博客:明月轩

本系列教程采用知识共享署名-非商业性使用-相同方式共享 2.5 中国大陆许可协议

对于未接触过软件工程和编程范式的人来说,很容易写出这样的代码

In [2]:
from datetime import date, datetime
class User:
    def __init__(self, name, birthday):
        self.birthday = birthday
        self.name = name
        self.age = 2020 - birthday.year
In [3]:
some_user = User('Daniel',date(1995,1,1))
some_user.age
Out[3]:
25

年龄是可以通过当前时间和生日动态计算出来的,重点在于属性的访问上,当代码作者意识到这种年龄计算的荒谬时很可能他已经写了很多some_user.age在项目里了。如果这时新建方法get_age()去替代原模式,对既有项目的破坏性会很大。

Python 对此提供了一个解决方法——动态属性。通过标记 @property 将函数标识为属性,通过标记@property_name.settr 动态修改属性。类比于依赖注入中的 get, set 方法。

In [4]:
from datetime import date, datetime
class User:
    def __init__(self, name, birthday):
        self.birthday = birthday
        self.name = name
        self._age = 0
        
    @property
    def age(self):
        return datetime.now().year - self.birthday.year

    @age.setter
    def age(self, val):
        self._age = val
In [5]:
some_user = User('Daniel', date(1995,1,1))
some_user.age
Out[5]:
25
In [6]:
some_user.age = 18
print(some_user.age, some_user._age)
25 18

此时其他位置的.age不用改动,通过动态属性对于age的访问与修改,都可以自由设计逻辑了

这里对属性访问再多说一点。Python 中对象属性访问的入口是__getattribute__方法,只要尝试访问某个属性就会被调用,无论是否真实存在。所以在实际编程中,除非特别确定否则不要覆盖__getattribute__方法,容易破坏类的功能

In [3]:
class Student:
    def __init__(self, name, std_id):
        self.name = name
        self.std_id = std_id
    
    def __getattribute__(self, item):
        return 'Hacked'
In [5]:
std = Student('Tom', 2002001)
print(std.name, std.std_id, std.class_id)  # 访问入口被错误覆盖,无法正常访问
Hacked Hacked Hacked

但属性访问不到确实是必须应对的问题,Python 对此提供了稍弱一点的方法__getattr__,只有当属性访问失败时才会被调用,可以通过它来修正访问逻辑或调用格式

In [6]:
class Employee:
    def __init__(self, name, info={}):
        self.name = name
        self.info = info

    def __getattr__(self, item):
        return self.info[item]
In [7]:
emp = Employee('Daniel',{'locate':'Queen Street','age':25})
print(emp.name, emp.age, emp.locate)  # 错误访问被修正
Daniel 25 Queen Street

属性描述符

动态属性可以方便我们设计访问和设置规则,但缺点是和具体属性是绑定在一起的,如果每个属性都添加一点类型检查逻辑,使用动态属性时会面临要编写大量重复代码,复用率很低。这时就要用到属性描述符了,将属性描述(类型检查,边界检查.....)独立封装

具体来说,Python 提供了两种属性描述符——数据描述符和非数据描述符。数据描述符要求类必须实现__get____set____delete__中任意一个,非数据描述符要求实现__get__,多用于一般函数方法上

In [28]:
import numbers

class IntField:
    '''整数标识'''

    __counter = 0

    def __init__(self):
        cls = self.__class__
        prefix = cls.__name__
        index = cls.__counter
        self.storage_name = '_{}_{}'.format(prefix, index)  # 保证每个描述符名称唯一
        cls.__counter += 1

    def __set__(self, instance, value):
        if not isinstance(value, numbers.Integral):
            print('Need integer')
        elif value < 0:
            print('Should be positive')
        else:
            setattr(instance, self.storage_name, value)

    def __get__(self, instance, owner):  # owner 为托管类
        return getattr(instance, self.storage_name)

这里我们定义了一个整数的数据描述符。为了能在规定某个对象属性为整数时,既能做到类型绑定,也能对该绑定关系作出唯一标识。在初始化函数中我们构造了标识符字符串,storage_name 就是对象下的属性名称

__set__的三个参数,self 是实例所属的类,instance 是该实例,value 是要设置的值,我们进行了类型、边界检查。setattr 是全局方法,等同于 obj.attr_name = val 运算

__get__方法里,owner 是托管类,也就是使用了描述符此时想访问值的类。getattr 同是全局方法,等同于 obj.attr

In [26]:
class User:
    age = IntField()  # 将整型描述绑定到 age 属性上

    def __init__(self, name, age):
        self.age = age  # 不用担心类型检查的问题
        self.name = name
In [30]:
user = User('Daniel', 19)
user.age = 'abc'  # 描述符限制,不会成功
Need integer
In [31]:
user.__dict__  # 注意年龄的存储名称
Out[31]:
{'_IntField_0': 19, 'name': 'Daniel'}

有数据描述符时,属性访问规则发生了变化

如果user是某个类的实例,那么user.age(以及等价的 getattr(user,'age')) 首先调用__getattribute__。如果类定义了__getattr__方法,那么在__getattribute__抛出AttributeError的时候就会调用到__getattr__,而对于描述符__get__的调用,则是发生在__getattribute__内部的。

比如对于user=User(),那么user.age访问顺序如下:

  1. 如果“age”是出现在User或其基类的__dict__中,且age是数据描述符,那么调用其__get__方法,否则
  2. 如果“age”出现在obj的__dict__中,那么直接返回obj.dict__['age'],否则
  3. 如果“age”出现在User或其基类的__dict__
    1. 如果age是非数据描述符,那么调用其__get__方法,否则
    2. 返回__dict__['age']
  4. 如果User有__getattr__方法,调用__getattr__方法,否则
  5. 抛出AttributeError

对象的创建

通常我们已经习惯了使用__init__实例化对象,但实际执行过程中对象的创建分两步走:

  1. 使用__new__创建对象,需要传入对象的类cls(Python 解析器默认执行),期间可以自行设计修改创立逻辑,最后将对象返回
  2. 使用__init__初始化对象,如果__new__没有返回对象则不执行
In [32]:
class Build:
    def __new__(cls, *arg, **kw):
        print('Hacked')
        return 
    def __init__(self):
        print('Success')
In [33]:
build = Build()
Hacked
In [35]:
class OtherBuild:
    def __new__(cls, *arg, **kw):
        print('Build object')
        return super().__new__(cls)
    def __init__(self):
        print('Initialize object')
In [36]:
otherbuild = OtherBuild()
Build object
Initialize object

元类

之前说到过 Python 里一切皆对象,那类本身作为对象是被谁创建的呢?答案是元类,Python 中用metaclass指定元类,而 type 正是元类之一。所以创建类的一般过程如下:

  1. 如果当前类定义中指定了metaclass,那就使用该元类创建当前类,否则
  2. 前往当前类的基类中寻找metaclass,找到了就用相应元类创建当前类,否则
  3. 使用 type 创建当前类的类对象,全局唯一

type 方法接收参数个数不同,行为不同。当使用 type(name, bases, dict) 形式时,会动态创建一个类出来,bases是元组,存储基类,dict 字典存放属性和方法声明

In [42]:
def say(self):  # 不要忘了 self 参数
    print('Hello, I am created by type')

user = type('User', (), {'name':'abc','say':say})  # 和 class 创建 User 效果相同
print(user)
user().say()
<class '__main__.User'>
Hello, I am created by type

有了元类就可以制定类的生成规则了,比如注入一些方法,规定必须实现的抽象方法等等,工程实践可以参照 Django 框架的 orm 源码。要注意元类在开发过程中不是必须的。

In [47]:
class Meta(type):
    def __new__(cls, *args, **kwds):
        print('Build class')
        print(args,kwds)
        return super().__new__(cls, *args, **kwds)

class Son(metaclass=Meta):
    pass
Build class
('Son', (), {'__module__': '__main__', '__qualname__': 'Son'}) {}