跳转到内容
新建笔记

Python 特殊方法与对象协议

特殊方法把自定义对象接入 Python 的操作协议:obj()、len(obj)、下标访问、比较和迭代都有相应入口。重点是返回正确的结果并遵守协议,而不是给普通方法随意加双下划线。本文示例以 Python 3.11 为基线,每段都可单独保存并运行。

包文件与模块元数据,例如 __init__.py、__main__.py、__all__、__future__、__name__、__file__、__doc__、__package__、__loader__、__spec__、__qualname__、__annotations__、__module__、__bases__、__dict__,集中说明于包、模块与导入。其中 __qualname__ 不包含模块名,__spec__ 是导入信息对象,不是导入器本身。

操作相关名称必须注意的行为
创建、初始化__new__、__init__前者返回对象,后者初始化已有实例且应返回 None
对象终结__del__不保证在期望时刻或解释器退出时可靠执行
文本、字节、格式化__str__、__repr__、__bytes__、__format__按协议分别返回字符串或字节串
比较__lt__、__le__、__eq__、__ne__、__gt__、__ge__对不支持的类型通常返回 NotImplemented
哈希、真值__hash__、__bool__相等对象必须有相同哈希;__bool__ 返回布尔值
属性读写__getattribute__、__getattr__、__setattr__、__delattr__、__dir__委托基础实现以避免递归;缺失属性应有明确边界
调用__call__让实例接受 obj(...);参数是否匹配仍需检查
长度、下标__len__、__getitem__、__setitem__、__delitem__切片也是传给下标方法的一种键
迭代、成员判断__iter__、__next__、__reversed__、__contains__迭代器结束时抛出 StopIteration
字典缺失键__missing__dict 子类的 d[key] 可触发;get() 不会触发
pickle 重建与状态__reduce__、__reduce_ex__、__getnewargs__、__getstate__、__setstate__重建参数、状态与协议版本是不同职责
实例属性布局__slots__是类变量,限制特定布局下的新属性,不等于不可变

下面把一个读数对象同时接入文本、字节、格式化、真值与调用协议。format(value, spec) 才是把格式规格用于数值的直接方式;spec.format(value) 含义不同。

import math
class Reading:
def __new__(cls, value):
if not math.isfinite(float(value)):
raise ValueError("value must be finite")
return super().__new__(cls)
def __init__(self, value):
self.value = float(value)
def __repr__(self):
return f"Reading({self.value!r})"
def __str__(self):
return f"{self.value} V"
def __bytes__(self):
return str(self).encode("utf-8")
def __format__(self, format_spec):
return f"{format(self.value, format_spec)} V"
def __bool__(self):
return bool(self.value)
def __call__(self, factor=1.0):
return self.value * factor
reading = Reading(3.5)
assert repr(reading) == "Reading(3.5)"
assert str(reading) == "3.5 V"
assert bytes(reading) == b"3.5 V"
assert f"{reading:.2f}" == "3.50 V"
assert reading(2) == 7.0 and reading() == 3.5
assert callable(reading) and bool(reading)
assert not Reading(0)
try:
Reading(float("nan"))
except ValueError:
pass
else:
raise AssertionError("non-finite reading was accepted")
print("representation and call checks passed")

这里的 __new__ 只是展示“创建前验证”的位置,普通类往往只需写 __init__。repr() 应尽量提供明确的调试表示,不强制要求每个对象的表示都能由 eval() 重建。__del__ 也不是 del variable 的直接回调:删除名称、对象不再可达与执行终结方法不能混为一谈。资源释放应使用明确的关闭操作或上下文管理。基础定义见 Python 数据模型。

比较和哈希必须一起设计

跳转到“比较和哈希必须一起设计”

以下键对象只与相同类型比较。冻结字段使参与比较和哈希的值在正常属性赋值中保持稳定;total_ordering 根据 __eq__ 与 __lt__ 补齐其余排序操作。

from dataclasses import dataclass, FrozenInstanceError
from functools import total_ordering
@total_ordering
@dataclass(frozen=True, eq=False)
class SampleKey:
channel: int
sample: int
def __eq__(self, other):
if type(other) is not type(self):
return NotImplemented
return (self.channel, self.sample) == (other.channel, other.sample)
def __lt__(self, other):
if type(other) is not type(self):
return NotImplemented
return (self.channel, self.sample) < (other.channel, other.sample)
def __hash__(self):
return hash((type(self), self.channel, self.sample))
a, same, later = SampleKey(1, 2), SampleKey(1, 2), SampleKey(1, 3)
assert a == same and hash(a) == hash(same)
assert len({a, same, later}) == 2
assert a < later and a <= same and later > a and later >= a
assert a != later and a != (1, 2)
assert a.__eq__((1, 2)) is NotImplemented
try:
a < (1, 2)
except TypeError:
pass
else:
raise AssertionError("unsupported ordering was accepted")
try:
a.sample = 99
except FrozenInstanceError:
pass
else:
raise AssertionError("frozen field changed")
print("comparison and hash checks passed")

NotImplemented 是一个返回值,与抛出 NotImplementedError 不同。单写 __eq__ 而没有提供适当哈希的类,通常会变成不可哈希对象;可变对象若按会变化的内容计算哈希,就可能破坏字典和集合中的查找。排序方法之间也不会仅因定义了 __lt__ 就自动全部出现,示例的补齐来自 functools.total_ordering。冻结数据类的行为见 dataclasses 文档。

属性钩子要委托基础实现

跳转到“属性钩子要委托基础实现”

普通实例属性读取先经 __getattribute__;正常查找以 AttributeError 失败后,点号或 getattr() 等访问才会尝试 __getattr__。下面只提供一个虚拟属性 double,其他缺失名称仍抛出 AttributeError,从而让 hasattr() 能正确报告不存在。

class Channel:
def __init__(self, value):
object.__setattr__(self, "_reads", [])
self.value = value
def __getattribute__(self, name):
if name != "_reads":
object.__getattribute__(self, "_reads").append(name)
return object.__getattribute__(self, name)
def __getattr__(self, name):
if name == "double":
return self.value * 2
raise AttributeError(name)
def __setattr__(self, name, value):
if name == "value" and type(value) is not int:
raise TypeError("value must be an int")
object.__setattr__(self, name, value)
def __delattr__(self, name):
if name == "value":
raise AttributeError("value is required")
object.__delattr__(self, name)
def __dir__(self):
return sorted(set(super().__dir__()) | {"double"})
channel = Channel(3)
assert channel.value == 3 and channel.double == 6
assert "value" in channel._reads and "double" in dir(channel)
assert not hasattr(channel, "unknown")
channel.note = "temporary"
del channel.note
assert not hasattr(channel, "note")
for operation, expected in [
(lambda: setattr(channel, "value", "3"), TypeError),
(lambda: delattr(channel, "value"), AttributeError),
]:
try:
operation()
except expected:
pass
else:
raise AssertionError("invalid attribute operation succeeded")
print("attribute hook checks passed")

如果在 __setattr__ 中再次执行 setattr(self, ...),就会重新进入同一个钩子;__delattr__ 和 __getattribute__ 也有同样的递归风险。仅打印日志却没有返回查找结果的 __getattribute__ 会让正常属性读取得到 None。因此示例把实际读写明确交给 object。

容器与迭代器承担不同职责

跳转到“容器与迭代器承担不同职责”

容器可以每次返回一个新的迭代器;迭代器本身通常让 iter(iterator) 返回自己。下面的 Bag 委托列表处理索引、切片和越界,Countdown 则保存迭代进度。

class Bag:
def __init__(self, values):
self.items = list(values)
def __len__(self):
return len(self.items)
def __getitem__(self, key):
return self.items[key]
def __setitem__(self, key, value):
self.items[key] = value
def __delitem__(self, key):
del self.items[key]
def __iter__(self):
return iter(self.items)
def __reversed__(self):
return reversed(self.items)
def __contains__(self, value):
return value in self.items
class Countdown:
def __init__(self, start):
if type(start) is not int or start < 0:
raise ValueError("start must be a non-negative int")
self.remaining = start
def __iter__(self):
return self
def __next__(self):
if self.remaining == 0:
raise StopIteration
self.remaining -= 1
return self.remaining + 1
bag = Bag([10, 20, 30])
assert len(bag) == 3 and bag[1:] == [20, 30]
bag[1] = 25
del bag[0]
assert list(bag) == [25, 30] and list(reversed(bag)) == [30, 25]
assert 25 in bag and 99 not in bag
assert iter(bag) is not iter(bag)
assert not Bag([])
try:
bag[99]
except IndexError:
pass
else:
raise AssertionError("out-of-range index was accepted")
iterator = Countdown(3)
assert iter(iterator) is iterator
assert next(iterator) == 3 and list(iterator) == [2, 1]
for _ in range(2):
try:
next(iterator)
except StopIteration:
pass
else:
raise AssertionError("exhausted iterator resumed")
assert list(Countdown(0)) == []
print("container and iterator checks passed")

Bag 没有定义 __bool__,因此空容器通过长度为零表现为假。__len__ 本身应返回非负整数。__reversed__ 返回反向遍历的迭代器,不必返回原迭代器的“反向版本”。迭代终止约定见 Python 迭代器类型。

缺失键、特殊方法查找与 __slots__

跳转到“缺失键、特殊方法查找与 __slots__”

下面三个短类分别展示容易混淆的边界:缺失字典键与普通属性不同;给单个实例添加 __len__ 不能使 len() 自动采用它;slots 限制新属性的前提是实例没有可用的属性字典。

class DefaultDict(dict):
def __missing__(self, key):
return "missing:" + str(key)
mapping = DefaultDict(existing=1)
assert mapping["absent"] == "missing:absent"
assert mapping.get("absent") is None
assert "absent" not in mapping
class Plain:
pass
plain = Plain()
plain.__len__ = lambda: 9
assert plain.__len__() == 9
try:
len(plain)
except TypeError:
pass
else:
raise AssertionError("instance hook unexpectedly implemented len")
Plain.__len__ = lambda self: 4
assert len(plain) == 4 and plain.__len__() == 9
class Slotted:
__slots__ = ("value",)
def __init__(self, value):
self.value = value
class FlexibleChild(Slotted):
pass
class SlottedChild(Slotted):
__slots__ = ()
for item in (Slotted(1), SlottedChild(1)):
item.value = 2
assert item.value == 2 and not hasattr(item, "__dict__")
try:
item.extra = 3
except AttributeError:
pass
else:
raise AssertionError("undeclared attribute was accepted")
flexible = FlexibleChild(1)
flexible.extra = 3
assert flexible.__dict__ == {"extra": 3}
print("missing key, lookup and slots checks passed")

示例没有把缺失键插入字典;是否插入取决于 __missing__ 的实现。get() 的边界见 字典类型文档。隐式特殊方法查找通常绕过实例属性;需要改变对象参与语言操作的方式时,应在类上定义相应方法。slots 不阻止对已有槽赋新值,而没有继续声明 slots 的子类通常重新获得实例字典。

pickle:分别设计重建参数与状态

跳转到“pickle:分别设计重建参数与状态”

以下示例只反序列化自己刚生成的字节。pickle 数据可触发代码执行,不能把不可信字节交给 loads()。类应定义在可导入模块的顶层,跨进程恢复时还必须能找到相同的模块和类;函数内部的局部类通常不能直接 pickle。

import pickle
class Snapshot:
init_calls = 0
def __new__(cls, name):
instance = super().__new__(cls)
instance.name = name
return instance
def __init__(self, name):
type(self).init_calls += 1
self.values = []
self.cache = []
def __getnewargs__(self):
return (self.name,)
def __getstate__(self):
return {"version": 1, "values": self.values}
def __setstate__(self, state):
if state["version"] != 1:
raise ValueError("unsupported snapshot version")
self.values = state["values"]
self.cache = []
original = Snapshot("sensor-A")
original.values.extend([1, 2, 3])
original.cache.append("derived result")
for protocol in range(2, pickle.HIGHEST_PROTOCOL + 1):
restored = pickle.loads(pickle.dumps(original, protocol=protocol))
assert restored is not original
assert restored.name == "sensor-A" and restored.values == [1, 2, 3]
assert restored.cache == []
assert Snapshot.init_calls == 1
class ReducedValue:
def __init__(self, value):
self.value = value
def __reduce__(self):
return (type(self), (self.value,))
def __reduce_ex__(self, protocol):
return self.__reduce__()
for protocol in range(pickle.HIGHEST_PROTOCOL + 1):
value = ReducedValue(42)
copy = pickle.loads(pickle.dumps(value, protocol=protocol))
assert type(copy) is ReducedValue and copy.value == 42
print("pickle reconstruction checks passed")

Snapshot 的 __getnewargs__ 给恢复阶段的 __new__ 提供位置参数;状态钩子保存业务数据并重新建立缓存。默认实例恢复过程不会像普通构造那样再次调用 __init__。如果 __new__ 需要仅限关键字参数,应考虑 __getnewargs_ex__。

ReducedValue 演示更底层的重建配方:返回“可调用对象、参数元组”,此例用类本身作可调用对象,所以恢复时会正常调用它的构造流程。pickle 优先采用 __reduce_ex__ 并传入协议版本;此类无需区分版本,才统一转交 __reduce__。不要把该简化配方直接用于所有带内部状态的对象。详见 pickle 的类实例与重建协议。

普通属性、方法与继承关系见类、实例与继承;调用时的参数规则见函数与内置工具。