我以前理解 Python 实例方法时,一直把 self 的自动传入当成一种语言层面的特殊规则。比如下面这段代码:
class Model:
def predict(self, x):
return x * 2
model = Model()
model.predict(3)
通常我们会把最后一句理解成 Model.predict(model, 3)。这个理解在结果上没有问题,但它其实略过了一个更有意思的问题:model 究竟是在什么时候和 predict 绑定在一起的?
是在真正调用 predict(3) 时,解释器临时把 model 塞进去,还是在读取 model.predict 时,绑定就已经发生了?
顺着这个问题继续往下看,就会自然碰到 Python 对象模型中非常重要的一套机制:Descriptor。
从 model.predict 开始,而不是 model.predict(3)
理解这个问题的关键,是先把函数调用拆开:
method = model.predict
result = method(3)
过去我关注的往往是第二句,但真正发生“魔法”的地方其实是第一句。
可以直接观察:
class Model:
def predict(self, x):
return x * 2
model = Model()
print(Model.__dict__["predict"])
print(model.predict)
Model.__dict__["predict"] 得到的是一个普通函数,而 model.predict 得到的则是一个 bound method。也就是说,在真正执行 () 之前,Python 已经完成了一次转换:
function
↓
bound method
因此,更准确的说法并不是“调用实例方法时 Python 自动传入 self”,而是:在访问 model.predict 这个属性时,Python 已经生成了一个绑定到 model 的方法对象。
真正的函数调用发生在这之后。
普通函数为什么可以自动绑定实例?
原因在于,Python 中普通的函数对象本身实现了 __get__ 方法。
def func(self, x):
return x
hasattr(func, "__get__")
# True
这意味着普通函数实际上也是一种 Descriptor。
当函数定义在类中:
class Model:
def predict(self, x):
return x * 2
predict 实际保存在:
Model.__dict__["predict"]
中。当我们通过实例访问:
model.predict
Python 会调用类似下面的逻辑:
raw_func = Model.__dict__["predict"]
bound_method = raw_func.__get__(model, Model)
得到的 bound_method 内部保存了原始函数和当前实例:
bound_method.__func__ is raw_func
bound_method.__self__ is model
所以:
bound_method(3)
最终才等价于:
raw_func(model, 3)
这时 self 的来源就很清楚了:它不是函数执行之后才由解释器临时插入的,而是在属性访问阶段,通过函数对象的 Descriptor 行为完成绑定的。
从这个角度看,所谓的“实例方法”其实没有那么特殊。普通函数只要作为类属性存在,在通过实例访问时,就可以借助 function.__get__ 生成 bound method。
为什么把函数放进实例就不会自动绑定?
理解到这里以后,我又碰到了一个以前觉得很奇怪的问题。
假设直接把函数赋值给实例:
class Model:
pass
def predict(self, x):
return x * 2
model = Model()
model.predict = predict
这时执行:
model.predict(3)
会因为缺少 self 而报错。
一开始这很容易让人困惑:既然普通函数都有 __get__,为什么这里没有自动绑定?
关键并不在于函数有没有 __get__,而在于它存放在哪里。
执行:
model.predict = predict
之后,函数被放进了实例自己的字典:
model.__dict__["predict"]
Python 从实例字典中找到这个对象以后,会直接返回它,而不会再把它当作类上的 Descriptor 去调用 __get__。因此这里的:
model.predict(3)
实际上更接近:
predict(3)
自然缺少第一个参数。
如果换成:
Model.predict = predict
情况就不同了。此时函数进入 Model.__dict__,之后再通过 model.predict 访问时,它会作为类属性参与 Descriptor 机制,于是自动完成实例绑定。
这个例子让我意识到,Descriptor 并不是孤立运行的,它必须放到 Python 整个属性查找机制中理解。
Descriptor 到底是什么?
Descriptor 的定义本身其实很简单。一个对象只要定义了下面这些特殊方法中的部分方法:
__get__
__set__
__delete__
就可以参与 Descriptor 协议。
例如:
class Descriptor:
def __get__(self, instance, owner):
...
def __set__(self, instance, value):
...
def __delete__(self, instance):
...
然后把这个对象放在类上:
class Model:
value = Descriptor()
此时访问:
model.value
就不一定只是“从某个地方拿出一个值”,而可能触发:
Model.__dict__["value"].__get__(model, Model)
因此,我现在更愿意把 Descriptor 理解成:
Descriptor 定义了一个类属性在被访问、赋值或删除时应该表现出什么行为。
从这个角度看,它和 Python 中其他特殊协议的设计其实很相似。例如 __call__ 定义的是“一个对象被 () 调用时应该发生什么”,而 __get__ 定义的是“这个对象作为类属性被读取时应该发生什么”。
如果类比 C++,__call__ 很容易让人想到 operator()。两者并不完全等价,但思路比较接近:对象通过实现特定协议,可以重新定义某一种操作的行为。
Descriptor 更进一步,它允许一个对象参与 Python 的属性访问过程。这也是为什么很多看起来像 Python“语法魔法”的功能,本质上都可以建立在 Descriptor 之上。
__call__ 和 __get__ 是两套完全不同的机制
这个区别在类装饰器中尤其明显。
假设定义一个简单的类装饰器:
class Decorator:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
print("before")
return self.func(*args, **kwargs)
用它装饰普通函数没有什么问题,因为装饰以后得到的是一个实现了 __call__ 的对象,因此仍然可以执行:
foo(...)
但如果把它用于实例方法:
class Model:
@Decorator
def predict(self, x):
return x * 2
问题就不一样了。
装饰之前,Model.__dict__["predict"] 是普通函数。普通函数天然拥有 __get__,因此通过实例访问时能够生成 bound method。
装饰之后,Model.__dict__["predict"] 已经变成一个 Decorator 实例。如果这个实例只有 __call__,它虽然仍然可以被调用,却不再天然拥有普通函数的实例绑定行为。
这件事很好地说明了两者的区别:
__call__
决定对象被调用时发生什么
__get__
决定对象作为类属性被读取时发生什么
也就是说,callable 和 method binding 根本不是同一件事情。
如果希望类装饰器依然能够正确装饰实例方法,就需要主动实现 Descriptor 协议。例如:
from types import MethodType
class Decorator:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
def __get__(self, instance, owner):
if instance is None:
return self
return MethodType(self, instance)
这里的 MethodType 用来显式构造 bound method。它把一个 callable 和具体实例组合起来:
bound = MethodType(func, obj)
得到的对象内部可以理解为保存了:
bound.__func__ is func
bound.__self__ is obj
因此执行:
bound(*args)
效果就类似:
func(obj, *args)
这和普通函数通过 function.__get__ 自动生成 bound method,本质上是同一类绑定过程。
Descriptor 并不负责整个属性查找
这里还有一个很容易混淆的问题。
我最开始会把 __get__ 理解成“拦截对象属性访问”。这个说法虽然能帮助入门,但并不准确,因为真正负责:
obj.x
整个属性查找流程的,其实是:
__getattribute__
更准确地说,每一次正常的属性读取都会先进入类似:
type(obj).__getattribute__(obj, "x")
的过程。
__getattribute__ 会负责查找类、实例、MRO 以及 Descriptor,并根据查找结果决定最终返回什么。Descriptor 的 __get__ 只是这个流程中的一个局部协议。
因此我现在会把两者的关系理解为:
__getattribute__
整个属性访问过程的调度器
Descriptor.__get__
查找到特定 Descriptor 后调用的读取接口
也就是说,Descriptor 并不会主动监听所有属性访问。只有当 __getattribute__ 在属性查找过程中发现对应的类属性是 Descriptor 时,才会进一步调用它。
我曾经混淆的两个 __dict__
理解默认属性查找流程时,经常会看到类似下面这样的伪代码:
def default_getattribute(obj, name):
cls_attr = find_in_mro(type(obj), name)
if is_data_descriptor(cls_attr):
return cls_attr.__get__(obj, type(obj))
if name in obj.__dict__:
return obj.__dict__[name]
if is_descriptor(cls_attr):
return cls_attr.__get__(obj, type(obj))
if cls_attr exists:
return cls_attr
raise AttributeError(name)
我第一次看到这里时有一个疑问:函数不也是放在 __dict__ 中的吗?为什么不会在检查 obj.__dict__ 时直接返回?
后来才发现,我把两个完全不同的字典混在了一起:
Model.__dict__
是类字典,而:
model.__dict__
是实例字典。
类中正常定义的方法:
class Model:
def predict(self):
...
存在于:
Model.__dict__["predict"]
而不是:
model.__dict__["predict"]
因此读取 model.predict 时,流程大致是这样的:
沿 MRO 在类中找到 predict
↓
predict 不是 data descriptor
↓
检查 model.__dict__
↓
实例中没有同名 predict
↓
predict 是 non-data descriptor
↓
调用 function.__get__(model, Model)
↓
返回 bound method
把这两个 __dict__ 区分清楚以后,很多以前零散记忆的规则突然就统一起来了。
Data Descriptor 与 Non-data Descriptor
Descriptor 还可以进一步分成 data descriptor 和 non-data descriptor。
只实现 __get__ 的对象通常称为 non-data descriptor,Python 中普通函数就是一个典型例子。
如果一个 Descriptor 实现了 __set__ 或 __delete__,则属于 data descriptor。两者真正重要的区别,在于它们在属性查找过程中的优先级不同。
一个比较实用的简化版本是:
data descriptor
>
instance.__dict__
>
non-data descriptor
>
普通类属性
这个顺序解释了一个很常见的现象:为什么实例属性可以覆盖普通实例方法。
class Model:
def predict(self):
return "class method"
model = Model()
model.predict = lambda: "instance function"
print(model.predict())
# instance function
普通函数属于 non-data descriptor,而 model.__dict__ 的优先级更高。因此实例中一旦存在同名属性,就会直接返回实例属性,不再触发类中函数的 __get__。
而 property 一类对象通常属于 data descriptor,它的优先级高于实例字典,所以行为又不一样。
这也是 Descriptor 很适合实现字段系统的原因:规则放在类上,数据放在实例上。
例如可以定义一个只能保存正数的 Descriptor:
class PositiveNumber:
def __set_name__(self, owner, name):
self.public_name = name
self.storage_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
try:
return instance.__dict__[self.storage_name]
except KeyError:
raise AttributeError(
f"{self.public_name!r} has not been set"
) from None
def __set__(self, instance, value):
if not isinstance(value, (int, float)):
raise TypeError(
f"{self.public_name} must be numeric"
)
if value <= 0:
raise ValueError(
f"{self.public_name} must be positive"
)
instance.__dict__[self.storage_name] = value
之后:
class TrainingConfig:
learning_rate = PositiveNumber()
batch_size = PositiveNumber()
def __init__(self, learning_rate, batch_size):
self.learning_rate = learning_rate
self.batch_size = batch_size
Descriptor 本身始终保存在 TrainingConfig 的类字典中,而不同实例的数据则分别保存在自己的实例字典:
config.__dict__
# {'_learning_rate': 0.001, '_batch_size': 8}
这样,字段访问规则只需要在类中定义一份,每个实例只保存自己的实际数据。ORM、字段校验以及很多框架内部的属性管理机制,本质上都可以利用类似的设计。
再回头看 self
理解完这一整套流程以后,再看最开始的:
model.predict(3)
我脑子中的过程已经变成了:
访问 model.predict
↓
进入 __getattribute__
↓
沿 MRO 找到 Model.predict
↓
发现它是普通 function
↓
function 属于 non-data descriptor
↓
调用 function.__get__(model, Model)
↓
生成 bound method
↓
bound method 保存 model
↓
执行 bound_method(3)
最终它才表现得像:
Model.predict(model, 3)
所以,“Python 会自动给实例方法传入 self”并没有错,但这是站在语法使用层面得到的结论。如果继续追问它是怎么做到的,最终答案其实落在了 Python 的 Descriptor 和属性查找机制上。
而一旦理解了这一点,很多原本看起来互不相关的知识也开始连接起来:为什么普通方法会产生 bound method,为什么函数直接放进实例字典不会绑定,为什么实例属性能够覆盖普通方法,为什么 property 又具有不同的优先级,以及为什么某些类装饰器会破坏实例方法的 self 绑定。
我现在会把这套关系简单记成:
__call__
定义对象被调用时的行为
Descriptor.__get__
定义类属性被读取时的行为
Descriptor.__set__
定义类属性被赋值时的行为
Descriptor.__delete__
定义类属性被删除时的行为
__getattribute__
负责整个属性查找与 Descriptor 调度
所以,我最开始那句“__get__ 会拦截对象访问”,现在会改成一个更准确的版本:
__get__是 Descriptor 的读取接口,用于定义某个类属性通过实例或类访问时的行为;整个对象的属性读取流程,则由__getattribute__统一调度。
普通实例方法所谓的“自动传入 self”,也不再是一条孤立的语法规则。它只是普通函数作为 non-data descriptor,在属性读取阶段动态生成 bound method 之后,自然产生的结果。