CoderXL's Blog

Back

Name, Attribute, and Item#

Name#

相比于 C++ 等传统语言,Python 的语言模型有很多特别之处。

从表示一段数据的方式上看,C++ 的变量像是“有类型的存储位置”,而 Python 的变量像是“绑定到对象的名字”。

以 C++ 中普通的 int 变量为例,变量对应一块存储空间。在它的生命周期内,赋值改变的是其中保存的数值,变量的地址和声明的类型不变。指针、引用等情况需要另外讨论,这里先不展开。

Python 的语义围绕对象设计,对象自带类型标记,并自动分配内存空间;变量名只是对象的名字。因此一个对象可能有多个名字。变量名可以随时重新绑定任何对象,但是对象本身不一定能被改变。

此处对比一下 Python 和 C++ 的代码,会更容易理解。

x = 1
print(id(x))
x = 2
print(id(x))
python

在 Python 中,变量名绑定到对象,对裸名字赋值相当于将这个名字绑定到右侧表达式得到的对象。这个对象可能已经存在,不一定是刚刚创建的。

这段代码执行时,内存中存在 1 和 2 两个 int 对象,x 从指向 1 变成了指向 2. 此外 Python 会使用垃圾回收机制清除不再需要的对象。

通过 id 可以看到 x 指向的对象发生了变化。


一个重要的理解就是:Python 中,对裸名字的赋值(如 x = ...)改变的是名字的绑定,而不是原对象的内容。属性赋值和下标赋值则有各自的规则,后面还会谈到其中一部分。

仍然对比 Python 和 C++ 的行为。

在 Python 中,传参会让形参绑定到实参所指的对象。形参和外部的名字可以指向同一个对象,但它们仍然是两个独立的名字。因此,给形参重新赋值,不会改变外部名字的绑定。

def plus_one(x: int):
    print(x is a)
    x = x + 1
    print(x is a)

a = 0
plus_one(a)
print(a)
python
True
False
0

这是因为,x = x + 1 没有修改原整数对象的值,而是得到值为 x + 1 的整数对象,并让名字 x 指向它。而外部的名字 a 仍然指向旧对象,因此值不变。


不过,共享对象本身仍然可以被修改,前提是它支持这种修改。

def append_one(x):
    x.append(1)
    x = []

a = []
append_one(a)
print(a)
python
[1]

x.append(1) 修改了两者共同指向的列表,而 x = [] 只让局部名字 x 指向另一个列表。修改对象和重新绑定名字,是两件不同的事。

Attribute#

了解完名字(name)之后,我们会发现对象的属性(attribute)实际上是名字的一种延伸。

属性可以先理解为依附于对象的名字,这里称被访问的对象是 owner.
“依附”不意味着私有,外部代码同样可以访问它。

裸名字也需要放在某个命名空间(namespace)中理解。例如,两个函数里都可以有一个叫 x 的局部名字,它们不会因此成为同一个名字。

因此,在理解普通实例属性时,可以借用 [owner.]name 这个形式:既看名字本身,也看它属于谁。obj1.x 和 obj2.x 是否访问同一个属性,不能只看 x,还要看 obj1 和 obj2 是否指向同一个对象。

不过,这只是帮助理解的类比。裸名字按作用域规则查找,属性按属性访问规则查找;Python 并不是把裸名字统一改写成了某个隐藏对象的属性。下面先讨论普通实例属性,暂不考虑继承和自定义属性访问。

为了加深对这个结论的理解,同时继续展示名字和对象的区别,来看一个例子:

class MyClass:
	def __init__(self):
		self.x = 0
python
obj1 = MyClass()

obj2 = obj1
obj1.x = 1

print(obj2.x)
python
1

这说明 obj1.x 和 obj2.x 是同一个名字,因为 obj1 和 obj2 是同一个对象。

flowchart LR

A["Namespace"] -- "obj1" --> B["<__main__.MyClass object>"]

B  -- "x" --> C["< int object >"]

B  -- "obj1" -----> B

以上述 Case #3 为例,左侧方框代表命名空间,其余方框代表对象;带名字的箭头代表绑定或引用关系。

Item#

除了裸名字和属性,还有一种常见的访问形式:obj[key]. 对于列表,key 可以是元素的下标;对于字典,则可以是键。这里先以列表为例。

列表可以理解为按位置记录对象引用的一张表。因此,给列表的某个位置赋值,修改的是列表中保存的引用,而不是原来那个元素对象。

a = [0]
b = a
x = a[0]

a[0] = 1

print(b)
print(x)
python
[1]
0

a 和 b 指向同一个列表,a[0] = 1 修改了这个列表,所以通过 b 也能看到变化。但整数对象 0 没有被修改,名字 x 仍然指向它。

请注意,x = a[0] 得到的是这个位置当时引用的对象,并没有让 x 持续追踪列表的第 0 个位置。之后替换这个位置的元素,不会连带改变 x 的绑定。

如果元素本身是可变对象,那么修改元素和替换元素,也需要区分:

a = [[]]
x = a[0]

x.append(1)
print(a)

a[0] = []
print(a)
print(x)
python
[[1]]
[[]]
[1]

x.append(1) 修改了列表元素所指的对象;a[0] = [] 则让列表的这个位置指向另一个对象。前面讨论的“修改对象”和“改变引用”,在这里仍然适用。


不过,下标访问并不是列表独有的语法。对于普通实例,Python 会通过其类型上的特殊方法处理这些操作:

操作对应的方法
obj[key]__getitem__(self, key)
obj[key] = value__setitem__(self, key, value)
del obj[key]__delitem__(self, key)

对象的类型决定了接受什么样的 key,以及如何读取、修改或删除对应的 item. 列表按位置访问,字典按键访问,自定义类型也可以定义自己的行为。不支持的操作会报错,例如元组支持读取元素,却不支持给元素位置赋值。

因此,obj.x 和 obj['x'] 是两套不同的访问机制,不会因为其中都有一个 x 就自动访问同一个位置。

到这里,可以把三种赋值放在一起看:x = value 重新绑定名字,obj.x = value 执行属性赋值,obj[key] = value 执行下标赋值。对于后两者,具体行为还要看对象的类型如何定义。

下标访问让我们看到,一种简短的语法,背后可以是一套由对象类型定义的行为。接下来回到属性访问,看看点号背后还能发生什么。

Descriptor#

在 Attribute 一节中,我们把普通实例属性理解成了对象自己的一本名册。和下标访问一样,属性访问也有自己的处理规则,比“按名字取出对象”多了一层机制。

Descriptor(描述符)协议允许你自定义属性的获取、赋值、删除行为。实现了这个协议的对象,就称为 descriptor.

这意味着,看到 obj.x 时,不能立即断定它只是在读取某个已经存好的值;它也可能在执行一段代码。

属性也可以是一个入口#

先看一个例子:

class Double:
    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return instance.x * 2

class MyClass:
    doubled = Double()

    def __init__(self, x):
        self.x = x

obj = MyClass(3)
print(obj.doubled)
obj.x = 5
print(obj.doubled)
print(obj.__dict__)
python
6
10
{'x': 5}

obj 的实例字典里只有 x,没有 doubled. 但 obj.doubled 仍然能得到结果,并且结果会随 x 改变。

这是因为,doubled 在类上绑定的是一个 Double 对象。读取 obj.doubled 时,Python 找到了这个对象,并调用它的 __get__,把返回值作为本次属性访问的结果。

这里有三个不同的角色:self 是 descriptor 对象,instance 是正在被访问的实例 obj,owner 是实例的类 MyClass. 注意,这里的 owner 是协议参数的惯用名称,具体指类,不是前文泛指的属性访问对象。

对于这个例子,可以把访问过程理解成:

descriptor = MyClass.__dict__['doubled']
print(descriptor.__get__(obj, MyClass))
python
10

直接从类字典取值可以拿到 descriptor 本身,不会触发它的 __get__. 而通过 MyClass.doubled 访问时,同样会调用 __get__,只是 instance 为 None. 上面的代码选择在这种情况下返回 descriptor 自己。

赋值不一定替换 descriptor#

一个对象的类型只要定义了 __get__、__set__、__delete__ 中任意一个方法,就可以参与描述符协议。以普通实例的属性访问为例,它们分别对应读取、赋值和删除。

再看一个同时实现三种操作的例子:

class Field:
    def __set_name__(self, owner, name):
        self.storage_name = '_' + name

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return getattr(instance, self.storage_name)

    def __set__(self, instance, value):
        setattr(instance, self.storage_name, value)

    def __delete__(self, instance):
        delattr(instance, self.storage_name)

class MyClass:
    x = Field()

    def __init__(self, x):
        self.x = x

obj1, obj2 = MyClass(1), MyClass(2)
obj1.x = 3

print(obj1.x, obj2.x)
print(obj1.__dict__)
print(isinstance(MyClass.__dict__['x'], Field))

del obj1.x
print(obj1.__dict__)
python
3 2
{'_x': 3}
True
{}

这里,__set_name__ 是类创建时的一个通知:告诉 Field 对象,它被放在了 MyClass 的 x 这个名字下面。于是它记下对应的存储名 _x. 这个方法是辅助机制,不是成为 descriptor 的必要条件。

obj1.x = 3 会调用 __set__,实际写入 obj1._x;del obj1.x 会调用 __delete__,实际删除 obj1._x. 类上的 Field 对象始终存在,实例字典中也始终没有 x 这一项。

请注意,同一个类属性上的 descriptor 通常由多个实例共享,而每个实例的数据需要另找地方保存。 这里把值存在各自的实例上,所以修改 obj1.x 不影响 obj2.x. 如果直接把值存成 descriptor 的 self.value,这些实例就会读写同一份数据。

另外,不能在这个 __set__ 里直接写 instance.x = value,否则会再次触发同一个 __set__,不断递归。换用 _x,正是为了把对外的属性入口和实际存储位置分开。

谁优先?#

回到第一个例子。如果实例字典里也出现了 doubled,Python 应该返回它,还是调用 Double.__get__?

这取决于 descriptor 的种类。

只定义 __get__ 的称为 non-data descriptor;定义了 __set__ 或 __delete__ 的称为 data descriptor。这个分类看的是类型定义了哪些方法,不是它是否真的保存数据。

对于普通实例的默认读取规则,先沿类的 MRO(方法解析顺序)找到第一个同名类属性,再结合实例字典判断优先级:

  1. 如果找到的是提供 __get__ 的 data descriptor,调用它。
  2. 否则,如果实例字典有这个名字,返回实例字典中的值。
  3. 否则,如果找到的类属性提供 __get__,调用它;没有则直接返回该类属性。
  4. 都没找到则抛出 AttributeError;点号访问还可以在此时交给定义好的 __getattr__ 处理。

这个顺序解释了下面两个结果:

沿用前面的 Double:

class MyClass:
    doubled = Double()

    def __init__(self):
        self.x = 3

obj = MyClass()
print(obj.doubled)
obj.doubled = 100
print(obj.doubled)
del obj.doubled
print(obj.doubled)
python
6
100
6

实例属性遮住了类上的 non-data descriptor;删掉实例属性之后,descriptor 又会参与读取。

这些是默认属性访问机制中的规则。Descriptor 并不是独立于属性访问的魔法,而是 __getattribute__ 等方法所实现的流程的一部分;自定义这些方法时,也可能改变这套行为。具体规则可以参见 Python 数据模型 ↗。

其实你早就用过了#

如果你用过 @property,那么你已经用过 data descriptor. 例如:

class MyClass:
    def __init__(self, x):
        self.x = x

    @property
    def doubled(self):
        return self.x * 2

obj = MyClass(3)
print(obj.doubled)

try:
    obj.doubled = 100
except AttributeError:
    print('不能直接赋值')
python
6
不能直接赋值

它同样让读取属性变成了一次计算。不过,与前面只定义 __get__ 的 Double 不同,没有 setter 的 property 仍然是 data descriptor;赋值时会报错,而不是生成一个同名实例属性把它遮住。property 帮你包装了这些协议方法,详见 内置函数文档 ↗。

更常见的例子是方法。普通 Python 函数也实现了 __get__,是 non-data descriptor. 把函数放在类上,再通过实例读取时,就会得到绑定方法。

class MyClass:
    def show(self):
        return self.x

obj = MyClass()
obj.x = 7
method = obj.show

print(method.__self__ is obj)
print(method.__func__ is MyClass.__dict__['show'])
print(method())
python
True
True
7

method 同时记住了原函数和实例。调用 method() 时,会自动把这个实例作为第一个参数传入。也就是说,self 的自动传入,来自读取属性时的绑定过程。 关于函数如何变成绑定方法,可以继续看官方的 Descriptor Guide ↗。

回看全文,裸名字让我们找到对象,普通实例属性和列表元素让对象之间建立引用,而 descriptor 又让属性访问本身成为一个可以定义行为的入口。同样是 obj.x,它既可能取出一个保存好的对象,也可能计算一个结果,或者得到一个绑定了实例的方法。

Python 颠覆认知的冷知识 #2
https://blog.leosrealms.top/blog/miscellaneous/2026-09-24-python-blows-your-mind-2
Author CoderXL
Published on 2026年9月24日
Comment seems to stuck. Try to refresh?✨