Python: 淺談函數局部變量"快"在哪

前言

這兩天在 CodeReview 時,看到這樣的代碼segmentfault

# 僞代碼
import somelib
class 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數組

然而印象中這個是有優化空間的,因而提出調整方案:ide

import somelib
class 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: utf8
a = 1
def test(b):
    c = 'test'    
    print a   # 全局變量
    print b   # 局部變量
    print c   # 局部變量

test(3)
# 輸出
1
3
test

簡單來講,局部變量就是隻做用於所在的函數域,超過做用域就被回收code

理解了什麼是局部變量,就須要談談 Python 函數 和 局部變量 的愛恨情仇,由於若是不搞清楚這個,是很難感覺到到底快在哪裏;orm

爲避免枯燥,以上述的代碼來闡述吧,順便附上 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 的數組裏面讀,故名字也這樣取了。

那麼是否存在這樣的一個 LOAD_LOCAL

答案是有的,不過人家不叫這個,而是叫 LOAD_LOCALS,並且這個指令在這裏倒是徹底不一樣的含義,爲什麼?

由於這個指令幾乎不會在函數運行出現,而是在類定義時纔會出現(若其餘同窗發現其餘場景也能看到這個,求分享):

# 測試代碼
class A(object):
    s = 3
# 字節碼
  2           0 LOAD_CONST               0 ('A')
              3 LOAD_NAME                0 (object)
              6 BUILD_TUPLE              1
              9 LOAD_CONST               1 (<code object A at 0x109b2bcb0, file "s.py", line 2>)
             12 MAKE_FUNCTION            0
             15 CALL_FUNCTION            0
             18 BUILD_CLASS
             19 STORE_NAME               1 (A)
             22 LOAD_CONST               2 (None)
             25 RETURN_VALUE

-------------------- 上面 CALL_FUNCTION 執行的內容以下 -------
  2           0 LOAD_NAME                0 (__name__)
              3 STORE_NAME               1 (__module__)

  3           6 LOAD_CONST               0 (3)
              9 STORE_NAME               2 (s)
             12 LOAD_LOCALS
             13 RETURN_VALUE

這裏的 LOAD_NAMESTORE_NAME 打了一套組合拳,把 值 和 符號 關聯了起來,並存到 f->f_locals

那麼問題來了:f->f_locals 是什麼?怎麼存?

這裏的 f 就是一個幀對象,而 f_locals 是它的一個屬性。而這個屬性又比較神奇,在幀對象建立時,會被置爲字典,而在函數機制內,又會被置爲 NULL, 由於在函數機制內,就會用上面那套 fastlocals了。

那麼在這裏,就會引出一個小問題,有個叫 locals() 的函數,來打印局部變量,這又是怎麼回事? 在另外一篇文章已經談到,歡迎移步: https://segmentfault.com/a/11...

接回上文,既然 f->f_locals 是字典,那就按照咱們理解的字典那樣存就行了唄~

這樣就到了久違的 LOAD_LOCALS 了,具體實現:

TARGET_NOARG(LOAD_LOCALS)
 {
     if ((x = f->f_locals) != NULL)
     {
         Py_INCREF(x);
         PUSH(x);
         DISPATCH();
     }
     PyErr_SetString(PyExc_SystemError, "no locals");
     break;
 }

很通俗易懂,就是把剛纔提到的、存了好多符號的 字典,拿出來塞到這個運行時棧 (下文會介紹到這個) 。

塞這個有啥用呢?這煞費苦心的一切,都是爲了別人好啊!這種種的一切,都是爲了 BUILD_CLASS 準備,由於須要利用這些來建立類!

那麼關於類的知識,暫告一段落,下回再分解,我們跑題都快跑出九霄凌外了

那麼主角來了,咱們要重點理解這個,由於這個確實還挺有意思。

Python 函數執行

Python 函數的構建和運行,說複雜不復雜,說簡單也不簡單,由於它須要區分不少狀況,比方說須要區分 函數 和 方法,再而區分是有無參數,有什麼參數,有木有變長參數,有木有關鍵參數。

所有展開仔細講是不可能的啦,不過能夠簡單圖解下大體的流程(忽略參數變化細節):

Python: 淺談函數局部變量"快"在哪

一路順流而下,直達 fast_function,它在這裏的調用是:

// ceval.c -> call_function

x = fast_function(func, pp_stack, n, na, nk);

參數解釋下:

  1. func: 傳入的 test;
  2. pp_stack: 近似理解調用棧 (py方式);
  3. na: 位置參數個數;
  4. nk: 關鍵字個數;
  5. n = na + 2 * nk;
    那麼下一步就看看 fast_function 要作什麼吧。

初始化一波

  1. 定義 co 來存放 test 對象裏面的 func_code
  2. 定義 globals 來存放 test 對象裏面的 func_globals (字典)
  3. 定義 argdefs 來存放 test 對象裏面的 func_defaults (構建函數時的關鍵字參數默認值)

來個判斷,若是 argdefs 爲空 && 傳入的位置參數個數 == 函數定義時候的位置形參個數 && 沒有傳入關鍵字參數

那就

  1. 當前線程狀態coglobals 來新建棧對象 f;
  2. 定義 fastlocals ( fastlocals = f->f_localsplus; );
  3. 把 傳入的參數所有塞進去 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_FUNCTIONLOAD_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。用圖表示就是:

Python: 淺談函數局部變量"快"在哪

有童鞋可能會忽然懵了,爲何忽然來了個 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.test_dict = {}

    def test(self):
        print self.test_dict

這種方式通常是沒什麼問題的,也很規範。到那時若是是下面的操做,那就有問題了:

class SS(object):
    def __init__(self):
        self.test_dict = {}

    def test(self):
        num = 10
        for i in range(num):
            self.test_dict[i] = i

這段代碼的性能損耗,會隨着 num 的值增大而增大, 若是下面循環中還要涉及到更多類屬性的讀取、修改等等,那影響就更大了

這個類屬性若是換成 全局變量,也會存在相似的問題,只是說在操做類屬性會比操做全局變量要頻繁得多。

咱們直接看看二者的差距有多大把?

import timeit
class SS(object):
    def test(self):
        num = 100
        self.test_dict = {}        # 爲了公平,每次執行都一樣初始化新的 {}
        for i in range(num):
            self.test_dict[i] = i

    def test_local(self):
        num = 100
        test_dict = {}             # 爲了公平,每次執行都一樣初始化新的 {}
        for i in range(num):
            test_dict[i] = i
        self.test_dict = test_dict

s = SS()
print timeit.timeit(stmt=s.test_local)
print timeit.timeit(stmt=s.test)

Python: 淺談函數局部變量"快"在哪

經過上圖能夠看出,隨着 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 (test_dict)
             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 (test_dict)
             37 LOAD_FAST                3 (i)
             40 STORE_SUBSCR
             41 JUMP_ABSOLUTE           25
        >>   44 POP_BLOCK

 15     >>   45 LOAD_FAST                2 (test_dict)
             48 LOAD_FAST                0 (self)
             51 STORE_ATTR               1 (test_dict)

上面兩段就是兩個方法的 for block 內容,你們對比下就會知道, s.test相比於 s.test_local, 多了個 LOAD_ATTR 放在 FOR_ITERPOP_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 可以稍微快點點點點點。

QQ技術討論交流羣: 238757010
轉載請註明來源: http://www.javashuo.com/article/p-ympuyneo-ep.html

相關文章
相關標籤/搜索