楔子 當(dāng)解釋器啟動(dòng)后,首先會(huì)進(jìn)行運(yùn)行時(shí)環(huán)境的初始化。注意這里的運(yùn)行時(shí)環(huán)境,它和之前說(shuō)的執(zhí)行環(huán)境是不同的概念。運(yùn)行時(shí)環(huán)境是一個(gè)全局的概念,而執(zhí)行環(huán)境是一個(gè)棧幀。 關(guān)于運(yùn)行時(shí)環(huán)境的初始化是一個(gè)很復(fù)雜的過(guò)程,涉及到 Python 進(jìn)程、線程的創(chuàng)建,類型對(duì)象的完善等非常多的內(nèi)容,我們暫時(shí)先不討論。這里就假設(shè)初始化動(dòng)作已經(jīng)完成,我們已經(jīng)站在了虛擬機(jī)的門檻外面,只需要輕輕推動(dòng)第一張骨牌,整個(gè)執(zhí)行過(guò)程就像多米諾骨牌一樣,一環(huán)扣一環(huán)地展開。 在介紹字節(jié)碼的時(shí)候我們說(shuō)過(guò),解釋器可以看成是:編譯器+虛擬機(jī),編譯器負(fù)責(zé)將源代碼編譯成 PyCodeObject 對(duì)象,而虛擬機(jī)則負(fù)責(zé)執(zhí)行。整個(gè)過(guò)程如下: 所以我們的重點(diǎn)就是虛擬機(jī)是怎么執(zhí)行 PyCodeObject 對(duì)象的?整個(gè)過(guò)程是什么,掌握了這些,你對(duì)虛擬機(jī)會(huì)有一個(gè)更深的理解。 虛擬機(jī)的運(yùn)行框架 在介紹棧幀的時(shí)候我們說(shuō)過(guò),Python 是一門動(dòng)態(tài)語(yǔ)言,一個(gè)變量指向什么對(duì)象需要在運(yùn)行時(shí)才能確定,這些信息不可能靜態(tài)存儲(chǔ)在 PyCodeObject 對(duì)象中。 所以虛擬機(jī)在運(yùn)行時(shí)會(huì)基于 PyCodeObject 對(duì)象動(dòng)態(tài)創(chuàng)建出一個(gè)棧幀對(duì)象,然后在棧幀里面執(zhí)行字節(jié)碼。而創(chuàng)建棧幀,主要使用以下兩個(gè)函數(shù):
但這兩個(gè)函數(shù)都屬于高層的封裝,它們最終都會(huì)調(diào)用 _PyEval_EvalCodeWithName 函數(shù)。這個(gè)函數(shù)內(nèi)部的邏輯我們就不看了,只需要知道它在執(zhí)行完畢之后棧幀就創(chuàng)建好了,而棧幀才是我們的重點(diǎn),因?yàn)榇a在執(zhí)行期間所依賴的上下文信息全部由棧幀來(lái)維護(hù)。 一旦棧幀對(duì)象初始化完畢,那么就要進(jìn)行處理了,處理的時(shí)候會(huì)調(diào)用以下兩個(gè)函數(shù)。
當(dāng)然啦,上面這兩個(gè)函數(shù)同樣屬于高層的封裝,最終會(huì)調(diào)用 _PyEval_EvalFrameDefault 函數(shù),虛擬機(jī)就是通過(guò)該函數(shù)來(lái)完成字節(jié)碼的執(zhí)行。
到目前為止總共出現(xiàn)了 6 個(gè)函數(shù),用一張圖來(lái)描述一下它們的關(guān)系: 所以 _PyEval_EvalFrameDefault 函數(shù)是虛擬機(jī)運(yùn)行的核心,并且代碼量很大。 可以看到這一個(gè)函數(shù)大概在 3100 行左右,不過(guò)也僅僅是代碼量大而已,因?yàn)樗倪壿嫼芎美斫狻?/span>
該函數(shù)首先會(huì)初始化一些變量,PyCodeObject 對(duì)象包含的信息不用多說(shuō),還有一個(gè)重要的動(dòng)作就是對(duì)指針 stack_pointer 進(jìn)行初始化。stack_pointer 指向運(yùn)行時(shí)棧的棧頂,關(guān)于運(yùn)行時(shí)??赡苡腥藭簳r(shí)還不理解它是做什么的,別急,馬上你就知道它的作用了。
所以對(duì) stack_pointer 初始化的時(shí)候,將它的值初始化為 f->f_stacktop,讓它指向運(yùn)行時(shí)棧的棧頂。但操作運(yùn)行時(shí)棧是通過(guò) stack_pointer 操作的,隨著元素的添加和刪除,棧頂位置會(huì)變,所以后續(xù)它反過(guò)來(lái)還要再賦值給 f_stacktop。 然后棧幀中的 f_code 就是 PyCodeObject 對(duì)象,該對(duì)象的 co_code 字段則保存著字節(jié)碼指令序列。而虛擬機(jī)執(zhí)行字節(jié)碼就是從頭到尾遍歷整個(gè) co_code,對(duì)指令逐條執(zhí)行的過(guò)程。 估計(jì)有人對(duì)棧幀和 PyCodeObject 對(duì)象的底層結(jié)構(gòu)已經(jīng)記不太清了,這里為了方便后續(xù)內(nèi)容的理解,我們將它們的結(jié)構(gòu)再展示一下。 所以字節(jié)碼也叫作指令序列,它就是一個(gè)普普通通的 bytes 對(duì)象,對(duì)于 C 而言則是一個(gè)字符數(shù)組,一條指令就是一個(gè)字符、或者說(shuō)一個(gè)整數(shù)。而在遍歷的時(shí)候會(huì)使用以下兩個(gè)變量:
當(dāng)然別忘記棧幀的 f_lasti 成員,它記錄了上一條已經(jīng)執(zhí)行過(guò)的字節(jié)碼指令的偏移量。
那么這個(gè)動(dòng)作是如何一步步完成的呢?其實(shí)就是一個(gè) for 循環(huán)加上一個(gè)巨大的 switch case 結(jié)構(gòu)。
在這個(gè)執(zhí)行架構(gòu)中,對(duì)字節(jié)碼的遍歷是通過(guò)宏來(lái)實(shí)現(xiàn)的:
首先每條字節(jié)碼指令都會(huì)帶有一個(gè)參數(shù),co_code 中索引為 0 2 4 6 8... 的整數(shù)便是指令,索引為 1 3 5 7 9... 的整數(shù)便是參數(shù)。所以 co_code 里面并不全是字節(jié)碼指令,每條指令后面都還跟著一個(gè)參數(shù)。因此 next_instr 每次向后移動(dòng)兩個(gè)字節(jié),便可跳到下一條指令。
我們?cè)倏匆幌律厦娴暮辏琁NSTR_OFFSET 計(jì)算的顯然就是下一條待執(zhí)行的指令和第一條指令之間的偏移量;然后是 NEXTOPARG,里面的變量 word 就是待執(zhí)行的指令。 當(dāng)然,由于 word 占兩字節(jié),所以也包括了參數(shù)。其中 word 的前 8 位是指令 opcode,后 8 位是參數(shù) oparg。然后在解析出來(lái)指令以及參數(shù)之后,再執(zhí)行 next_instr++,跳到下一條指令。 而接下來(lái)就要執(zhí)行上面剛解析出來(lái)的字節(jié)碼指令了,會(huì)利用 switch 語(yǔ)句對(duì)指令進(jìn)行判斷,根據(jù)判斷的結(jié)果選擇不同的 case 分支。 每一個(gè) case 分支,對(duì)應(yīng)一個(gè)字節(jié)碼指令的實(shí)現(xiàn),不同的指令執(zhí)行不同的 case 分支。所以這個(gè) switch case 語(yǔ)句非常的長(zhǎng),函數(shù)總共 3000 多行,這個(gè) switch 就占了2400行。因?yàn)橹噶罘浅6?,比如:LOAD_CONST, LOAD_NAME, YIELD_FROM等等,而每一個(gè)指令都要對(duì)應(yīng)一個(gè) case 分支。 然后當(dāng)匹配到的 case 分支執(zhí)行完畢時(shí),說(shuō)明當(dāng)前的這一條字節(jié)碼指令就執(zhí)行完畢了,那么虛擬機(jī)的執(zhí)行流程會(huì)跳轉(zhuǎn)到標(biāo)簽 fast_next_opcode 所在位置,或者 for 循環(huán)所在位置。但不管如何,虛擬機(jī)接下來(lái)的動(dòng)作就是獲取下一條字節(jié)碼指令和指令參數(shù),完成對(duì)下一條指令的執(zhí)行。 所以通過(guò) for 循環(huán)一條一條遍歷 co_code 中包含的所有字節(jié)碼指令,然后交給內(nèi)部的 switch 語(yǔ)句、選擇不同的 case 分支進(jìn)行執(zhí)行,如此周而復(fù)始,最終完成了對(duì)整個(gè) Python 程序的執(zhí)行。 盡管目前只是簡(jiǎn)單的分析,但相信你也能大體地了解 Python 執(zhí)行引擎的整體結(jié)構(gòu)。說(shuō)白了 Python 虛擬機(jī)就是將自己當(dāng)成一個(gè) CPU,在棧幀中一條條的執(zhí)行指令,而執(zhí)行過(guò)程中所依賴的常量、變量等,則由棧幀的其它成員來(lái)維護(hù)。 因此在虛擬機(jī)的執(zhí)行流程進(jìn)入了那個(gè)巨大的 for 循環(huán),并取出第一條字節(jié)碼指令交給里面的 switch 語(yǔ)句之后,第一張多米諾骨牌就已經(jīng)被推倒,命運(yùn)不可阻擋的降臨了。一條接一條的指令如同潮水般涌來(lái),浩浩蕩蕩,橫無(wú)際涯。 運(yùn)行時(shí)棧的一些 API 這里先來(lái)簡(jiǎn)單介紹一下運(yùn)行時(shí)棧,它是參數(shù)的容身之所,比如虛擬機(jī)在執(zhí)行 a + b 的時(shí)候,知道這是一個(gè)加法操作。但在執(zhí)行加法的時(shí)候,加號(hào)兩邊的值是多少,它要怎么獲取呢?這時(shí)候就需要一個(gè)棧來(lái)專門保存相應(yīng)的參數(shù)。 在執(zhí)行加法之前,先將 a 和 b 壓入棧中,然后執(zhí)行加法的時(shí)候,再將 a 和 b 從棧里面彈出來(lái)即可。現(xiàn)在有一個(gè)印象,一會(huì)兒我們通過(guò)反編譯查看字節(jié)碼指令的時(shí)候,就一切都清晰了。 然后再來(lái)看看運(yùn)行時(shí)棧相關(guān)的一些 API。 API 非常多,但操作運(yùn)行時(shí)棧都是通過(guò)操作 stack_pointer 實(shí)現(xiàn)的。假設(shè)運(yùn)行時(shí)棧內(nèi)部有三個(gè)元素,從棧底到棧頂分別是整數(shù) 1、2、3,那么運(yùn)行時(shí)棧的結(jié)構(gòu)就是下面這樣。 然后看一下這些和運(yùn)行時(shí)棧相關(guān)的 API 都是干嘛的。 STACK_LEVEL():
返回運(yùn)行時(shí)棧的元素?cái)?shù)量。 EMPTY():
判斷運(yùn)行時(shí)棧是否為空。 TOP():
查看當(dāng)前運(yùn)行時(shí)棧的棧頂元素。 SECOND():
查看從棧頂元素開始的第二個(gè)元素,所以隨著元素不斷添加,棧頂元素也在不斷發(fā)生變化,而 stack_pointer 也在不斷變化。 THIRD():
查看從棧頂元素開始的第三個(gè)元素。 FOURTH():
查看從棧頂元素開始的第四個(gè)元素。 PEEK(n):
查看從棧頂元素開始的第 n 個(gè)元素。 SET_TOP(v):
將當(dāng)前運(yùn)行時(shí)棧的棧頂元素設(shè)置成 v,同理還有 SET_SECOND,SET_THIRD,SET_FOURTH,SET_VALUE。 PUSH(v): 往運(yùn)行時(shí)棧中壓入一個(gè)元素。
假設(shè)當(dāng)前運(yùn)行時(shí)棧有 1、2、3 總共三個(gè)元素,我們往棧里面壓入一個(gè)元素 4,那么運(yùn)行時(shí)棧就會(huì)變成下面這個(gè)樣子。 Python 的變量都是一個(gè)指針,所以 stack_pointer 是一個(gè)二級(jí)指針,它永遠(yuǎn)指向棧頂位置,只不過(guò)棧頂位置會(huì)變。 POP(v): 從運(yùn)行時(shí)棧彈出一個(gè)元素,注意它和 TOP 的區(qū)別,TOP 是返回棧頂元素,但不彈出。
假設(shè)當(dāng)前運(yùn)行時(shí)棧有 1、2、3 總共三個(gè)元素,我們彈出一個(gè)元素,那么運(yùn)行時(shí)棧就會(huì)變成下面這個(gè)樣子。 stack_pointer 指向棧頂位置,所以它向棧底移動(dòng)一個(gè)位置,就相當(dāng)于元素被彈出了。 通過(guò)反編譯查看字節(jié)碼 我們寫一段簡(jiǎn)單的代碼,然后反編譯,看看虛擬機(jī)是如何執(zhí)行字節(jié)碼的。
在編譯的時(shí)候,常量和符號(hào)(變量)都會(huì)被靜態(tài)收集起來(lái),然后我們反編譯一下看看字節(jié)碼,直接通過(guò) dis.dis(co) 即可。結(jié)果如下:
解釋一下每一列的含義:
我們從上到下依次解釋每條指令都干了什么? 0 LOAD_CONST:表示加載一個(gè)常量(指針),并壓入運(yùn)行時(shí)棧。后面的指令參數(shù) 0 表示從常量池中加載索引為 0 的常量,至于 89 則表示加載的常量是 89。所以最后面的括號(hào)里面的內(nèi)容實(shí)際上起到的是一個(gè)提示作用,告訴你加載的對(duì)象是什么。 2 STORE_NAME:表示將 LOAD_CONST 加載的常量用一個(gè)名字綁定起來(lái),放在所在的名字空間中。后面的 0 (chinese) 則表示使用符號(hào)表中索引為 0 的名字(符號(hào)),且名字為 "chinese"。 所以像 chinese = 89 這種簡(jiǎn)單的賦值語(yǔ)句,會(huì)對(duì)應(yīng)兩條字節(jié)碼指令。 然后 4 LOAD_CONST、6 STORE_NAME 和 8 LOAD_CONST、10 STORE_NAME 的作用顯然和上面是一樣的,都是加載一個(gè)常量,然后將某個(gè)符號(hào)和常量綁定起來(lái),并放在名字空間中。 12 LOAD_NAME:加載一個(gè)變量,并壓入運(yùn)行時(shí)棧。而后面的 0 (chinese) 表示加載符號(hào)表中索引為 0 的變量的值,然后這個(gè)變量叫 chinese。14 LOAD_NAME 也是同理,將符號(hào)表中索引為 1 的變量的值壓入運(yùn)行時(shí)棧,并且變量叫 math。此時(shí)棧里面有兩個(gè)元素,從棧底到棧頂分別是 chinese 和 math。 16 BINARY_ADD:將上面兩個(gè)變量從運(yùn)行時(shí)棧彈出,然后執(zhí)行加法操作,并將結(jié)果壓入運(yùn)行時(shí)棧。 18 LOAD_NAME:將符號(hào)表中索引為 2 的變量 english 的值壓入運(yùn)行時(shí)棧,此時(shí)棧里面有兩個(gè)元素,從棧底到棧頂分別是 chinese + math 的返回結(jié)果和 english。 20 BINARY_ADD:將運(yùn)行時(shí)棧里的兩個(gè)元素彈出,然后執(zhí)行加法操作,并將結(jié)果壓入運(yùn)行時(shí)棧。此時(shí)棧里面有一個(gè)元素,就是 chinese + math + english 的返回結(jié)果。 22 LOAD_CONST:將常量 3 壓入運(yùn)行時(shí)棧,此時(shí)棧里面有兩個(gè)元素; 22 BINARY_TRUE_DIVIDE:將運(yùn)行時(shí)棧里的兩個(gè)元素彈出,然后執(zhí)行除法操作,并將結(jié)果壓入運(yùn)行時(shí)棧,此時(shí)棧里面有一個(gè)元素; 24 STORE_NAME:將元素從運(yùn)行時(shí)棧里面彈出,并用符號(hào)表中索引為 3 的變量 avg 和它綁定起來(lái),然后放在名字空間中。 28 LOAD_CONST:將常量 None 壓入運(yùn)行時(shí)棧,然后通過(guò) 30 RETURN_VALUE 將其從棧中彈出,然后返回。 所以 Python 虛擬機(jī)就是把自己想象成一顆 CPU,在棧幀中一條條執(zhí)行字節(jié)碼指令,當(dāng)指令執(zhí)行完畢或執(zhí)行出錯(cuò)時(shí),停止執(zhí)行。 我們通過(guò)幾張圖展示一下上面的過(guò)程,為了閱讀方便,這里將相應(yīng)的源代碼再貼一份:
我們說(shuō)模塊也有自己的作用域,并且是全局作用域,所以虛擬機(jī)也會(huì)為它創(chuàng)建棧幀。而在代碼還沒(méi)有執(zhí)行的時(shí)候,棧幀就已經(jīng)創(chuàng)建好了,整個(gè)布局如下。 這里補(bǔ)充一個(gè)知識(shí)點(diǎn),非常重要,首先我們看到棧幀里面有一個(gè) f_localsplus 屬性,它是一個(gè)數(shù)組。雖然聲明的時(shí)候?qū)懼L(zhǎng)度為 1,但實(shí)際使用時(shí),長(zhǎng)度不受限制,和 Go 語(yǔ)言不同,C 數(shù)組的長(zhǎng)度不屬于類型的一部分。 所以 f_localsplus 是一個(gè)動(dòng)態(tài)內(nèi)存,運(yùn)行時(shí)棧所需要的空間就存儲(chǔ)在里面。但這塊內(nèi)存并不光給運(yùn)行時(shí)棧使用,它被分成了四塊。 函數(shù)的局部變量是靜態(tài)存儲(chǔ)的,那么都存在哪呢?答案是在 f_localsplus 里面,而且是開頭的位置。在獲取的時(shí)候直接基于索引操作即可,因此速度會(huì)更快。所以源碼內(nèi)部還有兩個(gè)宏: fastlocals 就是棧幀的 f_localsplus,而函數(shù)在編譯的時(shí)候就知道某個(gè)局部變量在 f_localsplus 中的索引,所以通過(guò) GETLOCAL 獲取即可。同理 SETLOCAL 則是創(chuàng)建一個(gè)局部變量。 至于 cell 對(duì)象和 free 對(duì)象則是用來(lái)處理閉包的,而 f_localsplus 的最后一塊內(nèi)存則用于運(yùn)行時(shí)棧。 所以 f_localsplus 是一個(gè)數(shù)組,它是一段連續(xù)內(nèi)存,只不過(guò)從邏輯上講,它被分成了四份,每一份用在不同的地方。但它們整體是連續(xù)的,都是數(shù)組的一部分。按照新一團(tuán)團(tuán)長(zhǎng)丁偉的說(shuō)法:彼此是雞犬相聞,但又老死不相往來(lái)。 但我們當(dāng)前是以模塊的方式編譯的,里面所有的變量都是全局變量,而且也不涉及閉包啥的,所以這里就把 f_localsplus 理解為運(yùn)行時(shí)棧即可。 接下來(lái)就開始執(zhí)行字節(jié)碼了,next_instr 指向下一條待執(zhí)行的字節(jié)碼指令,顯然初始狀態(tài)下,下一條待執(zhí)行的指令就是第一條指令。 于是虛擬機(jī)開始加載:0 LOAD_CONST,該指令表示將常量加載進(jìn)運(yùn)行時(shí)棧,而要加載的常量在常量池中的索引,由指令參數(shù)表示。
該指令的參數(shù)為 0,所以會(huì)將常量池中索引為 0 的元素 89 壓入運(yùn)行時(shí)棧,執(zhí)行完之后,棧幀的布局就變成了下面這樣:
接著虛擬機(jī)執(zhí)行 2 STORE_NAME 指令,從符號(hào)表中獲取索引為 0 的符號(hào)、即 chinese。然后將棧頂元素 89 彈出,再將符號(hào) chinese 和整數(shù)對(duì)象 89 綁定起來(lái)保存到 local 名字空間中。
執(zhí)行完之后,棧幀的布局就變成了下面這樣: 此時(shí)運(yùn)行時(shí)棧為空,local 名字空間多了個(gè)鍵值對(duì)。 同理剩余的兩個(gè)賦值語(yǔ)句也是類似的,只不過(guò)指令參數(shù)不同,比如 6 STORE_NAME 加載的是符號(hào)表中索引為 1 的符號(hào),8 STORE_NAME 加載的是符號(hào)表中索引為 2 的符號(hào),分別是 math 和 english。 然后 12 LOAD_NAME 和 14 LOAD_NAME 負(fù)責(zé)將符號(hào)表中索引為 0 和 1 的變量的值壓入運(yùn)行時(shí)棧:
上面兩條指令執(zhí)行完之后,棧幀的布局就變成了下面這樣: 接下來(lái)執(zhí)行 16 BINARY_ADD,它會(huì)將棧里的兩個(gè)元素彈出,然后執(zhí)行加法操作,最后再將結(jié)果入棧。
BINARY_ADD 指令執(zhí)行完之后,棧幀的布局就變成了下面這樣: 然后 18 LOAD_NAME 負(fù)責(zé)將符號(hào)表中索引為 2 的變量 english 的值壓入運(yùn)行時(shí)棧;而指令 20 BINARY_ADD 則是繼續(xù)執(zhí)行加法操作,并將結(jié)果設(shè)置在棧頂;然后 22 LOAD_CONST 將常量 3 再壓入運(yùn)行時(shí)棧。 這三條指令執(zhí)行之后,運(yùn)行時(shí)棧變化如下: 接著是 24 BINARY_TRUE_DIVIDE,它的邏輯和 BINARY_ADD 類似,只不過(guò)一個(gè)執(zhí)行除法,一個(gè)執(zhí)行加法。
24 BINARY_TRUE_DIVIDE 執(zhí)行完之后,運(yùn)行時(shí)棧如下: 然后 26 STORE_NAME 將棧頂元素 93.0 彈出,并將符號(hào)表中索引為 3 的變量 avg 和它綁定起來(lái),放到名字空間中。因此最終棧幀關(guān)系圖如下: 以上就是虛擬機(jī)對(duì)這幾行代碼的執(zhí)行流程,整個(gè)過(guò)程就像 CPU 執(zhí)行指令一樣。 我們?cè)儆?Python 代碼描述一遍上面的邏輯:
現(xiàn)在你是不是對(duì)虛擬機(jī)執(zhí)行字節(jié)碼有一個(gè)更深的了解了呢?當(dāng)然字節(jié)碼指令有很多,不止我們上面看到的那幾個(gè)。你可以隨便寫一些代碼,然后分析一下它的字節(jié)碼指令是什么樣的。
變量賦值時(shí)用到的指令 這里我們來(lái)介紹幾個(gè)在變量賦值的時(shí)候,所用到的指令。因?yàn)槌霈F(xiàn)頻率極高,所以有必要單獨(dú)說(shuō)一下。 下面來(lái)實(shí)際操作一波,看看這些指令: 0 LOAD_CONST:加載字符串常量 "female"; 2 STORE_FAST:在局部作用域中定義一個(gè)局部變量 gender,和字符串對(duì)象 "female" 建立映射關(guān)系,本質(zhì)上就是讓變量 gender 保存這個(gè)字符串對(duì)象的地址; 4 LOAD_GLOBAL:在局部作用域中加載一個(gè)內(nèi)置變量 print; 6 LOAD_FAST:在局部作用域中加載一個(gè)局部變量 gender; 14 LOAD_GLOBAL:在局部作用域中加載一個(gè)全局變量 name; 0 LOAD_CONST:加載字符串常量 "古明地戀"; 2 STORE_GLOBAL:在局部作用域中定義一個(gè)被 global 關(guān)鍵字聲明的全局變量; 0 LOAD_CONST:加載字符串常量 "古明地覺(jué)"; 2 STORE_NAME:在全局作用域中定義一個(gè)全局變量 name,并和上面的字符串對(duì)象進(jìn)行綁定; 4 LOAD_NAME:在全局作用域中加載一個(gè)內(nèi)置變量 print; 6 LOAD_NAME:在全局作用域中加載一個(gè)全局變量 name; 以上我們就通過(guò)代碼實(shí)際演示了這些指令的作用,它們和常量、變量的加載,以及變量的定義密切相關(guān),可以說(shuō)常見(jiàn)的不能再常見(jiàn)了。你寫的任何代碼在反編譯之后都少不了它們的身影,因此有必要提前解釋一下。
變量賦值的具體細(xì)節(jié) 這里再通過(guò)變量賦值感受一下字節(jié)碼的執(zhí)行過(guò)程,首先關(guān)于變量賦值,你平時(shí)是怎么做的呢? 這些賦值語(yǔ)句背后的原理是什么呢?我們通過(guò)字節(jié)碼來(lái)逐一回答。 1)a, b = b, a 的背后原理是什么? 想要知道背后的原理,查看它的字節(jié)碼是我們最好的選擇。
里面關(guān)鍵的就是 ROT_TWO 指令,雖然我們還沒(méi)看這個(gè)指令,但也能猜出來(lái)它負(fù)責(zé)交換棧里面的兩個(gè)元素。假設(shè) a 和 b 的值分別為 22、33,整個(gè)過(guò)程如下: 來(lái)看一下 ROT_TWO 指令。
因此執(zhí)行完 ROT_TWO 指令之后,棧頂元素就是 b,棧的第二個(gè)元素就是 a。然后后面的兩個(gè) STORE_NAME 會(huì)將棧里面的元素 b、a 依次彈出,賦值給 a、b,從而完成變量交換。 2)a, b, c = c, b, a 的背后原理是什么? 老規(guī)矩,還是查看字節(jié)碼,因?yàn)橐磺姓嫦喽茧[藏在字節(jié)碼當(dāng)中。
整個(gè)過(guò)程和 a, b = b, a 是相似的,首先 LOAD_NAME 將變量 c、b、a 依次壓入棧中。由于棧先入后出的特性,此時(shí)棧的三個(gè)元素按照順序(從棧頂?shù)綏5祝┓謩e是 a、b、c。 然后是 ROT_THREE 和 ROT_TWO,毫無(wú)疑問(wèn),這兩個(gè)指令執(zhí)行完之后,會(huì)將棧的三個(gè)元素調(diào)換順序,也就是將 a、b、c 變成 c、b、a。 最后 STORE_NAME 將棧的三個(gè)元素 c、b、a 依次彈出,分別賦值給 a、b、c,從而完成變量的交換。 因此核心就在 ROT_THREE 和 ROT_TWO 上面,由于后者上面已經(jīng)說(shuō)過(guò)了,所以我們看一下 ROT_THREE。
棧頂元素是 top、棧的第二個(gè)元素是 second、棧的第三個(gè)元素是 third,然后將棧頂元素設(shè)置為 second、棧的第二個(gè)元素設(shè)置為 third、棧的第三個(gè)元素設(shè)置為 top。 所以棧里面的 a、b、c 在經(jīng)過(guò) ROT_THREE 之后就變成了 b、c、a,顯然這還不是正確的結(jié)果。于是繼續(xù)執(zhí)行 ROT_TWO,將棧的前兩個(gè)元素進(jìn)行交換,執(zhí)行完之后就變成了 c、b、a。 假設(shè) a、b、c 的值分別為 "a"、"b"、"c",整個(gè)過(guò)程如下: 3)a, b, c, d = d, c, b, a 的背后原理是什么?它和上面提到的 1)和 2)有什么區(qū)別呢? 我們還是看一下字節(jié)碼。
依舊是將等號(hào)右邊的變量,按照從左往右的順序,依次壓入棧中,但此時(shí)沒(méi)有直接將棧里面的元素做交換,而是構(gòu)建一個(gè)元組。因?yàn)橥鶙@锩鎵喝肓怂膫€(gè)元素,所以 BUILD_TUPLE 后面的 oparg 是 4,表示構(gòu)建長(zhǎng)度為 4 的元組。
此時(shí)棧里面只有一個(gè)元素,指向一個(gè)元組。接下來(lái)是 UNPACK_SEQUENCE,負(fù)責(zé)對(duì)序列進(jìn)行解包,它的指令參數(shù)也是 4,表示要解包的序列的長(zhǎng)度為 4,我們來(lái)看看它的邏輯。
最后 STORE_NAME 將 d c b a 依次彈出,賦值給變量 a b c d,從而完成變量交換。所以當(dāng)交換的變量多了之后,不會(huì)直接在運(yùn)行時(shí)棧里面操作,而是將棧里面的元素挨個(gè)彈出,構(gòu)建元組;然后再按照指定順序,將元組里面的元素重新壓到棧里面。 假設(shè)變量 a b c d 的值分別為 1 2 3 4,我們畫圖來(lái)描述一下整個(gè)過(guò)程。 不管是哪一種做法,Python在進(jìn)行變量交換時(shí)所做的事情是不變的,核心分為三步走。首先將等號(hào)右邊的變量,按照從左往右的順序,依次壓入棧中;然后對(duì)運(yùn)行時(shí)棧里面元素的順序進(jìn)行調(diào)整;最后再將運(yùn)行時(shí)棧里面的元素挨個(gè)彈出,還是按照從左往右的順序,再依次賦值給等號(hào)左邊的變量。 只不過(guò)當(dāng)變量不多時(shí),調(diào)整元素位置會(huì)直接基于棧進(jìn)行操作;而當(dāng)達(dá)到四個(gè)時(shí),則需要額外借助于元組。 然后多元賦值也是同理,比如 a, b, c = 1, 2, 3,看一下它的字節(jié)碼。
元組直接作為一個(gè)常量被加載進(jìn)來(lái)了,然后解包,再依次賦值。 4)a, b, c, d = d, c, b, a 和 a, b, c, d = [d, c, b, a] 有區(qū)別嗎? 答案是沒(méi)有區(qū)別,兩者在反編譯之后對(duì)應(yīng)的字節(jié)碼指令只有一處不同。
前者是 BUILD_TUPLE,現(xiàn)在變成了 BUILD_LIST,其它部分一模一樣,所以兩者的效果是相同的。當(dāng)然啦,由于元組的構(gòu)建比列表快一些,因此還是推薦第一種寫法。 5)a = b = c = 123 背后的原理是什么? 如果變量 a、b、c 指向的值相同,比如都是 123,那么便可以通過(guò)這種方式進(jìn)行鏈?zhǔn)劫x值。那么它背后是怎么做的呢?
出現(xiàn)了一個(gè)新的字節(jié)碼指令 DUP_TOP,只要搞清楚它的作用,事情就簡(jiǎn)單了。
所以 DUP_TOP 干的事情就是將棧頂元素拷貝一份,再重新壓到棧里面。另外不管鏈?zhǔn)劫x值語(yǔ)句中有多少個(gè)變量,模式都是一樣的. 我們以 a = b = c = d = e = 123 為例:
將常量壓入運(yùn)行時(shí)棧,然后拷貝一份,賦值給 a;再拷貝一份,賦值給 b;再拷貝一份,賦值給 c;再拷貝一份,賦值給 d;最后自身賦值給 e。 以上就是鏈?zhǔn)劫x值的秘密,其實(shí)沒(méi)有什么好神奇的,就是將棧頂元素進(jìn)行拷貝,再依次賦值。 但是這背后有一個(gè)坑,就是給變量賦的值不能是可變對(duì)象,否則容易造成 BUG。
雖然 Python 一些皆對(duì)象,但對(duì)象都是通過(guò)指針來(lái)間接操作的。所以 DUP_TOP 是將字典的地址拷貝一份,而字典只有一個(gè),因此最終 a、b、c 會(huì)指向同一個(gè)字典。 6)a is b 和 a == b 的區(qū)別是什么? is 用于判斷兩個(gè)變量是不是引用同一個(gè)對(duì)象,也就是保存的對(duì)象的地址是否相等;而 == 則是判斷兩個(gè)變量引用的對(duì)象是否相等,等價(jià)于 a.__eq__(b) 。
這兩個(gè)語(yǔ)句的字節(jié)碼指令是一樣的,唯一的區(qū)別就是指令 COMPARE_OP 的參數(shù)不同。
我們看到指令參數(shù)一個(gè)是 8、一個(gè)是 2,然后是 COMPARE_OP 指令的背后邏輯:
所以邏輯很簡(jiǎn)單,核心就在 cmp_outcome 函數(shù)中。
我們實(shí)際舉個(gè)栗子:
a 和 b 都是 3.14,兩者是相等的,但不是同一個(gè)對(duì)象。 反過(guò)來(lái)也是如此,如果 a is b 成立,那么 a == b 也不一定成立??赡苡腥撕闷?,a is b 成立說(shuō)明 a 和 b 指向的是同一個(gè)對(duì)象,那么 a == b 表示該對(duì)象和自己進(jìn)行比較,結(jié)果應(yīng)該始終是相等的呀,為啥也不一定成立呢?以下面兩種情況為例:
__eq__ 返回 False,此時(shí)雖然是同一個(gè)對(duì)象,但是兩者不相等。
nan 是一個(gè)特殊的浮點(diǎn)數(shù),意思是 not a number(不是一個(gè)數(shù)字),用于表示空值。而 nan 和所有數(shù)字的比較結(jié)果均為 False,即使是和它自身比較。 但需要注意的是,在使用 == 進(jìn)行比較的時(shí)候雖然是不相等的,但如果放到容器里面就不一定了。舉個(gè)例子:
出現(xiàn)以上結(jié)果的原因就在于,元素被放到了容器里,而容器的一些 API 在比較元素時(shí)會(huì)先判定它們存儲(chǔ)的對(duì)象的地址是否相同,即:是否指向了同一個(gè)對(duì)象。如果是,直接認(rèn)為相等;否則,再去比較對(duì)象維護(hù)的值是否相等。
因此 np.nan in lst 的結(jié)果為 True,lst.count(np.nan) 的結(jié)果是 3,因?yàn)樗鼈儠?huì)先比較對(duì)象的地址。地址相同,則直接認(rèn)為對(duì)象相等。
提到 is 和 ==,那么問(wèn)題來(lái)了,在和 True、False、None 比較時(shí),是用 is 還是用 == 呢? 由于 True、False、None 它們不僅是關(guān)鍵字,而且也被看做是一個(gè)常量,最重要的是它們都是單例的,所以我們應(yīng)該用 is 判斷。 另外 is 在底層只需要一個(gè) == 即可完成;但 Python 的 ==,在底層則需要調(diào)用 PyObject_RichCompare 函數(shù)。因此 is 在速度上也更有優(yōu)勢(shì),== 操作肯定比函數(shù)調(diào)用要快。 小結(jié) 以上我們就研究了虛擬機(jī)是如何執(zhí)行字節(jié)碼的,相信你對(duì) Python 虛擬機(jī)也有了更深的了解。說(shuō)白了虛擬機(jī)就是把自己當(dāng)成一顆 CPU,在棧幀中不停地執(zhí)行字節(jié)碼指令。 而執(zhí)行邏輯就是 _PyEval_EvalFrameDefault 里面的那個(gè)大大的 for 循環(huán),for 循環(huán)里面有一個(gè)巨型 switch,case 了所有的分支,不同的分支執(zhí)行不同的邏輯。 |
|
來(lái)自: 古明地覺(jué)O_o > 《待分類》