先从一个很基础的问题说起:[1] + [2]这行代码,Python 是怎么知道要返回[1, 2]的?你可能会说,这是语法层面的加法,列表本来就能相加。但再往深一层想,这个“本来能相加”的能力,并不像 C++ 那样由编译器针对内置类型写死,而是被设计成了一套“协议约定”。在 Python 里,这种约定靠的是一组以双下划线开头和结尾的方法,也就是大家常说的魔术方法(dunder method)。
写过一段时间 Python 的人,基本都会用__init__,但很少有人能把这一整套双下划线方法讲清楚。我在刚入行时也有同样的困惑:为什么 Python 不像 Java 那样,万物皆方法,非要搞出len(obj)这种看起来像全局函数的东西?后来读了一些内部实现相关的资料才明白,双下划线方法不是某种语法装饰,而是 Python 整个对象系统的地基。它决定了你的自定义类能不能被len()调用、能不能用for遍历、能不能被放进set、能不能用with管理、能不能像函数一样执行。这篇文章我就把这套东西从头到尾拆开聊一遍,包括每个方法什么时候被调用、怎么实现、有哪些坑,以及我实际排错时积累的一些经验。
这篇文章适合两类人:一类是已经会写基础 Python、但想真正理解“对象协议”的开发者;另一类是正在设计自己模块或类库、希望自定义类能和内置类型一样好用的同学。我会用大量可运行的代码示例,尽量让每个结论都能直接落地上手。
1. 魔术方法不是魔法:Python 为什么要把接口约定藏在双下划线后面
1.1 从len(obj)而不是obj.len()说起
在所有编程语言里,Python 算是少数几个“内置函数 + 特殊方法”双重结构的语言。你用len("abc")、len([1,2,3]),甚至len(自定义对象)都能得到长度;换成 Java,你得在类里自己写size(),再用obj.size()去调。这两种设计没有绝对优劣,但 Python 这种方案有一个非常隐秘的优点:它把“一个对象应该具备什么能力”这件事,从“继承自某个基类”解耦成了“实现了某个协议”。
举个例子,Python 的len()在 CPython 内部,本质上做的事情差不多是这样的:先检查传入对象的类型,然后在类型对象上查找名为__len__的方法。如果找到了,就调用它并返回结果;找不到,就抛出TypeError: object of type 'XXX' has no len()。换句话说,len()不是一个真正意义上的全局函数,而是一个“协议入口”。只要你的类实现了__len__,不需要继承任何框架基类,它就是 Sized 协议的一员。
这种设计的影响很深远。你在写代码时,不需要关心传入的到底是一个list、dict、str,还是某个第三方库里的自定义集合,只要它实现了__len__,len()就一定能用。这正是 Python “鸭子类型”哲学的底层支撑:不看对象是什么类,只看它能不能响应这个操作。
1.2 双下划线到底在防什么
新手常问:为什么这些方法的名字非要长得这么丑,前后各加两个下划线?这其实是一个“既防冲突,又防误用”的设计。
先说防冲突。Python 的类里可以有成千上万个普通方法名,如果特殊方法叫init、len、add,很容易和用户自己的业务方法撞车。一旦撞上,解释器行为就会变得不可预期。加上双下划线之后,“系统协议”和“用户方法”在命名空间上被天然隔开了。比如你在类里定义一个add(self, x)方法,那纯粹是业务方法,不会影响+运算符;但如果你定义的是__add__(self, other),那这个对象就可以被+操作。这个区别非常重要,我见过有同事在自定义类里写了add方法,然后试图用obj1 + obj2去触发它,结果得到一个TypeError,后来排查半天才发现方法名写错了。
再说防误用。双下划线前缀在 Python 的语法里还有一层含义:类内部的__name形式的成员会被“名称改写”(name mangling),变成_ClassName__name。虽然__init__这种双下划线开头且结尾的魔法方法不会触发改写规则,但双下划线前缀本身已经传递了一个信号:“这是解释器预留的协议,不是在正常业务代码里应该频繁手动调用的东西。”你当然可以显式调用obj.__len__(),但绝大多数场景下,你应该写len(obj)。这一层“防误用”的意图,既是设计哲学,也是写代码时的可读性约定。
为了便于理解,可以把这套机制类比成充电接口标准。USB-C 接口规定了引脚怎么排列、怎么握手,充电头不用关心对面是手机还是耳机;Python 的魔术方法就是“协议引脚”,len()、+、for、with这些语法和内置函数是“充电头”。只要你的对象按照协议把引脚做出来,任何通用入口都能直接驱动它。这就是为什么说,掌握了魔术方法,等于真正掌握了“如何让自定义对象融入 Python 生态”。
下面我用一张表把日常最常用的协议入口和触发场景梳理清楚,后面章节会逐个展开。
| 语法或内置函数 | 触发的方法 | 典型使用场景 |
|---|---|---|
len(obj) | obj.__len__() | 容器类、集合类 |
obj[i]/obj[i] = v | obj.__getitem__()/obj.__setitem__() | 序列、映射、自定义容器 |
for x in obj | obj.__iter__()+obj.__next__() | 可迭代对象、迭代器 |
x in obj | obj.__contains__() | 成员判断 |
obj + other | obj.__add__(other) | 运算重载 |
str(obj)/print(obj) | obj.__str__() | 用户可读输出 |
repr(obj) | obj.__repr__() | 开发者调试输出 |
with obj as x: | obj.__enter__()/obj.__exit__() | 资源管理 |
obj() | obj.__call__() | 可调用对象、函数式编程 |
bool(obj)/if obj: | obj.__bool__() | 真值判断 |
2. 从零手写一个“活成原生类型”的对象:成绩单类的完整实现
概念讲多了容易飘,这一节我直接带你手写一个类。定义一个大学的成绩单对象ScoreReport,它需要能计算总成绩、比较两个成绩单的高低、可以把两个成绩单合并、可以用for遍历每次考试成绩、可以判断某门课在不在成绩单里、还能被len()和bool()调用。我故意选这个稍微综合的场景,是因为它能把最常见的魔术方法一次性串起来,而不是每讲一个方法就造一个没意义的玩具类。
2.1 初始化与展示:__init__、__repr__、__str__
class ScoreReport: def __init__(self, owner, scores=None): self.owner = owner # 这里用 list(scores or []) 而不是直接赋值,避免多个实例共享同一个可变 list self.scores = list(scores or []) def __repr__(self): return f"ScoreReport(owner={self.owner!r}, scores={self.scores!r})" def __str__(self): course_str = ", ".join(f"{name}:{score}" for name, score in self.scores) return f"{self.owner} 的成绩单:{course_str}"先说一个最重要的坑:__init__里的list(scores or [])是必须的,不能写成self.scores = scores or []。如果直接把参数列表赋值给实例属性,那么两个不同的ScoreReport可能会共享同一个底层列表,一个实例改了数据,另一个也跟着变。这个坑表面上是列表引用问题,本质上是你写类时的可变对象复制意识。
接下来看__repr__和__str__的区别。这两个方法平时容易混,但它们面向的对象完全不同:
__repr__是给开发者看的,它的目标是在异常、日志、IDE 调试器里让你一眼看穿这个对象的内部结构。理想情况下,repr(obj)的返回值应该能直接重建一个等价对象,所以我在里面用了!r格式符,保证字符串内容带引号,这样ScoreReport(owner='张三', scores=[...])可以直接被eval解析。__str__是给最终用户看的,print(obj)和str(obj)会优先调用它。它不需要能重建对象,但应该让用户读起来舒服。
这里有个非常实用的细节:如果只实现了__repr__没实现__str__,print(obj)也会退回去调用__repr__。反之则不行。所以我个人的习惯是:永远先写__repr__,它是调试底线;当需要面向用户输出时再补__str__。
2.2 让对象有长度和真值:__len__与__bool__
def __len__(self): return len(self.scores) def __bool__(self): return bool(self.scores)实现__len__后,len(score_report)就能用了。很多人不知道的是,__len__还关系到一个隐藏行为:如果你没有定义__bool__,Python 会退化成len(obj) == 0来判断真假。这就是为什么空列表、空字典、空字符串在if条件里是False——它们都实现了__len__,而长度为 0 时会被当成假值。
一旦自己实现了__bool__,这个退化机制就被覆盖了。在上面的例子里,成绩单里哪怕有一门课成绩为 0,bool()也返回True,因为[("数学", 0)]这个列表非空。如果业务逻辑认为“没有有效成绩才是空”,你可以把这个判断写在__bool__里做更精细的控制。
2.3 比较和运算:__eq__与__lt__、__add__
def __eq__(self, other): if not isinstance(other, ScoreReport): return NotImplemented return self.owner == other.owner and self.scores == other.scores def __lt__(self, other): if not isinstance(other, ScoreReport): return NotImplemented return sum(score for _, score in self.scores) < sum(score for _, score in other.scores) def __add__(self, other): if not isinstance(other, ScoreReport): return NotImplemented return ScoreReport( owner=self.owner + " & " + other.owner, scores=self.scores + other.scores, )关于__eq__,我最想强调的一点是:不要在一个不匹配的类型面前直接返回False,而是应该返回NotImplemented。
很多初学者会写成:
def __eq__(self, other): return self.owner == other.owner and self.scores == other.scores当other是一个整数或字符串时,这段代码会抛AttributeError,因为int没有owner这个属性。而返回NotImplemented的意思是:“我不擅长和这个类型比较,请你让对方也试试,如果对方也拒绝,Python 才会兜底返回False。”同样地,__lt__返回NotImplemented是为了支持反向比较,比如a > b实际会尝试b < a。
实现了__lt__之后,sorted([s1, s2, s3])就能直接用,因为排序算法只需要知道“谁小于谁”。如果以后还需要>、>=、<=,Python 会自动从__lt__推导出部分比较结果,但你也可以显式实现__gt__等方法来精确控制。
__add__的设计也要多说一句:我在返回值里直接创建了一个新的ScoreReport,而不是修改self。这是符合直觉的——+运算在语义上应该是“生成一个新对象”,而不是原地修改旧对象。如果你希望支持s1 += s2这种原地操作,可以单独实现__iadd__。
2.4 迭代与成员判断:__iter__、__next__、__contains__、__getitem__
def __iter__(self): # 注意:这里为了演示故意用一个游标属性 self._idx = 0 return self def __next__(self): if self._idx >= len(self.scores): raise StopIteration value = self.scores[self._idx] self._idx += 1 return value def __contains__(self, item): return any(course == item for course, _ in self.scores) def __getitem__(self, index): return self.scores[index]迭代器协议是很多新手卡壳的地方。__iter__必须返回一个迭代器对象,这个迭代器对象要有__next__方法。我在例子里直接让ScoreReport自己充当了迭代器,这是一种简写,但要注意它的副作用:对象内部多了一个_idx游标状态,如果同一个对象被两个for循环嵌套遍历,游标会互相干扰,出现很难排查的诡异行为。
更稳妥的做法是单独写一个迭代器类,或者直接让__iter__返回生成器:
def __iter__(self): for course, score in self.scores: yield course, score这段代码更符合实际工程里的写法。把__iter__写成生成器函数之后,for name, score in report:就能像遍历列表一样遍历成绩单。
__contains__是in操作符的底层实现。如果没写__contains__,Python 会退化为逐个调用__iter__遍历查找;再不行,就退回用__getitem__从下标 0 开始逐个取,直到抛IndexError。所以你会发现,就算只实现了__getitem__不实现__iter__,对象也能被for循环遍历,这是 Python 为了兼容旧式序列而保留的“退路协议”。但我建议不要依赖这个退路,能用__iter__就用__iter__,语义更清晰,性能也更好。
至于__getitem__,它实现了下标访问report[0],同时因为它返回的是scores的切片,report[0:2]也能工作——列表切片会自动转发给__getitem__的slice参数,这一点很多教程都讲得不够细。
2.5 把成绩单类放在一起看效果
if __name__ == "__main__": s1 = ScoreReport("张三", [("数学", 88), ("英语", 92)]) s2 = ScoreReport("李四", [("数学", 95), ("英语", 80)]) print(repr(s1)) # ScoreReport(owner='张三', scores=[('数学', 88), ('英语', 92)]) print(s1) # 张三 的成绩单:数学:88, 英语:92 print(len(s1)) # 2 print(bool(ScoreReport("空"))) # False print(s1 == s2) # False print(s1 < s2) # False,张三总分180 < 李四总分175? 不对,这里是180 > 175,所以False print(s1 + s2) # 张三 & 李四 的成绩单:数学:88, 英语:92, 数学:95, 英语:80 for course, score in s1: print(course, score) # 数学 88 / 英语 92 print("数学" in s1) # True print(s1[1]) # ('英语', 92)一个自定义类,通过这一组双下划线方法,就获得和内置容器几乎一样的体验。这也是这篇文章里最重要的一段代码,建议你把它完整跑一遍,观察每个操作实际触发了哪个方法。
3. 最容易被坑的两组对照:初始化与析构、属性访问的底层链路
3.1__new__和__init__不是一回事
很多教程会把__init__叫“构造函数”,这是不严谨的。真正的“构造”发生在__new__里:它负责分配内存并返回一个实例;__init__只是在这个已经存在的实例上做初始化。完整流程是先__new__后__init__,而且有个非常重要的规则:如果__new__返回的不是cls类型的实例,__init__根本不会被调用。
那实际写代码时什么时候要重写__new__?答案是:场景很少,但一旦遇到就必须用它。最典型的案例是单例模式:
class SingleConnection: _instance = None def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance重写__new__时,一定要返回super().__new__(cls)的结果,否则创建出来的可能不是cls的实例,导致后续逻辑全部错乱。另一个场景是继承不可变类型,比如你想写一个元组子类,在__init__里修改字段是无效的,必须在__new__里做好全部处理——不可变类型的字段在__init__阶段已经不能改了。
3.2__getattr__与__getattribute__:属性访问的完整链路
这两个方法名字很像,行为差异却非常大。
__getattribute__是一个无条件拦截器。只要访问实例的任何属性,不管这个属性存不存在,都会先经过它。__getattr__是一个兜底处理器。只有正常的属性查找流程全部失败之后,它才会被调用。
看一段代码就清楚了:
class Demo: def __init__(self): self.name = "demo" def __getattribute__(self, name): print("getattribute called:", name) return super().__getattribute__(name) def __getattr__(self, name): return f"fallback: {name}" d = Demo() print(d.name) # 先打印 getattribute called: name,再打印 demo print(d.not_exist) # 先打印 getattribute called: not_exist,再打印 fallback: not_exist看到没有,即使是访问一个不存在的属性,也会先调__getattribute__,查找失败后才落到__getattr__。这解释了为什么在__getattr__里“安全地”访问self.xxx有时会出问题:如果self.xxx本身不存在,并且你在这个方法里又写了self.xxx,就会形成一个隐形的递归循环。正确的做法是直接操作self.__dict__,或者调用object.__getattribute__(self, name)来绕过这个兜底逻辑。
__getattr__最有实用价值的场景是“懒加载”。比如你在读取后端的 JSON 配置时,希望把config["database"]["host"]变成config.database.host,又不想为每个字段手写属性:
class AttrDict: def __init__(self, data): self.__dict__["_data"] = data def __getattr__(self, name): try: value = self._data[name] except KeyError: raise AttributeError(name) if isinstance(value, dict): return AttrDict(value) return value这里我把原始字典存在self.__dict__["_data"],而不是self._data = data,就是为了避免self._data的访问触发__getattr__,从而造成递归。这个 trick 非常实用,建议记下来。
3.3__setattr__与__delattr__:赋值和删除也不是省油的灯
和读属性对应,写操作也有两个钩子:__setattr__在self.xxx = yyy时触发,__delattr__在del self.xxx时触发。一个经典的应用是做不可变对象或者字段校验:
class ValidatedScore: def __setattr__(self, name, value): if name == "score" and not (0 <= value <= 100): raise ValueError("score must be between 0 and 100") super().__setattr__(name, value)不过这里最大的坑是:在__setattr__内部如果写self.score = value,会再次触发__setattr__,陷入无限递归。正确的姿势是用super().__setattr__(name, value)或者直接写self.__dict__[name] = value。
3.4 改了__eq__却忘了__hash__:字典和集合里的幽灵问题
Python 有一个非常硬性的约定:两个对象如果相等(a == b为True),那么它们的哈希值必须相等(hash(a) == hash(b))。这个约定的底层逻辑是set和dict的实现原理:它们先通过哈希值定位到“桶”,再用==比较桶里的对象。如果两个相等对象哈希不同,它们会被放进不同的桶,导致查找永久失败。
麻烦的地方在于,当你自定义了__eq__之后,Python 会自动把该类的__hash__设为None,对象就变成“不可哈希”了。不信你试试:
class Person: def __init__(self, name): self.name = name def __eq__(self, other): return isinstance(other, Person) and self.name == other.name p = Person("张三") hash(p) # TypeError: unhashable type: 'Person'这是 Python 的自我保护机制:它知道你重定义了相等语义,但不知道你的相等逻辑是否基于可变字段,所以它不敢再默认按对象 id 计算哈希。如果你确定这个对象在哈希表生命周期内不会被修改,可以显式补上:
def __hash__(self): return hash(self.name)这里隐含的另一个教训是:如果你用一个可变对象作为dict的 key,那么这个对象一旦被修改,哈希值就变了,之后你再按原来的 key 去查,永远找不到。所以被放进set和dict的对象的参与哈希的字段,必须是不可变的。
3.5 别把__del__当 C++ 析构函数用
最后一个容易引起误解的是__del__。在 CPython 里,它确实会在对象的引用计数归零时被调用,但你不能依赖它在确定的时间点执行。原因有两点:一是引用计数只适用于 CPython,换成 PyPy 这类解释器,回收时机完全不同;二是循环引用会导致对象进入垃圾回收器(gc模块)的待处理列表,__del__的执行会被推迟到 GC 真正清理的时机。
所以,如果你写:
def __del__(self): self.file.close()这在很多情况下都没问题,但进程退出时如果还有对象没被回收,文件可能不会被优雅关闭。正确的资源管理方式是配合with语句使用上下文管理器,也就是下一章要讲的__enter__和__exit__。__del__更适合作为“最后的保底清理”,而不是主力资源释放手段。
4. 让对象从“能跑”到“好用”:上下文管理、下标访问与可调用对象
4.1__enter__和__exit__:把资源管理变成语法级承诺
with open("file.txt") as f:是 Python 里最常用的资源管理方式,而它的底层就是__enter__和__exit__。任何实现了这两个方法的对象都可以被with语句接管。__enter__的返回值会成为as后面的变量,__exit__在代码块结束后必定被调用,不管代码块内部有没有抛异常。
一个常见的实战场景是“自动计时器”:
import time class Timer: def __enter__(self): self.start = time.perf_counter() return self def __exit__(self, exc_type, exc_val, exc_tb): self.elapsed = time.perf_counter() - self.start print(f"执行耗时: {self.elapsed:.4f}s") with Timer(): total = sum(range(1_000_000))__exit__有四个参数:exc_type、exc_val、exc_tb,分别对应异常类型、异常实例、异常 traceback。如果代码块里没有异常,这三个参数都是None。如果你想让with块吞掉某个异常(通常不推荐),让__exit__返回True即可,异常会被静默处理;返回None或False,异常继续向上传播。
这个机制非常适合做数据库事务。可以在__enter__里开启事务,__exit__里根据有没有异常决定commit还是rollback。这也是为什么 ORM 和数据库驱动几乎都提供上下文管理器接口的原因。
4.2__getitem__和__setitem__:让对象像列表和字典一样可读写
我在第 2 章已经实现了__getitem__,这一节把它的机制彻底说透。obj[key]会被 Python 翻译成type(obj).__getitem__(obj, key)。注意,这里的查找是按类型进行的,不是直接在对象上找。这也是为什么给某个实例单独挂载一个__getitem__属性不会生效,因为它影响不了类型的查询路径。
__getitem__不仅能接收整数下标,还能接收slice对象和任意 key:
class MultiIndex: def __getitem__(self, key): if isinstance(key, slice): return f"slice from {key.start} to {key.stop}" return f"single key: {key}" m = MultiIndex() print(m[1]) # single key: 1 print(m[1:3]) # slice from 1 to 3配合__setitem__、__delitem__后,对象就能像一个“行为可控的字典”:你可以在赋值时做类型检查,可以在删除时触发清理回调,甚至可以实现类似“带默认值的字典”的效果。
4.3__call__:让实例拥有函数的能力
在 Python 里,函数是一等公民,但有时候你需要一个“带状态的函数”。这时让你的对象实现__call__,它就能像普通函数一样被调用。
class Adder: def __init__(self, n): self.n = n def __call__(self, x): return x + self.n add_5 = Adder(5) print(add_5(3)) # 8这里add_5是一个对象,但它可以像函数一样使用。这种模式在几种场景下特别好用:
- 策略模式:把不同的处理逻辑封装成可调用对象,互相替换。
- 装饰器工厂:实现一个带参数的装饰器。
- 回调函数:给框架传入一个带
__call__的实例,它既能执行逻辑,又能保存中间状态。
一个更进阶的用途是让类本身“可配置”。你在__init__里存好参数,__call__里执行主逻辑,然后把这个对象丢给高层框架。框架不关心它到底是不是函数,只要能调用就行,这就是接口与具体实现的解耦。
4.4__contains__、__iter__和__getitem__的优先级关系
很多人在写自定义容器时会困惑:in操作到底触发哪个方法?其实 Python 的查找顺序非常明确:
- 先找
__contains__,找到了直接用它判断。 - 如果没有
__contains__,就尝试用__iter__逐个遍历比对。 - 如果
__iter__也没有,就退回__getitem__从下标 0 开始逐个取,直到IndexError。
这个优先级设计很符合“最精确的方法最优先”的原则。你在设计容器类时,如果成员判断能被一个 O(1) 的集合计算完成,那一定要实现__contains__,否则默认的遍历方式可能要 O(n)。这个优化在数据量大时非常明显。
4.5 一张表看清常用魔术方法的使用时机
| 你写的代码 | 解释器实际调用 | 备注 |
|---|---|---|
obj + other | type(obj).__add__(obj, other) | 左边不行会尝试other.__radd__ |
obj * n | type(obj).__mul__(obj, n) | 类似的还有__rmul__ |
len(obj) | type(obj).__len__(obj) | 返回必须是非负整数 |
obj[i] | type(obj).__getitem__(obj, i) | i 可以是 int、slice、str 等 |
obj[i] = v | type(obj).__setitem__(obj, i, v) | 赋值表达式 |
for i in obj | type(obj).__iter__(obj) | 没有__iter__时退回__getitem__ |
x in obj | type(obj).__contains__(obj, x) | 没有__contains__时退回迭代 |
with obj as v: | type(obj).__enter__(obj)/type(obj).__exit__(...) | __exit__接收异常参数 |
str(obj)/print(obj) | type(obj).__str__(obj) | 没有__str__时退回__repr__ |
repr(obj) | type(obj).__repr__(obj) | 调试器、交互式终端调用 |
bool(obj) | type(obj).__bool__(obj) | 没有__bool__时退回__len__ |
obj() | type(obj).__call__(obj) | 让实例可调用 |
hash(obj) | type(obj).__hash__(obj) | 与__eq__必须同步实现 |
a == b | type(a).__eq__(a, b) | 也可触发type(b).__eq__(b, a) |
5. 实战排查与性能调优中的几个观察视角
5.1 用“打印大法”观察魔术方法的真实调用时机
魔术方法最大的特点是“隐式调用”。你在代码里写下一个+,并不能直接看到__add__被调用。排查问题时,我经常会在方法内部临时加一行print,观察它的触发轨迹。
class DebugList: def __init__(self, items): self.items = list(items) def __len__(self): print("__len__ called") return len(self.items) def __getitem__(self, idx): print(f"__getitem__ called: {idx}") return self.items[idx] dl = DebugList([1, 2, 3]) print(len(dl)) # __len__ called for i in dl: # 会连续打印 __getitem__ called: 0 / 1 / 2 / 3 pass注意最后这个for循环,会打印__getitem__ called: 0、1、2、3。第 3 次是 Python 在尝试通过越界IndexError判断“迭代结束”。看到这你就能明白,Python 的旧版序列迭代协议确实是这么工作的。
如果不想给每个方法都加打印,还有一个偷懒技巧:在__getattribute__里临时加一行print(name),能看到实例上所有属性访问的完整轨迹。但这个方法日志量极其庞大,而且容易刷屏,一般只在排查递归问题时才用。
5.2__slots__:不是魔术方法,却是性能问题的常见解药
严格来说,__slots__是在类定义时声明的一个类属性,它不是双下划线开头的“协议方法”,但它对理解对象属性机制非常有帮助,所以放在这里一起说。
默认情况下,Python 的每个实例都有一个__dict__字典,用来存放实例属性。这个字典带来了灵活的属性增删能力,但也带来了内存开销。如果一个类你会创建成千上万个实例,并且字段是固定的,可以用__slots__禁用这个字典:
class Point: __slots__ = ("x", "y") def __init__(self, x, y): self.x = x self.y = y加上之后,实例不再有__dict__,属性访问在底层变成了类似 C 结构体成员的固定偏移量访问,内存占用大幅下降,属性访问速度也会快一些。代价是不能随意给实例添加新属性。当你发现程序内存占用异常时,优先检查有没有大量对象都在维护着自己的__dict__。
5.3 魔术方法查找走的不是实例字典
一个非常隐蔽的坑是:魔术方法的查找发生在类型(类)上,而不是实例上。这意味着,如果你这样写:
class A: pass a = A() a.__len__ = lambda: 42 len(a) # TypeError: object of type 'A' has no len()这个len(a)依然会抛异常,因为len()在type(a)即A类上找__len__,而实例上的__len__属性不影响类的协议查询。理解这一点对调试非常有帮助。有些库允许用户为单个实例动态挂载方法,看起来“像”实现了协议,但实际上只要不是定义在类上的,内置函数一律不认。
5.4 常见报错的快速定位
我整理几个最常见的异常和它们的真实原因,排查时可以先对照一下:
| 报错信息 | 真实原因 |
|---|---|
TypeError: object of type 'X' has no len() | 类没有实现__len__,或者手误写成了__length__之类的 |
TypeError: 'X' object is not iterable | 类没有实现__iter__和__getitem__,for无路可走 |
TypeError: 'X' object is not subscriptable | 类没有实现__getitem__,却用了obj[i] |
TypeError: 'X' object does not support item assignment | 类没有实现__setitem__,却用了obj[i] = v |
TypeError: 'X' object is not callable | 类没有实现__call__,却写了obj() |
AttributeError: __enter__ | 类没有实现__enter__/__exit__,却用了with obj: |
TypeError: unhashable type: 'X' | 类定义了__eq__但没定义__hash__,或者哈希字段是可变的 |
RecursionError | __getattr__/__getattribute__/__setattr__内部又访问了同名字段,出现无限递归 |
5.5 一个务实的开发习惯:写完类先补“协议三件套”
在我自己写类库和接手别人代码时,很容易通过一个类有没有补全基础协议方法,来判断作者的工程经验。所谓“协议三件套”就是:__repr__、__eq__、__hash__。
__repr__让调试信息和日志输出不再是一堆<__main__.X object at 0x...>,直接能看到关键字段。__eq__让对象之间的比较符合业务直觉,而不是默认比较内存地址。__hash__让对象能安全地放进set、dict、frozenset,配合__eq__一起用才不会被临时报unhashable的错卡住。
这三个方法补完之后,你的类才真正具备“值对象”的资格。尤其在写数据模型、领域对象、配置项这类场景时,这套组合带来的提升是立竿见影的。
我个人在实际调试中还有一个体会:如果某天你发现一个自定义对象在set里出现了“重复元素”或者dict的 key 明明看起来一样却查不到,先别怀疑是哈希算法的问题,第一反应应该是去看这个类的__eq__和__hash__是否共同实现了、它们依赖的字段是不是可变对象。这类问题往往藏得很深,因为表面报错并不直接指向魔术方法。掌握双下划线这套协议之后,你会发现自己写的类能和内置类型一样顺手,排错速度也会快一个档次。