Blog

Python 数据模型完全指南

Python 里的每个运算符、每个内置函数、每一处语法,都是在向你的对象提问。数据模型就是这些问题的清单。

这个系列的前十篇讲的是一个个具体的方法。这一篇讲的是它们底下的那个想法,一句话就能说完:

Python 的语法是一组问题,魔术方法(也叫双下划线方法)就是你的对象给出的回答。

len(x) 并不是一个去检查 x 的函数。它是 Python 在向 x 提问,也就是调用 x.__len__(),再把答案报出来。a + bfor i in xwith f:x[0]if x:str(x)x() 也都一样。它们没有一个对内置类型开小灶。list 回答的问题,你的类同样能回答。

所以写得好的 Python 类用起来不会有外挂的感觉。它不是在模仿内置类型,而是在回答同样的问题。

下面的代码都在 Python 3.12 上运行,输出是从真实会话里粘贴过来的。

看 Python 怎么提问

class Probe:
    def __len__(self):
        print('  Python asked __len__')
        return 3
print(len(Probe()), bool(Probe()))
  Python asked __len__
  Python asked __len__
3 True

问了两次。len() 是直接问的。bool() 也问了,因为类里没有 __bool__,它就退回去问 __len__:长度不为零就是真值。

这条回退链是数据模型里第二件值得知道的事:大多数问题都有回退。 Python 先问具体的问题,再问更笼统的问题,最后才用默认行为。

创建与销毁

__new__ 负责分配,__init__ 负责填充。__init__ 你会经常写,__new__ 几乎用不到。它只在不可变类型上才重要,因为不可变类型没有“分配之后”这个阶段让你去赋值。

别碰 __del__。它的运行时机无法预测,解释器退出时可能根本不运行,还会吞掉异常。清理工作应该交给上下文管理器,后面会讲。

表示:三个问题,不是两个

第 3 篇讲了 __repr____str__。其实还有第三个,你的对象能不能配合 f-string 用,靠的就是它:

class Temp:
    def __init__(self, c): self.c = c
    def __str__(self): return f"{self.c}C"
    def __format__(self, spec):
        if spec == 'f': return f"{self.c * 9/5 + 32:.1f}F"
        if spec == 'k': return f"{self.c + 273.15:.2f}K"
        if not spec: return str(self)
        return format(self.c, spec)

t = Temp(100)
print(f"{t} | {t:f} | {t:k} | {t:>8.1f}")
100C | 212.0F | 373.15K |    100.0

f"{t:f}" 会把字符串 "f" 传给 __format__,含义由你的对象决定。datetime%Y 生效用的正是这个办法。f-string 并没有为它开特例,而是 datetime.__format__ 自己选择把格式规范交给 strftime

最后一个分支把不认识的格式规范都交给底层的数字处理。这种委托模式值得照抄:自己关心的格式规范自己解释,其余的往下传。

比较,以及千万不能忘的那一个

from functools import total_ordering
@total_ordering
class Version:
    def __init__(self, major, minor): self.major, self.minor = major, minor
    def __repr__(self): return f"Version({self.major}, {self.minor})"
    def __eq__(self, o): return (self.major, self.minor) == (o.major, o.minor)
    def __lt__(self, o): return (self.major, self.minor) < (o.major, o.minor)

v1, v2 = Version(1, 2), Version(1, 10)
print(v1 < v2, v1 >= v2, v1 != v2)
print(sorted([Version(2,0), Version(1,10), Version(1,2)]))
True False True
[Version(1, 2), Version(1, 10), Version(2, 0)]

@total_ordering 根据 __eq____lt__ 补齐 <=>>=。注意 Version(1, 2) < Version(1, 10) 的结果是 True:这里比较的是元组,不是字符串。按字符串给版本号排序,1.10 就会排到 1.2 前面。

!= 是白送的:只要你不覆盖它,Python 就会根据 __eq__ 推出它。别去覆盖。

还有第 7 篇里那条人人都会栽的规则:定义了 __eq__,就必须定义 __hash__,否则你的对象会变得不可哈希。

属性访问

大多数人从来不碰这一层,而 @property 的原理就在这里。

class Loud:
    def __getattr__(self, name):
        return f"(no attribute {name!r}, and this ran instead)"
l = Loud()
l.real = 1
print(l.real)
print(l.missing)
1
(no attribute 'missing', and this ran instead)

__getattr__ 是回退,只有正常查找失败时才运行。所以用它做代理和惰性加载既便宜又安全。

__getattribute__ 就不同了,每一次访问都会运行:

class Watch:
    def __init__(self): self.x = 1
    def __getattribute__(self, name):
        if not name.startswith('_'):
            print(f'  looked up {name!r}')
        return object.__getattribute__(self, name)
w = Watch()
_ = w.x
  looked up 'x'

注意它调用的是 object.__getattribute__,而不是 self.x,后者会无限递归。__setattr__ 也有同样的陷阱:

class Frozen:
    def __init__(self, x):
        object.__setattr__(self, 'x', x)      # bypass our own __setattr__
    def __setattr__(self, name, value):
        raise AttributeError(f"{type(self).__name__} is read-only")
f = Frozen(1)
print(f.x)
try:
    f.x = 2
except AttributeError as err:
    print(type(err).__name__ + ':', err)
1
AttributeError: Frozen is read-only

__init__ 必须绕过自己的 __setattr__,才能设置任何属性。@dataclass(frozen=True) 大致就是这么实现的。

描述器:@property 到底是什么

描述器是定义了 __get____set__ 的对象。当它作为类属性被访问时,会发生什么由它来决定。@property 就是一个描述器,方法、classmethodstaticmethod 也都是。

如果同一套校验在多个字段上反复出现,直接写一个描述器就很划算:

class Positive:
    def __set_name__(self, owner, name):
        self.name = '_' + name
    def __get__(self, obj, objtype=None):
        if obj is None: return self
        return getattr(obj, self.name)
    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self.name.lstrip('_')} must be positive, got {value}")
        setattr(obj, self.name, value)

class Product:
    price = Positive()
    weight = Positive()
    def __init__(self, price, weight):
        self.price, self.weight = price, weight

p = Product(10, 2)
print(p.price, p.weight)
try:
    Product(-1, 2)
except ValueError as err:
    print(type(err).__name__ + ':', err)
10 2
ValueError: price must be positive, got -1

两个字段,一个校验器,错误信息里的字段名也是对的。描述器靠 __set_name__ 知道自己叫什么:创建类的时候,Python 会把属性名传给它。

换成两组 @property,就是十二行几乎一样的代码。五个字段就是三十行。

迭代

class Countdown:
    def __init__(self, n): self.n = n
    def __iter__(self):
        current = self.n
        while current > 0:
            yield current
            current -= 1

print(list(Countdown(4)))
a, b, *rest = Countdown(5)
print(a, b, rest)
[4, 3, 2, 1]
5 4 [3, 2, 1]

__iter__ 写成生成器,是让对象变成可迭代对象最短的正确写法:不用单独写迭代器类,不用 __next__,也不用手动抛出 StopIteration

每次调用它都会生成一个新的生成器,所以这个对象可以迭代不止一次。如果让 __iter__ 返回 self,遍历一遍就耗尽了。这是常见又让人摸不着头脑的 bug。

解包直接就能用。list()sum()max()in 和各种推导式也都一样。

运算符,以及反射的那一对

class Metres:
    def __init__(self, v): self.v = v
    def __repr__(self): return f"Metres({self.v})"
    def __add__(self, other):
        if isinstance(other, Metres): return Metres(self.v + other.v)
        return NotImplemented
    def __radd__(self, other):
        if other == 0: return self         # makes sum() work
        return NotImplemented

print(Metres(1) + Metres(2))
print(sum([Metres(1), Metres(2), Metres(3)]))
Metres(3)
Metres(6)

对于 a + b,Python 先问 a.__add__(b)。如果返回 NotImplemented,再问 b.__radd__(a)。所以就算整数完全不知道你的类型是什么,1 + your_object 也能成立。

这里的 __radd__ 纯粹是为了让 sum() 能用。sum0 开始往上加,第一步就是 0 + Metres(1),只有你的 __radd__ 能回答。

还有就地运算的版本(+= 对应 __iadd__)。除非就地修改真的更快,否则别写。没有它的话,+= 会退回到 __add__ 再重新绑定,这通常正是你想要的。

上下文管理器

class Timer:
    def __enter__(self):
        self.events = ['enter']
        return self
    def __exit__(self, exc_type, exc, tb):
        self.events.append('exit' if exc_type is None else f'exit({exc_type.__name__})')
        return False

with Timer() as t2:
    t2.events.append('body')
print(t2.events)

t3 = Timer()
try:
    with t3:
        raise ValueError('boom')
except ValueError:
    pass
print(t3.events)
['enter', 'body', 'exit']
['enter', 'exit(ValueError)']

不管发生了什么,__exit__ 都会运行,而且会被告知正在传播的是哪个异常。返回 False 让异常继续传播;返回 True 会吞掉异常,这种事应该很少做,而且要想清楚了再做。

清理工作就该放在这里,而不是 __del__

类的创建

class Plugin:
    registry = {}
    def __init_subclass__(cls, /, name=None, **kw):
        super().__init_subclass__(**kw)
        Plugin.registry[name or cls.__name__.lower()] = cls

class Csv(Plugin, name='csv'): pass
class Json(Plugin): pass
print(Plugin.registry)
{'csv': <class '__main__.Csv'>, 'json': <class '__main__.Json'>}

有人继承你的类时,__init_subclass__ 就会运行,还能接收类定义里给出的关键字参数。插件可以自己注册,不需要装饰器,也不需要元类。

元类的大部分正当用途,都被这个钩子取代了。如果你正准备写元类,先看看 __init_subclass____set_name__ 能不能解决。通常都能。

模式匹配也是在提问

match 是数据模型里最新的部分,你的类也能回答它。前提是你得告诉 Python,位置上的每个槽对应什么。

class Point:
    __match_args__ = ('x', 'y')
    def __init__(self, x, y): self.x, self.y = x, y

def describe(p):
    match p:
        case Point(0, 0):          return 'origin'
        case Point(0, y):          return f'on the y axis at {y}'
        case Point(x, 0):          return f'on the x axis at {x}'
        case Point(x, y) if x == y: return f'on the diagonal at {x}'
        case Point(x, y):          return f'at {x},{y}'

for p in [Point(0,0), Point(0,5), Point(3,0), Point(4,4), Point(1,2)]:
    print(' ', describe(p))
  origin
  on the y axis at 5
  on the x axis at 3
  on the diagonal at 4
  at 1,2

__match_args__ 是一个元组,指明位置模式依次对应哪些属性。没有它,位置匹配会直接报错,而不是悄悄匹配失败:

class NoMatch:
    def __init__(self, x): self.x = x
try:
    match NoMatch(1):
        case NoMatch(1): pass
except TypeError as err:
    print(type(err).__name__ + ':', err)
TypeError: NoMatch() accepts 0 positional sub-patterns (1 given)

关键字模式(case NoMatch(x=1))不需要它也能用,因为属性名是直接写出来的。数据类(dataclass)则会按字段顺序自动提供它:

@dataclass
class DC:
    x: int
    y: int
print('dataclass gets it free:', DC.__match_args__)
match DC(1, 2):
    case DC(x=1, y=v): print(f'  matched with y={v}')
dataclass gets it free: ('x', 'y')
  matched with y=2

有一点要小心:__match_args__ 定死了一个顺序,以后调整这个顺序,所有位置模式的含义都会悄悄改变。关键字模式没有这个问题,所以字段超过两个时,最好用关键字模式。

异步协议是同样的问题,只是要 await

上面的每个协议都有对应的异步版本。with 变成 async with,问的是 __aenter__ / __aexit__for 变成 async for,问的是 __aiter__ / __anext__

import asyncio

class Fetcher:
    async def __aenter__(self):
        self.events = ['open']
        return self
    async def __aexit__(self, *exc):
        self.events.append('close')
        return False

class Ticker:
    def __init__(self, n): self.n = n
    def __aiter__(self):
        self.i = 0
        return self
    async def __anext__(self):
        if self.i >= self.n:
            raise StopAsyncIteration
        self.i += 1
        await asyncio.sleep(0)
        return self.i

async def main():
    async with Fetcher() as f:
        f.events.append('body')
    print(' ', f.events)
    print(' ', [x async for x in Ticker(3)])

asyncio.run(main())
  ['open', 'body', 'close']
  [1, 2, 3]

和同步版本相比,有两处细节不同。__aiter__ 不是协程,它直接返回异步迭代器,只有 __anext__ 需要 await。另外,结束信号是 StopAsyncIteration,不是 StopIteration;抛错了会得到一个让人困惑的 RuntimeError,而不是干净地结束。

其余部分都是你已经熟悉的样子。

问题汇总

你写的 Python 问的 回退到
repr(x) __repr__ 默认的 <Class object at 0x…>
str(x), print(x) __str__ __repr__
f"{x:spec}" __format__ 格式规范为空时用 __str__
x == y __eq__ 标识(is
x != y __ne__ not __eq__
x < y __lt__ TypeError
hash(x) __hash__ 基于 id,除非定义了 __eq__
len(x) __len__ TypeError
if x: __bool__ __len__,再没有就恒为真
x[k] __getitem__ TypeError
for i in x __iter__ 从索引 0 开始调用 __getitem__
k in x __contains__ __iter__,然后 __getitem__
a + b __add__ b.__radd__(a)
x() __call__ TypeError
with x: __enter__ / __exit__ TypeError
x.missing __getattribute__ __getattr__,然后 AttributeError
case C(a, b) __match_args__ 位置模式抛 TypeError
async with x: __aenter__ / __aexit__ TypeError
async for i in x __aiter__ / __anext__ TypeError

哪些不要实现

数据模型很大,其中大部分不是给你用的。

__del__:时机不可预测,退出时可能被跳过,还会吞掉异常。用上下文管理器。

__getattribute__:每次访问都会运行,很容易写出无限递归,还会拖慢整个类。真实场景里几乎都可以用 __getattr__ 解决。

元类:常见的理由,__init_subclass____set_name__ 都能覆盖。

默认就加 __slots__:见第 10 篇。用它有两个好理由,“看起来更整洁”不在其中。

还有一条总的原则:每个魔术方法都是对对象行为的一个承诺。 没人需要的承诺,只会让你多一件要守住的事。__repr__ 永远要实现;值类型要实现 __eq____hash__;其余的,等调用方真的想写那种语法时再加。

五件事

  • Python 的语法是问题,魔术方法是回答。内置类型没有任何特殊待遇。
  • 大多数问题都有回退链:boollenstrrepritergetitemaddradd
  • @property 是描述器。同一套校验在多个字段上重复时,直接写一个描述器。
  • 处理不了的运算,就返回 NotImplemented,让另一个操作数有机会处理。
  • 实现一个魔术方法就是做一个承诺。只实现调用方会用到的。

数据模型的其余部分都在语言参考手册里。现在再读,你会发现它是一份问题清单,而不是一堆魔法。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。