ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python 元类实例解析

Python 元类实例解析

前言


理解元类(metaclass)的道理不难,难的是把它用到具体场景上。网上很多文章只给一句「元类是用来创建类的类」就收尾,读者看完仍然不知道它到底能解决什么问题、写出来长什么样、打印出来是什么顺序。


本文不谈抽象定义,直接上四个可以照抄运行的例子:观察元类生命周期的追踪器、单例(Singleton)、接口方法校验、自动生成__repr__。每个例子都给完整代码和应当出现的输出,并逐行解释为什么是这样。看代码之前先记住一条判断原则:元类管的是「类」这一层的事,管不了「单个实例」的行为——这个边界划错了,例子就看不懂。


需要说明的是,本文所有输出都是按语言参考和数据模型的规则逐步推演出来的,本机没有 Python 解释器,无法实际执行验证;如果你在自己机器上跑出来不一致,以官方文档和实际解释器为准。


一、例子一:给元类装一台「追踪器」


先看最基础的例子,把三个钩子的先后顺序看清:


# 适用于 Python 3.0+
class Meta(type):
def __new__(mcls, name, bases, namespace):
print(f"1. Meta.__new__ 创建类 {name}")
return super().__new__(mcls, name, bases, namespace)

def __init__(cls, name, bases, namespace):
print(f"2. Meta.__init__ 初始化类 {name}")
super().__init__(name, bases, namespace)

def __call__(cls, *args, **kwargs):
print(f"3. Meta.__call__ 准备实例 {cls.__name__}")
return super().__call__(*args, **kwargs)


print("--- 定义类 ---")
class User(metaclass=Meta):
def __init__(self, name):
self.name = name
print("5. User.__init__ 实例初始化完成")


print("--- 创建实例 ---")
u = User("Alice")

输出应依次为:


--- 定义类 ---
1. Meta.__new__ 创建类 User
2. Meta.__init__ 初始化类 User
--- 创建实例 ---
3. Meta.__call__ 准备实例 User
5. User.__init__ 实例初始化完成

注意第 1、2 步在class语句执行时就已经发生;第 3 步才进入「造对象」阶段。中间缺了「4」,因为默认的User.__new__没有打印。类之所以能像函数一样被调用,正是因为它的元类定义了__call__。


二、例子二:单例


单例的经典需求是:无论创建多少次,都返回同一个对象。用元类实现,只要拦截「类的调用」即可:


# 适用于 Python 3.0+
class Singleton(type):
_instances = {}

def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]


class Config(metaclass=Singleton):
def __init__(self):
self.values = {}
print("Config 初始化一次")


first = Config()
second = Config()
print(first is second) # True
first.values["debug"] = True
print(second.values) # {'debug': True}

Config()第一次执行时_instances里没有这个键,于是走super().__call__真正建实例并缓存;第二次直接取出缓存。因此first is second为True,打印「Config 初始化一次」也只会出现一次。


这里_instances定义在元类Singleton上,通过cls._instances访问——cls是Config,普通属性查找到自己的类体、基类里都没有,就会继续到元类里找到它。这个查找路径跟「实例找类属性」是同一套机制,只是层级上移了一层。


需要点破一个常见误判:这个单例不是线程安全的。两个线程同时第一次调用Config(),可能都判断到键不存在,于是各建一个实例,后写的覆盖先写的。要真在多线程环境用,得加锁,或者干脆用模块级对象(模块在导入时只执行一次,天生就是单例)。这里只讲原理,不写成锁的完整方案。


三、例子三:接口方法校验


需求:继承某个基类的子类,必须实现指定方法,否则在类定义时就报错,而不是等到调用时才AttributeError。


# 适用于 Python 3.0+
class RequireMethods(type):
def __new__(mcls, name, bases, namespace):
cls = super().__new__(mcls, name, bases, namespace)
required = getattr(cls, "required_methods", ())
missing = [n for n in required if n not in namespace]
if bases and missing: # 有基类才校验,跳过基类本身
raise TypeError(f"{name} 未实现: {', '.join(missing)}")
return cls


class Handler(metaclass=RequireMethods):
required_methods = ("handle",)


class JsonHandler(Handler):
def handle(self, data):
return f"json:{data}"


class BrokenHandler(Handler):
pass # 这里会在定义时就抛 TypeError

BrokenHandler的类体执行时,元类的__new__发现handle不在它的命名空间namespace里,直接raise TypeError。因为required_methods写在Handler上,子类通过getattr(cls, ...)能拿到。


判断if bases的作用是跳过Handler自身——它自己没有实现handle,但并不该报错。这种「基类豁免」是写校验元类时最常见的漏判。


四、例子四:__prepare__与自动__repr__


Python 3.6 起,类命名空间默认就是保持插入顺序的映射。所以除非你要换一个特殊映射(比如记录属性被访问顺序的自定义 dict),否则不用写__prepare__。它的签名固定为类方法:


# 适用于 Python 3.6+
class Meta(type):
@classmethod
def __prepare__(mcls, name, bases, **kwargs):
return {} # 返回值会被交给 __new__

def __new__(mcls, name, bases, namespace, **kwargs):
return super().__new__(mcls, name, bases, namespace)

官方特别说明:__prepare__返回的映射不是最终的类__dict__——它会被复制到一个新的有序映射,再包成只读代理。所以别把那个映射存起来当__dict__用。


另一个实用例子是自动补__repr__:


# 适用于 Python 3.0+
class AutoRepr(type):
def __new__(mcls, name, bases, namespace):
cls = super().__new__(mcls, name, bases, namespace)
if "__repr__" not in namespace:
def __repr__(self):
body = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
return f"{type(self).__name__}({body})"
cls.__repr__ = __repr__
return cls


class Point(metaclass=AutoRepr):
def __init__(self, x, y):
self.x = x
self.y = y


print(Point(1, 2)) # Point(x=1, y=2)

判据是"__repr__" not in namespace,不是hasattr(cls, "__repr__")——后者永远为真,因为object已经提供了__repr__。用namespace判断才能区分「自己定义过」和「继承来的」。vars(self)返回实例的__dict__,顺序即属性赋值顺序。


对比:三种「改类」手段怎么选




手段介入时机典型用途是否会漏



类装饰器类对象造好之后加方法、注册、加文档每个类都得手动写@deco

__init_subclass__子类被创建时(父类里定义,3.6+)注册子类、校验子类只对子类生效,父类自己触发不了

元类类被创建的全过程校验、改命名空间、拦截调用影响所有使用者,耦合较重



顺序上,元类最先得到机会(__prepare__、__new__),__init_subclass__在类对象构造时由type.__new__调用,类装饰器排在最后。能用__init_subclass__就别上元类,能用装饰器就别用__init_subclass__——按简单程度从后往前选。


常见坑点



  1. 单例元类忘了定义_instances


❌def __call__(cls, *a, **k): if cls not in cls._instances: ...(元类里没有_instances) →AttributeError: type object 'Config' has no attribute '_instances'✅ 在元类体里先写_instances = {}。



  1. 元类__new__丢掉**kwargs


❌def __new__(mcls, name, bases, ns):而类定义写成class A(Base, tag=1, metaclass=M)。 → 多出来的关键字无处安放,构造失败。 ✅ 签名收下**kwargs并透传给super().__new__(mcls, name, bases, ns, **kwargs)。



  1. __prepare__没写成类方法


❌def __prepare__(mcls, name, bases, **kw):(少了@classmethod) → 按普通函数绑定,拿到的第一个参数不是元类。 ✅ 加上@classmethod。



  1. 把__prepare__返回的映射当成类__dict__


❌self.ns = ns; return ns,之后指望A.__dict__ is self.ns。 → 官方说明会复制成新的有序映射并包成只读代理,两者不是同一个对象。 ✅ 只把它当作「类体执行期间的临时命名空间」。



  1. 用hasattr判断子类有没有实现某个方法


❌if not hasattr(cls, "handle"): raise TypeError→ 基类一旦定义了handle就永远为真,校验失效。 ✅if "handle" not in namespace,只看子类自己写的命名空间。



  1. 以为元类能管实例的行为


❌ 在元类__new__里写def speak(self): ...然后指望每个实例有不同数据。 ✅ 元类管类;实例级数据要放在__init__里、或写__setattr__/ 描述符。



  1. 校验元类忘了豁免基类本身


❌if missing: raise TypeError(...)→ 定义基类时因为没有实现抽象方法而立刻报错,类根本建不出来。 ✅ 加if bases and missing:的短路条件。



  1. 沿用 Python 2 的元类写法


❌class Config(object): __metaclass__ = Singleton(Python 3 里这个属性不再生效,等于没写元类)。 ✅class Config(metaclass=Singleton):。另外 Python 2.7 已于 2020 年 1 月 1 日停止维护,Python 3 里已无旧式类,class Foo:与class Foo(object):完全等价。


总结




例子拦截的钩子一句话原理



生命周期追踪__new__/__init__/__call__建类走前两个,建实例走第三个

单例__call__缓存第一次的实例并复用(需自行处理并发)

接口校验__new__在建类时检查命名空间缺哪些方法

自动__repr____new__类建好后按需补方法,用namespace判断是否已定义



四个例子的共同点是:它们都在「类」这一层动手,和单个实例的数据无关。真正写代码时,请按「装饰器 →__init_subclass__→ 元类」的顺序从简选择,元类是最后手段而不是炫技手段。




RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进