
前两周帮同事排查一个 Python 问题现象特简单他把一个User对象直接print到控制台输出是__main__.User object at 0x7f8b3c4d5e60。他一脸懵地跑来问“我明明给这个类写了一堆属性和方法为什么打印出来只有一串内存地址”这个场景在 Python 面向对象的学习和实战里太常见了。本质原因就一句话这个类没有实现__repr__或__str__这类的魔法方法。魔法方法也叫特殊方法、双下划线方法dunder methods是 Python 对象模型提供的一套以双下划线开头和结尾的方法名比如__init__、__str__、__eq__、__getitem__。它们决定了对象在解释器里“长什么样”“怎么被比较”“怎么被遍历”“怎么进 with 语句”。理解并且会用它们才算真正脱离了“会写 class 和 def 但不懂类机制”的阶段。这篇文章不是照着官方文档念一遍方法名大全而是从实际开发和调试角度出发把我在项目中真正用到的、踩过坑的魔法方法拆开讲清楚并配上能直接跑起来的代码。适合已经掌握 Python 基础语法、正在学面向对象的读者也适合那些写类总感觉“差点意思”的程序员。1. 从一次打印调试说起魔法方法到底在管什么事1.1 打印对象为什么只显示内存地址先还原一下开头那个场景。假设我们有一个最简单的用户类class User: def __init__(self, user_id, name): self.user_id user_id self.name name user User(1, 张三) print(user)运行结果就是__main__.User object at 0x7f8b3c4d5e60。你可能会说“这很正常啊对象本来就没有默认的打印格式。”但问题是Python 不是没给这个能力而是默认行为就是打印类名和内存地址。如果你想让它打印成User(user_id1, name张三)就得自己实现__repr__。这里要理解一个核心机制print(obj)的行为本质上是调用str(obj)而str(obj)会优先调用对象的__str__如果类没实现__str__解释器会退回去调用__repr__如果__repr__也没实现才会走到object基类默认的内存地址展示逻辑。换句话说print输出能不能看懂完全看你有没有写对魔法方法。1.2 双下划线是语言层面的“行为契约”不是语法糖很多人第一眼看到__init__会把它理解成“构造函数”。这个理解在 Python 里其实不准确Python 真正的构造函数是__new____init__只是初始化方法。类似这种概念混淆学习时一定要警惕。魔法方法与传统意义上的“事件回调”或“钩子函数”有一个关键区别普通方法是你主动调用魔法方法是解释器在特定操作发生时自动调用的。比如你写obj other解释器会查找obj.__add__你写obj[key]解释器会查找obj.__getitem__你写if obj:解释器会查找obj.__bool__或obj.__len__。正是这套“约定”让自定义类可以无缝地融入 Python 的语法体系而不是只能通过方法调用。我在带新人时经常强调一句话魔法方法就是你和 Python 解释器之间的协议。你按协议实现方法解释器就让你享受语法层面的待遇你不实现Python 就只给你一套默认行为往往会让你觉得“这对象用起来好别扭”。1.3 怎么快速盘点一个类当前有哪些魔法方法实际调试中我不可能把几十个魔法方法都背下来但可以用一个直接有效的方法print(dir(User))dir()会列出类的所有属性和方法其中带双下划线的就是当前类能响应或继承到的魔法方法。我通常会在写完一个新类后跑一下dir()看看哪些是我需要定制的、哪些是继承来的默认实现。这个方法在排查“为什么我的对象不能 len()”“为什么不能比较大小”这类问题时特别管用——先看dir()确认这个类到底有没有实现对应方法。2. 对象的出生与离场new、init、del的正确分工2.1 先有对象后有初始化很多 Python 开发者写了几年代码从来没自己实现过__new__这很正常。但理解它的存在对把握对象生命周期非常有帮助。__new__是真正创建对象实例的方法它接收的第一个参数是类本身cls返回值通常是一个新实例__init__负责在实例创建完成后对其进行初始化第一个参数是实例本身self。创建流程是解释器先调用__new__拿到实例对象再自动调用__init__对这个对象做初始化。伪代码大致是这样obj cls.__new__(cls, *args, **kwargs) if isinstance(obj, cls): cls.__init__(obj, *args, **kwargs)一个很典型的场景是单例模式的实现。下面这个写法在面试和项目中都很常见class Config: _instance None def __new__(cls): if cls._instance is None: cls._instance super().__new__(cls) return cls._instance def __init__(self): if not hasattr(self, initialized): self.settings {} self.initialized True config1 Config() config2 Config() print(config1 is config2) # True注意这里有一个容易忽略的细节即使__new__每次都返回同一个实例Python 依然会在每次Config()调用后触发一次__init__。如果不在__init__里加hasattr判断settings就会被反复重置。这个坑我在早期写框架代码时踩过后来才明白__init__是每次调用都会执行的跟单例不单例没有关系。2.2 不可变对象的创建思路__new__还有一种用法是模拟不可变对象。Python 自带的tuple、str是不可变类型自定义类默认是可变类型因为实例属性随时可改。如果希望自定义类像namedtuple一样在创建后不能改名最直接的方式是在__new__里把属性写进去但不定义__setattr__的修改路径。不过在实践中我更推荐直接用dataclasses或namedtuple它们已经帮你把事情做完了。2.3del的坑别把关键清理逻辑放这里__del__是析构方法在对象被垃圾回收时调用。听起来很适合“关文件”“断连接”但实际上别这么干。原因有三个__del__的调用时机不稳定。对象引用计数归零、解释器退出、循环引用被垃圾回收时都可能触发也可能因为异常导致不触发。如果在__del__里访问了全局变量或模块级对象而此时模块已经被部分清理会直接抛出奇怪的异常。显式del obj只是删除一个引用不代表立刻调用__del__。我在早期一个爬虫项目里把数据库连接关闭逻辑写在__del__里结果程序退出时偶发连接泄漏排查了很久才发现是__del__时机不可控导致的。后来统一改成上下文管理器问题彻底消失。所以在 Python 里资源释放的正确姿势是使用with语句而不是依赖析构方法。后面我会单独讲__enter__和__exit__。3. 让对象可读、可判真repr、str、bool的工程价值3.1 为调试器和日志写一个完整的repr如果说只能挑一个魔法方法推荐大家实现我第一个推荐__repr__。它的价值不是“让打印好看”而是让调试信息变得可读。一个理想的__repr__应该尽量满足两个条件第一返回的字符串应该尽量清楚地表达对象的核心状态第二能通过eval(repr(obj))重建对象是理想状态但在自定义类中不强求。我实际写的时候一般配合!r格式符让字符串类型的值带引号class User: def __init__(self, user_id, name, email): self.user_id user_id self.name name self.email email def __repr__(self): return ( fUser(user_id{self.user_id!r}, fname{self.name!r}, email{self.email!r}) ) user User(1, 张三, zhangsanexample.com) print(user) # User(user_id1, name张三, emailzhangsanexample.com)注意!r的作用它会把值用repr()处理从而字符串会自动带上引号。这样一眼就能看出name是字符串而不是数字或 else。这里还有一个重要规则如果类只实现了__repr__而没有实现__str__调用print(obj)和str(obj)时会自动回退使用__repr__。相反如果只实现了__str__repr(obj)不会回退到__str__。所以优先实现__repr__性价比最高。3.2str与repr的分工开发者和用户看到的不一样我需要把这两个方法的定位说清楚__repr__面向开发者追求“看到字符串就能知道对象内部状态”__str__面向最终用户追求“友好、美观、可读”。理论上可以在一个类里把两者区分开class User: def __init__(self, user_id, name): self.user_id user_id self.name name def __repr__(self): return fUser({self.user_id!r}, {self.name!r}) def __str__(self): return f用户{self.name}(ID: {self.user_id})这样在交互式命令行里直接输入user回车看到的是repr风格print(user)或f{user}看到的是面向用户的风格。这种区分在写 API 客户端、ORM 模型、配置类时非常常见。3.3 真假值与bool自定义“这个对象是否为空”Python 里所有对象都能直接放进if判断默认情况下自定义类实例都是True。如果你希望对象能表达“空/非空”的概念和容器一样可以实现__bool__也可以实现__len__。class Order: def __init__(self): self.items [] def __bool__(self): return bool(self.items) order Order() if order: print(有商品) else: print(空订单)我实际项目里很少单独实现__bool__更多是利用__len__的规则只要实现了__len__Python 就会在bool(obj)时调用它长度为 0 就视为False。比如下面这个“待办清单”类class TodoList: def __init__(self): self.todos [] def __len__(self): return len(self.todos) todos TodoList() if not todos: print(没有待办事项)在这里if not todos会判断为真因为len(todos) 0。这一点对写过自定义容器的人来说很顺手不需要额外写__bool__。4. 容器协议让自定义类支持 len()、下标与迭代4.1len与getitem被当作序列的最小条件在 Python 里如果你希望自定义类支持len(obj)就实现__len__希望支持obj[index]就实现__getitem__。有趣的是在 Python 的早期设计中一个类只要实现了__getitem__即使没有__iter__也能被for循环遍历老版本代码里经常能看到这种“鸭子类型”迭代。我自己在写数据封装类时最常用的是让一个“分页结果”对象同时支持长度、下标和遍历这样业务代码写起来会舒服很多class Page: def __init__(self, items, page_no, page_size, total): self.items items self.page_no page_no self.page_size page_size self.total total def __len__(self): return len(self.items) def __getitem__(self, index): return self.items[index] def __iter__(self): return iter(self.items) def __repr__(self): return ( fPage(page_no{self.page_no}, fcount{len(self.items)}, total{self.total}) )这样做以后分页结果可以直接用len(page)取当前页条数、page[0]取第一条、for item in page遍历所有条目打印日志时还自带简洁描述。相比客户端代码每次都要写page.items这种封装把使用成本降到了最低。4.2 迭代器协议iter和next怎么配合迭代器协议是 Python 的一大精髓。一个对象支持for循环本质上是因为它实现了迭代协议__iter__返回一个迭代器迭代器实现__next__每次调用返回下一个元素没有元素时抛出StopIteration。我们经常见到的range(10)、列表、字典背后都是这套机制。自己写一个简单的迭代器也不复杂class Countdown: def __init__(self, start): self.current start def __iter__(self): return self def __next__(self): if self.current 0: raise StopIteration value self.current self.current - 1 return value for num in Countdown(3): print(num) # 3 # 2 # 1这里有一个重要区别__iter__返回的对象如果是self说明这个对象同时也是它自己的迭代器这种写法在简单场景下没问题但如果要在同一个对象上多次遍历迭代器状态会被上一次遍历耗尽不能复用。更稳妥的做法是让容器对象实现__iter__返回一个独立的迭代器对象。日常开发中真正需要“手写迭代器”的场景不算多因为生成器函数已经极大地简化了迭代器的创建。比如for num in countdown_generator(3)一个带yield的函数就搞定了。但理解迭代协议对排查StopIteration、理解itertools等模块都有很大帮助。4.3 一个分页结果对象的封装实例我再把上面的Page类扩展一下让它更贴近真实项目。假设从数据库查询用户列表返回一共 100 条每页 20 条当前是第 2 页。封装好之后业务代码可以这样写page query_users(page_no2, page_size20) print(len(page)) # 当前页的数量 print(page[0]) # 当前页第一条 for user in page: print(user.name) print(page) # Page(page_no2, count20, total100)这个类的价值在于把一个数据访问结果包装成了“像列表一样的东西”上层代码不关心它是数据库查询、远程 API 还是文件读取的结果。只要实现了这些协议就能像内置容器一样被消费。这种设计在团队协作中很受欢迎因为它让函数接口更稳定也让测试更容易——你可以在测试里直接构造一个Page对象模拟数据来源。5. 运算符重载与比较体系eq、hash与排序5.1 先记住一个前提 与哈希是绑定关系Python 里比较调用的是__eq__而把对象放进set、当dict的键时需要调用__hash__获取哈希值。语言规范里有条硬规定两个对象如果相等那么它们的__hash__返回值必须相同。反过来说如果__hash__不同Python 会直接认为两个对象不相等根本不会调用__eq__。这个约束有一个重要的实际后果如果你重写了__eq__但没有定义__hash__Python 会自动把该类的__hash__设为None对象立刻变得不可哈希。你一旦尝试把它放进set或作为dict的 key就会收到TypeError: unhashable type: YourClass。5.2 为什么定义了eq之后对象就“不可哈希”这个设计初看有点反直觉但其实是故意为之。试想一下一个可变对象作为字典的键如果在使用过程中它的__eq__结果变了那么哈希值也需要跟着变否则字典就找不到它了而 Python 的dict假定键的哈希值不变。为了避免这个安全隐患Python 干脆规定只要自定义类重写了__eq__又没有明确给出__hash__就让这个类不可哈希强制你思考对象到底可不可变。看一个具体的例子class Point: def __init__(self, x, y): self.x x self.y y def __eq__(self, other): if not isinstance(other, Point): return NotImplemented return self.x other.x and self.y other.y p1 Point(1, 2) p2 Point(1, 2) print(p1 p2) # True # print({p1: a}) # TypeError: unhashable type: Point如果你的类确实是不可变的比如所有属性在创建后不会修改那么可以再补一个__hash__class Point: def __init__(self, x, y): self.x x self.y y def __eq__(self, other): if not isinstance(other, Point): return NotImplemented return self.x other.x and self.y other.y def __hash__(self): return hash((self.x, self.y))这里hash((self.x, self.y))是把两个坐标打包成一个元组再哈希和 Python 内置元组的哈希算法保持一致。但注意如果x或y在对象生命周期里会变上面的写法会违反“相等对象哈希值相同”的约束所以可变对象就不要定义__hash__。这个“不可变才能当字典键”的原则是 Python 容器设计的核心前提之一。5.3lt和 total_ordering补齐完整比较运算除了排序还会用到、、、。Python 里这四种比较分别对应__lt__、__le__、__gt__、__ge__。理论上你可以四个都实现但大量代码里只实现__lt__和__eq__就够了因为functools.total_ordering装饰器会自动补全其他比较运算符。from functools import total_ordering total_ordering class Score: def __init__(self, value): self.value value def __eq__(self, other): return self.value other.value def __lt__(self, other): return self.value other.value a Score(90) b Score(85) print(a b) # True print(a b) # True print(b a) # True从total_ordering的实现原理来看它根据__lt__推断__gt__就是other self根据__eq__推断__ne__就是not (self other)这样省去了大量重复代码。我在做数据模型类时经常会用到这个装饰器因为它保持了代码精简又能保证比较运算符完整可用。这里的一个注意点是total_ordering依赖运算符重载的反射机制如果other不是同类对象最好在比较方法里返回NotImplemented让 Python 尝试调用对方的镜像方法而不是直接抛异常。6. 属性访问的“拦路虎”getattr、setattr、getattribute6.1getattr的典型场景与性能陷阱__getattr__只有在属性正常查找失败时才会调用。我最早接触它是在封装字典配置时希望config.timeout能直接映射到config[timeout]上class Config: def __init__(self, data): self._data dict(data) def __getattr__(self, name): if name.startswith(_): raise AttributeError(name) if name in self._data: return self._data[name] raise AttributeError(f{type(self).__name__} object has no attribute {name}) config Config({timeout: 3, retry: 5}) print(config.timeout) # 3 print(config.retry) # 5这个技巧在解析 JSON 配置、API 响应、环境变量时非常有杀伤力写起来就像访问普通属性一样自然。但这里有一个需要警惕的性能陷阱__getattr__是在属性查找失败后才触发而不是每次访问都会触发所以正常情况下性能损失不大问题在于如果你在__getattr__里写了一些重逻辑比如读数据库、发请求、做大量计算而调用方又经常访问不存在的属性就很容易在排查时发现“莫名其妙的性能抖动”。我的经验是__getattr__里只做轻量字典映射和错误抛出绝不写重逻辑。另外如果你需要区分“这个属性确实不存在”和“内部_data里没有”就一定要对下划线开头的属性提前放行否则hasattr(obj, _something)这类内部检查会被带偏。6.2setattr中如何避免递归__setattr__会拦截所有属性赋值。它的典型场景是做参数校验、属性追踪、数据模型标准化。我第一次在项目里用__setattr__是为了自动把传入的字段写进内部字典class Model: def __init__(self): self._data {} def __setattr__(self, name, value): if name.startswith(_): object.__setattr__(self, name, value) else: self._data[name] value注意__init__里的self._data {}其实也会触发__setattr__。因为_data以下划线开头所以走object.__setattr__直接设置到实例字典里否则就会进入无限递归__setattr__里调用self._data[name] value又会触发一次__setattr__然后又去执行self._data[name] value形成一个死循环。这个坑非常经典。正确做法就是在自定义__setattr__时凡是涉及内部下划线属性的赋值一律使用object.__setattr__(self, name, value)。这是绕过__setattr__的标准姿势。6.3getattribute能做什么但通常不推荐碰__getattribute__和__getattr__虽然只差几个字母但行为完全不同前者在每次属性访问时都会被调用后者只在查找失败时调用。实现__getattribute__等于给类的每个属性访问套上一层拦截器性能开销大而且极其容易写成递归死循环因为你在方法内部访问self.xxx会再次触发__getattribute__。def __getattribute__(self, name): # 必须调用 object.__getattribute__ value object.__getattribute__(self, name) return value在业务代码里我几乎没见过需要手写__getattribute__的地方。它更多出现在 ORM、代理模式、AOP 这类底层框架中用来做统一拦截、日志记录或属性委托。如果你只是想实现“属性不存在时给默认值”用__getattr__就够如果想统一记录属性访问可以使用装饰器或代理类而不是直接改写__getattribute__。我的建议是知道它的存在和原理日常编码优先绕开。7. with 背后的协议enter、exit与资源管理7.1 with 语句实际上在调用什么Python 里最常见的资源管理方式就是with open(...) as f:。很多人天天用却不知道with背后的机制其实就是两个魔法方法__enter__和__exit__。执行with obj as x:时流程如下调用obj.__enter__()返回值赋值给x执行with代码块中的语句无论代码块是否抛出异常都会调用obj.__exit__(exc_type, exc_val, exc_tb)如果__exit__返回True则表示该异常已被吞掉with块外部不会继续传播如果返回False或None异常会继续抛出。class ManagedFile: def __init__(self, filename): self.filename filename def __enter__(self): print(打开文件) self.file open(self.filename, r) return self.file def __exit__(self, exc_type, exc_val, exc_tb): print(关闭文件) self.file.close() # 返回 False 或者不返回让异常正常传播 with ManagedFile(test.txt) as f: content f.read()这里的关键是__exit__接收三个异常参数。你可以在里面针对特定异常做特殊处理甚至决定是否吞掉异常。比如在测试工具里我希望某个代码块里抛出的断言异常被记录但不中断测试就可以在__exit__里捕获后返回True。7.2 事务提交与回滚的封装实例__enter__和__exit__在业务代码中最典型的应用是数据库事务封装。假设我们有一个数据库连接对象希望事务代码块正常结束时自动提交异常时自动回滚class Transaction: def __init__(self, connection): self.connection connection def __enter__(self): return self.connection def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is None: self.connection.commit() else: self.connection.rollback()业务代码就变成了with Transaction(conn) as cursor: cursor.execute(update users set name李四 where id1)这比手动写try/except/finally要清晰得多也从根本上避免了“忘记提交”或“忘记回滚”的问题。我在多个项目中都用过这种模式它把资源释放、异常处理、事务边界统一收纳进协议里调用方只需关注业务本身。7.3 contextlib.contextmanager用生成器简化上下文管理器实现上下文管理器不一定非要写一个类。Python 的contextlib提供了contextmanager装饰器可以把一个生成器函数包装成上下文管理器用yield来划分“进入时”和“退出时”from contextlib import contextmanager contextmanager def transaction(connection): try: yield connection connection.commit() except Exception: connection.rollback() raise with transaction(conn) as cursor: cursor.execute(update users set name李四 where id1)这一种写法在需要快速封装资源管理逻辑时非常高效。我在写临时计时器、临时目录、数据库事务时通常优先考虑contextmanager只有需要在一个类里维护大量状态时才会写完整的类式上下文管理器。需要注意的是yield之前的代码相当于__enter__yield之后、函数结束前的代码相当于__exit__异常会从yield处重新抛出所以try/finally或try/except的配合是必须的。8. 更进一步call、描述符与元类的边缘玩法8.1call让实例像函数一样调用如果一个类实现了__call__那么它的实例可以像函数一样直接加括号调用。这个特性在实现无状态的策略模式、装饰器、偏函数时特别有用。class Multiplier: def __init__(self, factor): self.factor factor def __call__(self, value): return value * self.factor double Multiplier(2) triple Multiplier(3) print(double(5)) # 10 print(triple(5)) # 15和嵌套函数、lambda相比__call__的优势在于实例可以携带状态。比如上面这个Multiplier因子存在实例里调用时不需要每次传参。在实现装饰器类时__call__几乎就是标配在统一接口、策略切换的场景下对象和函数可以无缝替换调用方完全不需要关心它到底是不是函数。8.2 描述符property、classmethod 的底层原理描述符是 Python 属性机制中藏得最深的一块也是很多“魔法”的真正源头。一个类只要实现了__get__、__set__或__delete__中的任意一个方法它就是一个描述符。当一个类的类属性指向描述符对象时对该实例属性访问会被描述符拦截。property、classmethod、staticmethod本质上都是描述符。理解这一层后你就能明白为什么property可以把方法“伪装”成属性为什么类方法自动接收cls。实际项目中可以用描述符写一个带校验的字段类型这也是 ORM 字段的实现思路class PositiveNumber: def __set_name__(self, owner, name): self.name _ name def __get__(self, instance, owner): if instance is None: return self return getattr(instance, self.name) def __set__(self, instance, value): if value 0: raise ValueError(值必须大于 0) setattr(instance, self.name, value) class Product: price PositiveNumber() p Product() p.price 100 # 正常 p.price -1 # ValueError: 值必须大于 0这段代码里price不是一个普通属性而是一个描述符。每次对p.price赋值都会经过PositiveNumber.__set__的校验。Django 的models.IntegerField、SQLAlchemy 的Column底层都是类似机制。8.3 什么时候该用魔法方法什么时候别硬上讲了这么多真正回到项目里该怎么做我的个人判断标准很简单如果你在开发框架、基础组件、公共库、数据模型魔法方法是设计接口的重要工具该用就用。如果你只是在写一次性脚本或业务模块内部类优先用普通方法和明确的函数调用不为“魔法”而魔法。如果某个对象需要在语法层面表现出“像内置类型一样”的特征比如支持len、能比较、能迭代、能进with那么魔法方法是不可避免的。如果只是想让代码“看起来高级”强行重载__getattribute__或__setattr__反而会给后续维护者带来沉重心理负担。我见过不少初学者喜欢把所有能想到的魔法方法都堆到一个类里结果代码变得极其难调试。魔法方法真正的价值在于“用最少的代码让对象行为符合直觉”这个“直觉”通常指的是 Python 内置类型的交互方式而不是花哨技巧。最后再分享一个小技巧调试魔法方法相关问题时不要靠猜直接在方法里加一行print(f调用 {method_name}参数是 {args})或者用pdb断点看解释器到底调用了哪个方法、传了什么参数。很多看似神奇的问题比如“为什么我的对象放进集合之后找不到了”“为什么判断结果不对”其实都是__eq__和__hash__没有配合好。把执行链路打出来看一眼就明白了。魔法方法不是需要背的 API 清单它们是一套帮助你理解 Python“对象如何与语言交互”的窗口。真正写过几次自定义容器、比较器、上下文管理器之后你会发现自己对 Python 的理解会明显上一个大台阶。