红茶的个人站点

  • 首页
  • 专栏
  • 开发工具
  • 其它
  • 隐私政策
Awalon
Talk is cheap,show me the code.
  1. 首页
  2. 编程语言
  3. Python
  4. 正文

Python学习笔记38:类元编程

2021年6月4日 1276点热度 0人点赞 0条评论

《Fluent Python》中多次提到,在Python中,类和函数都是一类对象,这指的是其开放程度对于Python语言的定制者和普通开发者是相同的,拥有同样的权限,我们可以像Python语言的底层开发人员那样,对类和函数进行定制和改造,以实现某种“高级需求”。

虽然对于普通的开发者来说似乎用不到,但作为语言学习的一部分,了解这部分知识至少对帮助你更深入的理解这门语言是有益的。而书中也提到,Python之所以能流行,似乎这种对于初学者好用的同时为资深开发者提供高级工具的特性带来很多益处。

在接下来我会按照《Fluent Python》中的内容进行说明类元编程,我认为书中这部分的切入点是非常恰当的,同时我也找不出更好的知识组织脉络,所以完全按照原书。此外我同样找不到比原书更恰当的示例,所以示例部分也同样会大部分都按照原书进行编写。

这同时也是《Fluent Python》的最后一部分内容,很高兴能和大家分享所学所思。

类工厂函数

所谓的类工厂函数,和之前的属性描述符工厂函数没有本质的区别,最大的区别就是其生产的不是普通对象,也不是函数,而是类。

我们要时刻清楚,在Python中,类不过也是一种特殊类型的对象罢了,自然可以“动态”创建。

namedtuple

在Python学习笔记19:列表 III,提到过具名元组namedtuple,其实质上就是一个类工厂函数,通过指定类名和属性,会返回一个类,利用返回的类我们可以创建相应的实例:

from collections import namedtuple
Student = namedtuple('Student', 'age name sex')
s1 = Student(15,'Han Meimei','female')
s2 = Student(20, 'Li Lei', 'male')
print(s1)
print(s2.name)
# Student(age=15, name='Han Meimei', sex='female')
# Li Lei

现在应该会理解namedtuple为什么会指定一个参数Student了,既然要创建一个类,自然要指定相应的类名。

虽然这里Student = namedtuple('Student', 'age name sex')相当于给新的类重新指定了一个全局变量Student,当然这个全局变量可以和新的类不同名,但那样显然没有必要。

我们现在按照标准库的namedtuple的功能,创建一个表现完全相同的类工厂函数,来说明如何通过工厂函数创建一个新的类。

def named_tuple_factory(clsName, fields):
    try:
        fields = fields.replace(',', ' ').split(' ')
    except AttributeError:
        pass
    clsBody = {}
​
    def __init__(self, *args, **kwArgs):
        if args:
            for key, value in zip(fields, args):
                setattr(self, key, value)
        if kwArgs:
            for key, value in kwArgs.items():
                if key in fields:
                    setattr(self, key, value)
​
    def __str__(self):
        fieldsStr = ",".join("{}={!s}".format(key, value)
                             for key, value in self.__dict__.items())
        return "{}({})".format(clsName, fieldsStr)
    clsBody['__init__'] = __init__
    clsBody['__str__'] = __str__
    return type(clsName, (object,), clsBody)
​
​
Student = named_tuple_factory('Student', 'name,age,sex')
s1 = Student('Han Meimei', 16, 'female')
s2 = Student('Li Lei', 20, 'male')
print(s1)
print(s2.name)
Student = named_tuple_factory('Student', 'name age sex')
s1 = Student('Han Meimei', 16, 'female')
print(s1)
Student = named_tuple_factory('Student', ('name', 'age', 'sex'))
s1 = Student(name='Han Meimei', age=16, sex='female')
print(s1)
# Student(name=Han Meimei,age=16,sex=female)
# Li Lei
# Student(name=Han Meimei,age=16,sex=female)
# Student(name=Han Meimei,age=16,sex=female)

这段示例代码中最重要的部分位于工厂函数的返回值:return type(clsName, (object,), clsBody),这里是利用type返回了一个新的类型。

事实上type本身就是一个类,我们通常使用的type(obj)会返回对象的类型,而三个参数的type(clsName, bases, clsBody)将创建一个新的类,其第一个参数clsName是类名,第二个参数bases是类继承的基类,这是一个元组,所以在示例中我们定义为(object,),表示仅从object继承。第三个参数clsBody是类的定义体,包括类属性和类方法,其类型为字典。

所以我们只需要将精力集中在如何构建类的定义体上就可以了。

在工厂函数的开头,我们使用下面的方式简单处理将会作为新类的实例属性的字段:

    try:
        fields = fields.replace(',', ' ').split(' ')
    except AttributeError:
        pass

这里使用了一个小技巧,就像后面测试代码Student = named_tuple_factory('Student', ('name', 'age', 'sex'))展示的那样,我们可能传入的不仅仅是一个字符串,也可能是一个可迭代类型,我们希望这样的情况下也能正常创建类型。所以这里使用异常捕获,当fields不支持字符串的replace操作时,我们就视其为一个可迭代对象,直接用其作为实例属性的字段名称使用。这样做比进行类型检查isinstance更为简单和宽泛。

clsBody = {}是一个字典,用于存储我们新创建的类型定义体。

def __init__(self, *args, **kwArgs):和def __str__(self):函数是新创建的类型的两个方法,其定义方式和在类中定义毫无区别,只不过是在类定义之外而已。

当然这里第一个参数命名可以不使用self,不过那样不会有任何优点,相反的是,在编写这段代码时候我强迫自己想象是直接在Student类中编写代码,只不过通过闭包可以使用fields变量,这样做对我编写这段代码极为有帮助。

为了能同时处理位置参数和关键字参数,这里的初始化方法使用了*args, **kwArgs这样的参数列表。同时,在迭代的时候我们使用了if key in fields:做属性名称检查,以避免用户通过关键字参数传递创建非法的属性。

在《Fluent Python》中,作者使用的是定义__slots__字段的方式创建新类型,这样做可以免去上面所说的麻烦,因为我们在Python学习笔记26:符合Python风格的对象中说过,使用__slots__后,就没办法给实例添加新的属性,自然也不可能创建非法属性。

    clsBody = {}
    clsBody['__slots__'] = tuple(fields)

其它代码改动较少,这里不全部展示,完整代码请见Github仓库中的class_factory_test2.py。

类装饰器

在Python学习笔记37:属性描述符中,我们展示了如何使用非同名存储的方式实现属性描述符。而这种PositiveNumber#xxx的方式有些美中不足,毕竟数字对于我们理解代码并不友好,如果能使用属性名称的方式就好了,但是这里存在一个问题,即在我们给委托类绑定属性描述符的时候,委托类并没有完成类定义,更谈不上在此时获取类的属性名称,所以我们也没办法给类绑定的属性描述符指定相应的属性名称。

但是这一切都可以通过类装饰器实现。

所谓类装饰器,对比函数装饰器,顾名思义就是用来装饰类的。其本质依然是一个函数,只不过其参数是类,而返回的也是类。其目的是用于给类添加一些额外特性,就像我们用函数装饰器装饰函数一般。

这里通过修改Python学习笔记37:属性描述符中的示例代码,展示如何使用类装饰器来装饰类。

def class_decorator(cls):
    for key, value in cls.__dict__.items():
        if isinstance(value, PositiveNumber):
            value.realName = "{}#{}".format(value.__class__.__name__, key)
    return cls
​
@class_decorator
class Order:
    quantity = PositiveNumber()
    price = PositiveNumber()

这里同样只给出了关键代码,完整代码请见Github仓库中的class_decorator_test.py。

像上面这样,只需要创建一个类装饰器class_decorator,并使用和函数装饰器类似的方式,通过@class_decorator绑定到类定义,就可以达成在类Order定义结束之后改变其行为的目标。此时再创建的Order实例,其实际存储属性名称将会是PositiveNumber#price。

类装饰器是在Python3中引入的,这无疑相当方便,但类装饰器也并不是完美无缺的,它存在一个问题:使用类装饰器装饰过的类,其子类可能不会继承装饰后的行为。

如果直接在上面的例子中修改,会发现Order的子类依然具有类装饰器修改后的行为,这点会在接下来的示例中说明,这里不直接展示这个例子,想看这个示例的,请见Github仓库中的class_decorator_test2.py。

def class_decorator(cls):
    def new_func(self):
        print("new_func() is called")
    cls.func_x = new_func
    return cls
​
@class_decorator
class TestClass:
    def func_x(self):
        print('func_x() is called')
​
class SubTestClass(TestClass):
    def func_x(self):
        print('SubTestClass.func_x() is called')
​
class SubTestClass2(TestClass):
    def func_x(self):
        super().func_x()
​
test = TestClass()
test.func_x()
test2 = SubTestClass()
test2.func_x()
test3 = SubTestClass2()
test3.func_x()
# new_func() is called
# SubTestClass.func_x() is called
# new_func() is called

这里使用一个简单的类TestClass进行说明,类装饰器class_decorator的功能很简单:将TestClass中的方法func_x进行替换。在第一个子类SubTestClass中,我们重写了函数func_x,并且没有调用super().func_x(),结果就是调用test2.func_x()的时候似乎有没有类装饰器结果都没有区别。而第二个子类SubTestClass2虽然重写了func_x方法,但事实上调用了super().func_x(),其本质与不重写直接继承没有区别,而结果也说明了这里test3.func_x()的输出new_func() is called就是装饰后的父类方法的输出。

上面的这种区别很符合OOP的行为,而装饰器更像是我们临时给某个父类打了个补丁,具体是否会影响到子类,需要看子类在具体方法中的实现,如果其调用了父类的相应方法,比如super().func_x(),就会受到类装饰器的影响,如果没有,则不会。

上面Order的例子中,类装饰器是直接作用于类属性的,所以自然会影响到子类,除非子类覆盖相应的类属性。

如果你想无论子类如何,都要进行“定制”,则需要用到元类,这也是类元编程的核心概念,会在之后讲到。

导入时和运行时

作为一种动态语言,Python在导入时会创建所有的类和函数定义,这里需要特别注意的是,除了函数、方法体中的代码,大部分代码都将被执行,其中包括类定义中的代码。

这也不难理解,如果类定义体中的代码不执行,解释器就无法创建这个用户自定义的数据类型。

这里创建一个imported_test.py用于展示导入时候哪些部分代码将会被执行:

print('imported_test.py start')
class TestClass():
    print("TestClass body")
    def test(self):
        print("TestClass.test() body")
print('imported_test.py end')
if __name__ == '__main__':
    test = TestClass()
    test.test()

进行导入测试:

import imported_test
# imported_test.py start
# TestClass body
# imported_test.py end

直接执行imported_test.py:

#imported_test.py start
#TestClass body
#imported_test.py end
#TestClass.test() body

不严谨地说,执行就是在导入的基础上,执行主程序部分的代码。

这里之所以要提导入时和运行时的区别,是为了便于接下来理解元类的运作方式。

元类

文章的标题“类元编程”,其实指的是针对于类的元编程。元编程的意思可以简单理解为更高级的编程。

做个不恰当的比喻,类和函数是我们用来创建代码的工具,我们使用这些工具编程,而更高级的编程就是针对这些工具的编程,比如针对类的编程,我们可以叫做“类元编程”。

类通常可以看做是创建对象的“蓝图”,而元类我们可以理解为创建类的蓝图。

在大多数情况下,Python中创建类的元类都是同一个:type。

type

Python中type的作用其实和Java中的Class类似,其都是用于类元编程的一种基础类型,也就是所谓的用于创建类的类。这或许很拗口,简单地来说,我们创建对象的模版是类,程序执行的时候会按照类中的定义创建对应的对象。而创建类的模版就是元类,解释器会按照元类中的设置创建相关的类。

这的确有点俄罗斯套娃的意思。

在Python中,元类要么是type,要么是type的子类。

按照上面的定义,我们也可以说对象是类的实例,那么类同样是元类的实例。

这种对应关系我们可以通过UML图示说明:

image-20210604164514316

在这里,用户自定义类TestClass和TestClass2都是type的实例,虚线箭头表示实现(Realization)。type以及两个自定义类都是object的子类,实线箭头表示继承(Generalization)。

  • 这里有个特殊定义:type的元类是它自己,这是为了避免“无限循环”。

  • 事实上Python中的所有类都继承自object。

除了type继承自object以外,object同样是type的实例:

image-20210604165440180

两个UML图都是对的,只是侧重面不同,前者侧重继承关系,后者侧重实现关系。

下面我们继续用属性描述符那个例子说明如何使用元类来定制一个类。

UML图使用EA绘制,工程文件保存在Github仓库中。

定制描述符的元类

class OrderMetaClass(type):
    def __init__(self, clsName, bases, clsBody):
        for key, value in clsBody.items():
            if isinstance(value, PositiveNumber):
                value.realName = "{}#{}".format(value.__class__.__name__, key)
​
​
class Order(metaclass=OrderMetaClass):
    quantity = PositiveNumber()
    price = PositiveNumber()

这里同样只展示关键代码,完整代码见Github仓库中的metaclass_test.py。

我们之前已经说过了,元类就是type或者type的子类,所以这里创建的元类OrderMetaClass必须继承type。

之所以元类可以修改和定制类,是因为元类的初始化方法__init__可以通过解释器接收关联的类的全部信息,体现在参数中的三个参数:clsName, bases, clsBody,这三个参数与使用type创建新类型时候接收的三个参数是完全一致的,分别是类名、继承的类以及类定义体。

具体的修改属性描述符的相关代码和之前几乎没有区别,这里不做赘述。

我们在这里使用元类实现了和之前类装饰器同样的效果,但是它们有着细微的差别。

class TestMetaClass(type):
    def __init__(cls, clsName, bases, clsBody):
        super().__init__(clsName, bases, clsBody)
        print('TestMetaClass.__init__() is called')
        def new_func(self):
            print("new_func() is called")
        cls.func_x = new_func
​
​
class TestClass(metaclass=TestMetaClass):
    def func_x(self):
        print('func_x() is called')
​
​
class SubTestClass(TestClass):
    def func_x(self):
        print('SubTestClass.func_x() is called')
​
​
class SubTestClass2(TestClass):
    def func_x(self):
        super().func_x()
​
​
test = TestClass()
test.func_x()
test2 = SubTestClass()
test2.func_x()
test3 = SubTestClass2()
test3.func_x()
# TestMetaClass.__init__() is called
# TestMetaClass.__init__() is called
# TestMetaClass.__init__() is called
# new_func() is called
# new_func() is called
# new_func() is called

这里使用前边说明类装饰器在继承时候的特点的示例,将类装饰器改为使用元类,可以看到输出都变成了new_func() is called,也就是说无论子类如何实现具体方法,都将被元类修改。同时也能看到,元类的初始化方法被执行了三次,正是这三次初始化方法的执行,TestClass及其子类的行为都被修改。

这里使用cls作为元类初始化方法的第一个参数来命名,是因为元类的实例就是具体的类,所以使用cls可以更明确地说明元类和其关联的类的关系。

__prepare__

我们已经看到的元类的强大,但是这里还有个小小的缺陷。

在元类的初始化方法中,参数clsBody表示类定义,其数据结构是字典,这就带来一个问题:字典是没有顺序的,同样的,这个参数自然也就无法保留属性在类中的定义顺序。

如果我们的程序依赖属性在类中的定义顺序,我们就会无能为力。

Python的解决方式是为元类引入了一个新的魔术方法__prepare__,这个方法会在元类的构造方法和初始化方法之前被调用,其返回的结果会被作为构造方法和初始化方法第三个参数clsBody的存储容器。

也就是说如果不定义__prepare__,元类使用的参数clsBody会使用默认的字典进行存储,自然是无序的,但是如果我们通过__prepare__返回一个有序的映射结构,相应的clsBody就会是有序的。

这点很像是所谓的"如果解决不了问题,我们就解决提出问题的人",滑稽。

我们下面通过一个示例说明如何使用:

import collections
class TestMetaClass(type):
    @classmethod
    def __prepare__(cls, name, bases):
        return collections.OrderedDict()

    def __init__(cls, name, bases, clsBody):
        super().__init__(name, bases, clsBody)
        for key, value in clsBody.items():
            if not callable(value):
                print("{}={}".format(key,value))
        

class TestClass(object,metaclass=TestMetaClass):
    attr1 = 1
    attr2 = 2
    def __init__(self) -> None:
        pass

tc = TestClass()
# __module__=__main__
# __qualname__=TestClass
# attr1=1
# attr2=2

这个示例展示了如何使用__prepare__在元类中顺序输出类定义中的属性,需要注意的是__prepare__方法是一个类方法,由@classmethod装饰器声明,这是因为在解释器调用__prepare__的时候,元类的实例尚未创建(这就是我们强调的,__prepare__在__new__和__init__之前调用)。

类的通用属性

最后,列举一些类的通用属性:

  • cls.__bases__

    如同元类中的参数bases,表示类的相关基类。

  • cls.__qualname__

    3.3引入的新属性,表示类的限定名称,即会带上类所属的包路径。

  • cls.__subclasses__()

    会返回类相关的子类信息,这个信息包含的是目前程序已加载的所有该类的子类。

  • cls.mro()

    返回类的方法解析顺序,解释器在创建实例的时候会调用这个方法。

最最后,重复一下《Fluent Python》的忠告,如果你不确定是不是要使用元类,那就不要使用。

类元编程是Python提供给底层开发以及框架开发的利器,普通开发者几乎不会使用到。

终于,《Fluent Python》的内容全部梳理完毕,虽然以前也阅读过这么厚的专业书籍,但是用学习笔记的方式记录和梳理内容还是第一次(之前的《Head First Python》虽然看着也同样厚,但其实内容上要简单和缩略的多)。

本以为这篇文章相对会简单一些,但同样不轻松,花费了整整一个下午的时间。知易行难吧,在写笔记的时候尤其会感受到这一点。

最后,谢谢阅读,是你们的鼓励和支持让我坚持写完了这个系列,谢谢大家。

当然,无论何种语言,学习本身是没有止境的,但我会换一个角度,比如算法和设计模式,会开一个新坑。

本系列文章的代码都存放在Github项目:python-learning-notes。

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: Python 元编程
最后更新:2021年11月27日

魔芋红茶

加一点PHP,加一点Go,加一点Python......

点赞
< 上一篇
下一篇 >

文章评论

取消回复

*

code

COPYRIGHT © 2021 icexmoon.cn. ALL RIGHTS RESERVED.
本网站由提供CDN加速/云存储服务

Theme Kratos Made By Seaton Jiang

宁ICP备2021001508号

宁公网安备64040202000141号