不知道有沒有小夥伴跟我一樣,剛開始學習 Python 的時候都聽說過 Python 是一種解釋型語言,因為它在運行的時候會逐行解釋並執行,而 C++ 這種是編譯型語言

不過我今天看到了一篇文章,作者提出 Python 其實也有編譯的過程,直譯器會先編譯再執行
不但如此,作者還認為【解釋】與【編譯】是錯誤的二分法、限制了程式語言的可能性。Python 既是解釋型語言,也是編譯型語言!
本文文字較多,乾貨滿滿,耐心看完相信你會有不小的收穫
原文:https://eddieantonio.ca/blog/2023/10/25/python-is-a-compiled-language/
前 言
本文所說的 Python ,不是指 PyPy、Mypyc、Numba、Cinder 等 Python 的替代版本,也不是像 Cython、Codon、mojo1這樣的類 Python 程式語言
我指的是常規的 Python——CPython
目前,我正在編寫一份教材,教學生如何閱讀和理解程序報錯資訊(programming error messages)。我們正在為三種程式語言(C、Python、Java)開設課程
程序報錯資訊的本質的關鍵點之一在於程序報錯是在不同階段生成的,有些是在編譯時生成,有些是在運行時生成
第一門課是針對 C 語言的,具體來說是如何使用 GCC 編譯器,以及演示 GCC 如何將程式碼轉換成可執行程序
預處理(preprocessing)
詞彙分析(lexical analysis)
語法分析(syntactic analysis)
語義分析(semantic analysis)
連結(linking)
除此之外,這節課還討論了在上述階段可能出現的程序報錯,以及這些報錯將如何影響所呈現的錯誤訊息。重要的是:早期階段的錯誤將阻止在後期階段檢測到錯誤(也就是說 A 階段的報錯出現之後,B 階段就算有錯誤也不會檢測出來)
當我將這門課調整成針對 Java 和 Python 時,我發現 Python 和 Java 都沒有前處理器(preprocessor),並且 Python 和 Java 的連結(linking)不是同一個概念
我忽略了上面這些變化,但是我偶然發現了一個有趣的現象:
編譯器在各個階段會生成報錯資訊,而且編譯器通常會在繼續執行之前把前面階段的報錯顯示出來,這就意味著我們可以通過在程序中故意創建錯誤來發現編譯器的各個階段
所以讓我們玩一個小遊戲來發現 Python 直譯器的各個階段
Which is the first error ?
我們將創建一個包含多個 bug 的 Python 程序,每個 bug 都試圖引發不同類型的報錯資訊
我們知道常規的 Python 每次運行只會報告一個錯誤,所以這個遊戲就是——哪條報錯會被首先觸發

每行程式碼都會產生不同的報錯:
1 / 0
將生成ZeroDivisionError: division by zero
print() = None
將生成SyntaxError: cannot assign to function call
if False
將生成SyntaxError: expected ':'
.ñ = "hello
將生成SyntaxError: EOL while scanning string literal
.
問題在於,哪個錯誤會先被顯示出來?需要注意的是:Python 版本很重要(比我想象的要重要),所以如果你看到不同的結果,請記住這一點
PS:下面運行程式碼所使用的 Python 版本為 Python 3.12
在開始執行程式碼之前,先想想【解釋】語言和【編譯】語言對你來說意味著什麼?
下面我將給出一段蘇格拉底式的對話,希望你能反思一下其中的區別
蘇格拉底:編譯語言是指程式碼在運行之前首先通過編譯器的語言。一個例子是 C 程式語言。要運行 C 程式碼,首先必須運行像 orclang
這樣的gcc
編譯器,然後才能運行程式碼。編譯後的語言被轉換為機器程式碼,即 CPU 可以理解的 1 和 0。
柏拉圖:等等,Java不是一種編譯語言嗎?
蘇格拉底:是的,Java是一種編譯語言。
柏拉圖:但是常規 Java編譯器的輸出不是一個
.class
檔案。那是位元組碼,不是嗎?
蘇格拉底:沒錯。位元組碼不是機器碼,但 Java 仍然是一種編譯語言。這是因為編譯器可以捕獲許多問題,因此您需要在程序開始運行之前更正錯誤。
柏拉圖:解釋型語言呢?
蘇格拉底:解釋型語言是依賴於一個單獨的程序(恰如其分地稱為直譯器)來實際運行程式碼的語言。解釋型語言不需要程式設計師先運行編譯器。因此,在程序運行時,您犯的任何錯誤都會被捕獲。Python 是一種解釋型語言,沒有單獨的編譯器,您犯的所有錯誤都會在運行時捕獲。
柏拉圖:如果 Python不是一種編譯語言,那麼為什麼標準庫包含名為
py_compile
and
compileall
的模組?
蘇格拉底:嗯,這些模組只是將 Python轉換為位元組碼。他們不會將 Python 轉換為機器程式碼,因此 Python 仍然是一種解釋型語言。
柏拉圖:那麼,Python和 Java都轉換為位元組碼了嗎?
蘇格拉底:對。
柏拉圖:那麼,為什麼Python是一種解釋型語言,而 Java卻是一種編譯型語言呢?
蘇格拉底:因為 Python 中的所有錯誤都是在運行時捕獲的。(ps:請注意這句話)
回合一
當我們執行上面那段有 bug 的程序時,將會收到下面的錯誤

檢測到的第一個報錯位於原始碼的最後一行。可以看到:在運行第一行程式碼之前,Python 必須讀取整個原始碼檔案
如果你腦子裡有一個關於【解釋型語言】的定義,其中包括」解釋型語言按順序讀取程式碼,一次運行一行」,我希望你忘掉它
我還沒有深入研究 CPython 直譯器的原始碼來驗證這一點,但我認為這是第一個檢測到的報錯的原因是Python 3.12 所做的第一個步驟是掃描(scanning ),也稱為詞法分析
掃描器將整個檔案轉換為一系列標記(token),然後繼續進行下一階段。
掃描器掃描到原始碼最後一行的字串字面值末尾少了個引號,它希望把整個字串字面值轉換成一個 token ,但是沒有結束引號它就轉換不了
在 Python 3.12 中,掃描器首先運行,所以這也是為什麼第一個報錯是unterminated string literal
回合二
我們把第四行的程式碼的 bug 修復好,第 1 2 3 行仍有 bug

我們現在來執行程式碼,看下哪個會首先報錯

這次是第二行報錯!同樣,我沒有去查看 CPython 的原始碼,但是我有理由確定掃描的下一階段是解析(parsing),也稱為語法分析
在運行程式碼之前會先解析原始碼,這意味著 Python 不會看到第一行的錯誤,而是在第二行報錯
我要指出我為這個小遊戲而編寫的程式碼是完全沒有意義的,並且對於如何修復 bug 也沒有正確的答案。我的目的純粹是編寫錯誤然後發現 python 直譯器現在處在哪個階段
我不知道print() = None
可能是什麼意思,所以我將通過將其替換為print(None)
來解決這個問題,這也沒有意義,但至少它在語法上是正確的。
回合三
我們把第二行的語法錯誤也修復了,但原始碼還有另外兩個錯誤,其中一個也是語法錯誤

回想一下,語法錯誤在回合二的時候優先顯示了出來,在回合三還會一樣嗎

沒錯!第三行的語法錯誤優先於第一行的錯誤
正如回合二一樣,Python 直譯器在運行程式碼之前會先解析原始碼,對其進行語法分析
這意味著 Python 不會看到第一行的錯誤,而是在第三行報錯
你可能想知道為什麼我在一個檔案中插入了兩個SyntaxError
,難道一個還不夠表明我的觀點嗎?
這是因為 Python 版本的不同會導致結果的不同,如果你在 Python3.8 或者更早的版本去運行程式碼,那麼結果如下
在 Python 3.8 中,第 2 輪報告的第一個錯誤訊息位於第 3 行:

修復第三行的錯誤之後,Python 3.8 在第 2 行報告以下錯誤訊息:

為什麼 Python 3.8 和 3.12 報錯順序不一樣?是因為 Python 3.9 引入了一個新的解析器。這個解析器比以前的naïve
解析器功能更強大
舊的解析器無法提前查看多個 token,這意味著舊解析器在技術上可以接受語法無效的 Python 程序
尤其是這種限制導致解析器無法識別賦值語句的左邊是否為有效的賦值目標,好比下面這段程式碼,舊解析器能夠接收下面的程式碼

上面這段程式碼沒有任何意義,甚至 Python 語法是不允許這麼使用的。為了解決這個問題,Python 曾經存在過一個獨立的,hacky 的階段(這個 hacky 我不知道用什麼翻譯比較好)
即Python會檢查所有的賦值語句,並確保賦值號左邊實際上是可以被賦值的東西
而這個階段是發生在解析之後,這也就是為什麼舊版本 Python 中會先把第二行的報錯先顯示出來
回合四
現在還剩最後一個錯誤了

我們來運行一下

需要注意的是,Traceback (most recent call last)
表示 Python 運行時報錯的主要內容,這裡在回合四才出現
經過前面的掃描、解析階段,Python 終於能夠運行程式碼了。但是當 Python 開始運行解釋第一行的時候,引發一個名為ZeroDivisionError
的報錯
為什麼知道現在處於【運行時】,因為 Python 已經列印出Traceback (most recent call last)
,這表示我們有一個堆疊跟蹤
堆疊跟蹤只能在運行時存在,這意味著這個報錯必須在運行時捕獲。
但這意味著在回合1~3 中遇到的報錯不是運行時報錯,那它們是什麼報錯?
Python 既是解釋型語言,也是編譯型語言
沒錯!CPython 直譯器實際上是一個直譯器,但它也是一個編譯器
我希望上面的練習已經說明了 Python 在運行第一行程式碼之前必須經過幾個階段:
掃描(scanning )
解析(parsing )
舊版本的 Python 多了一個額外階段:
掃描(scanning )
解析(parsing )
檢查有效的分配目標(checking for valid assignment targets)
讓我們將其與前面編譯 C 程序的階段進行比較:
預處理
詞彙分析(「掃描」的另一個術語)
語法分析(「解析」的另一個術語)
語義分析
連結
Python 在運行任何程式碼之前仍然執行一些編譯階段,就像 Java一樣,它會把原始碼編譯成位元組碼
前面三個報錯是 Python 在編譯階段產生的,只有最後一個才是在運行時產生,即ZeroDivisionError: division by zero
.
實際上,我們可以使用命令列上的compileall
模組預先編譯所有 Python 程式碼:

這會將當前目錄中所有 Python 檔案的編譯位元組碼放入其中__pycache__/
,並顯示任何編譯器錯誤
如果你想知道那個__pycache__/
檔案夾中到底有什麼,我為 EdmontonPy 做了一個演講,你應該看看!
演講地址:https://www.youtube.com/watch?v=5yqUTJuFuUk&t=7m11s
只有在 Python 被編譯為位元組碼之後,直譯器才會真正啟動,我希望前面的練習已經證明 Python 確實可以在運行時之前報錯
編譯語言和解釋語言是錯誤的二分法
每當一種程式語言被歸類為【編譯】或【解釋】語言時,我都會感到很討厭。一種語言本身不是編譯或解釋的
一種語言是編譯還是解釋(或兩者兼而有之!)是一個實現細節
我不是唯一一個有這種想法的人。Laurie Tratt 有一篇精彩的文章,通過編寫一個逐漸成為最佳化編譯器的直譯器來論證這一點
文章地址:https://tratt.net/laurie/blog/2023/compiled_and_interpreted_languages_two_ways_of_saying_tomato.html
還有一篇文章就是 Bob Nystrom 的 Crafting Interpreters。以下是第 2 章的一些引述:
編譯器和直譯器有什麼區別?
事實證明,這就像問水果和蔬菜之間的區別一樣。這似乎是一個二元的非此即彼的選擇,但實際上「水果」是一個植物學術語,而「蔬菜」是烹飪學術語。
嚴格來說,一個並不意味著對另一個的否定。有些水果不是蔬菜(蘋果),有些蔬菜不是水果(胡蘿蔔),但也有既是水果又是蔬菜的可食用植物,如西紅柿
當你使用 CPython 來運行 Python 程序時,原始碼會被解析並轉換成內部位元組碼格式,然後在虛擬機器中執行
從使用者的角度來看,這顯然是一個直譯器(因為它們從原始碼運行程序),但如果你仔細觀察 CPython(Python 也可譯作蟒蛇)的鱗狀表皮(scaly skin),你會發現它肯定在進行編譯
答案是:CPython 是一個直譯器,它有一個編譯器
那麼為什麼這很重要呢?為什麼在【編譯】和【解釋】語言之間做出嚴格的區分會適得其反?
【編譯】與【解釋】限制了我們認為程式語言的可能性
程式語言不必由它是編譯還是解釋來定義的!以這種僵化的方式思考限制了我們認為給定的程式語言可以做的事情
例如,JavaScript 通常被歸入「解釋型語言」類別。但有一段時間,在 Google Chrome 中運行的 JavaScript 永遠不會被解釋——相反,JavaScript 被直接編譯為機器程式碼!因此,JavaScript 可以跟上 C++ 的步伐
出於這個原因,我真的厭倦了那些說解釋型語言必然慢的論點——性能是多方面的,並且不僅僅取決於”默認”程式語言的實現
JavaScript 現在很快了、Ruby 現在很快了、Lua 已經快了一段時間了
那對於通常被標記為編譯型語言的程式語言呢?(例如 C)你是不會去想著解釋 C 語言程序的
語言之間真正的區別
語言之間真正的區別:【靜態】還是【動態】
我們應該教給學生的真正區別是語言特性的區別,前者可以靜態地確定,即只盯著程式碼而不運行程式碼,後者只能在運行時動態地知道
需要注意的是,我說的是【語言特性】而不是【語言】,每種程式語言都選擇自己的一組屬性,這些屬性可以靜態地或動態地確定,並結合在一起,這使得語言更「動態」或更「靜態」
靜態與動態是一個範圍,Python 位於範圍中更動態的一端。像 Java 這樣的語言比 Python 有更多的靜態特性,但即使是 Java 也包括反射之類的東西,這無疑是一種動態特性
我發現動態與靜態經常被混為一談,編譯與解釋混為一談,這是可以理解的
因為通常使用直譯器的語言具有更多的動態特性,如 Python、Ruby 和 JavaScript
具有更多靜態特性的語言往往在沒有直譯器的情況下實現,例如 C++ 和 Rust
然後是介於兩者之間的 Java
Python 中的靜態類型註釋已經逐漸(呵呵)在程式碼庫中得到採用,其中一個期望是:由於更多靜態的東西,這可以解鎖 Python 程式碼中的性能優勢
不幸的是,事實證明,Python 中的類型(是的,只是一般類型,考慮元類)和註釋本身都是Python 的動態特性,這使得靜態類型不是大夥所期望的性能優勢
最後總結一下:
CPython 是一個直譯器,它有一個編譯器(或者說 Python 既是解釋型語言,也是編譯型語言)
Python 是編譯的還是解釋的並不重要。重要的是,相對於那些具有更多靜態屬性(在編譯或解釋階段可以在運行前確定的屬性)的程式語言,Python 中可以在運行前確定的屬性相對較少,這意味著在 Python 中,許多屬性是在運行時動態確定的,而不是在編譯或解釋時靜態確定的
由於 Python 具有較少的靜態屬性,這意味著在運行時,某些錯誤可能只能在運行時才會顯現,而不是在編譯或解釋時就能被發現
這是真正重要的區別,這是一個比【編譯】和【解釋】更細緻、更微妙的區別。出於這個原因,我認為強調特定的靜態和動態特性是很重要的,而不是一昧的侷限於「解釋型」和「編譯型」語言之間的繁瑣的區別。
●Instagram 早期技術架構
●sed 原地替換檔案時遇到的趣事
●Linux 中如何修改終端提示符顏色?
●SELinux 入門 pt.1