Python 源文件編譯之后會得到什么,它的結(jié)構(gòu)是怎樣的?和字節(jié)碼又有什么聯(lián)系?
楔子
當(dāng)我們執(zhí)行一個 py 文件的時候,只需要在命令行中輸入 python xxx.py 即可,但你有沒有想過這背后的流程是怎樣的呢?
首先 py 文件不是一上來就直接執(zhí)行的,而是會先有一個編譯的過程,整個步驟如下:
圖片
這里我們看到了 Python 編譯器、Python 虛擬機(jī),而且我們平常還會說 Python 解釋器,那么三者之間有什么區(qū)別呢?
圖片
Python 編譯器負(fù)責(zé)將 Python 源代碼編譯成 PyCodeObject 對象,然后交給 Python 虛擬機(jī)來執(zhí)行。
那么 Python 編譯器和 Python 虛擬機(jī)都在什么地方呢?如果打開 Python 的安裝目錄,會發(fā)現(xiàn)有一個 python.exe,點擊的時候會通過它來啟動一個終端。
但問題是這個文件大小還不到 100K,不可能容納一個編譯器加一個虛擬機(jī),所以下面還有一個 python312.dll。沒錯,編譯器、虛擬機(jī)都藏身于 python312.dll 當(dāng)中。
因此 Python 雖然是解釋型語言,但也有編譯的過程。源代碼會被編譯器編譯成 PyCodeObject 對象,然后再交給虛擬機(jī)來執(zhí)行。而之所以要存在編譯,是為了讓虛擬機(jī)能更快速地執(zhí)行,比如在編譯階段常量都會提前分配好,而且還可以盡早檢測出語法上的錯誤。
pyc 文件是什么
在 Python 開發(fā)時,我們肯定都見過這個 pyc 文件,它一般位于 __pycache__ 目錄中,那么 pyc 文件和 PyCodeObject 之間有什么關(guān)系呢?
首先我們都知道字節(jié)碼,虛擬機(jī)的執(zhí)行實際上就是對字節(jié)碼不斷解析的一個過程。然而除了字節(jié)碼之外,還應(yīng)該包含一些其它的信息,這些信息也是 Python 運行的時候所必需的,比如常量、變量名等等。
我們常聽到 py 文件被編譯成字節(jié)碼,這句話其實不太嚴(yán)謹(jǐn),因為字節(jié)碼只是一個 PyBytesObject 對象、或者說一段字節(jié)序列。但很明顯,光有字節(jié)碼是不夠的,還有很多的靜態(tài)信息也需要被收集起來,它們整體被稱為 PyCodeObject。
而 PyCodeObject 對象中有一個字段 co_code,它是一個指針,指向了這段字節(jié)序列。但是這個對象除了有 co_code 指向的字節(jié)碼之外,還有很多其它字段,負(fù)責(zé)保存代碼涉及到的常量、變量(名字、符號)等等。
所以雖然編寫的是 py 文件,但虛擬機(jī)執(zhí)行的是編譯后的 PyCodeObject 對象。但是問題來了,難道每一次執(zhí)行都要將源文件編譯一遍嗎?如果沒有對源文件進(jìn)行修改的話,那么完全可以使用上一次的編譯結(jié)果。相信此時你能猜到 pyc 文件是干什么的了,它就是負(fù)責(zé)保存編譯之后的 PyCodeObject 對象。
現(xiàn)在我們知道了,pyc 文件里面保存的內(nèi)容是 PyCodeObject 對象。對于 Python 編譯器來說,PyCodeObject 對象是對源代碼編譯之后的結(jié)果,而 pyc 文件則是這個對象在硬盤上的表現(xiàn)形式。
當(dāng)下一次運行的時候,Python 解釋器會根據(jù) pyc 文件中記錄的編譯結(jié)果,直接建立內(nèi)存中的 PyCodeObject 對象,而不需要再重新編譯了,當(dāng)然前提是沒有對源文件進(jìn)行修改。
PyCodeObject 底層結(jié)構(gòu)
既然 PyCodeObject 對象是源代碼的編譯結(jié)果,那么搞清楚它的底層結(jié)構(gòu)就至關(guān)重要,下面來看一下它長什么樣子。相比以前的版本(比如 3.8),結(jié)構(gòu)變化還是有一點大的。
// Include/pytypedefs.h
typedef struct PyCodeObject PyCodeObject;
// Include/cpython/code.h
struct PyCodeObject _PyCode_DEF(1);
#define _PyCode_DEF(SIZE) { \
PyObject_VAR_HEAD \
\
PyObject *co_consts; \
PyObject *co_names; \
PyObject *co_exceptiontable; \
int co_flags; \
int co_argcount; \
int co_posonlyargcount; \
int co_kwonlyargcount; \
int co_stacksize; \
int co_firstlineno; \
int co_nlocalsplus; \
int co_framesize; \
int co_nlocals; \
int co_ncellvars; \
int co_nfreevars; \
uint32_t co_version; \
PyObject *co_localsplusnames; \
PyObject *co_localspluskinds; \
PyObject *co_filename; \
PyObject *co_name; \
PyObject *co_qualname; \
PyObject *co_linetable; \
PyObject *co_weakreflist; \
_PyCoCached *_co_cached; \
uint64_t _co_instrumentation_version; \
_PyCoMonitoringData *_co_monitoring; \
int _co_firsttraceable; \
void *co_extra; \
char co_code_adaptive[(SIZE)]; \
}
這里面的每一個字段,我們一會兒都會詳細(xì)介紹,并通過代碼逐一演示??傊?Python 編譯器在對源代碼進(jìn)行編譯的時候,針對每一個 code block(代碼塊),都會創(chuàng)建一個 PyCodeObject 與之對應(yīng)。
但多少代碼才算得上是一個 block 呢?事實上,Python 有一個簡單而清晰的規(guī)則:當(dāng)進(jìn)入一個新的名字空間,或者說作用域時,就算是進(jìn)入了一個新的 block 了。舉個例子:
class A:
a = 123
def foo():
a = []
我們仔細(xì)觀察一下上面這段代碼,它在編譯完之后會有三個 PyCodeObject 對象,一個是對應(yīng)整個 py 文件(模塊)的,一個是對應(yīng) class A 的,一個是對應(yīng) def foo 的。因為這是三個不同的作用域,所以會有三個 PyCodeObject 對象。
所以一個 code block 對應(yīng)一個作用域、同時也對應(yīng)一個 PyCodeObject 對象。Python 的類、函數(shù)、模塊都有自己獨立的作用域,因此在編譯時也都會有一個 PyCodeObject 對象與之對應(yīng)。
PyCodeObject 字段解析
PyCodeObject 我們知道它是干什么的了,那如何才能拿到這個對象呢?首先該對象在 Python 里面的類型是 <class 'code'>,但是底層沒有將這個類暴露給我們,因此 code 這個名字在 Python 里面只是一個沒有定義的變量罷了。
但我們可以通過其它的方式進(jìn)行獲取,比如函數(shù)。
def func():
pass
print(func.__code__) # <code object ......
print(type(func.__code__)) # <class 'code'>
我們可以通過函數(shù)的 __code__ 屬性拿到底層對應(yīng)的 PyCodeObject 對象,當(dāng)然也可以獲取里面的字段,我們來演示一下,并詳細(xì)介紹每個字段的含義。
PyObject_VAR_HEAD:變長對象的頭部信息
我們看到 Python 真的一切皆對象,源代碼編譯之后的結(jié)果也是一個對象。
co_consts:常量池,一個元組,保存代碼塊中創(chuàng)建的所有常量
def foo():
a = 123
b = "hello"
c = (1, 2)
d = ["x", "y"]
e = {"p": "k"}
f = {7, 8}
print(foo.__code__.co_consts)
"""
(None, 123, 'hello', (1, 2), 'x', 'y', 'p', 'k', 7, 8)
"""
co_consts 里面出現(xiàn)的都是編譯階段可以確定的常量,而 ["x", "y"] 和 {"p": "k"} 沒有出現(xiàn),由此我們可以得出,列表和字典絕不是在編譯階段構(gòu)建的。編譯時,只是收集了里面的元素,然后等到運行時再去動態(tài)構(gòu)建。
不過問題來了,在構(gòu)建的時候解釋器怎么知道是要構(gòu)建列表、還是字典、亦或是其它的什么對象呢?所以這就依賴于字節(jié)碼了,解釋字節(jié)碼的時候,會判斷到底要構(gòu)建什么樣的對象。
因此解釋器執(zhí)行的是字節(jié)碼,核心邏輯都體現(xiàn)在字節(jié)碼中。但是光有字節(jié)碼還不夠,它包含的只是程序的主干邏輯,至于變量、常量,則從符號表和常量池里面獲取。
然后還有一個細(xì)節(jié)需要注意:
def foo():
a = ["x", "y", "z"]
b = {1, 2, 3}
c = 3 + 4
print(foo.__code__.co_consts)
"""
(None, ('x', 'y', 'z'), frozenset({1, 2, 3}), 7)
"""
當(dāng)列表的長度不小于 3 時,里面的元素如果都可以在編譯階段確定,那么整體會作為一個元組被收集起來,這樣多條字節(jié)碼可以合并為一條。集合也是類似的,里面的元素整體會作為一個不可變集合被收集起來。
圖片
關(guān)于字節(jié)碼的更多細(xì)節(jié),我們后續(xù)再聊。
另外函數(shù)里面的變量 c 等于 3 + 4,但常量池里面直接存儲了 7,這個過程叫做常量折疊。常量之間的加減乘除,結(jié)果依舊是一個常量,編譯階段就會計算好。
def foo():
a = 1 + 3
b = "hello" + " " + "world"
c = ("a", "b") + ("c", "d")
print(foo.__code__.co_consts)
"""
(None, 4, 'hello world', ('a', 'b', 'c', 'd'))
"""
以上就是常量池,負(fù)責(zé)保存代碼塊中創(chuàng)建的所有常量。
co_names:符號表,一個元組,保存代碼塊中引用的其它作用域的變量
c = 1
def foo(a, b):
print(a, b, c)
d = (list, int, str)
print(foo.__code__.co_names)
"""
('print', 'c', 'list', 'int', 'str')
"""
雖然一切皆對象,但看到的都是指向?qū)ο蟮淖兞浚?print, c, list, int, str 都是變量,它們都不在當(dāng)前 foo 函數(shù)的作用域中。
co_exceptiontable:異常處理表
這個字段后續(xù)介紹異常處理的時候會細(xì)說,目前先有一個簡單的了解即可。當(dāng)解釋器執(zhí)行某個指令出現(xiàn)錯誤時,那么會引發(fā)一個異常,如果異常產(chǎn)生的位置位于 try 語句塊內(nèi),那么解釋器必須跳轉(zhuǎn)到相應(yīng)的 except 或 finally 語句塊內(nèi),這是顯然的。
在 Python 3.10 以及之前的版本,這個機(jī)制是通過引入一個獨立的動態(tài)棧,然后跟蹤 try 語句塊實現(xiàn)的。但從 3.11 開始,動態(tài)棧被替換成了靜態(tài)表,這個表由 co_exceptiontable 字段維護(hù),并在編譯期間就靜態(tài)生成了。
def foo():
try:
1 / 0
except Exception:
pass
print(foo.__code__.co_exceptiontable)
"""
b'\x82\x05\x08\x00\x88\t\x14\x03\x93\x01\x14\x03'
"""
異常處理表本質(zhì)上是一段字節(jié)序列,因為是靜態(tài)數(shù)據(jù),所以可以高效地讀取。這段字節(jié)序列里面包含了代碼塊中的 try / except / finally 信息,當(dāng)代碼在執(zhí)行過程中出現(xiàn)異常時,解釋器會查詢這張表,尋找與之匹配的 except 塊。
關(guān)于該字段的更多細(xì)節(jié),我們后續(xù)介紹異常捕獲的時候細(xì)說,總之通過將動態(tài)棧換成靜態(tài)表,可以大幅提升解釋器在異常處理時的效率。
co_flags:函數(shù)標(biāo)識
先來提出一個問題:
def some_func():
return "hello world"
def some_gen():
yield
return "hello world"
print(some_func.__class__)
print(some_gen.__class__)
"""
<class 'function'>
<class 'function'>
"""
print(some_func())
"""
hello world
"""
print(some_gen())
"""
<generator object some_gen at 0x1028a80b0>
"""
調(diào)用 some_func 會將代碼執(zhí)行完畢,調(diào)用 some_gen 會返回生成器,但問題是這兩者都是函數(shù)類型,為什么執(zhí)行的時候會有不同的表現(xiàn)呢?
可能有人覺得這還不簡單,Python 具有詞法作用域,由于 some_func 里面沒有出現(xiàn) yield 關(guān)鍵字,所以是普通函數(shù),而 some_gen 里面出現(xiàn)了 yield,所以是生成器函數(shù)。
從源代碼來看確實如此,但源代碼是要編譯成 PyCodeObject 對象的,在編譯之后,函數(shù)內(nèi)部是否出現(xiàn) yield 關(guān)鍵字這一信息要怎么體現(xiàn)呢?答案便是通過 co_flags 字段。
然后解釋器內(nèi)部定義了一系列的標(biāo)志位,通過和 co_flags 字段按位與,便可判斷函數(shù)是否具備指定特征。常見的標(biāo)志位如下:
// Include/cpython/code.h
// 函數(shù)參數(shù)是否包含 *args
#define CO_VARARGS 0x0004
// 函數(shù)參數(shù)是否包含 **kwargs
#define CO_VARKEYWORDS 0x0008
// 函數(shù)是否是內(nèi)層函數(shù)
#define CO_NESTED 0x0010
// 函數(shù)是否是生成器函數(shù)
#define CO_GENERATOR 0x0020
// 函數(shù)是否是協(xié)程函數(shù)
#define CO_COROUTINE 0x0080
// 函數(shù)是否是異步生成器函數(shù)
#define CO_ASYNC_GENERATOR 0x0200
我們實際測試一下,比如檢測函數(shù)的參數(shù)類型:
CO_VARARGS = 0x0004
CO_VARKEYWORDS = 0x0008
CO_NESTED = 0x0010
def foo(*args):
pass
def bar():
pass
# 因為 foo 的參數(shù)包含 *args,所以和 CO_VARARGS 按位與的結(jié)果為真
# 而 bar 的參數(shù)不包含 *args,所以結(jié)果為假
print(foo.__code__.co_flags & CO_VARARGS) # 4
print(bar.__code__.co_flags & CO_VARARGS) # 0
def foo(**kwargs):
pass
def bar():
pass
print(foo.__code__.co_flags & CO_VARKEYWORDS) # 8
print(bar.__code__.co_flags & CO_VARKEYWORDS) # 0
def foo():
def bar():
pass
return bar
# foo 是外層函數(shù),所以和 CO_NESTED 按位與的結(jié)果為假
# foo() 返回的是內(nèi)層函數(shù),所以和 CO_NESTED 按位與的結(jié)果為真
print(foo.__code__.co_flags & CO_NESTED) # 0
print(foo().__code__.co_flags & CO_NESTED) # 16
當(dāng)然啦,co_flags 還可以檢測一個函數(shù)的類型。比如函數(shù)內(nèi)部出現(xiàn)了 yield,那么它就是一個生成器函數(shù),調(diào)用之后可以得到一個生成器;使用 async def 定義,那么它就是一個協(xié)程函數(shù),調(diào)用之后可以得到一個協(xié)程。
這些在詞法分析的時候就可以檢測出來,編譯之后會體現(xiàn)在 co_flags 字段中。
CO_GENERATOR = 0x0020
CO_COROUTINE = 0x0080
CO_ASYNC_GENERATOR = 0x0200
# 如果是生成器函數(shù)
# 那么 co_flags & 0x20 為真
def foo1():
yield
print(foo1.__code__.co_flags & 0x20) # 32
# 如果是協(xié)程函數(shù)
# 那么 co_flags & 0x80 為真
async def foo2():
pass
print(foo2.__code__.co_flags & 0x80) # 128
# 顯然 foo2 不是生成器函數(shù)
# 所以 co_flags & 0x20 為假
print(foo2.__code__.co_flags & 0x20) # 0
# 如果是異步生成器函數(shù)
# 那么 co_flags & 0x200 為真
async def foo3():
yield
print(foo3.__code__.co_flags & 0x200) # 512
# 顯然它不是生成器函數(shù)、也不是協(xié)程函數(shù)
# 因此和 0x20、0x80 按位與之后,結(jié)果都為假
print(foo3.__code__.co_flags & 0x20) # 0
print(foo3.__code__.co_flags & 0x80) # 0
在判斷函數(shù)種類時,這種方式是最優(yōu)雅的。
co_argcount:可以通過位置參數(shù)傳遞的參數(shù)個數(shù)
def foo(a, b, c=3):
pass
print(foo.__code__.co_argcount) # 3
def bar(a, b, *args):
pass
print(bar.__code__.co_argcount) # 2
def func(a, b, *args, c):
pass
print(func.__code__.co_argcount) # 2
函數(shù) foo 中的參數(shù) a、b、c 都可以通過位置參數(shù)傳遞,所以結(jié)果是 3。而函數(shù) bar 則是兩個,這里不包括 *args。最后函數(shù) func 顯然也是兩個,因為參數(shù) c 只能通過關(guān)鍵字參數(shù)傳遞。
co_posonlyargcount:只能通過位置參數(shù)傳遞的參數(shù)個數(shù),Python3.8 新增
def foo(a, b, c):
pass
print(foo.__code__.co_posonlyargcount) # 0
def bar(a, b, /, c):
pass
print(bar.__code__.co_posonlyargcount) # 2
注意:這里是只能通過位置參數(shù)傳遞的參數(shù)個數(shù)。對于 foo 而言,里面的三個參數(shù)既可以通過位置參數(shù)、也可以通過關(guān)鍵字參數(shù)傳遞,所以個數(shù)是 0。而函數(shù) bar,里面的 a、b 只能通過位置參數(shù)傳遞,所以個數(shù)是 2。
co_kwonlyargcount:只能通過關(guān)鍵字參數(shù)傳遞的參數(shù)個數(shù)
def foo(a, b=1, c=2, *, d, e):
pass
print(foo.__code__.co_kwonlyargcount) # 2
這里是 d 和 e,它們必須通過關(guān)鍵字參數(shù)傳遞。
co_stacksize:執(zhí)行該段代碼塊所需要的??臻g
def foo(a, b, c):
name = "xxx"
age = 16
gender = "f"
c = 33
print(foo.__code__.co_stacksize) # 1
這個暫時不需要太關(guān)注,后續(xù)介紹棧幀的時候會詳細(xì)說明。
co_firstlineno:代碼塊的起始位置在源文件中的哪一行
def foo(a, b, c):
pass
# 顯然是文件的第一行
# 或者理解為 def 所在的行
print(foo.__code__.co_firstlineno) # 1
如果函數(shù)出現(xiàn)了調(diào)用呢?
def foo():
return bar
def bar():
pass
print(foo().__code__.co_firstlineno) # 4
如果執(zhí)行 foo,那么會返回函數(shù) bar,因此結(jié)果是 def bar(): 所在的行數(shù)。所以每個函數(shù)都有自己的作用域,以及 PyCodeObject 對象。
_co_cached:結(jié)構(gòu)體的倒數(shù)第六個字段,這里需要先拿出來解釋一下,它負(fù)責(zé)緩存以下字段
// Include/cpython/code.h
typedef struct {
// 指令集,也就是字節(jié)碼,它是一個 bytes 對象
PyObject *_co_code;
// 一個元組,保存當(dāng)前作用域中創(chuàng)建的局部變量
PyObject *_co_varnames;
// 一個元組,保存外層函數(shù)的作用域中被內(nèi)層函數(shù)引用的變量
PyObject *_co_cellvars;
// 一個元組,保存內(nèi)層函數(shù)引用的外層函數(shù)的作用域中的變量
PyObject *_co_freevars;
} _PyCoCached;
在之前的版本中,這些字段都是直接單獨定義在 PyCodeObject 中,并且開頭也沒有下劃線。當(dāng)然啦,如果是通過 Python 獲取的話,那么方式和之前一樣。
def foo(a, b, c):
name = "satori"
age = 16
gender = "f"
print(name, age, gender)
# 字節(jié)碼,一個 bytes 對象,它保存了要操作的指令
# 但光有字節(jié)碼是肯定不夠的,還需要其它的靜態(tài)信息
# 顯然這些信息連同字節(jié)碼一樣,都位于 PyCodeObject 中
print(foo.__code__.co_code)
"""
b'\x97\x00d\x01}\x03d\x02}\x04d\x03}\x05t\x01......'
"""
# 當(dāng)前作用域中創(chuàng)建的變量,注意它和 co_names 的區(qū)別
# co_varnames 保存的是當(dāng)前作用域中創(chuàng)建的局部變量
# 而 co_names 保存的是當(dāng)前作用域中引用的其它作用域的變量
print(foo.__code__.co_varnames)
"""
('a', 'b', 'c', 'name', 'age', 'gender')
"""
print(foo.__code__.co_names)
"""
('print',)
"""
然后是 co_cellvars 和 co_freevars,看一下這兩個字段。
def foo(a, b, c):
def bar():
print(a, b, c)
return bar
# co_cellvars:外層函數(shù)的作用域中被內(nèi)層函數(shù)引用的變量
# co_freevars:內(nèi)層函數(shù)引用的外層函數(shù)的作用域中的變量
print(foo.__code__.co_cellvars)
print(foo.__code__.co_freevars)
"""
('a', 'b', 'c')
()
"""
# foo 里面的變量 a、b、c 被內(nèi)層函數(shù) bar 引用了
# 所以它的 co_cellvars 是 ('a', 'b', 'c')
# 而 foo 不是內(nèi)層函數(shù),所以它的 co_freevars 是 ()
bar = foo(1, 2, 3)
print(bar.__code__.co_cellvars)
print(bar.__code__.co_freevars)
"""
()
('a', 'b', 'c')
"""
# bar 引用了外層函數(shù) foo 里面的變量 a、b、c
# 所以它的 co_freevars 是 ('a', 'b', 'c')
# 而 bar 已經(jīng)是最內(nèi)層函數(shù)了,所以它的 co_cellvars 是 ()
當(dāng)然目前的函數(shù)只嵌套了兩層,但嵌套三層甚至更多層也是一樣的。
def foo(a, b, c):
def bar(d, e):
print(a)
def func():
print(b, c, d, e)
return func
return bar
# 對于 foo 而言,它的內(nèi)層函數(shù)就是 bar,至于最里面的 func
# 由于它定義在 bar 的內(nèi)部,所以可以看做 bar 函數(shù)體的一部分
# 而 foo 里面的變量 a、b、c 都被內(nèi)層函數(shù)引用了
print(foo.__code__.co_cellvars) # ('a', 'b', 'c')
print(foo.__code__.co_freevars) # ()
bar = foo(1, 2, 3)
# 對于函數(shù) bar 而言,它的內(nèi)層函數(shù)就是 func
# 而顯然 bar 里面的變量 d 和 e 被 func 引用了
print(bar.__code__.co_cellvars) # ('d', 'e')
# 然后 bar 引用了外層函數(shù) foo 里面的 a、b、c
print(bar.__code__.co_freevars) # ('a', 'b', 'c')
# 所以 co_cellvars 和 co_freevars 這兩個字段的關(guān)系有點類似鏡像
co_cellvars 和 co_freevars 在后續(xù)介紹閉包的時候會用到,以上就是這幾個字段的含義。
co_nlocals:代碼塊中局部變量的個數(shù),也包括參數(shù)
def foo(a, b, *args, c, **kwargs):
name = "xxx"
age = 16
gender = "f"
c = 33
print(foo.__code__.co_varnames)
"""
('a', 'b', 'c', 'args', 'kwargs', 'name', 'age', 'gender')
"""
print(foo.__code__.co_nlocals)
"""
8
"""
co_varnames 保存的是代碼塊的局部變量,顯然 co_nlocals 就是它的長度。并且我們看到在編譯之后,函數(shù)的局部變量就已經(jīng)確定了,因為它們是靜態(tài)存儲的。
co_ncellvars:cell 變量的個數(shù),即 co_cellvars 的長度
該字段解釋器沒有暴露出來。
co_nfreevars:free 變量的個數(shù),即 co_freevars 的長度
該字段解釋器沒有暴露出來。
co_nlocalsplus:局部變量、cell 變量、free 變量的個數(shù)之和
該字段解釋器沒有暴露出來。
co_framesize:棧幀的大小
解釋器在將源代碼編譯成 PyCodeObject 之后,還要在此之上繼續(xù)創(chuàng)建 PyFrameObject 對象,即棧幀對象。也就是說,字節(jié)碼是在棧幀中被執(zhí)行的,棧幀是虛擬機(jī)執(zhí)行的上下文,局部變量、臨時變量、以及函數(shù)執(zhí)行的相關(guān)信息都保存在棧幀中。
當(dāng)然該字段解釋器也沒有暴露出來,我們后續(xù)會詳細(xì)討論它。
co_localsplusnames:一個元組,包含局部變量、cell 變量、free 變量,當(dāng)然嚴(yán)謹(jǐn)?shù)恼f法應(yīng)該是變量的名稱
而上面的 co_nlocalsplus 字段便是 co_localsplusnames 的長度。
- co_varnames:保存所有的局部變量;co_nlocals:局部變量的個數(shù)。
- co_cellvars:保存所有的 cell 變量;co_ncellvars:cell 變量的個數(shù);
- co_freevars:保存所有的 free 變量;co_nfreevars:free 變量的個數(shù);
所以可以得出如下結(jié)論:
圖片
這個字段很重要,之后會反復(fù)用到。
co_localspluskinds:標(biāo)識 co_localsplusnames 里面的每個變量的種類
我們說了,co_localsplusnames 里面包含了局部變量、cell 變量、free 變量的名稱,它們整體是作為一個元組存儲的。那么問題來了,當(dāng)從 co_localsplusnames 里面獲取一個變量時,解釋器怎么知道這個變量是局部變量,還是 cell 變量或者 free 變量呢?
所以便有了 co_localspluskinds 字段,它是一段字節(jié)序列,一個字節(jié)對應(yīng)一個變量。
// Include/internal/pycore_code.h
#define CO_FAST_HIDDEN 0x10
#define CO_FAST_LOCAL 0x20 // 局部變量
#define CO_FAST_CELL 0x40 // cell 變量
#define CO_FAST_FREE 0x80 // free 變量
比如 co_localspluskinds[3] 等于 0x20,那么 co_localsplusnames[3] 對應(yīng)的便是局部變量。這里可能有人好奇,CO_FAST_HIDDEN 表示的是啥?顧名思義,該宏對應(yīng)的是隱藏變量,所謂隱藏變量指的就是那些在當(dāng)前作用域中不可見的變量。
def foo():
lst = [x for x in range(10)]
比如列表推導(dǎo)式里面的循環(huán)變量,它就是一個隱藏變量,生命周期只局限于列表解析式內(nèi)部,不會泄露到當(dāng)前的局部作用域中。但 Python2 是會泄露的,如果你還要維護(hù) Python2 老項目的話,那么這里要多加注意。
圖片
以上就是 co_localspluskinds 字段的作用。
co_filename:代碼塊所在的文件的路徑
# 文件名:main.py
def foo():
pass
print(foo.__code__.co_filename)
"""
/Users/satori/Documents/testing_project/main.py
"""
如果你無法使用 IDE,那么便可通過該字段查看函數(shù)定義在哪個文件中。
co_name:代碼塊的名字
def foo():
pass
print(foo.__code__.co_name) # foo
對于函數(shù)來說,代碼塊的名字就是函數(shù)名。
co_qualname:代碼塊的全限定名
def foo():
pass
class A:
def foo(self):
pass
print(foo.__code__.co_qualname) # foo
print(A.foo.__code__.co_qualname) # A.foo
# 如果是獲取 co_name 字段,那么打印的則都是 "foo"
如果是類的成員函數(shù),那么會將類名一起返回。
co_linetable:存儲指令和源代碼行號之間的對應(yīng)關(guān)系
PyCodeObject 是源代碼編譯之后的產(chǎn)物,雖然兩者的結(jié)構(gòu)千差萬別,但體現(xiàn)出的信息是一致的。像源代碼具有行號,那么編譯成 PyCodeObject 之后,行號信息也應(yīng)該要有專門的字段來維護(hù),否則報錯時我們就無法快速定位到行號。
在 3.10 之前,行號信息由 co_lnotab 字段(一個字節(jié)序列)維護(hù),并且保存的是增量信息,舉個例子。
def foo():
name = "古明地覺"
hobby = [
"sing",
"dance",
"rap",
"??"
]
age = 16
我們通過 dis 模塊反編譯一下。
圖片
第一列數(shù)字表示行號,第二列數(shù)字表示字節(jié)碼指令的偏移量,或者說指令在整個字節(jié)碼指令集中的索引。我們知道字節(jié)碼指令集就是一段字節(jié)序列,由 co_code 字段維護(hù),并且每個指令都帶有一個參數(shù),所以偏移量(索引)為 0 2 4 6 8 ··· 的字節(jié)便是指令,偏移量為 1 3 5 7 9 ··· 的字節(jié)表示參數(shù)。
關(guān)于反編譯的具體細(xì)節(jié)后續(xù)會說,總之一個字節(jié)碼指令就是一個八位整數(shù)。對于當(dāng)前函數(shù)來說,它的字節(jié)碼偏移量和行號的對應(yīng)關(guān)系如下:
圖片
當(dāng)偏移量為 0 時,證明還沒有進(jìn)入到函數(shù)體,那么源代碼行號便是 def 關(guān)鍵字所在的行號。然后偏移量增加 2、行號增加 1,接著偏移量增加 4、行號增加 1、最后偏移量增加 8、行號增加 6。
那么 co_lnotab 便是 2 1 4 1 8 6,我們測試一下。
結(jié)果和我們分析的一樣,但 co_lnotab 字段是 3.10 之前的,現(xiàn)在已經(jīng)被替換成了 co_linetable,并且包含了更多的信息。當(dāng)然啦,在 Python 里面這兩個字段都是可以訪問的,盡管有一部分字段已經(jīng)被移除了,但為了保證兼容性,底層依舊支持我們通過 Python 訪問。
co_weakreflist:弱引用列表
PyCodeObject 對象支持弱引用,弱引用它的 PyObject * 會保存在該列表中。
以上就是 PyCodeObject 里面的字段的含義,至于剩下的幾個字段目前先跳過,后續(xù)涉及到的時候再說。
圖片
小結(jié)
- Python 解釋器 = Python 編譯器 + Python 虛擬機(jī)。
- 編譯器先將 .py 源碼文件編譯成 PyCodeObject 對象,然后再交給虛擬機(jī)執(zhí)行。
- PyCodeObject 對象可以認(rèn)為是源碼文件的另一種等價形式,但經(jīng)過編譯,虛擬機(jī)可以更快速地執(zhí)行。
- 為了避免每次都要對源文件進(jìn)行編譯,因此編譯后的結(jié)果會序列化在 .pyc 文件中,如果源文件沒有做改動,那么下一次執(zhí)行時會直接從 .pyc 中讀取。
- Python 的函數(shù)、類、模塊等,都具有各自的作用域,每個作用域?qū)?yīng)一個獨立的代碼塊,在編譯時,Python 編譯器會為每個代碼塊都創(chuàng)建一個 PyCodeObject 對象。
最后我們又詳細(xì)介紹了 PyCodeObject 里面的字段的含義,相比幾年前剖析的 Python3.8 版本的源碼,3.12 的改動還是比較大的,底層增加了不少字段,并且移除了部分字段。但對于 Python 使用者而言,還是和之前一樣,解釋器依舊將它們以 <class 'code'> 實例屬性的形式暴露了出來。