楔子
當我們想要執行一個py文件的時候,只需要python xxx.py即可,但是你有沒有想過這背后的流程是怎么樣的呢?從這里開始我們就開始進入到Python虛擬機的環節了,之前都是在介紹Python中的一些內置對象,不過虛擬機的執行流程、以及背后的原理卻更是值得我們關注的。
這里我們先來說一下Python執行py文件的流程:
1. 首先將文件里面的內容讀取出來, 所以從這個角度上講, 文件名不一定非要是.py結尾, .txt也是可以的, 只要文件里面的內容符合Python代碼規范即可
2. 讀取文件里面的內容之后會對其進行分詞, 將源代碼切分成一個一個的token
3. 然后Python編譯器會對token進行語法解析, 建立抽象語法樹(AST, abstract syntax tree)
4. 編譯器再將得到AST編譯成字節碼
5. 最終由Python虛擬機來執行字節碼
首先我們從中看到了Python編譯器、Python虛擬機,而且我們平常還會說Python解釋器,那么三者之間有什么區別呢?
實際上Python解釋器 = Python編譯器 + Python虛擬機,Python編譯器負責將Python源代碼編譯成字節碼
(包括文件讀取、分詞、建立AST、編譯成字節碼)
,Python虛擬機負責執行這些字節碼。
那么Python編譯器和Python虛擬機都在什么地方呢?如果打開Python的安裝目錄,會發現有一個python.exe,點擊的時候會通過它來啟動一個終端。
但問題是這個文件大小還不到100K,不可能容納一個解釋器加一個虛擬機,所以下面還有一個python38.dll,沒錯,編譯器、虛擬機都藏身於python38.dll當中。
因此Python代碼並不是直接就一步到胃、變成機器碼,而是先被Python編譯器編譯成字節碼,中間存在一個編譯的過程。而之所以要存在編譯,是為了能夠讓虛擬機更快速的執行,而且還可以盡早的檢測出語法上的錯誤。
那么下面我們就來看看Python中的字節碼長什么樣子。
Python編譯器的編譯結果--PyCodeObject對象
我們知道Python代碼的編譯結果是字節碼,里面必然隱藏了Python運行的秘密,因此不管是深入理解虛擬機還是調優Python的運行效率,字節碼都是繞不過去的一個坎。
注意:我們這里會研究字節碼,但是不會研究Python是怎么編譯得到字節碼。因為Python編譯器的工作原理和其它語言基本類似,很多關於編譯原理的書籍都有介紹,編譯這個過程不是Python特有的。並且研究Python的編譯過程,對於我們開發幫助不是很大。
所以我們只需要知道Python解釋器的背后有一個編譯器會通過"讀取文件"、"對源代碼分詞"、"分詞之后會語法解析建立AST"、"對AST編譯"得到字節碼即可,至於這一列步驟是怎么做的、是怎么將源代碼變成的字節碼不是我們需要關心的,我們的重點是研究字節碼本身以及虛擬機。
PyCodeObject對象和pyc文件
首先做Python開發的肯定都見過這個pyc文件,它一般位於__pycache__
目錄中,那么這個pyc文件和字節碼之間有什么關系呢?
首先我們知道,Python執行這個文件首先要進行的動作就是編譯,編譯會得到字節碼。然而除了字節碼之外,還應該包含一些其它的信息,這些信息也是Python運行的時候所必須的。
在編譯過程中,像常量值、字符串這些源代碼當中的靜態信息都會被Python編譯器收集起來,並且這些靜態信息也都會體現在編譯之后的結果里面。在Python運行期間,這些源文件提供的靜態信息都會被存儲在一個運行時的對象當中,當Python運行結束時,這個運行時對象中所包含的信息還會被存儲在一種文件中。這個對象和文件就是我們接下來要探討的重點:PyCodeObject對象和pyc文件。
Python中的字節碼只是一個PyBytesObject對象、或者說一段字節序列,PyCodeObject對象中有一個成員co_code,它是一個指針,指向了這段字節序列。但是這個對象除了有co_code指向字節碼之外,還有很多其它成員,負責保存代碼涉及到的常量、變量
(名字、符號)
等等所以我們知道了,pyc文件里面的內容是PyCodeObject對象。對於Python編譯器來說,PyCodeObject對象才是其真正的編譯結果,而pyc文件是這個對象在硬盤上表現形式。
在程序運行期間,編譯結果存在於內存的PyCodeObject對象當中,而Python結束運行之后,編譯結果又被保存到了pyc文件當中。當下一次運行的時候,Python會根據pyc文件中記錄的編譯結果直接建立內存中的PyCodeObject對象,而不需要再度重新編譯了。
python源碼中的PyCodeObject對象
我們說Python編譯器會將Python源代碼編譯成字節碼,虛擬機執行的也是字節碼,所以要理解虛擬機的運行時(runtime)
行為,就必須要先掌握字節碼。而我們說字節碼是被底層結構體PyCodeObject的成員co_code指向,那么我們就必須來看看這個結構體了,它的定義位於 Include/code.h 中。
typedef struct {
PyObject_HEAD /* 頭部信息, 我們看到真的一切皆對象, 字節碼也是個對象 */
int co_argcount; /* 可以通過位置參數傳遞的參數個數 */
int co_posonlyargcount; /* 只能通過位置參數傳遞的參數個數, Python3.8新增 */
int co_kwonlyargcount; /* 只能通過關鍵字參數傳遞的參數個數 */
int co_nlocals; /* 代碼塊中局部變量的個數,也包括參數 */
int co_stacksize; /* 執行該段代碼塊需要的棧空間 */
int co_flags; /* 參數類型標識 */
int co_firstlineno; /* 代碼塊在對應文件的行號 */
PyObject *co_code; /* 指令集, 也就是字節碼, 它是一個bytes對象 */
PyObject *co_consts; /* 常量池, 一個元組,保存代碼塊中的所有常量。 */
PyObject *co_names; /* 一個元組,保存代碼塊中引用的其它作用域的變量 */
PyObject *co_varnames; /* 一個元組,保存當前作用域中的變量 */
PyObject *co_freevars; /* 內層函數引用的外層函數的作用域中的變量 */
PyObject *co_cellvars; /* 外層函數中作用域中被內層函數引用的變量,本質上和co_freevars是一樣的 */
Py_ssize_t *co_cell2arg; /* 無需關注 */
PyObject *co_filename; /* 代碼塊所在的文件名 */
PyObject *co_name; /* 代碼塊的名字,通常是函數名或者類名 */
PyObject *co_lnotab; /* 字節碼指令與python源代碼的行號之間的對應關系,以PyByteObject的形式存在 */
//剩下的無需關注了
void *co_zombieframe; /* for optimization only (see frameobject.c) */
PyObject *co_weakreflist; /* to support weakrefs to code objects */
void *co_extra;
unsigned char *co_opcache_map;
_PyOpcache *co_opcache;
int co_opcache_flag;
unsigned char co_opcache_size;
} PyCodeObject;
這里面的每一個成員,我們后面都會逐一演示進行說明。總之Python編譯器在對Python源代碼進行編譯的時候,對於代碼中的每一個block,都會創建一個PyCodeObject與之對應。但是多少代碼才算得上是一個block呢?事實上,Python有一個簡單而清晰的規則:當進入一個新的名字空間,或者說作用域時,我們就算是進入了一個新的block了。這里又引出了名字空間,別急,我們后面會一點一點說,總之先舉個栗子:
class A:
a = 123
def foo():
a = []
我們仔細觀察一下上面這個文件,它在編譯完之后會有三個PyCodeObject對象,一個是對應整個py文件的,一個是對應class A的,一個是對應def foo的。因為這是三個不同的作用域,所以會有三個PyCodeObject對象。
在這里,我們開始提及Python中一個至關重要的概念--名字空間(name space)、也叫命名空間、名稱空間,都是一個東西。名字空間是符號的上下文環境,符號的含義取決於名字空間。更具體的說,一個變量名對應的變量值什么,在Python中是不確定的,需要命名空間來決定。
對於某個符號、或者名字(我們在前面系列中說過Python的變量只是一個名字)
,比如說上面代碼中的a,在某個名字空間中,它可能指向一個PyLongObject對象;而在另一個名字空間中,它可能指向一個PyListObject對象。但是在一個名字空間中,一個符號只能有一種含義。而且名字空間可以一層套一層的形成一條名字空間鏈
,Python虛擬機在執行的時候,會有很大一部分時間消耗在從名字空間鏈
中確定一個符號所對應的對象是什么。這也側面說明了,Python為什么比較慢。
如果你現在名字空間還不是很了解,不要緊,隨着剖析的深入,你一定會對名字空間和Python在名字空間鏈上的行為有着越來越深刻的理解。總之現在需要記住的是:一個code block對應一個名字空間(或者說作用域)
、同時也對應一個PyCodeObject對象。在Python中,類、函數、module都對應着一個獨自的名字空間,因此都會有一個PyCodeObject與之對應。
如何在Python中訪問PyCodeObject對象
那么我們如何才能在Python中獲取到PyCodeObject對象呢?PyCodeObject對象在Python中也是一個對象,它的類型對象是<class 'code'>
。但是這個類,底層沒有暴露給我們,所以code對於Python來說只是一個沒有定義的變量罷了。
但是我們可以通過其它的方式進行獲取,首先來看看如何通過函數來獲取該函數對應的字節碼。
def func():
pass
print(type(func.__code__)) # <class 'code'>
我們可以通過函數的__code__
拿到底層對應的PyCodeObject對象,當然也可以獲取里面的屬性,我們來演示一下。
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
注意:這里是只能通過位置參數傳遞的參數個數。
co_kwonlyargcount:只能通過關鍵字參數傳遞的參數個數
def foo(a, b=1, c=2, *, d, e):
pass
print(foo.__code__.co_kwonlyargcount) # 2
這里是d和e,它們必須通過關鍵字參數傳遞。
co_nlocals:代碼塊中局部變量的個數,也包括參數
def foo(a, b, *, c):
name = "xxx"
age = 16
gender = "f"
c = 33
print(foo.__code__.co_nlocals) # 6
局部變量:a、b、c、name、age、gender,所以我們看到在編譯成字節碼的時候函數內局部變量的個數就已經確定了,因為它是靜態存儲的。
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
# 顯然是文件的第一行
print(foo.__code__.co_firstlineno) # 1
如果函數出現了調用呢?
def foo():
return bar
def bar():
pass
print(foo().__code__.co_firstlineno) # 5
如果執行foo,那么會返回函數bar,調用的就是bar函數的字節碼,那么得到就是def bar():
所在的行數。因為每個函數都有自己獨自的命名空間,以及PyCodeObject對象。
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_varnames:一個元組,保存在當前作用域中的變量
c = 1
def foo(a, b):
print(a, b, c)
d = (list, int, str)
print(foo.__code__.co_varnames) # ('a', 'b', 'd')
a、b、d是位於當前foo函數的作用域當中的,所以編譯階段便確定了局部變量是什么。
co_consts:常量池,一個元組對象,保存代碼塊中的所有常量。
x = 123
def foo(a, b):
c = "abc"
print(x)
print(True, False, list, [1, 2, 3], {"a": 1})
return ">>>"
# list不屬於常量
print(foo.__code__.co_consts) # (None, 'abc', True, False, 1, 2, 3, 'a', '>>>')
co_consts里面出現的都是常量,而[1, 2, 3]
和{"a": 1}
,則是將里面元素單獨拿出來了。不過可能有人好奇里面的None是從哪里來的。首先a和b是不是函數的參數啊,所以co_consts里面還要有兩個常量,但是我們還沒傳參呢,所以使用None來代替。
co_freevars:內層函數引用的外層函數的作用域中的變量
def f1():
a = 1
b = 2
def f2():
print(a)
return f2
# 這里調用的是f2的字節碼
print(f1().__code__.co_freevars) # ('a',)
co_cellvars:外層函數中作用域中被內層函數引用的變量,本質上和co_freevars是一樣的
def f1():
a = 1
b = 2
def f2():
print(a)
return f2
# 但這里調用的是f1的字節碼
print(f1.__code__.co_cellvars) # ('a',)
co_filename:代碼塊所在的文件名
def foo():
pass
print(foo.__code__.co_filename) # D:/satori/1.py
co_name:代碼塊的名字,通常是函數名或者類名
def foo():
pass
print(foo.__code__.co_name) # foo
co_code:字節碼
def foo(a, b, /, c, *, d, e):
f = 123
g = list()
g.extend([tuple, getattr, print])
print(foo.__code__.co_code)
"""
b'd\x01}\x05t\x00\x83\x00}\x06|\x06\xa0\x01t\x02t\x03t\x04g\x03\xa1\x01\x01\x00d\x00S\x00'
"""
# 這便是字節碼, 當然單單是這些字節碼肯定不夠的, 所以還需要其它的靜態信息
# 其它的信息顯然連同字節碼一樣, 都位於PyCodeObject中
# co_lnotab: 字節碼指令與python源代碼的行號之間的對應關系,以PyByteObject的形式存在
print(foo.__code__.co_lnotab) # b'\x00\x01\x04\x01\x06\x01'
"""
然而事實上,Python不會直接記錄這些信息,而是會記錄增量值。比如說:
字節碼在co_code中的偏移量 .py文件中源代碼的行號
0 1
6 2
50 7
那么co_lnotab就應該是: 0 1 6 1 44 5
0和1很好理解, 就是co_code和.py文件的起始位置
而6和1表示字節碼的偏移量是6, .py文件的行號增加了1
而44和5表示字節碼的偏移量是44, .py文件的行號增加了5
"""
pyc文件
每一個PyCodeObject對象中都包含了一個co_code指針,指向code block中所有代碼經過編譯后得到的byte code序列。前面我們說到,Python會將字節碼序列和PyCodeObject對象一起存儲在pyc文件中。但不幸的是,事實並不總是這樣。有時,當我們運行一個簡單的程序時並沒有產生pyc文件,因此我們猜測:有些python程序只是臨時完成一些瑣碎的工作,這樣的程序僅僅只會運行一次,然后就不會再使用了,因此也就沒有保存至pyc文件的必要。
如果我們在代碼中加上了一個import abc這樣語句,再執行你就會發現Python為其生成了pyc文件,這就說明import會觸發pyc的生成。實際上,在運行過程中,如果碰到import abc這樣的語句,那么Python會在設定好的path中尋找abc.pyc或者abc.dll文件,如果沒有這些文件,而是只發現了abc.py,那么Python會先將abc.py編譯成PyCodeObject,然后創建pyc文件,並將PyCodeObject寫到pyc文件里面去。接下來,再對abc.pyc進行import動作,對,並不是編譯成PyCodeObject對象之后直接使用,而是先寫到pyc里面去,然后將pyc文件的PyCodeObject對象重新在內存中復制出來。
關於python的import機制,我們后面章節會剖析,這里只是用來完成pyc文件的觸發。當然得到pyc文件有很多方法,比如使用py_compile模塊。
# a.py
class A:
a = 1
# b.py
import a
執行b.py的時候,會發現創建了a.cpython-38.pyc。另外關於pyc文件的創建位置,會在當前文件的同級目錄下的__pycache__
目錄中創建,名字就叫做:py文件名.cpython-版本號.pyc
通過compile獲取PyCodeObject對象
事實上我們已經介紹了一種方法去獲取相應的PyCodeObject對象,但是還有沒有其他的方法呢?答案是通過內置函數compile,但是在介紹compile之前,先介紹一下eval和exec。
eval:傳入一個字符串,然后把字符串里面的內容拿出來。
a = 1
# 所以eval("a")就等價於a
print(eval("a")) # 1
print(eval("1 + 1 + 1")) # 3
# 注意:eval是有返回值的,返回值就是字符串里面內容。
# 或者說eval是可以作為右值的,比如a = eval("xxx")
# 所以eval里面絕不可以出現諸如賦值之類的,比如 print(eval("a = 3")),那么這個語句等價於print(a = 3),這樣顯然會出現語法錯誤的
# 因此eval里面把字符串剝掉之后就是一個普通的值,不可以出現諸如if、def等語句
try:
eval("xxx")
except NameError as e:
print(e) # name 'xxx' is not defined
exec:傳入一個字符串,把字符串里面的內容當成語句來執行,這個是沒有返回值,或者說返回值是None。
exec("a = 1") # 等價於把a = 1這個字符串里面的內容當成語句來執行
print(a) # 1
statement = """a = 123
if a == 123:
print("a等於123")
else:
print("a不等於123")
"""
exec(statement) # a等於123
# 注意:'a等於123'並不是exec返回的,而是把上面那坨字符串當成普通代碼執行的時候print出來的
# 這便是exec的作用。
# 那么它和eval的區別就顯而易見的,eval是要求字符串里面的內容能夠當成一個值來打印,返回值就是里面的值
# 而exec則是直接執行里面的內容
# 舉個例子
print(eval("1 + 1")) # 2
print(exec("1 + 1")) # None
exec("a = 1 + 1")
print(a) # 2
try:
eval("a = 1 + 1")
except SyntaxError as e:
print(e) # invalid syntax (<string>, line 1)
compile:關鍵來了,它執行后返回的就是一個code對象
statement = "a, b = 1, 2"
# 參數一:代碼
# 參數二:可以為這些代碼起一個文件名
# 參數三:執行方式,可以選擇三種方式。exec: 將源碼當做一個模塊來編譯;single: 用於編譯一個單獨d的Python語句(交互式下);eval:用於編譯一個eval表達式
# 這里顯然是exec
co = compile(statement, "夏色祭", "exec")
print(co.co_firstlineno) # 1
print(co.co_filename) # 夏色祭
print(co.co_argcount) # 0
# 這里是一個元組,因為我們是a, b = 1, 2這種方式賦值的,所以加載的是一個元組
print(co.co_consts) # ((1, 2), None)
statement = "a = 1;b = 2"
co = compile(statement, "夏色祭", "exec")
print(co.co_consts) # (1, 2, None)
print(co.co_names) # ('a', 'b')
我們后面在分析PyCodeObject的時候,會經常使用compile的方式。
pyc文件的生成
創建pyc文件的具體過程
前面我們提到,Python通過import module進行加載時,如果沒有找到相應的pyc或者dll文件,就會在py文件的基礎上自動創建pyc文件。所以想要了解pyc文件是怎么創建的,只需要了解PyCodeObject是如何寫入的即可。關於寫入pyc文件,主要寫入三個內容:
1. magic number
這是Python定義的一個整數值,不同版本的Python會定義不同的magic number,這個值是為了保證Python能夠加載正確的pyc。比如Python3.7不會加載3.6版本的pyc,因為Python在加載這個pyc文件的時候會首先檢測該pyc的magic number,如果和自身的magic number不一致,則拒絕加載。
2. pyc的創建時間
這個很好理解,因為編譯完之后要是把源代碼修改了怎么辦呢?因此會判斷源代碼的最后修改時間和pyc文件的創建時間,如果pyc文件的創建時間比源代碼修改時間要早,說明在生成pyc之后,源代碼被修改了,那么會重新編譯新的pyc,而反之則會直接加載pyc。
3.PyCodeObject對象
這個不用說了,肯定是要存儲的。當然還有字節碼,不過PyCodeObject里面的co_code指向了這個字節碼,所以我們就直接說PyCodeObject對象了。
文件的寫入
既然要寫入,那么肯定要有文件句柄,我們來看看:
//位置:Python/marshal.c
//FILE是一個文件句柄,可以把WFILE看成是FILE的包裝
typedef struct {
FILE *fp; //文件句柄
//下面的字段在寫入信息的時候會看到
int error;
int depth;
PyObject *str;
char *ptr;
char *end;
char *buf;
_Py_hashtable_t *hashtable;
int version;
} WFILE;
寫入magic number和時間:
寫入magic number和時間都是調用了PyMarshal_WriteLongToFile
,我們來看看長什么樣子。
void
PyMarshal_WriteLongToFile(long x, FILE *fp, int version)
{
//聲明char型的數組,元素個數為4個
char buf[4];
//聲明一個WFILE類型變量wf
WFILE wf;
//內存初始化
memset(&wf, 0, sizeof(wf));
//設置fp,文件句柄
wf.fp = fp;
//將buf數組的指針賦值給wf.ptr和wf.buf
wf.ptr = wf.buf = buf;
//相當於buf的最后一個元素的指針
wf.end = wf.ptr + sizeof(buf);
//寫錯誤
wf.error = WFERR_OK;
//寫入版本信息
wf.version = version;
//調用w_long將x也就是版本信息或者時間寫到wf里面去
w_long(x, &wf);
//刷到磁盤上
w_flush(&wf);
}
//所以我們看到這一步只是初始化一個WFILE對象,真正寫入則是調用w_long
static void
w_long(long x, WFILE *p)
{
w_byte((char)( x & 0xff), p);
w_byte((char)((x>> 8) & 0xff), p);
w_byte((char)((x>>16) & 0xff), p);
w_byte((char)((x>>24) & 0xff), p);
}
//w_long則是將要寫入的x一個字節一個字節寫到文件里面去。
寫入PyCodeObject對象:
寫入PyCodeObject對象則是調用了PyMarshal_WriteObjectToFile
,我們也來看看長什么樣子。
void
PyMarshal_WriteObjectToFile(PyObject *x, FILE *fp, int version)
{
char buf[BUFSIZ];
WFILE wf;
memset(&wf, 0, sizeof(wf));
wf.fp = fp;
wf.ptr = wf.buf = buf;
wf.end = wf.ptr + sizeof(buf);
wf.error = WFERR_OK;
wf.version = version;
if (w_init_refs(&wf, version))
return; /* caller mush check PyErr_Occurred() */
w_object(x, &wf);
w_clear_refs(&wf);
w_flush(&wf);
}
//可以看到,和PyMarshal_WriteLongToFile基本是類似的
//只不過PyMarshal_WriteLongToFile調用的是w_long,而PyMarshal_WriteObjectToFile調用的是w_object
static void
w_object(PyObject *v, WFILE *p)
{
char flag = '\0';
p->depth++;
if (p->depth > MAX_MARSHAL_STACK_DEPTH) {
p->error = WFERR_NESTEDTOODEEP;
}
else if (v == NULL) {
w_byte(TYPE_NULL, p);
}
else if (v == Py_None) {
w_byte(TYPE_NONE, p);
}
else if (v == PyExc_StopIteration) {
w_byte(TYPE_STOPITER, p);
}
else if (v == Py_Ellipsis) {
w_byte(TYPE_ELLIPSIS, p);
}
else if (v == Py_False) {
w_byte(TYPE_FALSE, p);
}
else if (v == Py_True) {
w_byte(TYPE_TRUE, p);
}
else if (!w_ref(v, &flag, p))
w_complex_object(v, flag, p);
p->depth--;
}
可以看到本質上還是調用了w_byte,但是在這里面我們並沒有看到諸如:列表、元組之類的數據的存儲過程,注意最后的w_complex_object,關鍵來了。
//源代碼很長, 具體邏輯就不貼了
//我們后面會單獨截取一部分進行分析
static void
w_complex_object(PyObject *v, char flag, WFILE *p)
{
Py_ssize_t i, n;
if (PyLong_CheckExact(v)) {
//......
}
else if (PyFloat_CheckExact(v)) {
if (p->version > 1) {
//......
}
else {
//......
}
}
else if (PyComplex_CheckExact(v)) {
if (p->version > 1) {
//......
}
else {
//......
}
}
else if (PyBytes_CheckExact(v)) {
//......
}
else if (PyUnicode_CheckExact(v)) {
if (p->version >= 4 && PyUnicode_IS_ASCII(v)) {
//......
}
else {
//......
}
}
else {
//......
}
}
else if (PyTuple_CheckExact(v)) {
//......
}
else if (PyList_CheckExact(v)) {
//......
}
else if (PyDict_CheckExact(v)) {
//......
}
else if (PyAnySet_CheckExact(v)) {
//......
}
else if (PyCode_Check(v)) {
PyCodeObject *co = (PyCodeObject *)v;
W_TYPE(TYPE_CODE, p);
w_long(co->co_argcount, p);
w_long(co->co_kwonlyargcount, p);
w_long(co->co_nlocals, p);
w_long(co->co_stacksize, p);
w_long(co->co_flags, p);
w_object(co->co_code, p);
w_object(co->co_consts, p);
w_object(co->co_names, p);
w_object(co->co_varnames, p);
w_object(co->co_freevars, p);
w_object(co->co_cellvars, p);
w_object(co->co_filename, p);
w_object(co->co_name, p);
w_long(co->co_firstlineno, p);
w_object(co->co_lnotab, p);
}
else if (PyObject_CheckBuffer(v)) {
//......
}
else {
W_TYPE(TYPE_UNKNOWN, p);
p->error = WFERR_UNMARSHALLABLE;
}
}
源代碼很長,這里就不一一分析了,可以自行查看。不過雖然長,但是邏輯很簡單,就是對不同的對象、執行不同的寫動作。然而其最終目的都是通過w_byte寫到pyc文件中。換句話說,Python在往pyc寫入list對象時,只是將list中包含的數值或者字符串等對象寫到了pyc文件中。同時這也意味着,Python在加載pyc文件時,必須基於這些數值或字符串重新構造出list對象。
對於PyCodeObject對象,很顯然,w_object會遍歷PyCodeObject中的所有域,將這些域依次寫入。
PyCodeObject *co = (PyCodeObject *)v;
W_TYPE(TYPE_CODE, p);
w_long(co->co_argcount, p);
w_long(co->co_kwonlyargcount, p);
w_long(co->co_nlocals, p);
w_long(co->co_stacksize, p);
w_long(co->co_flags, p);
w_object(co->co_code, p);
w_object(co->co_consts, p);
w_object(co->co_names, p);
w_object(co->co_varnames, p);
w_object(co->co_freevars, p);
w_object(co->co_cellvars, p);
w_object(co->co_filename, p);
w_object(co->co_name, p);
w_long(co->co_firstlineno, p);
w_object(co->co_lnotab, p);
但是當面對一個PyListObject對象時,會有什么變化呢?沒錯,會和PyCodeObject一樣,w_object還是會遍歷,然后將PyListObject對象中的每一個元素依次寫入到pyc文件中。
//可以看到PyTupleObject、PyListObject、PyDictObject都是采用了相同的姿勢
//注意里面的W_TYPE
else if (PyTuple_CheckExact(v)) {
n = PyTuple_Size(v);
if (p->version >= 4 && n < 256) {
W_TYPE(TYPE_SMALL_TUPLE, p);
w_byte((unsigned char)n, p);
}
else {
W_TYPE(TYPE_TUPLE, p);
W_SIZE(n, p);
}
for (i = 0; i < n; i++) {
w_object(PyTuple_GET_ITEM(v, i), p);
}
}
else if (PyList_CheckExact(v)) {
W_TYPE(TYPE_LIST, p);
n = PyList_GET_SIZE(v);
W_SIZE(n, p);
for (i = 0; i < n; i++) {
w_object(PyList_GET_ITEM(v, i), p);
}
}
else if (PyDict_CheckExact(v)) {
Py_ssize_t pos;
PyObject *key, *value;
W_TYPE(TYPE_DICT, p);
/* This one is NULL object terminated! */
pos = 0;
while (PyDict_Next(v, &pos, &key, &value)) {
w_object(key, p);
w_object(value, p);
}
w_object((PyObject *)NULL, p);
}
我們看到無論對於哪一個對象,在寫入之前,都會先調用W_TYPE寫一個類似於類型的東西,是的,諸如TYPE_LIST、TYPE_TUPLE、TYPE_DICT這樣的標識,對於pyc文件的加載起着至關重要的作用。
之前說過,Python僅僅將容器里面的數值和字符串寫入到pyc文件。當PyCodeObject寫入到pyc之后,所有的數據就變成了字節流,類型信息就丟失了。
def func():
lst = [1, 2, 3]
# 從當前來看,常量池中沒有任何關於列表的信息
print(func.__code__.co_consts) # (None, 1, 2, 3)
然鵝如果沒有類型信息,那么當python再次加載pyc文件的時候,就沒辦法知道字節流中隱藏的結構和蘊含的信息,所以Python必須往pyc文件寫入一個標識,這些標識正是Python定義的類型信息。如果Python在pyc中發現了這樣的標識,則預示着上一個對象結束,新的對象開始,並且也知道新對象是什么樣的對象,從而也知道該執行什么樣的加載動作,這些標識也是可以看到的。
//marshal.c
#define TYPE_NULL '0'
#define TYPE_NONE 'N'
#define TYPE_FALSE 'F'
#define TYPE_TRUE 'T'
#define TYPE_STOPITER 'S'
#define TYPE_ELLIPSIS '.'
#define TYPE_INT 'i'
/* TYPE_INT64 is not generated anymore.
Supported for backward compatibility only. */
#define TYPE_INT64 'I'
#define TYPE_FLOAT 'f'
#define TYPE_BINARY_FLOAT 'g'
#define TYPE_COMPLEX 'x'
#define TYPE_BINARY_COMPLEX 'y'
#define TYPE_LONG 'l'
#define TYPE_STRING 's'
#define TYPE_INTERNED 't'
#define TYPE_REF 'r'
#define TYPE_TUPLE '('
#define TYPE_LIST '['
#define TYPE_DICT '{'
#define TYPE_CODE 'c'
#define TYPE_UNICODE 'u'
#define TYPE_UNKNOWN '?'
#define TYPE_SET '<'
#define TYPE_FROZENSET '>'
到了這里可以看到,其實Python對於PyCodeObject對象的導出實際上是不復雜的,因為不管什么對象,最后都為歸結為兩種簡單的形式,一種是數值寫入,一種是字符串寫入。上面都是對數值的寫入,比較簡單,僅僅需要按照字節一次寫入pyc即可。然而在寫入字符串的時候,Python設計了一種比較復雜的機制,有興趣可以自己閱讀源碼,這里不再介紹。
# a.py
class A:
pass
def foo():
pass
我們之前說對於這樣的一個py文件,會創建三個PyCodeObject對象,但是寫到pyc文件里面的只有一個PyCodeObject對象,這難道不就意味着有兩個PyCodeObject丟失了嗎?其實很明顯,有兩個PyCodeObject對象是位於另一個PyCodeObject對象當中的。因此foo和A對應的PyCodeObject對象位於a.py這個PyCodeObject對象當中,准確的說是位於co_consts指向的常量池當中。
def f1():
def f2():
pass
pass
print(f1.__code__.co_consts)
# (None, <code object f2 at 0x000001BC5DF3D450, file "D:/satori/1.py", line 2>, 'f1.<locals>.f2')
我們看到f2對應的PyCodeObject確實位於f1的常量池當中,但其實說白了不過f1的常量池當中有一個指針在指向f2對應PyCodeObject罷了。只不過在寫入的時候,也會把指針的內容一塊寫進去,所以也可以理解就是寫了3個PyCodeObject對象。不過這都不是重點,重點是PyCodeObject對象是可以嵌套的,當在一個作用域內部發現了一個新的作用域,那么新的作用域對應的PyCodeObject對象會位於外層作用域的PyCodeObject對象的常量池中,或者說被常量池中的一個指針指向。
而在寫入pyc的時候會從最外層、也就是模塊的PyCodeObject對象開始寫入,如果碰到了包含的另一個PyCodeObject對象,那么就會遞歸地執行寫入新的PyCodeObject對象的操作。如此下去,最終所有的PyCodeObject對象都會寫入到pyc文件當中,因此pyc文件當中的PyCodeObject對象也是以一種嵌套的關系聯系在一起的。
def foo():
pass
def bar():
pass
class A:
def foo(self):
pass
def bar(self):
pass
這里問一下,上面那段代碼中創建了幾個PyCodeObject對象呢?
答案是6個,首先全局是一個,foo函數一個,bar函數一個,類A一個,類A里面的foo函數一個,類A里面的bar函數一個,所以一共是6個。
而且這里的PyCodeObject對象是層層嵌套的,一開始是對整個全局創建PyCodeObject對象,然后遇到了函數foo,那么再為函數foo創建一個PyCodeObject對象,依次往下。所以如果是常量值、字符串等等,則相當於是靜態信息,直接存儲起來便可;可如果是函數、類,那么會為其創建新的PyCodeObject對象,然后收集起來,所以A里面的foo函數對應的PyCodeObject對象是存在A對應PyCodeObject對象里面的;而A對應的PyCodeObject對象則是存在全局對應的PyCodeObject對象里面,當然此時還有外層的foo、bar函數。。
Python的字節碼與反編譯
關於Python的字節碼,是后面章節剖析虛擬機的重點,現在先來看一下。我們知道Python執行源代碼之前會對其進行編譯得到PyCodeObject對象,里面的co_code指向了字節碼序列,Python虛擬機會根據這些字節碼序列來進行一系列的操作(當然也依賴其它的靜態信息)
,從而完成對程序的執行。
當然每一個操作在python中都對應一個操作指令、或者操作數,總共一共定義了121個。其實說白了每個指令不過是一個整數罷了。
#define POP_TOP 1
#define ROT_TWO 2
#define ROT_THREE 3
#define DUP_TOP 4
#define DUP_TOP_TWO 5
#define NOP 9
#define UNARY_POSITIVE 10
#define UNARY_NEGATIVE 11
#define UNARY_NOT 12
#define UNARY_INVERT 15
#define BINARY_MATRIX_MULTIPLY 16
#define INPLACE_MATRIX_MULTIPLY 17
#define BINARY_POWER 19
#define BINARY_MULTIPLY 20
#define BINARY_MODULO 22
#define BINARY_ADD 23
#define BINARY_SUBTRACT 24
#define BINARY_SUBSCR 25
#define BINARY_FLOOR_DIVIDE 26
#define BINARY_TRUE_DIVIDE 27
#define INPLACE_FLOOR_DIVIDE 28
...
...
然后我們可以通過反編譯的方式查看一下:
# Python中的dis模塊是專門干這件事情
import dis
def foo(a, b):
c = a + b
return c
# 里面可以接收一個字節碼對象, 當然函數也是可以的, 會自動獲取co_code
dis.dis(foo)
"""
5 0 LOAD_FAST 0 (a)
2 LOAD_FAST 1 (b)
4 BINARY_ADD
6 STORE_FAST 2 (c)
6 8 LOAD_FAST 2 (c)
10 RETURN_VALUE
"""
字節碼反編譯后的結果多么像匯編語言!其中,第一列是源代碼行號,第二列是字節碼偏移量,第三列是操作數。
關於反編譯的內容,我們會在剖析函數的時候,深入介紹。