心痛
在上一篇文章中,,我們知道了py文件編譯之后會生成PyCodeObject對象,并且還會保存在pyc文件里,。那么我們在Python里面如何才能訪問到這個對象呢,?
首先PyCodeObject對象在Python里面的類型是<class 'code'>,,但是這個類Python沒有暴露給我們,因此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)然也可以獲取里面的成員,,我們來演示一下。 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
foo中的參數(shù)a,、b,、c都可以通過位置參數(shù)傳遞,所以結(jié)果是3,;對于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ù) bar,,里面的a,、b只能通過位置參數(shù)傳遞。 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_nlocals:代碼塊中局部變量的個數(shù),也包括參數(shù) 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,所以我們看到在編譯之后,,函數(shù)的局部變量就已經(jīng)確定了,,因為它們是靜態(tài)存儲的。 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)注,。 co_flags:參數(shù)類型標(biāo)識 如果一個函數(shù)的參數(shù)出現(xiàn)了 *args,,那么co_flags&0x04為真;如果一個函數(shù)的參數(shù)出現(xiàn)了 **kwargs,,那么co_flags&0x08為真,; def foo1(): pass # 結(jié)果全部為假 print(foo1.__code__.co_flags & 0x04) # 0 print(foo1.__code__.co_flags & 0x08) # 0
def foo2(*args): pass # co_flags & 0x04 為真,因為出現(xiàn)了 *args print(foo2.__code__.co_flags & 0x04) # 4 print(foo2.__code__.co_flags & 0x08) # 0
def foo3(*args, **kwargs): pass # 顯然 co_flags & 0x04 和 co_flags & 0x08 均為真 print(foo3.__code__.co_flags & 0x04) # 4 print(foo3.__code__.co_flags & 0x08) # 8
當(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 這個成員中,我們舉個栗子: # 如果是生成器函數(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
以上就是 co_flags 的作用,。
co_firstlineno:代碼塊在對應(yīng)文件的起始行 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,,最終得到的就是bar的字節(jié)碼,,因此返回def bar():所在的行數(shù)。所以每個函數(shù)都有自己的作用域,,以及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')
一切皆對象,,但看到的都是指向?qū)ο蟮淖兞浚?strong>print,、c,、list、int,、str都是變量,,它們都不在當(dāng)前foo函數(shù)的作用域中。 co_varnames:符號表,,一個元組,,保存在當(dāng)前作用域中的變量 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是位于當(dāng)前foo函數(shù)的作用域當(dāng)中的,,所以編譯階段便確定了局部變量是什么,。 co_consts:常量池,一個元組,,保存代碼塊中的所有常量 x = 123
def foo(a, b): c = "abc" print(x) print(True, False, list, [1, 2, 3], {"a": 1}) return ">>>"
print( foo.__code__.co_consts ) # (None, 'abc', True, False, 1, 2, 3, 'a', '>>>')
co_consts里面出現(xiàn)的都是常量,,但[1, 2, 3]和{"a": 1}卻沒有出現(xiàn),由此我們可以得出,,列表和字典絕不是在編譯階段構(gòu)建的,。編譯時,只是收集了里面的元素,,然后等到運行時再去動態(tài)構(gòu)建,。 不過問題來了,在構(gòu)建的時候解釋器怎么知道是要構(gòu)建列表,、還是字典,、亦或是其它的什么對象呢?所以這就依賴于字節(jié)碼了,,解釋字節(jié)碼的時候,,會判斷到底要構(gòu)建什么樣的對象。 因此解釋器執(zhí)行的是字節(jié)碼,,核心邏輯都體現(xiàn)在字節(jié)碼中,。但是光有字節(jié)碼還不夠,它包含的只是程序的主干邏輯,,至于變量,、常量,則從符號表和常量池里面獲取,。 co_freevars:內(nèi)層函數(shù)引用的外層函數(shù)的作用域中的變量 def f1(): a = 1 b = 2 def f2(): print(a) return f2
# 這里拿到的是f2的字節(jié)碼 print(f1().__code__.co_freevars) # ('a',)
函數(shù)f2引用了函數(shù)f1中的變量a,。
co_cellvars:外層函數(shù)的作用域中被內(nèi)層函數(shù)引用的變量,本質(zhì)上和co_freevars是一樣的 def f1(): a = 1 b = 2 def f2(): print(a) return f2
# 但這里調(diào)用的是f1的字節(jié)碼 print(f1.__code__.co_cellvars) # ('a',)
函數(shù)f1中的變量a被內(nèi)層函數(shù)f2引用了,。
co_filename:代碼塊所在的文件名 def foo(): pass print(foo.__code__.co_filename) # D:/satori/main.py
co_name:代碼塊的名字 def foo(): pass # 這里就是函數(shù)名 print(foo.__code__.co_name) # foo
co_code:字節(jié)碼 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'
這便是字節(jié)碼,,它只保存了要操作的指令,因此光有字節(jié)碼是肯定不夠的,,還需要其它的靜態(tài)信息,。顯然這些信息連同字節(jié)碼一樣,都位于PyCodeObject中,。 co_lnotab:字節(jié)碼指令與源代碼行號之間的對應(yīng)關(guān)系,,以PyByteObject的形式存在 def foo(a, b, /, c, *, d, e): f = 123 g = list() g.extend([tuple, getattr, print]) print(foo.__code__.co_lnotab) # b'\x00\x01\x04\x01\x06\x01'
我們知道一行py代碼會對應(yīng)多條字節(jié)碼指令,但事實上,co_lnotab沒有直接記錄這些信息,,記錄的是增量值,。比如說: 那么co_lnotab就應(yīng)該是: 0 1 6 1 44 5,其中0和1很好理解,,就是co_code和.py文件的起始位置,。 而6和1表示字節(jié)碼的偏移量增加了6,.py文件的行號增加了1,; 而44和5表示字節(jié)碼的偏移量增加了44,,而.py文件的行號增加了5。 以上我們就分析了PyCodeObject里面的成員都代表什么含義,。
我們上面通過函數(shù)的__code__屬性獲取了該函數(shù)的PyCodeObject對象,,但是還有沒有其他的方法呢?顯然是有的,,答案是通過內(nèi)置函數(shù)compile,,不過在介紹compile之前,先介紹一下eval和exec,。 eval:傳入一個字符串,然后把字符串里面的內(nèi)容拿出來,。 a = 1 # 所以eval("a")就等價于a print(eval("a")) # 1
print(eval("1 + 1 + 1")) # 3
注意:eval是有返回值的,,返回值就是字符串里面內(nèi)容?;蛘哒feval是可以作為右值的,,比如a=eval("xxx")。 所以eval里面一定是一個表達(dá)式,,表達(dá)式計算之后是一個具體的值,。絕不可以是語句,比如a=eval("b=3"),,這樣等價于a=(b=3),,顯然這會出現(xiàn)語法錯誤。 因此eval里面把字符串剝掉之后就是一個普通的值,,不可以出現(xiàn)諸如if,、def等語句。 try: eval("xxx") except NameError as e: print(e) # name 'xxx' is not defined
此時等價于xxx,,但是xxx沒有定義,,所以報錯。 # 此時是合法的,,等價于 print('xxx') print(eval("'xxx'")) # xxx
exec:傳入一個字符串,,把字符串里面的內(nèi)容當(dāng)成語句來執(zhí)行,這個是沒有返回值的,,或者說返回值是None,。 # 相當(dāng)于 a = 1 exec("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返回的,,而是把上面那坨字符串當(dāng)成普通代碼執(zhí)行的時候print出來的。這便是exec的作用,,將字符串當(dāng)成語句來執(zhí)行,。 那么它和eval的區(qū)別就顯而易見了,eval是要求字符串里面的內(nèi)容能夠當(dāng)成一個值,,返回值就是里面的值,。而exec則是直接執(zhí)行里面的內(nèi)容,返回值是None,。 print(eval("1 + 1")) # 2 print(exec("1 + 1")) # None
# 相當(dāng)于 a = 2 exec("a = 1 + 1") print(a) # 2
try: # 相當(dāng)于a=2,,但很明顯,a=2是一個語句 # 它無法作為一個值,,因此放到eval里面就報錯了 eval("a = 1 + 1") except SyntaxError as e: print(e) # invalid syntax (<string>, line 1)
還是很好區(qū)分的,,但是eval和exec在生產(chǎn)中盡量要少用。另外,,eval 和 exec 還可以接收第二個參數(shù)和第三個參數(shù),,我們在介紹名字空間的時候再說。
compile:關(guān)鍵來了,,它執(zhí)行后返回的就是一個PyCodeObject對象,。 這個函數(shù)接收哪些參數(shù)呢?參數(shù)一:當(dāng)成代碼執(zhí)行的字符串,;參數(shù)二:可以為這些代碼起一個文件名,;參數(shù)三:執(zhí)行方式,支持三種,,分別是exec,、single、eval,。 statement = "a, b = 1, 2" # 這里我們選擇 exec,,當(dāng)成一個模塊來編譯 co = compile(statement, "古明地覺的 Python小屋", "exec") print(co.co_firstlineno) # 1 print(co.co_filename) # 古明地覺的 Python小屋 print(co.co_argcount) # 0 # 我們是a, b = 1, 2這種方式賦值 # 所以(1, 2)會被當(dāng)成一個元組加載進(jìn)來 # 從這里我們看到,元組是在編譯階段就已經(jīng)確定好了 print(co.co_consts) # ((1, 2), None)
statement = """ a = 1 b = 2 """ co = compile(statement, "<file>", "exec") print(co.co_consts) # (1, 2, None) print(co.co_names) # ('a', 'b')
我們后面在分析PyCodeObject的時候,,會經(jīng)常使用compile的方式,。 然后 compile 還可以接收一個 flags 參數(shù),也就是第四個參數(shù),,如果指定為 1024,,那么得到的就不再是PyCodeObject對象了,而是一個_ast.Module 對象。 print( compile("a = 1", "<file>", "exec").__class__ ) # <class 'code'>
print( compile("a = 1", "<file>", "exec", flags=1024).__class__ ) # <class '_ast.Module'>
_ast 是用 C 實現(xiàn)的模塊,,內(nèi)嵌在解釋器里面,,用于幫助我們更好地理解Python的抽象語法樹。當(dāng)然,,構(gòu)建抽象語法樹的話,我們更習(xí)慣使用標(biāo)準(zhǔn)庫中的 ast 模塊,,它里面了導(dǎo)入了 _ast,。 那么問題來了,這個 _ast.Module 對象能夠干什么呢,?別著急,,我們后續(xù)在介紹棧幀的時候說,。不過由于抽象語法樹比較底層,對我們理解Python沒有什么實質(zhì)性的幫助,,因此知道 compile 的前三個參數(shù)的用法即可,。
關(guān)于Python的字節(jié)碼,,是后面剖析虛擬機的重點,,現(xiàn)在先來看一下。我們知道Python執(zhí)行源代碼之前會先編譯得到PyCodeObject對象,,里面的co_code指向了字節(jié)碼序列,。 Python虛擬機會根據(jù)這些字節(jié)碼序列來進(jìn)行一系列的操作(當(dāng)然也依賴其它的靜態(tài)信息),從而完成對程序的執(zhí)行,。 每個操作都對應(yīng)一個操作指令,、也叫操作碼,總共有120多種,定義在Include/opcode.h中,。 #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 ... ...
操作指令只是一個整數(shù),,然后我們可以通過反編譯的方式查看每行Python代碼都對應(yīng)哪些操作指令: # Python中的dis模塊專門負(fù)責(zé)干這件事情 import dis
def foo(a, b): c = a + b return c
# 里面接收一個字節(jié)碼 # 當(dāng)然函數(shù)也是可以的,會自動獲取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 """
字節(jié)碼反編譯后的結(jié)果多么像匯編語言,,其中第一列是源代碼行號,,第二列是字節(jié)碼偏移量,第三列是操作指令,。關(guān)于反編譯的內(nèi)容,,我們會在剖析函數(shù)的時候,深入介紹,。
|