Python 源文件編譯之后會得到什么,它的結構是怎樣的?和字節碼又有什么聯系?
楔子
當我們執行一個 py 文件的時候,只需要在命令行中輸入 python xxx.py 即可,但你有沒有想過這背后的流程是怎樣的呢?
首先 py 文件不是一上來就直接執行的,而是會先有一個編譯的過程,整個步驟如下:
圖片
這里我們看到了 Python 編譯器、Python 虛擬機,而且我們平常還會說 Python 解釋器,那么三者之間有什么區別呢?
圖片
Python 編譯器負責將 Python 源代碼編譯成 PyCodeObject 對象,然后交給 Python 虛擬機來執行。
那么 Python 編譯器和 Python 虛擬機都在什么地方呢?如果打開 Python 的安裝目錄,會發現有一個 python.exe,點擊的時候會通過它來啟動一個終端。

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
































