第 7 章 数据模型与魔术方法

这一章解开一个谜:为什么 Python 的 list 能用 len()、dict 能 [] 取值、for 能遍历一切?答案是「数据模型」——一套你也能给自己对象实现的魔术方法(dunder)。学会它,你写的类就能像内置类型一样自然地被使用。

墨叔:老哥,你在 Go 里写个 type User struct,要算长度?自己写 func (u User) Len() int。要打印?自己写 String()。Python 里你 len(x)、print(x)、x[0]、for v in x 全能用,而且你自己写的类也能这么用。秘密就在那些双下划线方法——__len__、__str__、__getitem__。今天咱们把这套「数据模型」拆开,让你写的对象不再像个外来的怪物。

老哥:我 AI 生成的类里从没写过 __len__,但 len(my_obj) 居然报错。

墨叔:报错就对了——len() 不是函数,是去调你对象的 __len__。你没实现,它就 TypeError。这其实是个设计哲学:Python 把「语言内置操作」开放成「可被你的类接管的方法」。你接了,对象就「像内置类型」;不接,就只是个普通实例。

一、dunder 方法总览(怎么用)

「dunder」= double underscore,双下划线开头结尾的方法。它们不是给你直接调用的(别写 obj.__len__(),写 len(obj)),而是钩住语言语法的回调。语法碰到 len(x),解释器转去调 x.__len__()。

按用途分几族,今天重点讲后面五块:

语法调用的方法干啥
len(x)x.__len__()长度
str(x) / print(x)x.__str__()给人看的字符串
repr(x)x.__repr__()给开发者看的字符串
x()x.__call__()把对象当函数调
x[k]x.__getitem__(k)索引/取值
for v in xx.__iter__()可迭代
x + yx.__add__(y)运算符

先给个最小骨架,看这些方法怎么挂:

class Counter:
    def __init__(self, data: dict) -> None:
        self.data = data
 
    def __len__(self) -> int:
        return len(self.data)
 
    def __getitem__(self, key: str):
        return self.data[key]
 
c = Counter({"a": 1, "b": 2})
print(len(c))      # 2  —— 走 __len__
print(c["a"])      # 1  —— 走 __getitem__

你看,Counter 没继承任何特殊基类,只是实现了两个 dunder,就获得了 len() 和 [] 的能力。这跟 Go 完全不同:Go 里 len() 只对内建类型(slice/map/array/string/channel)有效,你自定义类型想 len() 必须自己写方法并显式调用。Python 把「能不能 len、能不能索引」开放成协议,你的类型只要实现就自动接入全套语法糖。

二、str 与 repr:给用户看 vs 给开发者看

这是新手最混的一对。规则极简单:

  • __str__:给人看的,追求可读。print(obj)、str(obj) 用它。
  • __repr__:给开发者看的,追求能复现——最好 eval(repr(obj)) 能重建出等价对象。repr(obj)、交互式解释器直接输入对象名时用它。
from datetime import date
 
class Order:
    def __init__(self, oid: int, created: date) -> None:
        self.oid = oid
        self.created = created
 
    def __str__(self) -> str:
        return f"订单#{self.oid}({self.created})"          # 给人看,清爽
 
    def __repr__(self) -> str:
        return f"Order(oid={self.oid!r}, created={self.created!r})"  # 给开发者,可重建
 
o = Order(42, date(2026, 1, 1))
print(o)            # 订单#42(2026-01-01)   —— __str__
print(repr(o))      # Order(oid=42, created=datetime.date(2026, 1, 1))  —— __repr__

墨叔:记住黄金法则——__repr__ 是给 debugging 的,__str__ 是给产品的。如果只实现一个,实现 __repr__:Python 在没写 __str__ 时会回退用 __repr__,所以 __repr__ 是「保底」。但 __str__ 不会回退到 __repr__ 的反方向。

一个真实坑:很多人 __repr__ 写得跟 __str__ 一样「只有人类可读」,结果你打日志 logger.info("处理 %r", order) 拿到一串看不出字段的废话,排错时想重建对象却做不到。正确姿势:__repr__ 尽量长成「构造调用」的样子,!r 格式化确保字段也用 repr 嵌套。

对照 Go:Go 的 String() 方法(实现 fmt.Stringer)只有一种,没有「repr/str」之分。Python 这套双轨制让你同时照顾「日志里能重建」和「界面上好看」,代价是多写一个方法、还得想清楚两者语义边界。项目里:面向用户的 DTO 写 __str__,领域模型/值对象写 __repr__ 方便调试。

三、call:让对象可调用

实现 __call__,你的实例就能当函数用:obj(args) 等价于 obj.__call__(args)。这让你把「状态 + 行为」打包成一个可调用体——本质是带着闭包变量的函数,但比闭包更面向对象、可继承、可测。

class Retry:
    def __init__(self, times: int, backoff: float = 0.1) -> None:
        self.times = times
        self.backoff = backoff
 
    def __call__(self, func):
        import time
        def wrapper(*args, **kwargs):
            for i in range(self.times):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if i == self.times - 1:
                        raise
                    time.sleep(self.backoff * (2 ** i))
            return None
        return wrapper
 
@Retry(times=3)              # retry 是个实例,被当装饰器调 → 走 __call__
def fetch(url: str) -> str:
    ...
 
fetch("https://api")         # 实际调用 wrapper

这里 Retry(times=3) 创建实例,因为它有 __call__,被当成装饰器 @Retry(3) 用——Python 装饰器语法 @x 就是「对下面函数调用 x(func)」。对照 Go:Go 里「可调用对象」就是函数本身,type Retrier func() error 是函数类型;你要「带配置的调用器」得定义一个结构体 + 一个 Do 方法。Python 的 __call__ 把「配置 + 执行」融进一个可调用实例,调起来和函数无差别,这是它函数式+面向对象混合基因的典型产物。

注意区分:__call__ 让实例可调用,和「类装饰器」(类本身作为装饰器,调的是类)是两回事——类装饰器是 def __init__(self, func): self.func = func 然后实现 __call__ 接收原函数的返回值逻辑,正上面这个例。别和「类当装饰器工厂」搞混。

四、getitem 与 iter:索引与迭代

这两个让你自定义容器「像 list/dict 一样自然」。

__getitem__:支持 obj[key] 索引。如果你只实现它、没实现 __iter__,Python 还会自动把它变成可迭代——从 0 开始不断 obj[0]、obj[1]… 直到 IndexError。所以一个小类就能同时支持索引和 for 遍历:

class Range:
    def __init__(self, n: int) -> None:
        self.n = n
    def __getitem__(self, i: int):
        if i >= self.n:
            raise IndexError
        return i * i
 
r = Range(4)
print(r[2])            # 4
print(list(r))         # [0, 1, 4, 9]  —— 靠 __getitem__ 自动可迭代

但更规范的做法是显式实现 __iter__,返回一个迭代器(通常配合生成器,呼应第 4 章),这样 for、拆包、in 都对你生效:

class TokenStream:
    def __init__(self, text: str) -> None:
        self.tokens = text.split()
    def __iter__(self):
        return (t.upper() for t in self.tokens)   # 生成器,惰性
    def __getitem__(self, i):
        return self.tokens[i]
 
ts = TokenStream("hello world")
print("HELLO" in ts)          # True  —— __iter__ 支撑 in
print(ts[0], ts[-1])          # hello world

__getitem__ 还能实现切片——它收到的 key 可能是 slice 对象:

def __getitem__(self, key):
    if isinstance(key, slice):
        return self.data[key.start:key.stop:key.step]
    return self.data[key]

这让你的容器支持 obj[1:5] 像 list 一样。真实项目里:你写一个「时间序列」「分页结果集」「懒加载的远端列表」,实现 __getitem__/__len__/__iter__,调用方就能用 len()、[]、切片、for 全部标准语法操作它,无需学你的专属 API。对照 Go:Go 的自定义容器要 Get(i)、Len()、Range 各写各的,for 只能 for i, v := range s 且要求 s 是 array/slice/map/string 或你实现 range 协议(Go 1.23 才加 iter)。Python 这套协议让你「零适配接入语言原生语法」,是它表达力强的大头。

五、运算符重载:add 等

+、-、==、< 这些运算符,背后都是 dunder。x + y → x.__add__(y)(若返回 NotImplemented,再试 y.__radd__(x),这叫反射,支持不同类型运算)。这让你的领域对象能用数学式表达,代码读起来像业务语言。

from dataclasses import dataclass
 
@dataclass
class Money:
    cents: int
 
    def __add__(self, other: "Money") -> "Money":
        if not isinstance(other, Money):
            return NotImplemented          # 交给 other.__radd__,或触发 TypeError
        return Money(self.cents + other.cents)
 
    def __eq__(self, other) -> bool:
        return isinstance(other, Money) and self.cents == other.cents
 
    def __lt__(self, other: "Money") -> bool:
        if not isinstance(other, Money):
            return NotImplemented
        return self.cents < other.cents
 
a = Money(100)
b = Money(50)
print(a + b)          # Money(cents=150)
print(a > b)          # True
print(sorted([b, a])) # [Money(50), Money(100)]

注意 __eq__ 的写法:isinstance 守卫 + 返回 NotImplemented(不是 False!)。NotImplemented 告诉解释器「这组合我处理不了,你问问对方或报错」,而直接 return False 会让 Money(100) == "x" 静默成 False,埋雷。另外实现 __eq__ 会自动禁用默认 __hash__(对象变不可哈希),如果你还要当 dict key,记得显式 @property 之外加 __hash__ = ... 或用 @dataclass(frozen=True) 让它自动生成。

老哥:__add__ 返回新对象,那 += 呢?会不会改原地?

墨叔:问得刁。a += b 优先调 a.__iadd__(b)(in-place),没有才退回 a = a + b(走 __add__)。上面我只写了 __add__,所以 += 是「算新值再重新绑定」,不原地改——对不可变值对象(Money 该是不可变的)这正正确。如果你想让容器类支持原地append式的 +=,实现 __iadd__ 返回 self。Go 没有运算符重载,a.Add(b) 老老实实写,好处是不会有人重载 + 做出反直觉的事,代价是领域表达式不够「像自然语言」。Python 给了你这把刀——刀刃朝外时,请确保 a + b 的语义和直觉一致,别重载出 a + b 居然删除 a 这种反人类操作。

六、收益与代价(为什么这么设计)

收益:

  1. 语法自然。你的对象用 len()、[]、for、+ 全和内置类型一致,调用方零学习成本。写库的人爽,用库的人更爽。
  2. 组合性。实现了 __iter__ 的对象能进 sum()、max()、解包、*args,接入整条内建生态,不必为每种操作写专属方法。
  3. 表达力。领域模型用运算符表达,total = price + tax 比 total = price.add(tax) 更贴业务语言。

代价:

  1. 隐式性。x[0] 背后跑的是你的方法,调试时栈里多一层,且「看起来像简单取值」实则可能触发昂贵逻辑(比如远程拉取)。这和 @property 的陷阱同源(第 8 章)。
  2. 语义责任。__eq__/__add__ 写错,==、<、+ 全反直觉,且错误极隐蔽——因为你不会怀疑运算符。
  3. NotImplemented 纪律。忘记返回 NotImplemented 而返回 False/抛错,跨类型运算行为就怪。这是 dunder 里最易错的点。

对照 Go 的总账:Go 弃用运算符重载,换取「所见即所得、绝不反直觉」;Python 用它换取「表达自然、生态统一」。你从 Go 过来,请带着 Go 的「运算符不该有副作用」本能来写 dunder——dunder 里放纯计算、别放 I/O、别放会失败的网络调用。

七、贴近项目的例子:一个带缓存的向量类型

把 __len__/__getitem__/__iter__/__add__/__repr__ 揉一起,写一个「特征向量」,能当 list 用、能相加、能切片、调试还能重建:

from functools import lru_cache
 
class Vector:
    def __init__(self, values: list[float]) -> None:
        self._v = list(values)
 
    def __len__(self) -> int:
        return len(self._v)
 
    def __getitem__(self, i):
        return self._v[i]            # slice 也自动支持,因为 list 支持
 
    def __iter__(self):
        return iter(self._v)
 
    def __add__(self, other: "Vector") -> "Vector":
        if not isinstance(other, Vector) or len(self) != len(other):
            return NotImplemented
        return Vector([a + b for a, b in zip(self._v, other._v)])
 
    def __eq__(self, other) -> bool:
        return isinstance(other, Vector) and self._v == other._v
 
    def __repr__(self) -> str:
        return f"Vector({self._v!r})"
 
v = Vector([1.0, 2.0, 3.0])
print(len(v))            # 3
print(v[1:])             # [2.0, 3.0]
print(v + Vector([0, 0, 1]))   # Vector([1.0, 2.0, 4.0])
print(sum(v))            # 6.0  —— 靠 __iter__ 接入 sum

调用方完全不用知道 Vector 内部是 list,它「像 list 一样」被 len、索引、切片、+、进 sum()。这就是数据模型的威力:你写的类型,融入了 Python 自己的语法生态,而非站在外面。

八、total_ordering 与 hash/contains:补全比较与成员判断

墨叔:运算符那一节你写了 __eq__ 和 __lt__,但要支持 >, >=, <= 完整比较,理论上还得补 __ne__、__gt__、__ge__ 三个——纯样板,写错还容易不一致。标准库用 functools.total_ordering 帮你把这活儿包了:你只实现 __eq__ 加一个排序算子(__lt__/__le__/__gt__/__ge__ 之一),其余三个它自动生成。

from functools import total_ordering
 
@total_ordering
class Version:
    def __init__(self, major: int, minor: int) -> None:
        self.major = major
        self.minor = minor
 
    def __eq__(self, other) -> bool:
        return isinstance(other, Version) and (self.major, self.minor) == (other.major, other.minor)
 
    def __lt__(self, other) -> bool:
        if not isinstance(other, Version):
            return NotImplemented
        return (self.major, self.minor) < (other.major, other.minor)
 
v1, v2 = Version(1, 2), Version(1, 5)
print(v1 < v2, v1 <= v2, v1 == v2, v1 >= v2, v1 != v2)   # 全可用,只写了 __eq__+__lt__

注意 @total_ordering 的硬约束:必须实现 __eq__,且至少实现一个 __lt__/__le__/__gt__/__ge__,否则运行期报 TypeError。它是「少写样板」的甜头,代价是生成的比较有一次额外方法调用开销(比手写慢一丁点,通常无所谓),且新人不知道比较从哪来,调试时得知道有这装饰器。

再说 __hash__ 和「可哈希性」。Python 里能进 set/dict 键的对象必须可哈希,而规则是:定义了 __eq__ 的类,__hash__ 自动被置为 None(不可哈希),除非你显式定义。所以上面 Version 想当 dict key,得加:

def __hash__(self) -> int:
    return hash((self.major, self.minor))

而且铁律:__hash__ 必须与 __eq__ 一致——相等的对象必须哈希相同(a == b ⇒ hash(a) == hash(b)),且哈希值在对象「作为 key 期间」不能变。可变对象(改了字段哈希就变)绝不该可哈希,否则它做 dict key 时会「找不着自己」。这也是为什么 @dataclass 默认不可变才自动生成 __hash__,可变 dataclass 要你显式声明。

最后补一个高频 dunder:__contains__。实现它,x in obj 走 obj.__contains__(x),否则退回「笨办法」——用 __iter__ 逐个比对。你写个容器类,想让 in 快(比如底层是 set/字典),就实现 __contains__ 直接查:

class Whitelist:
    def __init__(self, items) -> None:
        self._s = set(items)
    def __contains__(self, x) -> bool:
        return x in self._s        # O(1),而非遍历
    def __iter__(self):
        return iter(self._s)

对照 Go:Go 的集合成员判断是 _, ok := m[x] 或手写 contains 函数,没有 in 运算符;Python 把 in 开放成协议,你的类型接了 __contains__ 就能用 x in obj 这种最自然的写法。代价同前——「看着像简单判断」可能触发你自定义逻辑,写 dunder 时别塞副作用。

把数据模型整套串起来,你该建立的直觉是:Python 把「语言的基本操作」(len/索引/迭代/比较/调用/打印/成员)全开放成可接管的方法。你接哪些,对象就像内置类型一样自然;接的时候守两条底线——纯计算无副作用、语义和直觉一致。这正是对照 Go「运算符不可重载、所见即所得」时,Python 给你的另一半自由与责任。

九、dunder 速查与避坑清单

墨叔:最后一节给你一张「常用 dunder 速查表 + 三条铁律」,写类时贴墙上。你不用记全,但要知道「这件事 Python 有没有现成协议」,别重复造轮子。

你想让对象支持实现的方法备注
len(x)__len__必须返回非负 int
x[k] / 切片__getitem__key 可能是 int 或 slice
x[k] = v__setitem__可变容器才需要
del x[k]__delitem__可变容器才需要
for v in x__iter__返回迭代器
v in x__contains__不写则退回 __iter__ 遍历
str(x)/print__str__给人看
repr(x)__repr__给开发者、可重建
x()__call__实例当函数调
x + y__add__不相等返回 NotImplemented
x == y__eq__定义后 __hash__ 失效,需显式补
hash(x)__hash__与 __eq__ 必须一致
abs(x)/~x 等__abs__/__invert__一元运算符同理成对
with x__enter__/__exit__即第 5 章内容
x.foo 找不到属性时__getattr__动态属性、代理常用

三条铁律,刻进肌肉:

  1. dunder 里只放纯计算,绝不放 I/O、网络、会失败的操作。x[0]、x + y 看起来像「取值」「加法」,调用方不会预期它打数据库或抛连接错误。要放副作用,你得在文档里大声写、或干脆不重载(用显式方法名如 obj.fetch(0))。这条是 Go「运算符不该有副作用」本能的延续。
  2. 比较/相等运算符一律 isinstance 守卫 + 返回 NotImplemented。忘写 isinstance 会在跨类型比较时崩或静默误判;返回 False 而非 NotImplemented 会吞掉本该报错的场景。参考本章思考题 2 的剖析。
  3. __repr__ 永远实现,且尽量长成构造调用。__str__ 可选但建议有;__repr__ 是调试/日志的保底,缺了你就只能在日志里看 <X object at 0x...> 干瞪眼。定义 __eq__ 别忘了 __hash__,否则对象进不了 set/dict 键。

对照 Go 收个尾:Go 没有 dunder,没有运算符重载,没有「让自定义类型像内置类型」的协议——换来的是「每个操作都显式、绝不反直觉」。Python 把这套协议开放给你,代价是「你写 dunder 时要像语言设计者一样负责」。高级工程师的标志,就是知道何时接协议让代码自然,何时克制不接以免埋雷。

【思考题】

  1. 你写了个类只实现了 __str__,没实现 __repr__。在交互式解释器里直接输入对象名 obj 回车,看到的是什么?如果在 logger.info("处理 %r", obj) 里用 %r,又会怎样?如果反过来只实现 __repr__ 不实现 __str__,print(obj) 看到什么?
  2. 你实现一个 __eq__ 时,直接 return self.cents == other.cents(没判断 isinstance)。这会带来哪两个隐患?为什么 Python 要求你返回 NotImplemented 而不是 False?
  3. 你有个「远端分页列表」类型 RemoteList,实现 __getitem__(i) 时每次都打一次网络请求取第 i 页。现在有人写 for x in remote_list 且没实现 __iter__。会发生什么性能问题?怎么改既能 for 遍历又不爆网络?(提示:结合 __iter__ 与第 4 章生成器)

【参考答案】

  1. 只实现 __str__:obj 回车(解释器走 __repr__)看到的是默认的 <__main__.ClassName object at 0x...> 这种无信息字符串——因为 __repr__ 没写,用内置默认;%r 同样走 __repr__,也是那串无信息。反过来只实现 __repr__ 不实现 __str__:print(obj) 会回退用 __repr__,所以看到的是你写的 repr 串。结论:__repr__ 是保底,务必实现;__str__ 是可选项,仅用于「给人看的展示」。调试/日志里永远想看 repr,所以 __repr__ 不能省。
  2. 隐患一:Money(100) == "100" 会直接 TypeError(字符串没有 .cents)而不是优雅地「不相等」,因为 other.cents 对字符串报错——其实这里会炸;若 other 恰有同名属性则更糟,会错判相等。隐患二:忘了 isinstance 还影响 <、> 等比较链,类型不对时静默出错或崩。返回 NotImplemented 而非 False 的原因:NotImplemented 告知解释器「我不处理这类型」,它会尝试 other.__eq__(self) 或最终 TypeError;若你直接 return False,则 Money(100) == SomeWrongType 静默成 False,本该报错或 NotImplemented 的情况被你掩盖成「不等」,下游逻辑据此分支就埋雷。正确写法:if not isinstance(other, Money): return NotImplemented。
  3. 因没实现 __iter__,Python 走「__getitem__ 自动迭代」协议:从 0 开始反复 remote_list[0]、remote_list[1]… 每次都触发一次网络请求取第 i 页——即「逐元素打网络、O(n) 次请求」,且 for 不会自动按页批量拉,网络和延迟爆炸。改法:显式实现 __iter__ 用生成器按页批量拉取,把网络 I/O 收敛成「每页一次」:
    def __iter__(self):
        page = 0
        while True:
            chunk = self._fetch_page(page)     # 一次网络拿一整页
            if not chunk:
                return
            yield from chunk                   # 惰性吐出本页元素
            page += 1
    这样 for x in remote_list 每页只打一次网络,且内存恒定(生成器惰性)。同时建议保留 __getitem__ 支持随机访问,但 for 走高效的 __iter__。这正是「把昂贵 I/O 收进合适协议、用生成器惰性吐数据」的工程实践(呼应第 4、6 章)。