pyc文件是怎麼創建的?

pyc文件的觸發

前面我們提到,每一個代碼塊(code block)都會對應一個PyCodeObject對象,Python會將該對象存儲在pyc文件中。但不幸的是,事實並不總是這樣。有時,當我們運行一個簡單的程序時並沒有產生pyc文件,因此我們猜測:有些Python程序只是臨時完成一些瑣碎的工作,這樣的程序僅僅只會運行一次,然後就不會再使用了,因此也就沒有保存至pyc文件的必要。

如果我們在代碼中加上了一個import abc這樣的語句,再執行你就會發現Python爲其生成了pyc文件,這就說明import會觸發pyc的生成。

實際上,在運行過程中,如果碰到import abc這樣的語句,那麼Python會在設定好的path中尋找abc.pyc或者abc.pyd文件。如果沒有這些文件,而是隻發現了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。

pyc文件裏面包含哪些內容

上面我們提到,Python通過import module進行加載時,如果沒有找到相應的pyc或者pyd文件,就會在py文件的基礎上自動創建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對象

這個不用說了,肯定是要存儲的。

pyc文件的寫入

下面就來看看pyc文件是如何寫入上面三個內容的。

既然要寫入,那麼肯定要有文件句柄,我們來看看:

//位置:Python/marshal.c

//FILE是 C 自帶的文件句柄
//可以把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和創建時間,它們會調用PyMarshal_WriteLongToFile函數進行寫入:

void
PyMarshal_WriteLongToFile(long x, FILE *fp, int version)
{  
    //magic number和創建時間,只是一個整數
    //在寫入的時候,使用char [4]來保存
    char buf[4];
    //聲明一個WFILE類型變量wf
    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;
    //調用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則是調用 w_byte 將 x 逐個字節地寫到文件裏面去。

而寫入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,也就是代碼中的最後一個else if分支。

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)) {
        //......
    }
    //如果是PyCodeObject對象的話
    //執行PyCodeObject對象的寫入邏輯
    else if (PyCode_Check(v)) {
        //......
    }
    //如果是Buffer的話,執行Buffer的寫入邏輯
    else if (PyObject_CheckBuffer(v)) {
        //......
    }
    else {
        W_TYPE(TYPE_UNKNOWN, p);
        p->error = WFERR_UNMARSHALLABLE;
    }
}

源代碼雖然長,但是邏輯非常單純,就是對不同的對象、執行不同的寫動作,然而其最終目的都是通過w_byte寫到pyc文件中。瞭解完函數的整體結構之後,我們再看一下具體細節,看看它在寫入對象的時候到底寫入了哪些內容?

static void
w_complex_object(PyObject *v, char flag, WFILE *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);
    }    
    //......
}

以列表和字典爲例,它們在寫入的時候實際上寫的是內部的元素,其它對象也是類似的。

def foo():
    lst = [1, 2, 3]

# 把列表內的元素寫進去了
print(
    foo.__code__.co_consts
)  # (None, 1, 2, 3)

但問題來了,如果只是寫入元素的話,那麼Python在加載的時候怎麼知道它是一個列表呢?所以在寫入的時候不能光寫數據,類型信息也要寫進去。我們再看一下上面列表和字典的寫入邏輯,裏面都調用了W_TYPE,它負責將類型信息寫進去。

因此無論對於哪種對象,在寫入具體數據之前,都會先調用W_TYPE將類型信息寫進去。如果沒有類型信息,那麼當Python加載pyc文件的時候,只會得到一坨字節流,而無法解析字節流中隱藏的結構和蘊含的信息。

所以在往pyc文件裏寫入數據之前,必須先寫入一個標識,諸如TYPE_LIST、TYPE_TUPLE、TYPE_DICT等等,這些標識正是對應的類型信息。

如果解釋器在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設計了一種比較複雜的機制,有興趣可以自己閱讀源碼,這裏不再介紹。

PyCodeObject的包含關係

有下面一個文件:

class A:
    pass

def foo():
    pass

顯然編譯之後會創建三個PyCodeObject對象,但是有兩個PyCodeObject對象是位於另一個PyCodeObject對象當中的。

也就是foo和A對應的PyCodeObject對象,位於模塊對應的PyCodeObject對象當中,準確的說是位於co_consts指向的常量池當中。舉個栗子:

def f1():
    def f2():
        pass
    pass

print(
    f1.__code__.co_consts
)  # (None, <code object f2 ...>, 'f1.<locals>.f2')

我們看到f2對應的PyCodeObject確實位於f1的常量池當中,準確的說是f1的常量池中有一個指針指向f2對應的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對象,然後再收集起來。

小結

以上就是pyc文件相關的內容,源文件在編譯之後會得到pyc文件。因此我們不光可以手動導入 pyc,用Python直接執行pyc文件也是可以的。

以上就是本次分享的所有內容,想要了解更多歡迎前往公衆號:Python編程學習圈,每日干貨分享

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章