类装饰器从入门到实战:用法、参数、元信息保留与避坑指南

发布时间:2026/10/13 19:09:27
类装饰器从入门到实战:用法、参数、元信息保留与避坑指南 很多人学装饰器学到函数装饰器就停了觉得会写个timer就够用了直到某天在源码里看到dataclass下面躺着一个 class或者在类里看到classmethod才意识到装饰器和水面下的冰山之间还差着一个“类”的距离。这篇DAY29我们就专门把“类的装饰器”这件事掰开揉碎——既要讲清楚类怎么被当作装饰器用也要讲清楚装饰器怎么作用在类身上最后聊一聊我实际改别人代码时被坑过几次的细节。无论你是刚把函数装饰器跑通的新手还是已经能在functools.wraps边缘试探的进阶选手这篇文章都能帮你把“类装饰器”的拼图补完。我会尽量把运行时机、参数传递、元信息保留这些问题说得比文档更有烟火气一点。1. 先别急着写代码装饰器到底在哪个时间点“动手”的很多关于装饰器的困惑追到根上都是同一个问题装饰器究竟是什么时候执行的我见过不少人在装饰器函数里写print然后发现模块一导入就打印了而不是调用被装饰函数时才打印于是就一脸懵。这个时机问题如果不先搞明白后面类的装饰器只会更乱。1.1 装饰器的本质是一次“替换命名”的过程先回到最朴素的定义装饰器是一个可调用对象它接收一个函数或类作为参数返回一个新的函数或类。语法糖decorator本质上是这样一步操作的简化写法def foo(): pass foo decorator(foo)这一步发生在模块加载阶段也就是 import 的时候。注意这里不是函数调用阶段而是函数定义之后立刻执行。所以装饰器内部如果有什么副作用动作会在导入时全部发生一次。这也是为什么很多人会把装饰器误写成“每次调用都会执行的东西”其实装饰器工厂本身只执行一次它返回的那个 wrapper 才是每次调用都会执行的。类的装饰器也一样只不过它接收和返回的对象从函数换成了类register class MyClass: pass # 等价于 class MyClass: pass MyClass register(MyClass)这个替换动作同样发生在模块加载阶段。理解这一点之后你就知道装饰器能做两件完全不同的事一种是在函数/类定义后立即做加工注册、打标记、收集元信息另一种是返回一个新的包装版本把原函数/类的调用行为改掉。1.2 一个判断装饰器行为的小方法我自己判断一个装饰器到底属于哪种就看它有没有inner这层包装。没有inner那它基本是在定义期做手脚有inner说明它在调用期有额外动作。# 定义期动作注册到某个注册表 registry [] def register(cls): registry.append(cls) return cls # 原样返回不包装 # 调用期动作包一层再返回 def add_greeting(func): def wrapper(*args, **kwargs): print(before) result func(*args, **kwargs) print(after) return result return wrapper前者返回的是原来的类后者返回的是新函数。这两者的区别在类的装饰器里会演化出非常多有趣的花样。记住这个判断标准后面看各种源码里的装饰器就不会迷路。2. 让类变成装饰器call和实例状态管理的设计逻辑函数装饰器好写但用类来做装饰器有什么好处最直接的一点类是带状态的。函数装饰器如果想在多次调用之间保存某些数据只能借助全局变量或者函数属性不仅丑而且难以复用。类装饰器把状态装进实例属性里结构上干净得多也好调试。2.1 从一个带调用计数的装饰器说起假设我们想做一个装饰器统计每个被装饰函数被调用的次数并且可以随时查出来。用函数装饰器写需要一个闭包变量def counter(func): count 0 # 闭包变量 def wrapper(*args, **kwargs): nonlocal count count 1 print(f第 {count} 次调用 {func.__name__}) return func(*args, **kwargs) wrapper.count 0 # 把初始值挂上去 return wrapper这样能跑但count到底放闭包里还是放函数属性上有点混乱。换成类装饰器思路就清楚多了class Counter: def __init__(self, func): self.func func self.count 0 def __call__(self, *args, **kwargs): self.count 1 print(f第 {self.count} 次调用 {self.func.__name__}) return self.func(*args, **kwargs)用法一模一样Counter def say_hello(name): print(fHello, {name}) say_hello(Tom) say_hello(Jerry) print(say_hello.count) # 实例属性直接可查这里的核心就是__call__魔术方法。任何定义了__call__的对象都可以像函数一样被调用。装饰器语法要求“装饰器本身可调用”那类实例通过__call__变成可调用对象自然也能当装饰器用。2.2 类装饰器应该在哪一层保留元信息函数装饰器有functools.wraps来把__name__、__doc__等信息复制到 wrapper 上。类装饰器里没有现成的wraps装饰器可以直接摆在__init__上面但可以用一个标准技巧先调用functools.wraps(func)返回一个更新函数再把它赋值到self的对应属性上。from functools import wraps class Counter: def __init__(self, func): wraps(func)(self) self.func func self.count 0 def __call__(self, *args, **kwargs): self.count 1 return self.func(*args, **kwargs)wraps(func)(self)这一行的意思是把func的__name__、__doc__、__module__等属性复制到self这个对象上。因为self是Counter的实例复制过来的属性会作为实例属性存在访问say_hello.__name__时依然能拿到原始函数名。这个写法我第一次看到时愣了一下但想通了之后就会觉得非常优雅——wraps本质上就是一个返回更新函数的工厂而更新函数的更新目标不限定必须是函数任意对象都可以。2.3 为什么状态装在实例里比闭包变量舒服闭包变量并非不能用但有几个隐藏问题。第一多个装饰器叠加时闭包变量容易互相干扰第二在wrapper内部改nonlocal变量在调试时很难直观看到当前值第三如果想给装饰器加配置项比如计数器起始值、是否打印日志类装饰器天然支持在__init__里加参数。一个带参数的类装饰器长这样class Prefix: def __init__(self, prefix: str): self.prefix prefix def __call__(self, func): def wrapper(*args, **kwargs): print(f{self.prefix} before) result func(*args, **kwargs) print(f{self.prefix} after) return result return wrapper Prefix([LOG]) def process(): print(processing...)这里Prefix([LOG])的执行过程是先创建Prefix实例再把process传给实例的__call__。所以类装饰器的参数化走的是__init__这比函数装饰器里的三层嵌套要直观得多——你不需要一层层剥洋葱参数在构造时就明确了。3. 装饰器作用在类上不写 inner 的加工型装饰器与回写技巧把装饰器用在函数上大家见得多了用在整个类上面反而容易被忽略。其实类装饰器在很多框架源码里是重头戏像dataclass、dataclass_json、各种 ORM 的model、Flask 的register_blueprint本质上都是类装饰器。3.1 类装饰器可以完全不改变原类最简单的类装饰器就是收集类定义到某个注册表里原样返回。这种模式在插件系统里极其常见。我做过一个模拟项目X需要让各个业务模块在启动时自动把自己注册到路由表里代码大概长这样# router.py ROUTES {} def register(path: str): def decorator(cls): ROUTES[path] cls return cls return decorator # handlers.py from router import register register(/api/health) class HealthHandler: def handle(self): return {status: ok} register(/api/user) class UserHandler: def handle(self): return {status: user}这个register的参数是字符串所以外面要再包一层形成一个三层结构。不过和函数装饰器的三层比比这里的decorator返回的是原来的cls没有包装所以不会修改类的任何行为。这种“原样返回 副作用收集”的模式是类装饰器里最简单也最实用的一类。3.2 类装饰器修改类属性的几种方式当然类装饰器也可以真的给类“动手脚”。常见操作有给类添加新方法、修改已有方法、添加类属性。直接上代码看效果def add_repr(cls): def __repr__(self): attrs , .join(f{k}{v!r} for k, v in self.__dict__.items()) return f{cls.__name__}({attrs}) cls.__repr__ __repr__ return cls add_repr class Point: def __init__(self, x, y): self.x x self.y y p Point(3, 5) print(p) # Point(x3, y5)这里的关键是类装饰器可以直接往cls的命名空间里塞方法。因为类本身就是type的实例你可以直接修改它的属性字典。这种方式比继承更灵活因为不需要创建一个新的类而是在原类上做就地加工。还有一类常见操作是“回写”——在装饰器里读取类的__init_subclass__或者元类信息再决定要不要修改。这个概念有点深可以先用一个简单例子理解回写类装饰器读取类的__dict__找到所有名字以on_开头的方法把它们收集到一个事件表里。def collect_events(cls): events {} for name, method in cls.__dict__.items(): if name.startswith(on_): events[name[3:]] method cls._events events return cls collect_events class Button: def on_click(self): print(clicked) def on_hover(self): print(hovered) def render(self): pass print(Button._events.keys()) # dict_keys([click, hover])这种“不改变调用行为只增加反射信息”的装饰器在框架代码里是神器。使用者不需要在类里显式声明事件注册只要方法名符合约定就自动被收集。3.3 结合init_subclass的进阶协作如果你不想用类装饰器而是想定义一些子类时自动完成类似加工可以用__init_subclass__它是在父类里定义的钩子方法每次子类创建时都会被调用class EventBase: def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) events {} for name, method in cls.__dict__.items(): if name.startswith(on_): events[name[3:]] method cls._events events class MyButton(EventBase): def on_click(self): print(clicked)这两者可以结合类装饰器负责“最终加工”__init_subclass__负责“继承时的默认行为”。在真实框架里往往两者都有比如某框架先用__init_subclass__收集所有带注册标记的子类再用类装饰器给最终用户提供一个友好入口。理解了这个协作关系你再看很多框架的“自动发现机制”就不会觉得是魔法了。4. 实测案例给类加一个带参数的日志装饰器并保留方法的元信息前面的原理拆得差不多了接下来用一个稍微完整一点的例子把函数装饰器、类装饰器、带参数、元信息保留这些点串起来。假设我们要写一个装饰器可以加在任意类上让类的每个方法在调用时自动打印“进入/退出”日志并且可以配置日志前缀。4.1 第一版思路确认与代码骨架这个需求很容易走偏——有人会想到用一个元类有人会想到动态替换__getattribute__。但类装饰器其实是最直白的方案我们接收一个类遍历它的所有方法用一个新的 wrapper 替换掉每个方法然后返回这个类。import functools import time def logged(prefixLOG): def class_decorator(cls): for name, method in list(cls.__dict__.items()): if callable(method) and not name.startswith(__): setattr(cls, name, _wrap_method(method, prefix)) return cls return class_decorator def _wrap_method(method, prefix): functools.wraps(method) def wrapper(*args, **kwargs): print(f[{prefix}] entering {method.__name__}) start time.perf_counter() try: return method(*args, **kwargs) finally: cost time.perf_counter() - start print(f[{prefix}] leaving {method.__name__}, cost{cost:.4f}s) return wrapper这里有几个决策点值得说明。第一遍历cls.__dict__而不是直接用dir(cls)是为了只看这个类自己定义的方法不包括继承来的。第二用list(cls.__dict__.items())是因为我们要在循环过程中给类设置新的属性虽然__dict__的视图在修改时不会直接报错但养成先转list的习惯能避免诡异问题。第三跳过__开头的方法主要是为了不动__init__、__repr__这类魔术方法因为很多魔术方法的调用机制很特殊比如__eq__是通过类型槽直接调用的替换了也不一定生效还会干扰调试。4.2 第二版处理继承方法、类和静态方法第一版有个问题如果类里有classmethod或staticmethodcls.__dict__里它们已经变成了classmethod和staticmethod对象callable()返回 True但直接包一层函数会把它们的特殊身份给丢掉。处理办法是先把它们还原成原始函数包好之后再重新封装def _wrap_member(method, prefix): if isinstance(method, classmethod): raw method.__func__ wrapped _wrap_method(raw, prefix) return classmethod(wrapped) if isinstance(method, staticmethod): raw method.__func__ wrapped _wrap_method(raw, prefix) return staticmethod(wrapped) return _wrap_method(method, prefix)然后把类装饰器里的setattr改成setattr(cls, name, _wrap_member(method, prefix))。这个细节我在第一次实现时踩过坑。当时日志装饰器用在某个图像处理Demo的类上类里有一个staticmethod的格式转换函数被包了一层之后调用时多了一个self参数直接报错。排查了半天才意识到staticmethod对象和普通函数在类型上的区别。4.3 第三版保留原类的引用方便排查如果第一版直接用setattr把方法替换掉原方法就丢了。在调试时你打印cls.method看到的是function对象但很难知道它包的是谁。这里有个小技巧把原始方法挂到 wrapper 的__wrapped__属性上functools.wraps会自动帮我们做这件事所以_wrap_method里functools.wraps(method)已经保留了原方法。如果你用的是类装饰器而不是函数装饰器functools.wraps照常可用。验证方式也很简单logged(prefixDB) class Repository: def query(self, sql): return [1, 2, 3] repo Repository() print(repo.query.__name__) # query print(repo.query.__wrapped__ is Repository.query.__wrapped__) # True__wrapped__的存在也让inspect.signature能正确显示原始签名IDE 的自动补全和跳转也会更友好。所以我的建议是所有装饰器代码都别忘了functools.wraps不管是函数装饰器还是类装饰器不管是包普通方法还是包classmethod。5. 类装饰器与元类、工厂函数的分工什么时候选哪一种写到这里你可能已经感觉到装饰器的能力很大大到有点“越权”的错觉——它可以修类、改方法、加属性这不和元类差不多吗确实三者在功能上有重叠但应用场景和心智负担完全不同。类装饰器、元类、类工厂函数三者的对比我用下面的表格来说明手段生效时机是否创建新类对继承的影响心智负担类装饰器类定义后立即执行可选无原类返回或新类替换低就是函数操作对象元类创建类时自动调用隐式创建新类影响所有子类中等理解__new__需要功夫类工厂函数显式调用时创建新类无自动影响中低就是函数返回类5.1 能用类装饰器的时候优先用类装饰器三个手段里我自己的优先级是类装饰器 类工厂函数 元类。原因很简单类装饰器的意图最清晰读者看到一个register就能推测它是在做注册或者加工而不会像元类那样“隐式地影响所有子类”。元类适合的场景是你需要对类的创建过程做强约束而且这种约束必须对子类自动生效。比如某个ORM框架要保证所有模型类都继承自同一个基类并且自动注入数据库表名用元类就比给每个类手动加装饰器靠谱。但如果你只是想让某个特定类多一个方法用元类就是把简单问题复杂化。5.2 一个“装饰器 工厂函数”的组合套路实际开发中我经常把两者结合。类装饰器负责“注册”工厂函数负责“按需创建”_registry {} def model(table_name): def deco(cls): cls._table table_name cls._fields _extract_fields(cls) _registry[table_name] cls return cls return deco def get_model(table_name): return _registry[table_name] model(user) class User: name: str age: int这样写的好处是你在定义User时看到的是一目了然的类定义而“把它和某个表关联起来”这个横向关注点被装饰器隔离了。类里面不用写任何注册代码后期要换注册方式比如改成配置文件驱动只需要动装饰器那一个函数。5.3 元类能做的事类装饰器未必不能做有一个典型的元类场景是“禁止类被重写某个方法”。用元类可以强制所有子类不得覆盖save方法class GuardMeta(type): def __new__(mcs, name, bases, namespace): if save in namespace and any(save in b.__dict__ for b in bases): raise TypeError(f{name} 不得覆盖 save) return super().__new__(mcs, name, bases, namespace)这个用类装饰器也能做到只要你有办法拦截子类的创建——但类装饰器拦截不了子类因为子类的创建过程是父类定义好了的元类逻辑在驱动。所以如果你的需求是“对一系列相关类做统一约束而且约束要传递到子类”元类才是正解。反之如果只是加工一个孤立的类类装饰器是更轻的选择。这句话值得记住类装饰器是“加工已完成的类”元类是“拦截正在创建中的类”。前者被动后者主动。6. 这些坑我全都踩过装饰器叠加顺序、wraps 丢失与调试建议最后这部分是经验之谈。类装饰器平时用得不多真正出问题的时候往往是框架代码里因为叠加、顺序、反射信息丢失导致的诡异行为。我挑几个最有代表性的坑聊聊。6.1 装饰器叠加的顺序离函数类最近的最先执行装饰器的执行顺序和书写顺序是反的。看这段代码logged(prefixA) logged(prefixB) class Service: def run(self): pass执行顺序是先执行logged(prefixB)得到处理后的类再把这个类传给logged(prefixA)。所以实际效果是 A 包裹 B运行日志时先打印 A 再打印 B。这个顺序和直觉相反很多人都会记反。我的记忆方法是装饰器像洋葱离洋葱芯被装饰对象最近的那层先粘上去最后的执行顺序是外层先跑。所以如果你想让某个装饰器的逻辑更早执行比如先做权限校验再记日志权限校验应该写在上面。6.2 类装饰器返回新类时平等对待“继承”与“组合”类装饰器有两种返回方式返回原类的修改版或者返回一个全新的包装类。前者叫修改式后者叫替换式。替换式有个容易踩的坑新类没有继承原类的话isinstance(obj, OriginalClass)会返回 False。def with_extra_method(cls): class NewClass(cls): # 继承原类保证 isinstance 关系 def extra(self): return extra return NewClass这里NewClass继承了原类所以实例依然是原类的实例。但如果你的需求是要完全替换类行为不保留继承关系那isinstance判断失效就是必然的代价。写代码之前想清楚这两者的取舍能避免很多调试时“为什么类型对不上”的困惑。6.3 调试类装饰器的技巧类装饰器写完之后我建议做三件事打印cls.__dict__.keys()确认新增或修改的属性都在。用inspect.signature(MyClass.method)看签名是否保持原始形态——这能快速发现wraps忘没忘。在装饰器内部加一个临时的print(fdecorating {cls.__name__})然后 import 一次模块确认装饰器只在导入阶段执行了一次而不是在类实例化时反复执行。第三个检查尤其重要。因为类装饰器在定义时执行如果你不小心把装饰器写成了“在__init__里执行”就会变成每次创建实例都重新加工类导致方法被反复包裹日志爆炸式增长。这种 bug 很难从报错信息里看出来因为代码逻辑完全是通的只是行为不对。另一个建议是给装饰器函数起名字的时候带上语气。比如register、logged、collect_events这种动词开头的名字阅读代码时就能立刻知道它是在“做一件事”而不是“描述一种属性”。如果名字叫with_logging或Logging读者还需要猜它到底改了什么。类装饰器本来就少见名字起得含糊的话路人读者很容易以为那只是一个普通标记。6.4 不要在类装饰器里修改class相关的名称最后一个坑是关于名称解析的。在类装饰器里如果你用动态setattr(cls, run, new_run)这段代码本身没问题。但如果你在装饰器里试图通过字符串修改__class__、__bases__这类属性Python 会直接报错。这不是装饰器的问题是类对象本身的某种机制在保护自己的类型标识。遇到这种需求时直接考虑换方案用元类改__bases__是可能的但装饰器不适合干这事。另外如果你在类装饰器里拿到的是cls在修改它的属性时注意不要把一个property对象和方法混着用。比如你想给类加一个计算属性直接setattr(cls, total, property(lambda self: sum(self.values)))是可以的。但如果不是property而是一个普通函数那类属性访问时不会自动绑定self调用时会少传一个参数报错信息会让人摸不着头脑。这个坑我跳进去过两次每次都是因为觉得“反正都是函数应该差不多”然后就差一个property包装差之毫厘谬以千里。个人经验是写完类装饰器永远先用最小例子验证再往业务代码里贴。装饰器这种东西组合起来行为非常复合你不是不能推理而是逻辑链条太长一次推理完容易漏。最小例子能帮你把“装饰器本身对不对”和“业务逻辑对不对”这两件事拆开调试难度瞬间降一半。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询