本篇文章給大家帶來的內容是關於Python函數局部變數如何執行?淺析python函數變數的應用 ,有一定的參考價值,有需要的朋友可以參考一下,希望對你有所協助。
前言
這兩天在 CodeReview 時,看到這樣的代碼
# 虛擬碼import somelibclass A(object): def load_project(self): self.project_code_to_name = {} for project in somelib.get_all_projects(): self.project_code_to_name[project] = project ...
意圖很簡單,就是將 somelib.get_all_projects
擷取的項目塞入的 self.project_code_to_name
然而印象中這個是有最佳化空間的,於是提出調整方案:
import somelibclass A(object): def load_project(self): project_code_to_name = {} for project in somelib.get_all_projects(): project_code_to_name[project] = project self.project_code_to_name = project_code_to_name ...
方案很簡單,就是先定義局部變數 project_code_to_name
,操作完,再賦值到self.project_code_to_name
。
在後面的測試,也確實發現這樣是會好點,那麼結果知道了,接下來肯定是想探索原因的!
局部變數
其實在網上很多地方,甚至很多書上都有講過一個觀點:訪問局部變數速度要快很多,粗看好像好有道理,然後又看到下面貼了一大堆測試資料,雖然不知道是什麼,但這是真的屌,記住再說,管他呢!
但是實際上這個觀點還是有一定的局限性,並不是放諸四海皆準。所以先來理解下這句話吧,為什麼大家都喜歡這樣說。
先看段代碼理解下什麼是局部變數:
#coding: utf8a = 1def test(b): c = 'test' print a # 全域變數 print b # 局部變數 print c # 局部變數test(3)
# 輸出13test
簡單來說,局部變數就是只作用於所在的函數域,超過範圍就被回收
理解了什麼是局部變數,就需要談談 Python 函數 和 局部變數 的愛恨情仇,因為如果不搞清楚這個,是很難感受到到底快在哪裡;
為避免枯燥,以上述的代碼來闡述吧,順便附上 test 函數執行 的 dis 的解析:
# CALL_FUNCTION 5 0 LOAD_CONST 1 ('test') 3 STORE_FAST 1 (c) 6 6 LOAD_GLOBAL 0 (a) 9 PRINT_ITEM 10 PRINT_NEWLINE 7 11 LOAD_FAST 0 (b) 14 PRINT_ITEM 15 PRINT_NEWLINE 8 16 LOAD_FAST 1 (c) 19 PRINT_ITEM 20 PRINT_NEWLINE 21 LOAD_CONST 0 (None) 24 RETURN_VALUE
在中比較清楚能看到 a、b、c 分別對應的指令塊,每一塊的第一行都是 LOAD_XXX
,顧名思義,是說明這些變數是從哪個地方擷取的。
LOAD_GLOBAL
毫無疑問是全域,但是 LOAD_FAST
是什麼鬼?似乎應該叫LOAD_LOCAL
吧?
然而事實就是這麼神奇,人家就真的是叫 LOAD_FAST
,因為局部變數是從一個叫 fastlocals
的數組裡面讀,所以名字也就這樣叫了(我猜的)。
那麼主角來了,我們要重點理解這個,因為這個確實還挺有意思。
Python 函數執行
Python 函數的構建和運行,說複雜不複雜,說簡單也不簡單,因為它需要區分很多情況,比方說需要區分 函數 和 方法,再而區分是有無參數,有什麼參數,有木有變長參數,有木有關鍵參數。
全部展開仔細講是不可能的啦,不過可以簡單圖解下大致的流程(忽略參數變化細節):
一路順流而下,直達 fast_function
,它在這裡的調用是:
// ceval.c -> call_functionx = fast_function(func, pp_stack, n, na, nk);
參數解釋下:
func: 傳入的 test
;
pp_stack: 近似理解調用棧 (py方式);
na: 位置參數個數;
nk: 關鍵字個數;
n = na + 2 * nk;
那麼下一步就看看 fast_function
要做什麼吧。
初始化一波
定義 co 來存放 test 對象裡面的 func_code
定義 globals 來存放 test 對象裡面的 func_globals
(字典)
定義 argdefs 來存放 test 對象裡面的 func_defaults
(構建函數時的關鍵字參數預設值)
來個判斷,如果 argdefs 為空白
&& 傳入的位置參數個數 == 函數定義時候的位置形參個數
&& 沒有傳入關鍵字參數
那就
用 當前線程狀態
、co
、globals
來建立棧對象 f
;
定義fastlocals
( fastlocals = f->f_localsplus; );
把 傳入的參數全部塞進去 fastlocals
那麼問題來了,怎麼塞?怎麼找到傳入了什麼鬼參數:這個問題還是只能有 dis
來解答:
我們知道現在這步是在 CALL_FUNCTION
裡面進行的,所以塞參數的動作,肯定是在此之前的,所以:
12 27 LOAD_NAME 2 (test) 30 LOAD_CONST 4 (3) 33 CALL_FUNCTION 1 36 POP_TOP 37 LOAD_CONST 1 (None) 40 RETURN_VALUE
在 CALL_FUNCTION
上面就看到 30 LOAD_CONST 4 (3)
,有興趣的童鞋可以試下多傳幾個參數,就會發現傳入的參數,是依次通過LOAD_CONST
這樣的方式載入進來,所以如何找參數的問題就變得呼之欲出了;
// fast_function 函數fastlocals = f->f_localsplus;stack = (*pp_stack) - n; for (i = 0; i < n; i++) { Py_INCREF(*stack); fastlocals[i] = *stack++; }
這裡出現的 n 還記得怎麼來的嗎?回顧上面有個 n = na + 2 * nk;
,能想起什麼嗎?
其實這個地方就是簡單的通過將 pp_stack
位移 n 位元組 找到一開始塞入參數的位置。
那麼問題來了,如果 n 是 位置參數個數 + 關鍵字參數,那麼 2 * nk 是什麼意思?其實這答案很簡單,那就是 關鍵字參數位元組碼 是屬於帶參數位元組碼, 是占 2位元組。
到了這裡,棧對象 f
的 f_localsplus
也登上曆史舞台了,只是此時的它,還只是一個未經人事的少年,還需曆練。
做好這些動作,終於來到真正執行函數的地方了: PyEval_EvalFrameEx
,在這裡,需要先交代下,有個和 PyEval_EvalFrameEx
很像的,叫 PyEval_EvalCodeEx
,雖然長得像,但是人家幹得活更多了。
請看回前面的 fast_function
開始那會有個判斷,我們上面說得是判斷成立的,也就是最簡單的函數執行情況。如果函數傳入多了關鍵字參數或者其他情況,那就複雜很多了,此時就需要由 PyEval_EvalCodeEx
處理一波,再執行 PyEval_EvalFrameEx
。
PyEval_EvalFrameEx
主要的工作就是解析位元組碼,像剛才的那些 CALL_FUNCTION
,LOAD_FAST
等等,都是由它解析和處理的,它的本質就是一個死迴圈,然后里面有一堆 swith - case
,這基本也就是 Python 的運行本質了。
f_localsplus 存 和 取
講了這麼長的一堆,算是把 Python 最基本的 函數調用過程簡單掃了個盲,現在才開始探索主題。。
為了簡單闡述,直接引用名詞:fastlocals
, 其中 fastlocals = f->f_localsplus
剛才只是簡單看到了,Python 會把傳入的參數,以此塞入 fastlocals
裡面去,那麼毋庸置疑,傳入的位置參數,必然屬於局部變數了,那麼關鍵字參數呢?那肯定也是局部變數,因為它們都被特殊對待了嘛。
那麼除了函數參數之外,必然還有函數內部的賦值咯? 這塊位元組碼也一早在上面給出了:
# CALL_FUNCTION 5 0 LOAD_CONST 1 ('test') 3 STORE_FAST 1 (c)
這裡出現了新的位元組碼 STORE_FAST
,一起來看看實現把:
# PyEval_EvalFrameEx 龐大 switch-case 的其中一個分支: PREDICTED_WITH_ARG(STORE_FAST); TARGET(STORE_FAST) { v = POP(); SETLOCAL(oparg, v); FAST_DISPATCH(); }# 因為有涉及到宏,就順便給出:#define GETLOCAL(i) (fastlocals[i])#define SETLOCAL(i, value) do { PyObject *tmp = GETLOCAL(i); \ GETLOCAL(i) = value; \ Py_XDECREF(tmp); } while (0)
簡單解釋就是,將 POP() 獲得的值 v,塞到 fastlocals 的 oparg 位置上。此處,v 是 "test", oparg 就是 1。用圖表示就是:
有童鞋可能會突然懵了,為什麼突然來了個 b
?我們又需要回到上面看 test 函數是怎樣定義的:
// 我感覺往回看的機率超低的,直接給出算了def test(b): c = 'test' print b # 局部變數 print c # 局部變數
看到函數定義其實都應該知道了,因為 b
是傳的參數啊,老早就塞進去了~
那儲存知道了,那麼怎麼取呢?同樣也是這段代碼的位元組碼:
22 LOAD_FAST 1 (c)
雖然這個用腳趾頭想想都知道原理是啥,但公平起見還是給出相應的代碼:
# PyEval_EvalFrameEx 龐大 switch-case 的其中一個分支:TARGET(LOAD_FAST){ x = GETLOCAL(oparg); if (x != NULL) { Py_INCREF(x); PUSH(x); FAST_DISPATCH(); } format_exc_check_arg(PyExc_UnboundLocalError, UNBOUNDLOCAL_ERROR_MSG, PyTuple_GetItem(co->co_varnames, oparg)); break;}
直接用 GETLOCAL
通過索引在數組裡取值了。
到了這裡,應該也算是把 f_localsplus
講明白了。這個地方不難,其實一般而言是不會被提及到這個,因為一般來說忽略即可了,但是如果說想在效能方面講究點,那麼這個小知識就不得忽視了。
變數使用姿勢
因為是物件導向,所以我們都習慣了通過 class
的方式,對於下面的使用方式,也是隨手就來:
class SS(object): def __init__(self): self.fuck = {} def test(self): print self.fuck
這種方式一般是沒什麼問題的,也很規範。到那時如果是下面的操作,那就有問題了:
class SS(object): def __init__(self): self.fuck = {} def test(self): num = 10 for i in range(num): self.fuck[i] = i
這段代碼的效能損耗,會隨著 num 的值增大而增大, 如果下面迴圈中還要涉及到更多類屬性的讀取、修改等等,那影響就更大了
這個類屬性如果換成 全域變數,也會存在類似的問題,只是說在操作類屬性會比操作全域變數要頻繁得多。
我們直接看看兩者的差距有多大把?
import timeitclass SS(object): def test(self): num = 100 self.fuck = {} # 為了公平,每次執行都同樣初始化新的 {} for i in range(num): self.fuck[i] = i def test_local(self): num = 100 fuck = {} # 為了公平,每次執行都同樣初始化新的 {} for i in range(num): fuck[i] = i self.fuck = fucks = SS()print timeit.timeit(stmt=s.test_local)print timeit.timeit(stmt=s.test)
通過可以看出,隨著 num 的值越大,for 迴圈的次數就越多,那麼兩者的差距也就越大了。
那麼為什麼會這樣,也是在位元組碼可以看出寫端倪:
// s.test >> 28 FOR_ITER 19 (to 50) 31 STORE_FAST 2 (i) 8 34 LOAD_FAST 2 (i) 37 LOAD_FAST 0 (self) 40 LOAD_ATTR 0 (hehe) 43 LOAD_FAST 2 (i) 46 STORE_SUBSCR 47 JUMP_ABSOLUTE 28 >> 50 POP_BLOCK// s.test_local >> 25 FOR_ITER 16 (to 44) 28 STORE_FAST 3 (i) 14 31 LOAD_FAST 3 (i) 34 LOAD_FAST 2 (hehe) 37 LOAD_FAST 3 (i) 40 STORE_SUBSCR 41 JUMP_ABSOLUTE 25 >> 44 POP_BLOCK 15 >> 45 LOAD_FAST 2 (hehe) 48 LOAD_FAST 0 (self) 51 STORE_ATTR 1 (hehe)
上面兩段就是兩個方法的 for block
內容,大家對比下就會知道, s.test
相比於 s.test_local
, 多了個 LOAD_ATTR
放在 FOR_ITER
和 POP_BLOCK
之間。
這說明什麼呢? 這說明,在每次迴圈時,s.test
都需要 LOAD_ATTR
,很自然的,我們需要看看這個是幹什麼的:
TARGET(LOAD_ATTR){ w = GETITEM(names, oparg); v = TOP(); x = PyObject_GetAttr(v, w); Py_DECREF(v); SET_TOP(x); if (x != NULL) DISPATCH(); break; }# 相關宏定義#define GETITEM(v, i) PyTuple_GetItem((v), (i))
這裡出現了一個陌生的變數 name
, 這是什嗎?其實這個就是每個 codeobject 所維護的一個 名字數組,基本上每個塊所使用到的字串,都會在這裡面存著,同樣也是有序的:
// PyCodeObject 結構體成員PyObject *co_names; /* list of strings (names used) */
那麼 LOAD_ATTR
的任務就很清晰了:先從名字列表裡面取出字串,結果就是 "hehe", 然後通過 PyObject_GetAttr 去尋找,在這裡就是在 s 執行個體中去尋找。
且不說尋找效率如何,光多了這一步,都能失之毫釐差之千裡了,當然這是在頻繁操作次數比較多的情況下。
所以我們在一些會頻繁操作 類/執行個體屬性
的情況下,應該是先把 屬性
取出來存到 局部變數
,然後用 局部變數
來完成操作。最後視情況把變動更新到屬性
上。
最後
其實相比變數,在函數和方法的使用上面更有學問,更值得探索,因為那個原理和表面看起來差別更大,下次有機會再探討。平時工作多注意下,才能使得我們的 PY 能夠稍微快點點點點點。