昊昊是一個前端工程師,最近涉及到工程化領域,想了解一些編譯的知識。剛好我比他研究的早一些,因此把我瞭解的東西給他介紹了一遍,因而就有了下面的對話。javascript
昊昊: 最近想了解一些編譯的東西,光哥,編譯究竟是什麼啊?css
我: 編譯啊就是一種轉換技術,從一門編程語言到另外一門編程語言,從高級語言轉換成低級語言,或者從高級語言到高級語言,這樣的轉換技術。html
昊昊: 什麼是高級語言,什麼是低級語言啊?前端
我: 低級語言
是與機器有關的,涉及到寄存器、cpu指令等,特別「低」,描述具體在機器上的執行過程,好比機器語言、彙編語言、字節碼等。高級語言
則沒有這些具體執行的東西,主要用來表達邏輯,並且提供了條件、循環、函數、面向對象等特性來組織邏輯,而後經過編譯來把這些描述好的高級語言邏輯自動轉換爲低級語言的指令,這樣既可以方便的表達邏輯,又不影響具體執行。說不影響執行也不太對,由於若是直接寫彙編,能寫出效率最高的代碼,可是若是是高級語言經過編譯來自動轉換爲低級語言,那麼就難以保證生成代碼的執行效率了,須要各類編譯優化,這是編譯領域的難點。vue
其實想一想,咱們把腦中的想法,把制訂好的方案轉換爲高級語言代碼,這個過程是否是也是轉換,可不能夠自動化呢,這就涉及到ai了。如今有理解需求文檔生成代碼的智能化技術的研究方向。java
昊昊: 那具體是怎麼轉換的呢?python
我: 要轉換首先得了解轉換的雙方,要轉換的是什麼,轉換到什麼。好比高級語言到高級語言,要轉換的是字符串,按照必定的格式組織的,這些格式分別叫作詞法、語法,總體叫作文法,那要轉換的目標呢,目標若是也是高級語言那麼要了解目標語言的格式,若是目標是低級語言,好比彙編,那要了解每條指令時幹啥的。而後就要進行語義等價的轉換,注意這個「語義等價」
,經過一門語言解釋另外一門語言,不能丟失或者添加一些語義,必定要先後一致才能夠。react
知道了轉換的雙方都是什麼,就能夠進行轉換了,首先得讓計算機理解要轉換的東西,什麼叫「計算機理解「
呢?就是把咱們規定的那些詞法、語法格式告訴計算機,怎麼告訴呢?就是數據結構,要按照必定的數據結構把源碼字符串解析後的結果組織起來,計算機就能處理了。 這個過程叫作 parse,要先分詞,再構形成語法樹。linux
其實不僅是編譯領域須要「理解」,頗有不少別的領域也要「理解」:webpack
全文搜索引擎也要先把搜索的字符串經過分詞器分詞,而後根據這些詞去用一樣分詞器分詞並作好索引的數據庫中去查,對詞的匹配結果進行打分排序,這樣就是全文搜索。
人工智能領域要處理的是天然語言,他也要按照詞法、語法、句法等等去「理解」,變成必定的數據結構以後,計算機才懂才能處理,而後就是各類處理算法的介入了。
分詞是按照狀態機來分的(有限狀態機 DFA
),這個是幹啥的,爲啥分詞須要它,我知道你確定有疑問。 由於詞法描述的是最小的單詞的格式,好比標識符不能以數字開頭,而後後面加字母數字下劃線等,這種,還有關鍵字 if、while、continue 等,這些不能再細分了,再細分沒意義啊。分詞就是把字符串變成一個個的最小單元的不能再拆的單詞,也叫 token,由於不一樣的單詞格式不一樣,總不能寫if else來處理不一樣的格式吧。其實還真能夠,wenyan 就是if else,吐槽一下。可是當有100中單詞的格式要處理,所有寫成if else,個人天,那代碼還能看麼。因此要把每一個單詞的處理過程當成一種狀態,處理到不一樣的單詞格式就跳到不一樣的狀態,跳轉的方式天然是根據當前處理的字符來的,處理一個字符串從開始狀態流轉到不一樣的狀態來處理,這樣就是狀態自動機,每一個token識別完了就能夠拋出來,最終產出的就是一個token數組。
其實狀態也不僅一級的,你想一想好比一個 html 標籤的開始標籤,能夠做爲一個狀態來處理,但這個狀態內部又要處理屬性、開始標籤等,這就是二級狀態,屬性又能夠再細分幾個狀態來處理,這是三級狀態,這是分治的思想,一層層的處理。
分詞以後咱們拿到了一個個的單詞,以後要把這些單詞進行組裝,生成 ast,爲啥必定要ast呢?我知道你確定想問。其實高級語言的代碼都是嵌套的,你看低級語言好比彙編,就是一條條指令,線性的結構,可是高級語言呢,有函數、if、else、while等各類塊,塊之間又能夠嵌套。因此天然要組織成一棵樹形數據結構讓計算機理解,就是Abtract Syntaxt Tree
,語法樹、並且是抽象的,也就是忽略了一些沒有含義的分隔符,好比html的<、>、</等字符,js的{ }() [] ;就是細節,不須要關心,註釋也會忽略掉,註釋只是分詞會分出來,可是不放到ast裏面。
怎麼組裝呢,仍是嵌套的組裝,那是否是要遞歸組裝,是的,你想的沒錯須要遞歸,不僅是這裏的ast組裝須要遞歸,後面的處理也不少遞歸,除非到了線性的代碼的階段,就像彙編那樣,你遞歸啥,沒嵌套的結構能夠遞歸了。
詞法咱們剛纔分析了,就是一個個的字符串格式,語法呢,是組裝格式,是單詞之間的組合方式。這也是爲啥咱們剛剛要先分詞了,要是直接從字符串來組裝ast,那麼處理的是字符串級別,而從token開始是單詞級別, 這就像讓你用積木造個城堡,可是積木也要你本身用泥巴造,那你怎麼造呢,能夠先把一個個積木造好,而後再去組裝成城堡,也能夠邊造積木邊組裝。不太小汽車的話你能夠邊製做積木,邊組裝,城堡級別的邊作積木邊組裝你能理清要造啥積木麼,就很難,因此仍是要看狀況。用這兩種方式來作parser的都有,簡單的能夠邊詞法分析,分析出熱乎乎的單詞而後立刻組裝到ast中, 好比html、css這種,可是像js、c++這種,若是不先分詞,直接從字符串開始造ast,我只能說太生猛了。
說了半天積木和組裝,那麼怎麼組裝呢,從左到右的處理token,遇到一個token怎麼知道他是啥語法呢,這就像怎麼知道一塊積木是屬於那個部件的。也有兩種思路,一種是你先肯定這個積木是屬於那個部件,而後找到那個部件的圖紙,按照圖紙來組裝,另外一種是你先組裝,組裝完了再看看這個是啥部件。這就是兩種方式,先根據一兩個積木肯定是哪一個部件,再按照圖紙組裝這個部件,這種是 ll
的方式,先組裝,組裝完了看看是啥部件,這種是 lr
的方式。ll的方式要肯定組裝的是啥ast節點要往下看幾個,根據要看幾個來肯定組裝的是什麼就分別是LL(1),LL(2)等算法。ll也就是遞歸降低,這是最簡單的組裝方式,固然有人以爲lr的方式也挺簡單。ll有個問題還必須得用lr解決,那就是遞歸降低遇到了左邊一直往下遞歸不到頭的狀況,要消除左遞歸,也就是你按照圖紙來組裝搞不定的時候,就先組裝再看看組裝出來的是啥吧。 這其實和人生挺像的,一種方式是往下看兩步而後決定當前怎麼走,另外一種方式是先走,走到哪步再說。其實我就屬於第二種,沒啥計劃性。
通過詞法、語法分析以後就產生了ast。用一棵樹形的數據結構來描述源代碼,從這裏開始就是計算機能夠理解的了,後續能夠解釋執行、能夠編譯轉換。無論是解釋仍是編譯都須要先parse,也就是要先讓計算機理解他是什麼,而後再決定怎麼處理。
後面把樹形的ast轉換爲另外一個ast,而後再打印成目標代碼的字符串,這是轉譯器,把ast解釋執行或者專成線性的中間代碼再解釋執行,這是解釋器,把ast轉成線性中間代碼,而後生成彙編代碼,以後作彙編和連接,生成機器碼,這是編譯器。
昊昊: 光哥,那編譯器是怎麼處理ast的啊?
我: 有了ast以後,計算機就能理解高級語言代碼了,可是編譯器要產生低級語言,好比彙編代碼,直接從ast開始距離比較遠。由於一個是嵌套的、樹形的,一個是線性的、順序的,因此啊,須要先轉成一種線性的代碼,再生成低級代碼。我以爲ast也能夠算一種樹形IR,IR
是immediate representation中間表示的意思。要先把AST轉成線性IR,而後再生成彙編、字節碼等。
咋翻譯,樹形的結構咋變成線性的呢? 明顯要遞歸啊,按照語法結構遞歸ast,進行每一個節點的翻譯,這叫作語法制導翻譯,用線性IR中的指令來翻譯AST節點的屬性。每一個節點的翻譯方式,if咋翻譯、while咋翻譯等能夠去看下相關資料,搜中間代碼生成就行了。
可是ast不能上來就轉中間代碼。
昊昊: 爲啥,ast不就能表示源碼信息了麼,爲啥不能直接翻譯成線性ir?
我: 由於還沒作語義檢查啊,結構對不必定意思對,就像「昊昊是隻豬」,這個符合語法吧,可是語義明顯不對啊,這不是罵人麼,因此要先作語義檢查。還有就是要推導出一些信息來,才能作後續的翻譯。
語義分析要檢查出語義的錯誤,好比類型是否匹配、引用的變量是否存在、break是否在while中等,主要要作做用域分析
、引用消解
、類型推導和檢查
、正確性檢查
等。
做用域分析就是分析函數、塊等,這些做用域內的變量都有啥,做用域之間的聯繫是怎樣的,其實做用域是一棵樹,從頂層做用域到子做用域能夠生成一個樹形數據結構。我記得有個作scope分析的webpack插件,他是把模塊也給連接起來了,造成了一個大的 scope graph,而後作分析。
做用域中有各類聲明,要把它們的類型、初始值、訪問修飾符等信息記錄下來,保存這個信息的結構叫符號表,這至關因而一個緩存,以後處理這個符號的時候直接去查符號表就行,不用再次從ast來找。
引用消解呢就是對每一個符號檢查下是否都能查找到定義,若是查找不到就報錯。類型方面你比較熟,js的源碼中確定不可能都寫類型,不少地方能夠直接推導出來,根據ast能夠得出類型的聲明,記錄到符號表中,以後遍歷ast,對各類節點取出聲明時的類型來進行檢查,不一致就報錯。還有其餘一些瑣碎的檢查,好比continue、break只能出如今while中等等一些檢查。
昊昊: 語義分析我懂了,就是檢查錯誤和記錄一些分析出的信息到符號表,那語義分析以後呢?
語義分析以後就表明着程序已經沒有語法和語義的錯誤了,能夠放心進行各類後續轉換,不會再有開發者的錯誤。以後先翻譯成線性IR,而後對線性IR進行優化,須要優化就是由於自動生成的代碼不免有不少冗餘,須要把各類不必的處理去掉。可是要保證語義不變。好比死代碼刪除、公共子表達式刪除、常量傳播等等。
線性IR的分析要創建流圖,就是控制流圖,控制流
就是根據if、while、函數調用等致使的程序跳轉,把順序執行的代碼和跳轉到的代碼之間鏈接起來就是一個圖,順序執行的代碼當作一個總體,叫作基本快。以後根據這個流圖作數據流分析,也就是分析一個變量流經了那些代碼,而後基於這些作各類優化。
這個部分叫作程序分析,或者靜態分析
,是一個專門的方向,能夠用於代碼漏洞的靜態檢查,能夠用於編譯優化,這個是比較難的。研究這個的博士都比較少。國內只有北大和南大開設程序分析課程。
優化以後的線性IR就能夠生成彙編代碼了,而後經過彙編器轉成機器碼,再連接一些標準庫,好比v8目錄下能夠看到builtins目錄,這裏就是各類編譯好的機器碼文件,能夠靜態連接成一個可執行文件。
昊昊: 哦,感受彙編和連接這兩步前端接觸不到啊。
我: 對的,由於js是解釋型語言,直接從源碼解釋執行,不要說js了,java的字節碼也不須要靜態連接。像c、c++這些生成可執行文件的才須要經過彙編器把代碼專成機器碼而後連接成一個文件。並且若是目標平臺有這些庫,那麼不須要靜態連接到一塊兒,能夠動態連接。你可能聽過.dll和.so這就分別是windows和linux的用於運行時動態加載的保存機器碼的文件。
你說的沒錯,前端領域基本不須要彙編和連接,就算是wasm,也是生成wasm 字節碼,以後解釋執行。前端主要仍是轉譯器。
昊昊: 那轉譯器在ast以後又作了哪些處理呢?
我: 轉譯器的目標代碼也是高級語言,也是嵌套的結構,因此從高級語言到高級語言是從樹形結構到樹形結構,不像翻譯成低級的指令方式組織的語言,還得先翻譯成線性IR,高級到高級語言的轉換,只須要ast,對ast作各類轉換以後,就能夠作代碼生成了。
昊昊: 我說呢,我就沒據說babel中有線性IR的概念。
我: 對的,無論是跨語言的轉換,好比 ts 轉 rust,仍是同語言的轉換js轉js都不須要線性結構,兩棵樹的轉換要啥線性中間代碼啊。 因此通常轉譯器都是 parse
、transform
、generate
這3個階段。
parse 廣義上來講包含詞法、語法和語義的分析,狹義的parse單指語法分析。這個沒必要糾結。
transform 就是對ast的增刪改,以後generator再把ast打印成字符串,咱們解析ast的時候把[]{} () 等分隔符去掉了,generate的時候再把細節加回來。
其實前端領域主要仍是轉譯器,由於主流js引擎執行的是源代碼,可是這個源代碼和咱們寫的源代碼還不太同樣,因此前端不少源碼到源碼的轉譯器來作這種轉換,好比babel、typescript、terser、eslint、postcss、prettier等。
babel 是把高版本es代碼轉成低版本的,而且注入polyfill。typescript是類型檢查和轉成js代碼。eslint是根據規範檢查,但--fix也能夠生成修復後的代碼。prettier也是用於格式化代碼的,比eslint處理的更多,不僅限於js。postcss主要是處理css的,posthtml用於處理html。相信你也用過不少了。taro這種小程序轉譯器就是基於babel封裝的。
昊昊: 哦,光哥,我大概知道編譯器和轉譯器都對ast作了啥處理了,這倆都是生成代碼的,那解釋器呢?
我: 對,首先轉譯器也是編譯器的一種,只不過比較特殊,叫作 transpiler
,通常的編譯器叫作compiler
。 解釋器和編譯器的區別確實是是否生成代碼,提早編譯成機器代碼的叫作 AOT
編譯器,運行時編譯成機器代碼的叫作 JIT
編譯器,
解釋器並不生成機器代碼,那它是怎麼執行的呢?知道你確定有疑問。
其實解釋器是用一門高級語言來解釋另外一門高級語言,好比c++,通常都用c++來寫解釋器,由於能夠作內存管理。用c++來寫js解釋器,像v八、spidermonkey等都是。咱們在有了ast而且作完語義分析以後就能夠遍歷ast,而後用c++來執行不一樣的節點了,這種叫作tree walker解釋器,直接解釋執行ast,v8引擎在17年以前都是這麼幹的。可是在17年以後引入了字節碼,由於字節碼能夠緩存啊,這樣下次再直接執行字節碼就不須要parse了。字節碼是種線性結構,也要作ast到線性ir的轉換,以後在vm上執行字節碼。
通常解釋線性代碼的好比彙編代碼、字節碼等這種的程序才叫作虛擬機
,由於機器代碼就是線性的,其實從ast開始就能夠解釋了,可是卻不叫vm,我以爲就是由於這個,和機器碼比較像的線性代碼的解釋器才叫 vm。
無論是解釋 ast 也好,仍是轉成字節碼再解釋也好,效率都不會特別高,由於是用別的高級語言來執行當前語言的代碼,因此要提升效率仍是得編譯成機器代碼,這種運行時編譯就是JIT編譯器,編譯是耗時的,因此也不是啥代碼都JIT,要作熱度的統計,到達了閾值纔會作JIT。而後把機器碼緩存下來,固然也多是緩存的彙編代碼,用到的時候再用匯編器轉成機器碼,由於機器代碼佔的空間比較大。
能夠對比v8來理解,v8 有parser、ignation解釋器、turbofan編譯器,還有gc。
ignation
解釋器就是把parse出的ast轉成字節碼,而後解釋執行字節碼,熱度到達閾值以後會交給turbofan編譯爲彙編代碼以後生成機器代碼,來加速。gc是獨立的作內存管理的。
turbofan
是渦輪增壓器,這個名字就能體現出 JIT 的意義。但JIT提高了執行速度,也有缺點,好比會使得js引擎體積更大,佔用內存更大,因此輕量級的js引擎不包含jit,這就是運行速度和包大小、內存空間之間的權衡。架構設計也常常要作這種兩邊均可以,可是要作選擇的trade off,咱們叫作方案勾兌。
說到權衡,我想起rn的js引擎hermes就改爲支持直接執行字節碼了,在編譯期間把js代碼編譯成字節碼,而後直接執行字節碼,這就是在跨端領域的js引擎的trade off。
昊昊:哦,光哥,我明白解釋器、編譯器、轉譯器都幹啥的了,那前端領域都有那些地方用到編譯原理的知識呢?
我:其實你也確定有個大概的瞭解了,可是不夠明確,我列一下我知道的。
工程化領域各類轉譯器
: babel、typescript、eslint、terser、prettier、postcss、posthtml、taro、vue template compiler等
js引擎
: v八、javascriptcore、quickjs、hermes等
wasm
: llvm能夠生成wasm字節碼,因此c++、rust等能夠轉爲llvm ir的語言均可以作wasm開發
ide 的 lsp
: 編程語言的語法高亮、智能提示、錯誤檢查等經過language service protocol協議來通訊,而lsp服務端主要是基於parser對正在編輯的文本作分析
本身如何實現一門語言呢?
昊昊: 我學了編譯原理能夠實現一門語言麼?
我: 其實編程語言主要仍是設計,實現的話首先實現 parser 和語義分析,後面分爲兩條路,一種是解釋執行的解釋器配合JIT編譯器的路,一種是編譯成彙編代碼碼,而後生成機器碼再連接成可執行文件的編譯器的路。
parser部分比較繁瑣,能夠用 antlr
這種parser生成器來生成,語義分析要本身寫,這個不太難,主要是對ast的各類處理。以後若是想作成編譯器,能夠用 llvm
這種通用的優化器和代碼生成器,clang、rust、swift都是基於它,因此很靠譜,能夠直接用。 若是作解釋器能夠寫 tree walker解釋器,或者再進一步生成線性字節碼,而後寫個vm來解釋字節碼。JIT編譯器也能夠用llvm來作。要把ast轉成llvm ir,也是樹形結構轉線性結構,這個仍是編譯領域很常見的操做。
其實編譯原理只是告訴你怎麼去實現,語言設計不關心實現,一門語言能夠實現爲編譯型也能夠實現爲解釋型,也能夠作成 java 那種先編譯後解釋,你看 hermes(react native 實現的 js 引擎) 不就是先把 js 編譯爲字節碼而後解釋執行字節碼麼。語言不分編譯解釋,這個概念要有,c也有解釋器,js也有編譯器,咱們說一門語言是編譯型仍是解釋型主要是主流的方式是編譯仍是解釋來決定的。
編程語言能夠分爲 GPL
和 DSL
兩種。
GPL是通用編程語言,它是圖靈完備的,也就是可以描述任何可計算問題,像c++、java、python、go、rust等這些語言都是圖靈完備的,因此一門語言能實現的另外一門語言都能實現,只不過實現難度不一樣。好比go語言內置協程實現,那麼寫高併發程序就簡單,java沒有語言級別的協程,那麼就要上層來實現。你可能聽到過設計模式是對語言缺陷的補充就是這個意思,不一樣語言設計思路不一樣,內置的東西也不一樣,有的時候須要運行時來彌補。、
編程語言有不一樣的設計思路,大的方向是編程範式,好比命令式、聲明式、函數式、邏輯式等,這些大的思路會致使語言的語法,內置的實現都不一樣,表達能力也不一樣。 這基本肯定了語言基調,後續再補也很難,就像js裏面實現函數式,你又不能限制人家不能用命令式,就很難寫出純粹的函數式代碼。
DSL 不是圖靈完備的,卻換取了某領域的更強的表達能力,好比html、css、正則表達式,jq的選擇器語法等等,比較像一種僞代碼,特定領域的表達能力很強,可是卻不是圖靈完備的不能描述全部可計算問題。
編譯原理是實現編程語言的步驟要學習的,更上層的語言設計還要學不少東西,最好能熟悉多門編程語言的特性。
昊昊: 光哥,那我該怎麼學習編譯原理呢?
我: 首先你要理解編譯都學什麼,看我上面對編譯、轉譯、解釋的科普大概能有個印象,而後查下相關資料。知道均可以幹啥了以後先寫parser,由於無論啥都要先parse成ast才能被「理解」和後續處理,學下有限狀態機來分詞和遞歸降低構造ast。推薦看下vue template compiler 的 parser,這種xml的parser比較簡單,適合入門。語言級別的parser細節不少,仍是得找一個來debug看。不過我以爲沒太大必要,通常也就寫個html parser,要是語言的,能夠用antlr生成。轉譯器確定要了解babel,這個是前端領域很不錯的轉譯器。
js引擎能夠嘗試用babel作parser,本身作語義分析,解釋執行ast試試,以後進一步生成字節碼或其餘線性ir,而後寫個vm來解釋字節碼。
還能夠學習wasm相關技術,那個是涉及到其餘語言編譯到wasm 字節碼的過程的。
我在寫一個《babel 插件通關祕籍》的小冊,裏面會實現 js 解釋器、轉譯器、type cheker、linter 等等,涉及到編譯原理的不少知識,或許能幫你入門編譯原理。
當你學完了編譯原理,就大概知道怎麼實現一門編程語言了,以後想深刻語言設計能夠多學一些其餘編程範式的語言,瞭解下各類語言特性,怎麼設計一門表達性強的gpl或者dsl。
也能夠進一步學習一下操做系統和體系結構,由於編譯之後的代碼仍是要在操做系統上以進程的形式運行的,那麼運行時該怎麼設計就要了解操做系統了。而後cpu指令集是怎麼用電路實現的,這個想深刻能夠去看下計算機體系結構。
不過,前端工程師不須要達到那種深度,可是眼界開闊點沒啥壞處。