一次Python內部的探險

Nikkko發表於2015-02-14

我最近花了一些時間在探索CPython,並且我想要在這裡分享我的一些冒險經歷。Allison Kaptur的excellent guide to getting started with Python internals 有一點囉嗦,我想逐步介紹我自己的探索過程會更加有條理性,這樣也許其他好奇的Python使用者可以跟著一起做。

1.注意到了一些奇怪的事情

一開始,我只是設定好Nose對一些我寫的Python 3程式碼進行測試。當我執行這些測試的時候,我得到了一個不可思議的錯誤資訊:”TypeError: bad argument type for built-in operation”,這是我之前在這個程式裡沒有見到過的。

最終造成這個錯誤的原因有一點顯而易見——我不小心在程式裡留了一個PDB斷點(import pdb; pdb.set_trace())。當我把它去掉後,測試正常執行了。

但是,我曾經使用Nose在Python 2 repos上進行測試,並且在那種情況下,錯誤留下的斷點並不會導致Nose崩潰,而是看上去像是“掛起”了。程式並不是真的掛起了——它僅僅是不顯示東西到stdout(標準輸出)了。Nose是故意這樣做的,而當我正在執行一套測試的時候這樣做是有意義的。我可能僅僅是想看測試的結果,而不是一大堆程式自己列印出來的狀態。如果你在這個指令碼里面敲擊“c”,Nose僅僅像通常那樣經過這個斷點。

通常情況下,我可能只是聳聳肩,移除掉這個斷點,然後繼續我的工作。但是!我在一個黑客學校並且有時間深入研究任何抓住我興趣的東西,所以我決定利用這次機會去窺探一下Python的核心。

2.製造一個最簡單的測試樣例。

結果這次的問題研究起來有一點複雜——我並不能確定問題是在Nose,還是PDB或者CPython自己的程式碼裡面。並且,我當然不能使用任何斷點,因為這些斷點會導致我的程式崩潰。

最終,在驗證了一些假設後,看上去PDB對input()的呼叫導致了崩潰。所以:在Python2和Python3裡面,input的實現有什麼不同嗎?或者是其他的某些東西不同?

我和Jesse一起進行除錯,最後我們意識到Nose以一種有趣的方式處理標準輸出:

這裡用sys.stout表示Python中的所有標準輸出,即表示所有向終端輸出的內容都會傳送到這裡。但由於我們可以像訪問其他Python變數那樣訪問sys.stout,所以我們可以改變這個sys.stout。而Nose將sys.stoud設定為StringIO(),而這只是任意一個字串。

如果你這麼做,print函式就不會工作了!

我們懷疑是否那一行就是問題所在,所以我們構造了一個簡單的測試樣例:

在Python 3 裡面執行這個會出現我們之前看到過的”bad argument for built-in operation”。所以現在我們知道該調查哪裡了!當你試圖改變sys.stdout的時候,內建函式input()以一種奇怪的方式中斷下來。

3.瞭解一點CPython!

所以我們想要看下‘input’是怎樣實現的。Python有一個非常酷的模組叫做’inspect’,能讓你檢查原始碼,像這樣:

然而當你想要對’input’呼叫’inspect.getsource’的時候,結果會是:“TypeError: is not a module, class, method, function, traceback, frame, or code object.”這意味著我們的函式不是用Python實現的——它是用C語言實現的,因此’inspect;模組不能為我們顯示它的程式碼。

……但是,利用cinspect模組的魔力,我們能檢視C原始碼!

很好,現在我們知道我們想要找的函式叫做’builtin_input’。這時,我們將要開始瀏覽C程式碼了,而不僅僅是Python程式碼,我們將要在中端除錯而不是在Python的直譯器裡。你不需要一定是一個C語言專家才能看明白接下來的東西——我大多數時候會以根據函式名稱進行推測的方式進行。

那麼,讓我們來檢索一下Cpython的原始碼,然後我們將發現’builtin_input’是’builtin_input_impl’的封裝,而’builtin_input_impl’是一個在bltinmodule.c裡面實現的一個方法。讓我們嘗試將Python載入到lldb C語言偵錯程式裡面並在那個方法的開頭設定一個斷點:

當單步步過原始碼的時候(這個過程和你在PDB裡面做的事情很像——不停敲擊”n”來執行下一行程式碼),我們發現問題第一次出現的那點程式碼:

第三行誤導了我:“如果編碼字串是空或者錯誤字串是空,那麼我們會得到一個錯誤”。但是,請等一下,難道一個空的錯誤字串不是意味著沒有錯誤被發現嗎?

因為這個,我進一步檢視了_PyUnicode_AsString的定義(另一個C函式):

那僅僅是一個巨集:“嘿,當我們呼叫_PyUnicode_AsString的時候,去呼叫PyUnicode_AsUTF8。”所以我們真正想要找的是PyUnicode_AsUTF8的定義:

……看上去這個函式所做的所有的事情是呼叫PyUnicode_AsUTF8AndSize,而這正是我們真正想要去閱讀的。

在PyUnicode_AsUTF8AndSize函式裡面有若干個錯誤情況,每一個都返回NULL。在錯誤情況裡面返回NULL而不是返回像-1這樣的錯誤程式碼對我來說很奇怪。也許這裡有其他我不熟悉的約定的考慮?

不管怎麼樣,為了顯示出我究竟陷入了哪一個錯誤情況,我進行了“列印除錯”——我在每一個可能的錯誤情況後面加入了一個列印語句,然後執行程式——這樣我們就能發現當我們呼叫PyUnicode_Check到底錯在了哪裡。

那麼,是否有在Python3裡面進行了而沒有在Python2裡面進行過的的檢查呢?嗯,我們能比較兩個版本的原始碼來找出這個答案。最後顯示出,Python 2 的原始碼沒有進行類似的編碼檢查,然而Python 3做了。所以,如果sys.stdout被錯誤編碼的東西代替了,它會在3裡面執行失敗,在2裡面就不會。

4.收穫!

看上去僅僅是找出一個非常普通的固定的BUG後面的原因,就做了非常多的工作。並且也許確實是這樣。但是!我們在這個過程中學到了一些很酷的東西。當我在驗證一些假設的時候我發現了很多Python處理標準輸入輸出的方式。我學到了更多如何閱讀大型的、很多巨集的C工程的經驗。我學到了GOTO語句仍然在使用,這讓我感到很吃驚。但是在連貫性上這樣做是有意義的——看上去如果不用GOTO在C裡面做一些像是異常處理的事情的時候將變的很繁瑣。並且瀏覽bltinmodule.c的input 函式在Python2 和Python3中的不同真的是一件很酷的事情——嚴格上來說,是檢查。他們重構和清理東西看上去很簡潔。

在Python裡面引用計數,我也發現了一些超級有意思的事情,我會在另外一篇文章中講。

(並且,非常感謝Leta幫助我編輯這篇文章的草稿!)

宣告:設定cinspect有一點複雜。在這個專案的README裡面的介紹會有一些幫助,但是注意“indexing your sources”這一步將會花很多時間。

如果你之前習慣使用gdb,那麼你僅僅需要知道的是lldb和它非常相似。如果你兩個都沒有用過,他們在除錯上有一點像PDB。

相關文章