本篇文章來聊一聊對象的創建,一個對象是如何從無到有產生的呢?
>>> n = 123>>> n123
比如在終端中執行 n = 123,一個整數對象就被創建好了,但它的背后都發生了什么呢?帶著這些疑問,開始今天的內容。
前面我們介紹了 Python 對象在底層的數據結構,知道了 Python 底層是通過 PyObject 實現了對象的多態。所以我們先來分析一下 Python 為什么慢?
在 Python 中創建一個對象,會分配內存并進行初始化,然后用一個 PyObject * 指針來維護這個對象,當然所有對象都是如此。因為指針是可以相互轉化的,所以變量在保存一個對象的指針時,會將指針轉成 PyObject * 之后再交給變量保存。
因此在 Python 中,變量的傳遞(包括函數的參數傳遞)實際上傳遞的都是泛型指針 PyObject *。這個指針具體指向什么類型的對象我們并不知道,只能通過其內部的 ob_type 字段進行動態判斷,而正是因為這個 ob_type,Python 實現了多態機制。
比如 a.pop(),我們不知道 a 指向的對象到底是什么類型,它可能是列表、也可能是字典,或者是我們實現了 pop 方法的自定義類的實例對象。至于它到底是什么類型,只能通過 ob_type 動態判斷。
如果 a 的 ob_type 為 &PyList_Type,那么 a 指向的對象就是列表,于是會調用 list 類型中定義的 pop 操作。如果 a 的 ob_type 為 &PyDict_Type,那么 a 指向的對象就是字典,于是會調用 dict 類型中定義的 pop 操作。所以變量 a 在不同的情況下,會表現出不同的行為,這正是 Python 多態的核心所在。
再比如列表,它內部的元素也都是 PyObject *,因為類型要保持一致,所以對象的指針不能直接存(因為類型不同),而是需要統一轉成泛型指針 PyObject * 之后才可以存儲。當我們通過索引獲取到該指針進行操作的時候,也會先通過 ob_type 判斷它的類型,看它是否支持指定的操作。所以操作容器內的某個元素,和操作一個變量并無本質上的區別,它們都是 PyObject *。
從這里我們也能看出來 Python 為什么慢了,因為有相當一部分時間浪費在類型和屬性的查找上面。
以變量 a + b 為例,這個 a 和 b 指向的對象可以是整數、浮點數、字符串、列表、元組、甚至是我們自己實現了 __add__ 方法的類的實例對象。因為 Python 的變量都是 PyObject *,所以它可以指向任意的對象,因此 Python 就無法做基于類型的優化。
首先 Python 底層要通過 ob_type 判斷變量指向的對象到底是什么類型,這在 C 的層面至少需要一次屬性查找。然后 Python 將每一個算術操作都抽象成了一個魔法方法,所以實例相加時要在類型對象中找到該方法對應的函數指針,這又是一次屬性查找。找到了之后將 a、b 作為參數傳遞進去,這會產生一次函數調用,會將對象維護的值拿出來進行運算,然后根據相加的結果創建一個新的對象,再將對象的指針轉成 PyObject * 之后返回。
所以一個簡單的加法運算,Python 內部居然做了這么多的工作,要是再放到循環里面,那么上面的步驟要重復 N 次。而對于 C 來講,由于已經規定好了類型,所以 a + b 在編譯之后就是一條簡單的機器指令,因此兩者在效率上差別很大。
當然我們不是來吐槽 Python 效率的問題,因為任何語言都有擅長的一面和不擅長的一面,這里只是通過回顧前面的知識來解釋為什么 Python 效率低。因此當別人問你 Python 為什么效率低的時候,希望你能從這個角度來回答它,主要就兩點:
建議不要一上來就談 GIL,那是在多線程情況下才需要考慮的問題。而且我相信大部分覺得 Python 慢的人,都不是因為 Python 無法利用多核才覺得慢的。
然后來說一說 Python 的 C API,這個非常關鍵。首先 Python 解釋器聽起來很高大上,但按照陳儒老師的說法,它不過就是用 C 語言寫出的一個開源軟件,從形式上和其它軟件并沒有本質上的不同。
比如你在 Windows 系統中打開 Python 的安裝目錄,會發現里面有一個二進制文件 python.exe 和一個動態庫文件 python312.dll。二進制文件負責執行,動態庫文件則包含了相應的依賴,當然編譯的時候也可以把動態庫里的內容統一打包到二進制文件中,不過大部分軟件在開發時都會選擇前者。
既然解釋器是用 C 寫的,那么在執行時肯定會將 Python 代碼翻譯成 C 代碼,這是毫無疑問的。比如創建一個列表,底層就會創建一個 PyListObject 實例,比如調用某個內置函數,底層會調用對應的 C 函數。
所以如果你想搞懂 Python 代碼的執行邏輯或者編寫 Python 擴展,那么就必須要清楚解釋器提供的 API 函數。而按照通用性來劃分的話,這些 API 可以分為兩種。
顧名思義,泛型 API 和參數類型無關,屬于抽象對象層。這類 API 的第一個參數是 PyObject *,可以處理任意類型的對象,API 內部會根據對象的類型進行區別處理。
而且泛型 API 的名稱也是有規律的,具有 PyObject_### 這種形式,我們舉例說明。
創建對象可以使用泛型 API,也可以使用特定類型 API,比如創建一個浮點數。
PyObject* pi = PyObject_New(PyObject, &PyFloat_Type);
通過泛型 API 可以創建任意類型的對象,因為該類 API 和類型無關。那么問題來了,解釋器怎么知道要給對象分配多大的內存呢?
在介紹類型對象的時候我們提到,對象的內存大小、支持哪些操作等等,都屬于元信息,而元信息會存在對應的類型對象中。其中 tp_basicsize 和 tp_itemsize 負責指定實例對象所需的內存空間。
// Include/objimpl.h#define PyObject_New(type, typeobj) ((type *)_PyObject_New(typeobj)) // Objects/object.cPyObject *_PyObject_New(PyTypeObject *tp){ // 通過 PyObject_Malloc 為對象申請內存,申請多大呢? // 會通過 _PyObject_SIZE(tp) 進行計算 PyObject *op = (PyObject *) PyObject_Malloc(_PyObject_SIZE(tp)); if (op == NULL) { return PyErr_NoMemory(); } // 設置對象的類型和引用計數 _PyObject_Init(op, tp); return op;}// Include/cpython/objimpl.hstatic inline size_t _PyObject_SIZE(PyTypeObject *type) { // 返回類型對象的 tp_basicsize return _Py_STATIC_CAST(size_t, type->tp_basicsize);}
泛型 API 屬于通用邏輯,而內置類型的實例對象一般會采用特定類型 API 創建。
// 創建浮點數,值為 2.71PyObject* e = PyFloat_FromDouble(2.71);// 創建一個可以容納 5 個元素的元組PyObject* tpl = PyTuple_New(5);// 創建一個可以容納 5 個元素的列表// 當然這是初始容量,列表是可以擴容的PyObject* lst = PyList_New(5);
和泛型 API 不同,使用特定類型 API 只能創建指定類型的對象,因為該類 API 是和類型綁定的。比如我們可以用 PyDict_New 創建一個字典,但不可能創建一個集合出來。
如果使用特定類型 API,那么可以直接分配內存。因為內置類型的實例對象,它們的定義在底層都是寫死的,解釋器對它們了如指掌,因此可以直接分配內存并初始化。
比如通過 e = 2.71 創建一個浮點數,解釋器看到 2.71 就知道要創建 PyFloatObject 結構體實例,那么申請多大內存呢?顯然是 sizeof(PyFloatObject),直接計算一下結構體實例的大小即可。
圖片
顯然一個 PyFloatObject 實例的大小是 24 字節,所以內存直接就分配了。分配之后將 ob_refcnt 初始化為 1、ob_type 設置為 &PyFloat_Type、ob_fval 設置為 2.71 即可。
同理可變對象也是一樣,因為字段都是固定的,內部容納的元素有多少個也可以根據賦的值得到,所以內部的所有字段占用了多少內存可以算出來,因此也是可以直接分配內存的。
還是那句話,解釋器對內置的數據結構了如指掌,因為這些結構在底層都是定義好的,源碼直接寫死了。所以解釋器根本不需要借助類型對象去創建實例對象,它只需要在實例對象創建完畢之后,將 ob_type 設置為指定的類型即可(讓實例對象和類型對象建立聯系)。
所以采用特定類型 API 創建實例的速度會更快,但這只適用于內置的數據結構,而我們自定義類的實例對象顯然沒有這個待遇。假設通過 class Person: 定義了一個類,那么在實例化的時候,顯然不可能通過 PyPerson_New 去創建,因為底層壓根就沒有這個 API。
這種情況下創建 Person 的實例對象就需要 Person 這個類型對象了,因此自定義類的實例對象如何分配內存、如何進行初始化,需要借助對應的類型對象。
總的來說,Python 內部創建一個對象有兩種方式:
lst = [] 和 lst = list() 都負責創建一個空列表,但這兩種方式有什么區別呢?
我們說創建實例對象可以通過解釋器提供的特定類型 API,用于內置類型;也可以通過實例化類型對象去創建,既可用于自定義類型,也可用于內置類型。
# 通過特定類型 API 創建>>> lst = [] >>> lst[]# 通過調用類型對象創建>>> lst = list() >>> lst[]
還是那句話,解釋器對內置數據結構了如指掌,并且做足了優化。
這些都會使用特定類型 API 去創建,直接為結構體申請內存,然后設置引用計數和類型,所以使用 [ ] 創建列表是最快的。
但如果使用 list() 創建列表,那么就產生了一個調用,要進行參數解析、類型檢測、創建棧幀、銷毀棧幀等等,所以開銷會大一些。
import timestart = 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"""
通過 [ ] 的方式創建一千萬次空列表需要 0.21 秒,但通過 list() 的方式創建一千萬次空列表需要 0.40 秒,主要就在于 list() 是一個調用,而 [ ] 直接會被解析成 PyListObject,因此 [ ] 的速度會更快一些。
所以對于內置類型的實例對象而言,使用特定類型 API 創建要更快一些。而且事實上通過類型對象去創建的話,會先調用 tp_new,然后在 tp_new 內部還是調用了特定類型 API。
比如:
前者是通過類型對象去創建的,后者是通過特定類型 API 創建。但對于內置類型而言,我們推薦使用特定類型 API 創建,會直接解析為對應的 C 一級數據結構,因為這些結構在底層都是已經實現好了的,可以直接用。而無需通過諸如 list() 這種調用類型對象的方式來創建,因為它們內部最終還是使用了 特定類型 API,相當于多繞了一圈。
不過以上都是內置類型,而自定義的類型就沒有這個待遇了,它的實例對象只能通過它自己創建。比如 Person 這個類,解釋器不可能事先定義一個 PyPersonObject 然后將 API 提供給我們,所以我們只能通過 Person() 這種調用類型對象的方式來創建它的實例對象。
另外內置類型被稱為靜態類,它和它的實例對象在底層已經被定義好了,無法動態修改。我們自定義的類型被稱為動態類,它是在解釋器運行的過程中動態構建的,所以我們可以對其進行動態修改。
這里需要再強調一點,Python 的動態性、GIL 等特性,都是解釋器在將字節碼翻譯成 C 代碼時動態賦予的,而內置類型在編譯之后已經是指向 C 一級的數據結構,因此也就喪失了相應的動態性。不過與之對應的就是效率上的提升,因為運行效率和動態性本身就是魚與熊掌的關系。
本文鏈接:http://www.tebozhan.com/showinfo-26-89706-0.html當創建一個 Python 對象時,背后都經歷了哪些過程?
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
上一篇: 高級程序員必須要會的五種編程范式
下一篇: Python 代碼重構的十個關鍵策略