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 指向的对象发生了变化。
#include <iostream>
using namespace std;
int main()
{
int x;
x=1;
cout<<&x<<endl;
x=2;
cout<<&x<<endl;
return 0;
}cpp在这段 C++ 代码中,x 对应固定的存储位置,对它赋值就是原地修改保存的整数。
通过取地址符 & 可以看到 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)pythonTrue
False
0这是因为,x = x + 1 没有修改原整数对象的值,而是得到值为 x + 1 的整数对象,并让名字 x 指向它。而外部的名字 a 仍然指向旧对象,因此值不变。
在 C++ 中,通过引用传参,使参数变量指向外部变量的内存,而后进行原位修改,从而实现在函数内部修改变量。
#include <iostream>
using namespace std;
void plus_one(int &x)
{
x = x+1;
}
int main()
{
int a = 0;
plus_one(a);
cout<<a<<endl;
return 0;
}cpp1不过,共享对象本身仍然可以被修改,前提是它支持这种修改。
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 = 0pythonobj1 = MyClass()
obj2 = obj1
obj1.x = 1
print(obj2.x)python1这说明 obj1.x 和 obj2.x 是同一个名字,因为 obj1 和 obj2 是同一个对象。
obj1, obj2 = MyClass(), MyClass()
obj2.x = obj1.x
obj1.x = 1
print(obj2.x)python0这说明 obj1.x 和 obj2.x 是两个名字,因为 obj1 和 obj2 是两个对象。
obj1 = MyClass()
obj1.obj1 = obj1
obj1.x = 1
print(obj1.obj1.x)python1这说明 obj1.x 和 obj1.obj1.x 是同一个名字,因为 obj1 和 obj1.obj1 是同一个对象。
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]
0a 和 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__)python6
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))python10直接从类字典取值可以拿到 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__)python3 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(方法解析顺序)找到第一个同名类属性,再结合实例字典判断优先级:
- 如果找到的是提供
__get__的 data descriptor,调用它。 - 否则,如果实例字典有这个名字,返回实例字典中的值。
- 否则,如果找到的类属性提供
__get__,调用它;没有则直接返回该类属性。 - 都没找到则抛出
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)python6
100
6实例属性遮住了类上的 non-data descriptor;删掉实例属性之后,descriptor 又会参与读取。
沿用前面的 Field:
class MyClass:
x = Field()
obj = MyClass()
obj.x = 3
obj.__dict__['x'] = 100
print(obj.__dict__)
print(obj.x)python{'_x': 3, 'x': 100}
3即使直接往实例字典中放入同名的 x,读取 obj.x 时仍然优先调用 Field.__get__,得到 _x 的值。
这些是默认属性访问机制中的规则。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('不能直接赋值')python6
不能直接赋值它同样让读取属性变成了一次计算。不过,与前面只定义 __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())pythonTrue
True
7method 同时记住了原函数和实例。调用 method() 时,会自动把这个实例作为第一个参数传入。也就是说,self 的自动传入,来自读取属性时的绑定过程。 关于函数如何变成绑定方法,可以继续看官方的 Descriptor Guide ↗。
回看全文,裸名字让我们找到对象,普通实例属性和列表元素让对象之间建立引用,而 descriptor 又让属性访问本身成为一个可以定义行为的入口。同样是 obj.x,它既可能取出一个保存好的对象,也可能计算一个结果,或者得到一个绑定了实例的方法。