當(dāng)創(chuàng)建一個(gè) Python 對(duì)象時(shí),背后都經(jīng)歷了哪些過(guò)程?
楔子
本篇文章來(lái)聊一聊對(duì)象的創(chuàng)建,一個(gè)對(duì)象是如何從無(wú)到有產(chǎn)生的呢?
>>> n = 123
>>> n
123
比如在終端中執(zhí)行 n = 123,一個(gè)整數(shù)對(duì)象就被創(chuàng)建好了,但它的背后都發(fā)生了什么呢?帶著這些疑問(wèn),開(kāi)始今天的內(nèi)容。
Python 為什么這么慢
前面我們介紹了 Python 對(duì)象在底層的數(shù)據(jù)結(jié)構(gòu),知道了 Python 底層是通過(guò) PyObject 實(shí)現(xiàn)了對(duì)象的多態(tài)。所以我們先來(lái)分析一下 Python 為什么慢?
在 Python 中創(chuàng)建一個(gè)對(duì)象,會(huì)分配內(nèi)存并進(jìn)行初始化,然后用一個(gè) PyObject * 指針來(lái)維護(hù)這個(gè)對(duì)象,當(dāng)然所有對(duì)象都是如此。因?yàn)橹羔樖强梢韵嗷マD(zhuǎn)化的,所以變量在保存一個(gè)對(duì)象的指針時(shí),會(huì)將指針轉(zhuǎn)成 PyObject * 之后再交給變量保存。
因此在 Python 中,變量的傳遞(包括函數(shù)的參數(shù)傳遞)實(shí)際上傳遞的都是泛型指針 PyObject *。這個(gè)指針具體指向什么類型的對(duì)象我們并不知道,只能通過(guò)其內(nèi)部的 ob_type 字段進(jìn)行動(dòng)態(tài)判斷,而正是因?yàn)檫@個(gè) ob_type,Python 實(shí)現(xiàn)了多態(tài)機(jī)制。
比如 a.pop(),我們不知道 a 指向的對(duì)象到底是什么類型,它可能是列表、也可能是字典,或者是我們實(shí)現(xiàn)了 pop 方法的自定義類的實(shí)例對(duì)象。至于它到底是什么類型,只能通過(guò) ob_type 動(dòng)態(tài)判斷。
如果 a 的 ob_type 為 &PyList_Type,那么 a 指向的對(duì)象就是列表,于是會(huì)調(diào)用 list 類型中定義的 pop 操作。如果 a 的 ob_type 為 &PyDict_Type,那么 a 指向的對(duì)象就是字典,于是會(huì)調(diào)用 dict 類型中定義的 pop 操作。所以變量 a 在不同的情況下,會(huì)表現(xiàn)出不同的行為,這正是 Python 多態(tài)的核心所在。
再比如列表,它內(nèi)部的元素也都是 PyObject *,因?yàn)轭愋鸵3忠恢?,所以?duì)象的指針不能直接存(因?yàn)轭愋筒煌切枰y(tǒng)一轉(zhuǎn)成泛型指針 PyObject * 之后才可以存儲(chǔ)。當(dāng)我們通過(guò)索引獲取到該指針進(jìn)行操作的時(shí)候,也會(huì)先通過(guò) ob_type 判斷它的類型,看它是否支持指定的操作。所以操作容器內(nèi)的某個(gè)元素,和操作一個(gè)變量并無(wú)本質(zhì)上的區(qū)別,它們都是 PyObject *。
從這里我們也能看出來(lái) Python 為什么慢了,因?yàn)橛邢喈?dāng)一部分時(shí)間浪費(fèi)在類型和屬性的查找上面。
以變量 a + b 為例,這個(gè) a 和 b 指向的對(duì)象可以是整數(shù)、浮點(diǎn)數(shù)、字符串、列表、元組、甚至是我們自己實(shí)現(xiàn)了 __add__ 方法的類的實(shí)例對(duì)象。因?yàn)?Python 的變量都是 PyObject *,所以它可以指向任意的對(duì)象,因此 Python 就無(wú)法做基于類型的優(yōu)化。
首先 Python 底層要通過(guò) ob_type 判斷變量指向的對(duì)象到底是什么類型,這在 C 的層面至少需要一次屬性查找。然后 Python 將每一個(gè)算術(shù)操作都抽象成了一個(gè)魔法方法,所以實(shí)例相加時(shí)要在類型對(duì)象中找到該方法對(duì)應(yīng)的函數(shù)指針,這又是一次屬性查找。找到了之后將 a、b 作為參數(shù)傳遞進(jìn)去,這會(huì)產(chǎn)生一次函數(shù)調(diào)用,會(huì)將對(duì)象維護(hù)的值拿出來(lái)進(jìn)行運(yùn)算,然后根據(jù)相加的結(jié)果創(chuàng)建一個(gè)新的對(duì)象,再將對(duì)象的指針轉(zhuǎn)成 PyObject * 之后返回。
所以一個(gè)簡(jiǎn)單的加法運(yùn)算,Python 內(nèi)部居然做了這么多的工作,要是再放到循環(huán)里面,那么上面的步驟要重復(fù) N 次。而對(duì)于 C 來(lái)講,由于已經(jīng)規(guī)定好了類型,所以 a + b 在編譯之后就是一條簡(jiǎn)單的機(jī)器指令,因此兩者在效率上差別很大。
當(dāng)然我們不是來(lái)吐槽 Python 效率的問(wèn)題,因?yàn)槿魏握Z(yǔ)言都有擅長(zhǎng)的一面和不擅長(zhǎng)的一面,這里只是通過(guò)回顧前面的知識(shí)來(lái)解釋為什么 Python 效率低。因此當(dāng)別人問(wèn)你 Python 為什么效率低的時(shí)候,希望你能從這個(gè)角度來(lái)回答它,主要就兩點(diǎn):
- Python 無(wú)法基于類型做優(yōu)化;
- Python 對(duì)象基本都存儲(chǔ)在堆上;
建議不要一上來(lái)就談 GIL,那是在多線程情況下才需要考慮的問(wèn)題。而且我相信大部分覺(jué)得 Python 慢的人,都不是因?yàn)?Python 無(wú)法利用多核才覺(jué)得慢的。
Python 的 C API
然后來(lái)說(shuō)一說(shuō) Python 的 C API,這個(gè)非常關(guān)鍵。首先 Python 解釋器聽(tīng)起來(lái)很高大上,但按照陳儒老師的說(shuō)法,它不過(guò)就是用 C 語(yǔ)言寫(xiě)出的一個(gè)開(kāi)源軟件,從形式上和其它軟件并沒(méi)有本質(zhì)上的不同。
比如你在 Windows 系統(tǒng)中打開(kāi) Python 的安裝目錄,會(huì)發(fā)現(xiàn)里面有一個(gè)二進(jìn)制文件 python.exe 和一個(gè)動(dòng)態(tài)庫(kù)文件 python312.dll。二進(jìn)制文件負(fù)責(zé)執(zhí)行,動(dòng)態(tài)庫(kù)文件則包含了相應(yīng)的依賴,當(dāng)然編譯的時(shí)候也可以把動(dòng)態(tài)庫(kù)里的內(nèi)容統(tǒng)一打包到二進(jìn)制文件中,不過(guò)大部分軟件在開(kāi)發(fā)時(shí)都會(huì)選擇前者。
既然解釋器是用 C 寫(xiě)的,那么在執(zhí)行時(shí)肯定會(huì)將 Python 代碼翻譯成 C 代碼,這是毫無(wú)疑問(wèn)的。比如創(chuàng)建一個(gè)列表,底層就會(huì)創(chuàng)建一個(gè) PyListObject 實(shí)例,比如調(diào)用某個(gè)內(nèi)置函數(shù),底層會(huì)調(diào)用對(duì)應(yīng)的 C 函數(shù)。
所以如果你想搞懂 Python 代碼的執(zhí)行邏輯或者編寫(xiě) Python 擴(kuò)展,那么就必須要清楚解釋器提供的 API 函數(shù)。而按照通用性來(lái)劃分的話,這些 API 可以分為兩種。
- 泛型 API;
- 特定類型 API;
泛型 API
顧名思義,泛型 API 和參數(shù)類型無(wú)關(guān),屬于抽象對(duì)象層。這類 API 的第一個(gè)參數(shù)是 PyObject *,可以處理任意類型的對(duì)象,API 內(nèi)部會(huì)根據(jù)對(duì)象的類型進(jìn)行區(qū)別處理。
而且泛型 API 的名稱也是有規(guī)律的,具有 PyObject_### 這種形式,我們舉例說(shuō)明。
圖片
所以泛型 API 一般以 PyObject_ 開(kāi)頭,第一個(gè)參數(shù)是 PyObject *,表示可以處理任意類型的對(duì)象。
特定類型 API
顧名思義,特定類型 API 和對(duì)象的類型是相關(guān)的,屬于具體對(duì)象層,只能作用在指定類型的對(duì)象上面。因此不難發(fā)現(xiàn),每種類型的對(duì)象,都有屬于自己的一組特定類型 API。
// 通過(guò) C 的 double 創(chuàng)建 PyFloatObject
PyObject* PyFloat_FromDouble(double v);
// 通過(guò) C 的 long 創(chuàng)建 PyLongObject
PyObject* PyLong_FromLong(long v);
// 通過(guò) C 的 char * 來(lái)創(chuàng)建 PyLongObject
PyObject* PyLong_FromString(const char *str, char **pend, int base)
以上就是解釋器提供的兩種 C API,了解之后我們?cè)賮?lái)看看對(duì)象是如何創(chuàng)建的。
對(duì)象是如何創(chuàng)建的
創(chuàng)建對(duì)象可以使用泛型 API,也可以使用特定類型 API,比如創(chuàng)建一個(gè)浮點(diǎn)數(shù)。
使用泛型 API 創(chuàng)建
PyObject* pi = PyObject_New(PyObject, &PyFloat_Type);
通過(guò)泛型 API 可以創(chuàng)建任意類型的對(duì)象,因?yàn)樵擃?API 和類型無(wú)關(guān)。那么問(wèn)題來(lái)了,解釋器怎么知道要給對(duì)象分配多大的內(nèi)存呢?
在介紹類型對(duì)象的時(shí)候我們提到,對(duì)象的內(nèi)存大小、支持哪些操作等等,都屬于元信息,而元信息會(huì)存在對(duì)應(yīng)的類型對(duì)象中。其中 tp_basicsize 和 tp_itemsize 負(fù)責(zé)指定實(shí)例對(duì)象所需的內(nèi)存空間。
// Include/objimpl.h
#define PyObject_New(type, typeobj) ((type *)_PyObject_New(typeobj))
// Objects/object.c
PyObject *
_PyObject_New(PyTypeObject *tp)
{
// 通過(guò) PyObject_Malloc 為對(duì)象申請(qǐng)內(nèi)存,申請(qǐng)多大呢?
// 會(huì)通過(guò) _PyObject_SIZE(tp) 進(jìn)行計(jì)算
PyObject *op = (PyObject *) PyObject_Malloc(_PyObject_SIZE(tp));
if (op == NULL) {
return PyErr_NoMemory();
}
// 設(shè)置對(duì)象的類型和引用計(jì)數(shù)
_PyObject_Init(op, tp);
return op;
}
// Include/cpython/objimpl.h
static inline size_t _PyObject_SIZE(PyTypeObject *type) {
// 返回類型對(duì)象的 tp_basicsize
return _Py_STATIC_CAST(size_t, type->tp_basicsize);
}
泛型 API 屬于通用邏輯,而內(nèi)置類型的實(shí)例對(duì)象一般會(huì)采用特定類型 API 創(chuàng)建。
使用特定類型 API 創(chuàng)建
// 創(chuàng)建浮點(diǎn)數(shù),值為 2.71
PyObject* e = PyFloat_FromDouble(2.71);
// 創(chuàng)建一個(gè)可以容納 5 個(gè)元素的元組
PyObject* tpl = PyTuple_New(5);
// 創(chuàng)建一個(gè)可以容納 5 個(gè)元素的列表
// 當(dāng)然這是初始容量,列表是可以擴(kuò)容的
PyObject* lst = PyList_New(5);
和泛型 API 不同,使用特定類型 API 只能創(chuàng)建指定類型的對(duì)象,因?yàn)樵擃?API 是和類型綁定的。比如我們可以用 PyDict_New 創(chuàng)建一個(gè)字典,但不可能創(chuàng)建一個(gè)集合出來(lái)。
如果使用特定類型 API,那么可以直接分配內(nèi)存。因?yàn)閮?nèi)置類型的實(shí)例對(duì)象,它們的定義在底層都是寫(xiě)死的,解釋器對(duì)它們了如指掌,因此可以直接分配內(nèi)存并初始化。
比如通過(guò) e = 2.71 創(chuàng)建一個(gè)浮點(diǎn)數(shù),解釋器看到 2.71 就知道要?jiǎng)?chuàng)建 PyFloatObject 結(jié)構(gòu)體實(shí)例,那么申請(qǐng)多大內(nèi)存呢?顯然是 sizeof(PyFloatObject),直接計(jì)算一下結(jié)構(gòu)體實(shí)例的大小即可。
圖片
顯然一個(gè) PyFloatObject 實(shí)例的大小是 24 字節(jié),所以內(nèi)存直接就分配了。分配之后將 ob_refcnt 初始化為 1、ob_type 設(shè)置為 &PyFloat_Type、ob_fval 設(shè)置為 2.71 即可。
同理可變對(duì)象也是一樣,因?yàn)樽侄味际枪潭ǖ?,?nèi)部容納的元素有多少個(gè)也可以根據(jù)賦的值得到,所以內(nèi)部的所有字段占用了多少內(nèi)存可以算出來(lái),因此也是可以直接分配內(nèi)存的。
還是那句話,解釋器對(duì)內(nèi)置的數(shù)據(jù)結(jié)構(gòu)了如指掌,因?yàn)檫@些結(jié)構(gòu)在底層都是定義好的,源碼直接寫(xiě)死了。所以解釋器根本不需要借助類型對(duì)象去創(chuàng)建實(shí)例對(duì)象,它只需要在實(shí)例對(duì)象創(chuàng)建完畢之后,將 ob_type 設(shè)置為指定的類型即可(讓實(shí)例對(duì)象和類型對(duì)象建立聯(lián)系)。
所以采用特定類型 API 創(chuàng)建實(shí)例的速度會(huì)更快,但這只適用于內(nèi)置的數(shù)據(jù)結(jié)構(gòu),而我們自定義類的實(shí)例對(duì)象顯然沒(méi)有這個(gè)待遇。假設(shè)通過(guò) class Person: 定義了一個(gè)類,那么在實(shí)例化的時(shí)候,顯然不可能通過(guò) PyPerson_New 去創(chuàng)建,因?yàn)榈讓訅焊蜎](méi)有這個(gè) API。
這種情況下創(chuàng)建 Person 的實(shí)例對(duì)象就需要 Person 這個(gè)類型對(duì)象了,因此自定義類的實(shí)例對(duì)象如何分配內(nèi)存、如何進(jìn)行初始化,需要借助對(duì)應(yīng)的類型對(duì)象。
總的來(lái)說(shuō),Python 內(nèi)部創(chuàng)建一個(gè)對(duì)象有兩種方式:
- 通過(guò)特定類型 API,用于內(nèi)置數(shù)據(jù)結(jié)構(gòu),即內(nèi)置類型的實(shí)例對(duì)象。
- 通過(guò)調(diào)用類型對(duì)象去創(chuàng)建(底層會(huì)調(diào)用泛型 API),多用于自定義類型。
[] 和 list(),應(yīng)該使用哪種方式
lst = [] 和 lst = list() 都負(fù)責(zé)創(chuàng)建一個(gè)空列表,但這兩種方式有什么區(qū)別呢?
我們說(shuō)創(chuàng)建實(shí)例對(duì)象可以通過(guò)解釋器提供的特定類型 API,用于內(nèi)置類型;也可以通過(guò)實(shí)例化類型對(duì)象去創(chuàng)建,既可用于自定義類型,也可用于內(nèi)置類型。
# 通過(guò)特定類型 API 創(chuàng)建
>>> lst = []
>>> lst
[]
# 通過(guò)調(diào)用類型對(duì)象創(chuàng)建
>>> lst = list()
>>> lst
[]
還是那句話,解釋器對(duì)內(nèi)置數(shù)據(jù)結(jié)構(gòu)了如指掌,并且做足了優(yōu)化。
- 看到 123,就知道創(chuàng)建 PyLongObject 實(shí)例;
- 看到 2.71,就知道創(chuàng)建 PyFloatObject 實(shí)例;
- 看到 ( ),就知道創(chuàng)建 PyTupleObject 實(shí)例;
- 看到 [ ],就知道創(chuàng)建 PyListObject 實(shí)例;
- ······
這些都會(huì)使用特定類型 API 去創(chuàng)建,直接為結(jié)構(gòu)體申請(qǐng)內(nèi)存,然后設(shè)置引用計(jì)數(shù)和類型,所以使用 [ ] 創(chuàng)建列表是最快的。
但如果使用 list() 創(chuàng)建列表,那么就產(chǎn)生了一個(gè)調(diào)用,要進(jìn)行參數(shù)解析、類型檢測(cè)、創(chuàng)建棧幀、銷毀棧幀等等,所以開(kāi)銷會(huì)大一些。
import time
start = time.perf_counter()
for _ in range(10000000):
lst = []
end = time.perf_counter()
print(end - start)
"""
0.2144167000001289
"""
start = time.perf_counter()
for _ in range(10000000):
lst = list()
end = time.perf_counter()
print(end - start)
"""
0.4079916000000594
"""
通過(guò) [ ] 的方式創(chuàng)建一千萬(wàn)次空列表需要 0.21 秒,但通過(guò) list() 的方式創(chuàng)建一千萬(wàn)次空列表需要 0.40 秒,主要就在于 list() 是一個(gè)調(diào)用,而 [ ] 直接會(huì)被解析成 PyListObject,因此 [ ] 的速度會(huì)更快一些。
所以對(duì)于內(nèi)置類型的實(shí)例對(duì)象而言,使用特定類型 API 創(chuàng)建要更快一些。而且事實(shí)上通過(guò)類型對(duì)象去創(chuàng)建的話,會(huì)先調(diào)用 tp_new,然后在 tp_new 內(nèi)部還是調(diào)用了特定類型 API。
比如:
- 創(chuàng)建列表:可以是 list()、也可以是 [ ];
- 創(chuàng)建元組:可以是 tuple()、也可以是 ( );
- 創(chuàng)建字典:可以是 dict()、也可以是 { };
前者是通過(guò)類型對(duì)象去創(chuàng)建的,后者是通過(guò)特定類型 API 創(chuàng)建。但對(duì)于內(nèi)置類型而言,我們推薦使用特定類型 API 創(chuàng)建,會(huì)直接解析為對(duì)應(yīng)的 C 一級(jí)數(shù)據(jù)結(jié)構(gòu),因?yàn)檫@些結(jié)構(gòu)在底層都是已經(jīng)實(shí)現(xiàn)好了的,可以直接用。而無(wú)需通過(guò)諸如 list() 這種調(diào)用類型對(duì)象的方式來(lái)創(chuàng)建,因?yàn)樗鼈儍?nèi)部最終還是使用了 特定類型 API,相當(dāng)于多繞了一圈。
不過(guò)以上都是內(nèi)置類型,而自定義的類型就沒(méi)有這個(gè)待遇了,它的實(shí)例對(duì)象只能通過(guò)它自己創(chuàng)建。比如 Person 這個(gè)類,解釋器不可能事先定義一個(gè) PyPersonObject 然后將 API 提供給我們,所以我們只能通過(guò) Person() 這種調(diào)用類型對(duì)象的方式來(lái)創(chuàng)建它的實(shí)例對(duì)象。
另外內(nèi)置類型被稱為靜態(tài)類,它和它的實(shí)例對(duì)象在底層已經(jīng)被定義好了,無(wú)法動(dòng)態(tài)修改。我們自定義的類型被稱為動(dòng)態(tài)類,它是在解釋器運(yùn)行的過(guò)程中動(dòng)態(tài)構(gòu)建的,所以我們可以對(duì)其進(jìn)行動(dòng)態(tài)修改。
這里需要再?gòu)?qiáng)調(diào)一點(diǎn),Python 的動(dòng)態(tài)性、GIL 等特性,都是解釋器在將字節(jié)碼翻譯成 C 代碼時(shí)動(dòng)態(tài)賦予的,而內(nèi)置類型在編譯之后已經(jīng)是指向 C 一級(jí)的數(shù)據(jù)結(jié)構(gòu),因此也就喪失了相應(yīng)的動(dòng)態(tài)性。不過(guò)與之對(duì)應(yīng)的就是效率上的提升,因?yàn)檫\(yùn)行效率和動(dòng)態(tài)性本身就是魚(yú)與熊掌的關(guān)系。