Skip to content

Commit

Permalink
实例对象的属性访问
Browse files Browse the repository at this point in the history
  • Loading branch information
satori1995 committed Jan 5, 2025
1 parent 67a6090 commit 5bc0ef4
Show file tree
Hide file tree
Showing 3 changed files with 390 additions and 0 deletions.
389 changes: 389 additions & 0 deletions src/80.实例对象的属性访问.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,389 @@
## 楔子

之前在讨论名字空间的时候提到,在 Python 中,形如 **x.y** 样式的表达式被称之为**属性引用**,其中 x 指向某个对象,y 为对象的某个属性。

那么下面来看看虚拟机是怎么实现属性引用的?

## 属性引用

还是看一个简单的类,然后观察它的字节码。

~~~Python
class Girl:

def __init__(self):
self.name = "satori"
self.age = 16

def get_info(self):
return f"name: {self.name}, age: {self.age}"

g = Girl()
# 获取 name 属性
name = g.name
# 获取 get_info 方法并调用
g.get_info()
~~~

想要了解背后都发生了什么,最直接的途径就是查看字节码,这里只看模块对应的字节码。

~~~C
// class Girl: 对应的字节码,这里就不赘述了
0 LOAD_BUILD_CLASS
2 LOAD_CONST 0 (<code object Girl at 0x7f9562476240, file "", line 2>)
4 LOAD_CONST 1 ('Girl')
6 MAKE_FUNCTION 0
8 LOAD_CONST 1 ('Girl')
10 CALL_FUNCTION 2
12 STORE_NAME 0 (Girl)

// g = Girl() 对应的字节码,不再赘述
14 LOAD_NAME 0 (Girl)
16 CALL_FUNCTION 0
18 STORE_NAME 1 (g)

// name = g.name 对应的字节码
// 加载变量 g
20 LOAD_NAME 1 (g)
// 获取 g.name,加载属性用的是 LOAD_ATTR
22 LOAD_ATTR 2 (name)
// 将结果交给变量 name 保存
24 STORE_NAME 2 (name)

// g.get_info() 对应的字节码
// 加载变量 g
26 LOAD_NAME 1 (g)
// 获取方法 g.get_info,加载方法用的是 LOAD_METHOD
28 LOAD_METHOD 3 (get_info)
// 调用方法,注意指令是 CALL_METHOD,不是 CALL_FUNCTION
// 但显然 CALL_METHOD 内部也是调用了 CALL_FUNCTION
30 CALL_METHOD 0
// 从栈顶弹出返回值
32 POP_TOP
34 LOAD_CONST 2 (None)
36 RETURN_VALUE
~~~

除了 LOAD_METHOD 和 LOAD_ATTR,其它的指令我们都见过了,因此下面重点分析这两条指令。

~~~C
case TARGET(LOAD_METHOD): {
// 从符号表中获取符号,因为是 g.get_info
// 那么这个 name 就指向字符串对象 "get_info"
PyObject *name = GETITEM(names, oparg);
// 获取栈顶元素 obj,显然这个 obj 就是代码中的实例对象 g
PyObject *obj = TOP();
// meth 是一个 PyObject * 指针,显然它要指向一个方法
PyObject *meth = NULL;

// 这里是获取和 "get_info" 绑定的方法,然后让 meth 指向它
// 具体做法是调用 _PyObject_GetMethod,传入二级指针 &meth
// 然后让 meth 存储的地址变成指向具体方法的地址
int meth_found = _PyObject_GetMethod(obj, name, &meth);

//如果 meth == NULL,raise AttributeError
if (meth == NULL) {
/* Most likely attribute wasn't found. */
goto error;
}

// 注意:无论是 Girl.get_info、还是 g.get_info,对应的指令都是 LOAD_METHOD
// 类去调用的话,说明得到的是一个未绑定的方法,说白了就等价于函数
// 实例去调用的话,会得到一个绑定的方法,相当于对函数进行了封装
// 关于绑定和未绑定我们后面会详细介绍
if (meth_found) {
// 如果 meth_found 为 1,说明 meth 是一个绑定的方法,obj 就是 self
// 将 meth 设置为栈顶元素,然后再将 obj 压入栈中
SET_TOP(meth);
PUSH(obj); // self
}
else {
// 否则说明 meth 是一个未绑定的方法
// 那么将栈顶元素设置为 NULL,然后将 meth 压入栈中
SET_TOP(NULL);
Py_DECREF(obj);
PUSH(meth);
}
DISPATCH();
}
~~~
获取方法是 LOAD_METHOD 指令 ,获取属性则是 LOAD_ATTR 指令,来看一下。
~~~C
case TARGET(LOAD_ATTR): {
// 可以看到和 LOAD_METHOD 本质上是类似的,但更简单一些
// name 依旧是符号,这里指向字符串对象 "name"
PyObject *name = GETITEM(names, oparg);
// 从栈顶获取变量 g
PyObject *owner = TOP();
// res 显然就是属性的值了,即 g.name
// 通过 PyObject_GetAttr 进行获取
PyObject *res = PyObject_GetAttr(owner, name);
Py_DECREF(owner);
// 设置为栈顶元素
SET_TOP(res);
if (res == NULL)
goto error;
DISPATCH();
}
~~~

所以这两个指令本身是很简单的,而核心在 PyObject_GetAttr 和 _PyObject_GetMethod 上面,前者用于获取属性、后者用于获取方法。先来看一下 PyObject_GetAttr 具体都做了什么事情。

~~~C
// Objects/object.c
PyObject *
PyObject_GetAttr(PyObject *v, PyObject *name)
{
// v: 对象,name: 属性名

// 获取实例对象 v 的类型对象
PyTypeObject *tp = Py_TYPE(v);
// name 必须是一个字符串
if (!PyUnicode_Check(name)) {
PyErr_Format(PyExc_TypeError,
"attribute name must be string, not '%.200s'",
name->ob_type->tp_name);
return NULL;
}
// 通过类型对象的 tp_getattro 字段获取实例对应的属性
if (tp->tp_getattro != NULL)
return (*tp->tp_getattro)(v, name);
// tp_getattr 和 tp_getattro 功能一样,但后者可以支持中文
if (tp->tp_getattr != NULL) {
const char *name_str = PyUnicode_AsUTF8(name);
if (name_str == NULL)
return NULL;
return (*tp->tp_getattr)(v, (char *)name_str);
}
// 属性不存在,抛出异常
PyErr_Format(PyExc_AttributeError,
"'%.50s' object has no attribute '%U'",
tp->tp_name, name);
return NULL;
}
~~~
PyTypeObject 里面定义了两个与属性访问相关的操作:tp_getattro 和 tp_getattr。其中前者是优先选择的属性访问动作,而后者已不推荐使用。这两者的区别在 PyObject_GetAttr 中已经显示得很清楚了,主要在属性名的使用上。
- tp_getattro 所使用的属性名是一个 PyUnicodeObject \*;
- 而 tp_getattr 所使用的属性名是一个 char \*。
如果这两个字段同时被定义,那么优先使用 tp_getattro。问题来了,自定义类对象的 tp_getattro 对应哪一个 C 函数呢?显然我们要去找 object。
object 在底层对应 PyBaseObject_Type,它的 tp_getattro 为 PyObject_GenericGetAttr,因此虚拟机在创建 Girl 这个类时,也会将此操作继承下来。
~~~C
// Objects/object.c
PyObject *
PyObject_GenericGetAttr(PyObject *obj, PyObject *name)
{
return _PyObject_GenericGetAttrWithDict(obj, name, NULL, 0);
}
PyObject *
_PyObject_GenericGetAttrWithDict(PyObject *obj, PyObject *name,
PyObject *dict, int suppress)
{
// 拿到 obj 的类型对象,对于当前的例子来说,显然是 class Girl
PyTypeObject *tp = Py_TYPE(obj);
// 描述符
PyObject *descr = NULL;
// 返回值
PyObject *res = NULL;
// 描述符的 __get__
descrgetfunc f;
Py_ssize_t dictoffset;
PyObject **dictptr;
// name 必须是字符串
if (!PyUnicode_Check(name)){
PyErr_Format(PyExc_TypeError,
"attribute name must be string, not '%.200s'",
name->ob_type->tp_name);
return NULL;
}
Py_INCREF(name);
// 属性字典不为空,是初始化是否完成的重要标志
// 如果为空,说明还没有初始化,那么需要先初始化
if (tp->tp_dict == NULL) {
if (PyType_Ready(tp) < 0)
goto done;
}
// 从 mro 顺序列表中获取属性对应的值,并检测是否为描述符
// 如果属性不存在、或者存在但对应的值不是描述符,则返回 NULL
descr = _PyType_Lookup(tp, name);
f = NULL;
if (descr != NULL) {
Py_INCREF(descr);
// 如果 descr 不为 NULL,说明该属性被代理了
// descr 是描述符,f 就是它的 __get__ 方法
// f = descr.__class__.__get__
f = descr->ob_type->tp_descr_get;
// __get__ 对应 PyTypeObject 的 tp_descr_get
        // __set__ 对应 PyTypeObject 的 tp_descr_set  
// 如果 f 不为 NULL,并且 descr 是数据描述符
if (f != NULL && PyDescr_IsData(descr)) {
// 那么直接调用描述符的 __get__ 方法,返回结果
res = f(descr, obj, (PyObject *)obj->ob_type);
if (res == NULL && suppress &&
PyErr_ExceptionMatches(PyExc_AttributeError)) {
PyErr_Clear();
}
goto done;
}
}
// 走到这里说明要获取的属性没有被代理,或者说代理它的是非数据描述符
    // 当然还有一种情况,这种情况上一篇文章貌似没提到
    // 就是属性被数据描述符代理,但是该数据描述符没有 __get__
    // 那么仍会优先从实例对象自身的 __dict__ 中寻找属性
if (dict == NULL) {
/* Inline _PyObject_GetDictPtr */
dictoffset = tp->tp_dictoffset;
// 如果 dict 为 NULL,并且 dictoffset 不为 0
// 说明继承自变长对象,那么要调整 tp_dictoffset
if (dictoffset != 0) {
if (dictoffset < 0) {
Py_ssize_t tsize;
size_t size;
tsize = ((PyVarObject *)obj)->ob_size;
if (tsize < 0)
tsize = -tsize;
size = _PyObject_VAR_SIZE(tp, tsize);
_PyObject_ASSERT(obj, size <= PY_SSIZE_T_MAX);
dictoffset += (Py_ssize_t)size;
_PyObject_ASSERT(obj, dictoffset > 0);
_PyObject_ASSERT(obj, dictoffset % SIZEOF_VOID_P == 0);
}
dictptr = (PyObject **) ((char *)obj + dictoffset);
dict = *dictptr;
}
}
// dict 不为 NULL,从属性字典中获取
if (dict != NULL) {
Py_INCREF(dict);
res = PyDict_GetItemWithError(dict, name);
if (res != NULL) {
Py_INCREF(res);
Py_DECREF(dict);
goto done;
}
else {
Py_DECREF(dict);
if (PyErr_Occurred()) {
if (suppress && PyErr_ExceptionMatches(PyExc_AttributeError)) {
PyErr_Clear();
}
else {
goto done;
}
}
}
}
// 程序走到这里,说明什么呢?显然意味着实例的属性字典里面没有要获取的属性
// 但如果下面的 f != NULL 成立,说明属性被代理了
    // 并且代理属性的描述符是非数据描述符,它的优先级低于实例
// 所以实例会先到自身的属性字典中查找,找不到再去执行描述符的 __get__
if (f != NULL) {
// 第一个参数是描述符本身,也就是 __get__ 里面的 self
// 第二个参数是实例对象,也就是 __get__ 里面的 instance
// 第三个参数是类对象,也就是 __get__ 里面的 owner
res = f(descr, obj, (PyObject *)Py_TYPE(obj));
if (res == NULL && suppress &&
PyErr_ExceptionMatches(PyExc_AttributeError)) {
PyErr_Clear();
}
goto done;
}
// 程序能走到这里,说明属性字典里面没有要找的属性,并且也没有执行描述符的 __get__
    // 但如果 describe 还不为 NULL,这说明什么呢?
    // 显然该属性仍被描述符代理了,只是这个描述符没有 __get__,如果是这种情况,那么会返回描述符本身
if (descr != NULL) {
res = descr;
descr = NULL;
goto done;
}
// 找不到,就报错
if (!suppress) {
PyErr_Format(PyExc_AttributeError,
"'%.50s' object has no attribute '%U'",
tp->tp_name, name);
}
done:
Py_XDECREF(descr);
Py_DECREF(name);
return res;
}
~~~

这里面有两个我们上一篇文章没有提到的地方,下面补充一下:

~~~Python
class Descriptor:

def __set__(self, instance, value):
print("__set__")


class B:
name = Descriptor()

b = B()
# b 的属性字典没有 name,描述符也没有 __get__
# 那么这个时候会返回描述符本身
print(b.name) # <__main__.Descriptor object at 0x0...>

# 此时属性字典里面有 name 了
b.__dict__["name"] = "古明地觉"
# 由于 name 是被数据描述符代理的,按理说获取属性时会执行数据描述符的 __get__
# 但是这个数据描述符压根没有 __get__,因此还是会从属性字典中查找
print(b.name) # 古明地觉
~~~

以上就是获取属性的逻辑,很好理解,用一张流程图总结一下。

![](./images/263.png)

获取方法也与之类似,调用的是 \_PyObject\_GetMethod,这里就不再看了。

## 再论描述符

前面我们看到,在 PyType_Ready 中,虚拟机会填充 tp_dict,其中与操作名对应的是一个个的描述符。那时我们看到的是描述符这个概念在 Python 内部是如何实现的,现在我们将要剖析的是描述符在 Python 的类机制中究竟会起到怎样的作用。

虚拟机对自定义类对象或实例对象进行属性访问时,描述符将对属性访问的行为产生重大影响。一般而言,如果一个类存在 \_\_get\_\_\_\_set\_\_\_\_delete\_\_ 操作(不要求三者同时存在),那么它的实例便可以称之为描述符。在 slotdefs 中,我们会看到这三种魔法方法对应的操作。

~~~C
TPSLOT("__get__", tp_descr_get, slot_tp_descr_get, wrap_descr_get,
"__get__($self, instance, owner, /)\n--\n\nReturn an attribute of instance, which is of type owner."),
TPSLOT("__set__", tp_descr_set, slot_tp_descr_set, wrap_descr_set,
"__set__($self, instance, value, /)\n--\n\nSet an attribute of instance to value."),
TPSLOT("__delete__", tp_descr_set, slot_tp_descr_set,
wrap_descr_delete,
"__delete__($self, instance, /)\n--\n\nDelete an attribute of instance."),
~~~
而在虚拟机访问实例对象的属性时,描述符的一个作用就是影响虚拟机对属性的选择。从 PyObject_GenericGetAttr 源码中可以看到,虚拟机会先在实例对象自身的 \_\_dict\_\_ 中寻找属性,也会在实例对象的类型对象的 mro 顺序列表中寻找属性,我们将前一种属性称之为**实例属性**,后一种属性称之为**类属性**。所以在属性的选择上,有如下规律:
- 虚拟机优先按照实例属性、类属性的顺序选择属性,即实例属性优先于类属性;
- 如果发现有一个同名、并且被数据描述符代理的类属性,那么该描述符会优先于实例属性被虚拟机选择;
这两条规则在对属性进行设置时仍然会被严格遵守,换句话说,如果执行 <font color="blue">ins.xxx = yyy</font> 时,在 type(ins) 中也出现了 xxx 属性、并且还被数据描述符代理了。那么不好意思,此时虚拟机会选择描述符,并执行它的 \_\_set\_\_ 方法;如果是非数据描述符,那么就不再走 \_\_set\_\_ 了,而是设置属性(因为压根没有 \_\_set\_\_),也就是 <font color="blue">a.\_\_dict\_\_['xxx'] = yyy</font>。
## 小结
以上就是实例对象的属性访问,但是还没结束,我们下一篇文章来重点讨论一下 self。
----
&nbsp;
**欢迎大家关注我的公众号:古明地觉的编程教室。**
![](./images/qrcode_for_gh.jpg)
**如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。**
![](./images/supports.png)
1 change: 1 addition & 0 deletions src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,4 @@
+ [77. 自定义类对象的底层实现与 metaclass](77.自定义类对象的底层实现与metaclass.md)
+ [78. 彻底搞懂描述符](78.彻底搞懂描述符.md)
+ [79. 实例对象是如何创建的?](79.实例对象是如何创建的?.md)
+ [80. 实例对象的属性访问](80.实例对象的属性访问.md)
Binary file added src/images/263.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 5bc0ef4

Please sign in to comment.