我以前理解 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 之后,自然产生的结果。