我所理解的Python元模型

虽然Python作为AI领域的第一语言,但是作为一个C#的深度使用者,对于Python这门编程语言确实有太多值得吐槽的地方。但是我觉得Python在设计上有一个绝对的亮点,也是我最喜欢的地方,那就是基于元类的元模型,在这篇文章中我会聊聊我对Python元模型理解。

1. 元宇宙类比

我们以前两年风光无限,现今基本无人提及的元宇宙来类比。从“创世”的角度来讲,元宇宙定义了宇宙(现实世界)的法则,宇宙是由元数据构建的一个实例。“元”和“实例”并非决定概念,元宇宙也是宇宙,元宇宙的法则由更高层次的“元元宇宙”来定义,元宇宙就是这个元元宇宙的实例。以此类推,不断抽象,直至尽头,在那里我们找不到法则的来源,于是有人将其视为“造物主”,老子将其称为“道”,我们不妨将这个宇宙称为创世宇宙。我们找不到创世宇宙的元,于是我们它自己作为自己的元,这样便形成一个自洽的闭环。既然我们将宇宙视为其元宇宙的实例,那么创世宇宙的实例也包含它自己

元可以比实例更简单,所谓大道至简,“道生一、一生二、二生三、三生万物”即为这个道理。元也可以比实例更复杂,它可以包含纷繁复杂的法则,我们只取其部分法则来构建实例。看过《完美世界》都知道,“下界八域”作为作为囚犯的放逐之地,是一个以“上界”为元宇宙构建的“降维宇宙”,由于法则不全,在下界修炼的境界只能达到“尊者境”,不能成神。

在Python的“元宇宙”也是如此,我们使用元为实例定义规则,并作为创建实例的工厂,所以类为实例的元。我们可以使用元类创建类,所以常规类是元类的实例元类是常规类的元。从这个意义上将,元类叫类元可能更贴切,但是元类除了表达“类的元”外,还体现了元类也是类的概念。既然元类是类,自然也可以有自己的元类。所以类即元,元亦是类,正如“元宇宙也是宇宙,宇宙亦可作为元宇宙”。

Python的创世宇宙是type,我们的类由它构建,所以type是类的元类。既然type是元类,所以type也具有类的属性。由于处在创世宇宙这个超然的位置,type可以视为type的实例

2. 元类的定义

在默认情况下,我们使用一个类去构建实例的时候,背后的流程是:

  • 将类和参数(含任意添加的关键字参数)传入类的__new__方法创建一个基础对象;
  • 将这个基础对象和参数传入类的__init__方法进行初始化;
  • 但会这个经过初始化的对象;

所以针对如下定义的Foobar类,两种创建实例(foobar1和foobar2)的方式是等效的。这里强调以下,虽然就定义看起来__new__方法像一个类方法,但是它本质上时一个静态方法。在它之上既没有标准@classmethod装饰器,调用的时候第一个参数也也必须指定。

from typing_extensions import Self
from typing import Any

class Foobar:
    def __new__(cls, *args: Any, **kwargs: Any) -> Self:
        return super().__new__(cls)

    def __init__(self, foo: int, bar: int) -> None:
        self.foo = foo
        self.bar = bar
    def __eq__(self, value: object) -> bool:
        if not isinstance(value, Foobar):
            return NotImplemented
        return self.foo == value.foo and self.bar == value.bar


foobar1 = Foobar(foo=111, bar=222)
foobar2 = Foobar.__new__(Foobar, foo=111, bar=222)
Foobar.__init__(foobar2, foo=111, bar=222)
assert foobar1 == foobar2

与之类似,元类作为一个类,它也可以定义__new____init__方法。类的这两个方法是为了初始化它的实例,元类的这两个方法的使命也是如此,只是元类的实例是以它为元类的类罢了。所以当Python解释器遇到某个具有元类的class定义时,它也会采用类似的方式来创建这个类:

  • 采用固定的参数调用元类的__new__方法,构建一个类对象,参数依次为:
    • 当前元类;
    • 类名;
    • 以字典表示的类成员;
    • 定义类时指定的关键字参数;
  • 采用固定的参数调用元类的__init__方法对上面创建的基础类对象进行初始化,self为类对象,其余参数包括:
    • 类名;
    • 以字典表示的类成员;
    • 定义类时指定的关键字参数;

以如下这个元类Meta为例。我们在定义的__new____init__会打印出所有的输出参数。在__new__方法中,我们将关键字参数添加到作为类成员的namespaces字典中,最后调用基类(type)的__new__方法将基础类对象创建出来。在__init__方法中,我们定义了一个作为字符串输出的函数repr,并将其作为类的__repr__方法。

from typing import Any

class Meta(type):
    def __new__(cls, name: str, bases: tuple[type, ...], namespaces: dict[str, Any], /, **kwds: Any) :
        print(f"""
__new__
    cls: {cls}  
    name: {name}
    bases: {bases}
    namespaces: {namespaces}
    kwds: {kwds}
""")  
        for key, value in kwds.items():
            namespaces[key] = value
  
        return super().__new__(cls, name, bases, namespaces)
    def __init__(self, name: str, bases: tuple[type, ...], dict: dict[str, Any], /, **kwds: Any) -> None:     
        print(f"""
__init__
    cls: {self}  
    name: {name}
    bases: {bases}
    dict: {dict}
    kwds: {kwds}
""")         
        def repr(self) -> str:
            express = ", ".join(f"{key}={value!r}" for key, value in kwds.items())
            return f"({express.strip()})"
        setattr(self, "__repr__", repr)

class Foo:
   ...    
class Bar(Foo,metaclass=Meta, x = -1, y = -1):
   ...

print(Bar()) 

我们定义的Meta作为类Bar的元类,FooBar的基类。在定义了Bar时,我们还指定了两个关键字参数,根据Meta__new__方法的定义,它们会作为类型的字段成员。我们创建了Bar对象,并将其作为参数调用print方法。程序执行后会产生如下的输出:

__new__
    cls: <class '__main__.Meta'>  
    name: Bar
    bases: (<class '__main__.Foo'>,)
    namespaces: {'__module__': '__main__', '__qualname__': 'Bar', '__firstlineno__': 33, '__static_attributes__': ()}
    kwds: {'x': -1, 'y': -1}


__init__
    cls: <class '__main__.Bar'>  
    name: Bar
    bases: (<class '__main__.Foo'>,)
    dict: {'__module__': '__main__', '__qualname__': 'Bar', '__firstlineno__': 33, '__static_attributes__': (), 'x': -1, 'y': -1}
    kwds: {'x': -1, 'y': -1}

(x=-1, y=-1)

3. 实例是由元类创建的

在上面,我们通过大量的篇幅旨在说明类实例创建的流程:先调用__new__构建基础对象,再将此对象交付给__init__方法进行初始化。现在我要说的是,这只是表象,并非本质,真正的事实是:某个类的实例是由元类创建的。既然我们在说“利用元类对象构建”这一动作,实际上已经将元类视为实例构建的工厂函数。在Python的世界里,万物皆对象。函数自然也不例外,函数是函数类的一个实例,函数(或者可执行对象Callable)类是一个拥有(自己定于或者从基类继承)__call__方法的类,调用某个函数本质上就是调用函数对象的__call__方法。当以函数的形式调用元类构建实例,意味着实例是通过定义在元类中的__call__方法创建的,如下这个演示实例充分说明了这一点:

from typing import Any

class Foo:
   ...

class Meta(type):
   def __call__(self, *args: Any, **kwds: Any) -> Any:
        assert self is Bar
        assert type(self) is Meta      
        assert args == ("111", "222")
        assert kwds == {"c": "333", "d": "444"}  
        return Foo()
   
class Bar(metaclass=TypeMeta):
   def __init__(self, a:str, b:str, **kwargs) -> None:
        self.x = a
        self.y = b
        self.kwargs = kwargs

assert isinstance(Bar("111", "222", c="333", d="444"), Foo)

作为一个方法,它的第一个参数永远是方法调用的主体对象(类方法的调用主体是类,实例方法的调用主体是类的实例),从__call__方法中的断言可以看出,调用此方法的主体对象为Bar这个类对象,type(self)返回的类自然就是定义这个类的元类Meta了。Bar("111", "222", c="333", d="444")注入的位置参数和关键字参数分别以元组和字段的形式赋值给argskwds

那么为什么对于普通类,比如Foo,我们调用Foo()能返回一个Foo对象呢?其实上述的规则依然适用:虽然Foo没有指定一个具体的元类,意味着type会作为兜底的元类Foo()返回的实例其实是由定义在type中的__call__方法创建的,该方法采用如下的方式构建对象。

class type:   
   def __call__(self, *args: Any, **kwds: Any) -> Any:
       instance = self.__new__(self, *args, **kwds) # type: ignore
       self.__init__(instance, *args, **kwds)
       return instance

这个方法采用如下的方式创建对象:

  • 先调用类的__new__构建一个基础对象;
  • 然后将该对象作为参数调用类的__init__方法进行初始化,最终返回此对象。

我们也不用考虑什么类有没有定义定义__new____init__,因为由终极基类object兜底:

  • 定义了无参的__new__会开辟一块内存作为创建的空对象;
  • 定义了的无参__init__方法什么也没做。

4. 终极元类type

上面我们从实例构建的角度简单说明了定义在__call__方法的逻辑,这个方法以及type类远没有这么简单。

4.1 type.__new__方法

我们首先来看看定义在type类中的__new__方法的逻辑,这个方法用于构建要给类对象,上面我们自定义元类Meta__new__方法最终就是调用这个方法(return super().__new__(cls))。如下所示的是该方法的签名:

class type:
    def __new__(
        cls: type[Self], 
        name: str, 
        bases: tuple[type, ...], 
        namespace: dict[str, Any], /, 
        **kwds: Any
    ) -> Self: ...

五个参数分别是:

  • cls:构建类的元类;
  • name:用于命名最终构建出来的类;
  • bases:作为构建类型的基类;
  • namespaces:类型成员;
  • kwds:额外关键字参数。

如果cls参数为type这个类对象,构建出来的就是一个常规的类型,它以name参数命名,以bases作为基类,并且具有namespaces定义的类型成员。kwds参数指定的关键字参数没有意义,如下的演示代码体现了这一点:

class Foo:
    x = -1

cls = type.__new__(type,"Bar",(Foo,),{"y": -1},)
assert cls.__name__ == "Bar"
assert cls.__bases__ == (Foo,)
assert type(cls) is type

bar = cls()
assert bar.x == -1
assert bar.y == -1

如果cls参数指定为一个自定义的元类就有点意思了。因为元类和type__new__方法都可以创建类型,因此会产生冲突,很明显后者具有更高的优先级(毕竟我们显式调用了__new__方法),所以自定义元类的__new_方法和__init__都不会生效。下面的演示程序证明了这一点。

class Baz:
    ...
log = []
class Meta(type):
    def __new__(cls, name: str, bases, namespaces, /, **kwds) :
        log.append(f"Meta.__new__ is called")
        return Baz
    def __init__(self, *args, **kwargs):
        log.append(f"Meta.__init__ is caled")
        self.z = -1

class Foo:
    x = -1

cls = type.__new__(Meta,"Bar",(Foo,),{"y": -1})

assert len(log) == 0
assert cls.__name__ == "Bar"
assert cls.__bases__ == (Foo,)
assert type(cls) is Meta

bar = cls()
assert bar.x == -1
assert bar.y == -1
assert not hasattr(bar, "z")

虽然元类定义的__new_方法和__init__不会生效,但是它指定的元类确实被设置成生成类型的元类(assert type(cls) is Meta)。如果我们在元类中重写了__call__方法,按照上面介绍的规则:当我们利用生成的类进行实例化时,背后调用的就是这个方法,这一点体现在如下的演示程序中。

class Baz:
    ...
class Meta(type):
    def __call__(self, *args, **kwargs):
        return Baz()
class Foo:
    x = -1

cls = type.__new__(Meta,"Bar",(Foo,),{"y": -1})
assert isinstance(cls(), Baz)

4.2 type函数

作为终极元类的type也是类,上面我们说了:当我们将类作为可执行的函数时,背后调用的元类的__call__方法。由于type的元类就是它自己,所以我们调用type函数本质上就是调用type__call__方法。我们知道type函数的逻辑是这样的:

  • 如果传入单一对象,它会返回对象的类。具体来说,指定的是一个常规对象,返回的是该对象的类;如果指定的是一个类,返回的是元类;如果没有显式指定元类,返回的自然就是兜底的元类type。这一个规则在如下的演示代码中得到了很好的体现:
class Meta(type):
    pass
class Foobar(metaclass=Meta):
    pass

assert type(Foobar()) is Foobar
assert type(Foobar) is Meta
assert type(type) is type
  • 如果不满足单一参数的要求,type函数最终会创建一个类,并要求输入参数依次为:类名、基类、类成员、关键字(前面加上type,刚好与__new__方法参数一致)
class Base():
    foo = -1

cls = type("Foobar", (Base,), {"bar": -1})
assert cls.__name__ == "Foobar"
assert cls.__bases__ == (Base,)    
assert cls.bar == -1 #type: ignore

instance = cls()
assert instance.foo == -1
assert instance.bar == -1 #type: ignore

所以type__call__会根据参数格式决定返回指定对象的类还是创建一个类,所以如下的定义更接近真实实现。

class type:
    def __call__(self, *args, **kwargs):
        # 单一参数,返回参数的类型
        if len(args) == 1 and not kwargs:
            obj = args[0]
            return getattr(obj, "__class__", type(obj))
        
        # 多参数,创建一个新的类
        instance = self.__new__(self, *args, **kwargs)
        self.__init__(instance, *args, **kwargs)
        return instance

5. 重新梳理类的生成和实例化

我现在对类的生成与基于类的实例化做一个总结:

对于一个采用class关键字编写的类代码片段,Python解释器会采用如下的方式来构建这个类:

  • 提取元类(如果没有显式指定,type类即为元类),并将它和类的定义信息(类名、基类、为类定义的所有成员和关键字参数)作为参数调用元类的__new__方法:
    • 对于自定义的元类,如果它(或者它自定义的基类)重写了__new__方法,原则上它可以返回任何一个类对象;
    • 如果没有显式指定元类,或者指定的自定义元类(包括它自定义的基类)没有重写__new__方法,那么type类的__new__方法会被调用。按照上面的逻辑,它会严格我们定义的形式生成并返回对应的类对象;
  • __new__方法返回的类对象,以及类的定义信息作为参数调用元类的__init__方法:
    • 如果指定的自定义元类(或者它自定义的基类)重写了__init__方法,如果没有采用slots模式,原则上它可以对传入的类对象做任意的加工;一旦类对象时基于slots模式创建的,由于类的内存布局已经固定,将不能对类成员进行添加和删除;
    • 如果没有显式指定元类,或者指定的自定义元类(包括它自定义的基类)没有重写__init__方法,那么type类的__init__方法会被调用,此方法为空方法,没有任何操作;

当我们将类对象作为工厂函数进行实例化的时候,调用的是元类的__call__方法,其中第一个参数为作为工厂函数的类对象:

  • 如果指定的自定义元类(或者它自定义的基类)重写了__call__方法,原则上它可以返回任何一个对象;
  • 如果没有显式指定元类,或者指定的自定义元类(包括它自定义的基类)没有重写__call__方法,那么type类的__call__方法会被调用,此时反映的就是默认的实例化流程
    • 调用类的__new__方法构建一个基础对象:
      • 如果类(或者它自定义的基类)重写了__new__方法,原则让它可以返回任何一个对象;
      • 否则,从object类继承的__new__方法被用于创建这个基础对象;
    • __new__方法构建的基础对象,连同指定的参数传入类的__init__方法:
      • 如果类(或者它自定义的基类)重写了__init__方法,原则上可以对提供的基础对象作任何加工;
      • 否则,从object的继承的__init__方法会被调用,但它什么都不会做;

6. 元类的实例方法

由于元类的实例是以它为元类的类,所以对于由它创建的类来说,定义在元类的实例方法就是它的类方法。以如下这个PointMeta元类为例,parse是它的实例方法。但是对于将它作为元类的Point来说,parse成了它的类方法。

class PointMeta(type):
    def __new__(cls, name, bases, namespace, **kwds):
        namespace["x"] = 0
        namespace["y"] = 0
        return super().__new__(cls, name, bases, namespace)
    
    def parse(cls, s):
        x_str, y_str = s.split(",")
        point = cls()
        point.x = int(x_str)
        point.y = int(y_str)
        return point
    
class Point(metaclass=PointMeta):
    pass

p = Point.parse("1,2")
assert p.x == 1
assert p.y == 2

7. 如何确定实例的类型

有没有想过当我们调用isinstance或者type函数时,它们如何确定指定对象所述的类。有人说不是每个对象都有一个__class__字段吗?没错,但是这个字段由来源于何处呢?不论什么编程语言,一个对象总是对应于一块(连续)或者多块(不连续)的内存空间,对象提供的所有信息皆来源于此,对象所属的类自然也不例外。所以了解了对象的内存布局,你就了解了对象的一切信息。对于一个常规的对象(不是类对象,类对象的布局要复杂得多),其内存布局取决于它是否采用slots模式。

7.1 非 slots 模式:动态字典布局(默认)

这是Python最常用的模式,其核心是灵活性,内存采用如下结构进行布局:

  • PyObject Header:包含引用计数和指向类型对象的指针。
  • dict 指针:指向一个真实的 Python 字典对象。
  • weakref 指针:用于支持弱引用。

动态性是这种布局的最大优势。由于属性(attribute)不存储在实例本身,而是存储在__dict__指针指向的字典里,我们可以随时添加任意成员。但带来的问题是内存开销大,由于字典为了减少冲突会预留空间,每个实例都要额外维护一个哈希表对象。每次定位数据成员进行的哈希也会影响访问速度较慢。

7.2 slots 模式

这种模式采用紧凑型数组布局。当我们定义了__slots__ = ('a', 'b'),Python会采用如下的结构布局对象内存:

  • PyObject Header:引用计数和类型指针。
  • 固定偏移量的属性位:在内存中直接为a和b预留位置(存储的是指向具体对象的指针)。

没有__dict__(除非你在slots里显式加上'dict')。这是静态编译语言采用的内存布局方式,这样导致实例只能拥有slots中定义的成员,尝试添加新属性会抛出AttributeError。虽然丧失了动态性,但是因极其紧凑内存布局去掉了整个字典对象的开销。在拥有数百万个小对象时,内存占用通常能降低40%-70%。属性访问变成了基地址 + 固定偏移量的直接内存寻址,不需要哈希计算,能力也得到提升。

在回到标题的问题:如何确定实例的类型?很简单,不论采用何种内存布局,它都有一个PyObject Header,其中包含执行类对象的指针,这就是判断对象类型的依据。那么在上述的实例化流程中,这个指针是由谁写入的呢?Python中针对某个类的实例化总是会调用object__new__方法,它会根据指定的类型计算对象所需的内存大小,并分配一段大小匹配的内存,然后将类对象的地址写入PyObject Header中的类型指针。有人可能会问,刚才不是说非slots模式下对象内存是动态分配的吗?话虽没错,但这里的动态性指定是__dict__指向的字典,这里的内存仅包含PyObject Header和额外两个指针而已。

class object:
  def __new__(cls) -> Self
posted @ 2026-04-11 09:04  Artech  阅读(140)  评论(2)    收藏  举报