打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

閱讀本系列文章將是「最殘酷的頭腦風暴,你們作好準備了嗎」html

本文是《打破國外壟斷,開發中國人本身的編程語言》系列文章的第1篇。本系列文章的主要目的是教你們學會如何從零開始設計一種編程語言(marvel語言),並使用marvel語言開發一些真實的項目,如移動App、Web應用等。marvel語言能夠經過下面3種方式運行:前端

  1. 解釋執行
  2. 編譯成Java Bytecode,利用JVM執行
  3. 編譯成二進制文件,本地執行(基於LLVM)

本系列文章實現的marvel語言並不像不少《本身動手》系列同樣,作一個玩具。marvel語言是一個工業級的編程語言,與kotlin、Java等語言是同一個級別,設計之初是爲了試驗編程語言的新特性。咱們團隊開發的超平臺開發系統UnityMarvel內嵌的Ori語言的部分特性也是來源於Marvel。關於UnityMarvel的細節後面會專門寫文章介紹。這裏先討論編譯器的問題。java

1. 若是系統軟件受到制約,有沒有可能突出重圍呢?

咱們知道,如今中美貿易戰如火如荼,可能之後使用國外不少軟件,尤爲是系統軟件,都會有一些問題。這就須要咱們在一些關鍵領域有本身能夠控制的技術和軟件,例如,操做系統、編程語言、數據庫、科學計算軟件等。其實這些種類的軟件中,大多都屬於基礎軟件,只有操做系統和編程語言(以及相關的IDE)能夠稱爲是系統軟件。算法

這裏先說說基礎軟件和系統軟件的區別。基礎軟件是指不少軟件都依賴的軟件,例如,流行的程序庫(如tensorflow、pytorch等)、數據庫(如MySQL、Oracle等)。但大多數基礎軟件的一個共同特色是隻服務於特定領域,例如,你不可能用MySQL開發一款遊戲,也不可能用tensorflow開發移動App。而在基礎軟件中有一小類,它們是通用的,幾乎適合於各個領域,咱們將這類軟件稱爲系統軟件。它們是整個IT領域的基礎架構。沒有它們,整個IT領域將不復存在。例如,目前,只有操做系統和編譯器符合這兩個特徵。你們能夠想一想,沒有了關係型數據庫,還有其餘類型的數據庫可使用,沒有了tensorflow,IT領域也不會中止運轉。但沒有了Windows、macOS、Linux、C語言、Java語言這些技術,世界將會怎樣,將會從新退回到工業文明時代。 因此係統軟件是基礎軟件的一個子集,並且必不可少。若是將基礎軟件和其餘軟件比做星球,那麼系統軟件就是星核。sql

在系統軟件中,編譯器是最容易突破的。由於編譯器(編程語言)的生態相比操做系統來講,更容易創建。這是由於目前有不少虛擬機能夠選擇,例如,最經常使用的是JVM,固然,還有微軟的.net core等技術。 若是咱們的編程語言能夠基於JVM,那麼就意味着能夠利用Java語言的全部生態,若是咱們的編程語言能夠用更容易的方式調用其餘語言(如C++、Go等),在某種程度上,也就能夠直接使用這些編程語言的生態。固然,還有更先進的超生態技術(UnityMarvel的Ori語言正是基於超生態技術的),總之,做爲一種新的編程語言,利用其餘的生態是最廉價的方式,固然,在語言發展的過程當中,也能夠逐漸創建本身的生態(至關於騎驢找馬),這也是一種策略。因此若是想突破,編譯器(編程語言)是最容易的一個。固然,若是擁有本身能夠控制的編程語言,能夠爲後期的操做系統提供支援,例如,利用超生態技術,在創建新操做系統以前,就爲該操做系統提早創建生態(這一點之後專門撰文闡述)。數據庫

2. 開發編程語言須要哪些知識

如今進入到最關鍵的部分了,開發一種編程語言到底須要哪些知識呢?其實須要的知識仍是蠻多的。最基礎的要求是必須至少會一種編程語言。如C、C++、Java、C#、Go、Python等。固然,推薦會3種以上的編程語言,由於咱們是在設計編程語言,不是在設計普通的軟件。在設計編程語言時,須要進行橫向比較,也就是須要參考其餘的編程語言,由於任何新技術都不可能100%徹底憑空產生,這些新技術都會或多或少地留下其餘同類技術的影子,編程語言也不例外。例如,UnityMarvel內嵌的Ori語言就是參考了數十種編程語言,以及加入了本身的新技術而最終造成的。express

除了要了解大量的編程語言外,還有不少與業務有關的知識須要掌握。主要的知識結構(不只僅這些,後面用到了再詳細講)以下:編程

(1)瞭解大量的編程語言(推薦3種以上)
(2)編譯原理的基礎知識
(3)算法能力
(4)編譯器前端生成器
(5)學習能力
(6)想象力windows

儘管開發編程語言並不會像大學學的編譯原理同樣從0開始構造一個編譯器,但編譯原理的基礎知識仍是要掌握的,不瞭解編譯原理的同窗,趕忙上B站、西瓜視頻、油管去補課,後期我也會結合marvel語言作相關的視頻課程,你們能夠關注哦!數據結構

算法就沒必要說了,編譯器裏面充斥着各類算法,編譯器的算法密度幾乎超過了絕大多數應用。任何形式的算法均可能涉及到,最基礎的數據結構必須掌握,其餘的算法,能學多少就學多少,多多益善。這個沒有固定的教程,也是須要不斷在實踐中學習。

開發編譯器的基本步驟以下圖所示。

打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

首先說明一點,並非全部的編譯器都嚴格按照這些步驟進行,有可能會將多個步驟合成一個步驟(例如,語法分析和語義分析合成一步,最後輸出AST),也有可能將一步分紅多個步驟,或者再增長一些與業務相關的步驟。

對於工業級編譯器來講,並不會從0開始實現詞法和語法分析器,並非這東西有多難,而是若是徹底手工編寫代碼,要添加或修改一個新語法,那簡直就是一場噩夢,由於要修改很是多的地方,並且一旦出錯,很是很差找緣由(由於代碼過於複雜)。因爲詞法分析和語法分析有規律可循,因此出現了不少經過文法生成詞法分析器和語法分析器的工具,因爲詞法分析與語法分析是編譯器前端的重要組成部分,因此這類工具一般稱爲「編譯器前端生成器」。比較著名的包括lex、yacc、javacc、antlr等。其中lex是專門用來生成詞法分析器的,yacc用來生成語法分析器的,javacc能夠同時生成詞法和語法分析器、antlr也一樣能夠生成詞法分析器和語法分析器。不過lex和yacc只支持C語言,javacc只支持Java語言。而antlr支持多種編程語言,例如Java、C++、JavaScript、Go、C#、Swift等。本系列文章也使用了antlr的最新版本antlr4來實現編譯器的前端(詞法分析器和語法分析器)。

這幾種工具都是依賴於文法生成詞法分析器和語法分析器的,例如,在antlr4中,若是要識別加減乘除四則運算,只須要編寫下面的文法便可。

expr:   expr op=('*'|'/') expr     
    |   expr op=('+'|'-') expr

文法是否是很簡單呢?但若是要編寫完善的代碼,可能須要上百行才能實現(咱們團隊實現的Ori語言,利用antlr4生成的詞法和語法分析器,總共6萬行Go語言代碼,咱們本身編寫了大概4萬行Go代碼,整個編譯器有超過10萬行代碼,3/5是自動生成的,2/5是本身編寫的)。並且文法還標識了優先級,antlr4規定,寫在前面的文法的優先級高於寫在後面的文法的優先級。咱們知道,對於四則運算來講,是先乘除,後加減,因此expr op=('*'|'/') expr 應該在expr op=('+'|'-') expr 前面,倒過來是不行了。若是要加更復雜的運算,例如,平方、開方、冪等,只須要修改這個文法便可,是否是很簡單呢?

前面說的前4點是硬知識,也有不少教程能夠學習,但最後兩點:學習能力和想象力,就要徹底靠本身的天賦了。由於前面4點能讓你作出一個看着還不錯的編譯器,但最後兩點能決定你作的編譯器有多強大。

實現一個編程語言,所涉及到的知識要比實現編譯器難度更大。由於若是實現編譯器,而且是已經存在的編程語言,因爲語法已經肯定,因此只須要實現出來便可。但編程語言不一樣,一切須要從新設計,尤爲是在涉及到新語法時,很是困難,須要瞭解的知識至關多,因此須要擁有快速學習能力,能夠在短期內學會並掌握任何知識和技術。另外,想象力更重要,由於設計一款新的編程語言,有些東西可能不只僅侷限於IT領域,也不只僅侷限於本身所從事的技術領域,例如。在Ori語言中,擁有一些創新的語法,須要同時適應相似JavaScript的單線程模式和Java的多線程模式。所以,擁有多維度的想象力纔是最終取得勝利的關鍵。

3. 本身設計的編程語言會流行嗎

我常常在網上看到不少同窗在問,爲何中國沒有本身流行的編程語言(儘管有易語言,但因爲是中文編程,因此註定不會全球流行,國內也並不算流行)呢?BAT等大廠爲什麼不開發一個呢? 而後有人回答,開發編程語言容易,關鍵是生態,還有人回答,BAT是由於沒有必要,由於編程語言沒有和KPI掛鉤,也有些人回答,開發一款編程語言,火起來很難。 其實這些均可能是緣由,但主要緣由其實就是需求沒有與行動掛鉤,或者說,如今的編程語言已經足夠知足需求了,沒有必要再開發一款新的編程語言,並且這些大廠的盈利壓力都很大,固然,還有技術積累的問題。

其實編程語言有不少種,有一種就是像Java、C#、C++同樣的通用編程語言,這類語言什麼都能作,是一種圖靈完備的編程語言。還有另一種編程語言,如SQL、VBA、ABAP(SAP的內嵌語言),這類屬於領域編程語言,他們也多是圖靈完備的,也可能不是圖靈完備的。一般使用這類編程語言完成某些特定的工做,如SQL操做數據庫,VBA操做Office、ABAP操做SAP數據等。其實在國內有不少公司內部已經提供了相似的領域語言,只是很是專業,功能單一,絕大多數人不清楚而已。

至於本身開發出來的編程語言是否會流行,其實大家想太多了。編程語言是爲了解決實際問題而存在的,不是爲了流行而存在的。就像衣服,最初的用途是爲了保暖,而不是時尚,當大多數人都使用本身生產的衣服保暖,那他就是流行款了!因此讓編程語言解決實際問題纔是優先要考慮,至於之後是否會流行,本身說了不算!

像咱們團隊開發的UM系統,其實原來壓根就沒打算本身開發編程語言,想直接使用JavaScript,不事後來發現,JavaScript太動態了,使用JavaScript根本沒有辦法作一款完美的IDE,並且功能有限,而且混亂。還有就是JS是動態語言,若是將其轉換爲靜態語言,會以犧牲性能爲代價,並且沒法有效融合單線程和多線程的特性,而且還沒法與UM IDE融爲一體,因此沒辦法,纔開發一款本身的編程語言Ori,而且融合了數十種編程語言的優秀特性,並且加入了更先進的特性(如內嵌SQL、虛擬組件、虛擬數據庫、支持跨平臺的語法、客戶端服務端一體化、柔性熱更新等),固然,這些特性須要與UM IDE配合才能使用。

4. 開發編程語言,從這裏起航:配置Antlr4環境

若是一上來就開發編程語言,估計你們就開始暈了,因此咱們先從最簡單的開始,就是先來編寫一個能夠解析加減乘除表達式的編譯器。咱們使用了antlr4來生成詞法分析器和語法分析器,因此先要配置一下antlr4的開發環境。

因爲antlr4使用Java開發,因此無論用什麼編程語言設計編譯器,JDK必須安裝,而且還須要一款強大的Java IDE,這裏推薦Intellij IDEA。咱們只使用Intellij IDEA的最基礎功能,因此CE(社區版)版足夠了,這個版本是免費的。

在安裝完Intellij IDEA CE後,到下面的頁面下載antlr4工具相關的庫。

https://www.antlr.org/download.html

進入頁面,找到下面的部分,點擊第1個連接下載便可。

打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

下載完antlr4的工具包後,找到其中的Java運行時庫,並用Intellij IDEA CE建立一個Java工程,而後直接將Antlr4 Java運行時庫複製到工程的lib目錄中(沒有lib目錄能夠創建一個),以下圖所示。

打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

而後在lib目錄的右鍵菜單中點擊「Mark Directory as」>「Sources Root」菜單項,將lib編程源代碼目錄,這樣Intellij IDEA CE就會搜索lib目錄中的全部庫。固然,能夠直接在模塊中引用antlr4的庫,不過將antlr4 運行時庫與工程放到一塊兒,這樣若是將工程複製到其餘機器上,就不會因爲antlr4的運行庫沒有複製而致使沒法運行了。

打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

而後須要安裝Intellij IDEA CE的Antlr插件。進入插件安裝頁面,若是沒有安裝antlr插件,選擇Marketplace標籤頁,輸入antlr搜索插件,一般第一個就是。點擊右側的install按鈕便可安裝。若是已經安裝,Antlr插件會出如今Installed頁面中,以下圖所示。
打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

安裝完Antlr插件後,新建立一個文件,將文件擴展名設置爲g4,就會看到文件前面的圖標變成了紅色,裏面有一個A字母,這就是Antlr4的標識,以下圖所示。

打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

5. Antlr4的Hello World

如今咱們開始進入激動人心的時刻了,用Antlr4親手作咱們的第一個編譯器:解析四則運算表達式的計算器。不過在完成這個編譯器以前,必定要了解一下Antlr4。

下面先給出一個能夠識別以hello開頭的詞組的識別程序的文法。首先建立一個名爲Hello.g4的文件,並輸入下面的代碼:

grammar Hello;
r  : 'hello' ID ;
ID : [a-z]+ ;
WS : [ \t\r\n]+ -> skip ;

你們先不須要管這些代碼是什麼意思,只須要照貓畫虎輸入便可。

而後在Hello.g4右鍵菜單點擊「Configure ANTLR」菜單項,會彈出以下圖的對話框,設置第一個文本輸入框,指定生成目錄,這裏指定與Hello.g4相同的目錄。Hello.g4生成的文件都會放在這個目錄中。
打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

而後點擊Hello.g4右鍵菜單的「Generate ANTLR Recognizer」菜單項,會自動生成一堆文件,以下圖所示。注意:Java文件都隱藏了擴展名。

打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

Hello.java和MyHelloVisitor.java是後來建立的,其餘文件都是自動生成的。其中HelloLexer.java是詞法分析器、HelloParser.java是語法分析器,其餘文件後面再說。

你們能夠打開這兩個文件,看到每個文件的內容都有上百行,這要是人工編寫,會累死人,而使用Antlr4,只須要4行文法就搞定。若是要添加或修改原來的語法,只須要修改Hello.g4文件,而後再從新生成一遍便可。

如今有一個問題,怎麼用Hello.g4生成的一堆文件呢?或者換種問法,生成的這些文件有什麼用呢?

Hello.g4生成的這些文件的主要目的就是進行詞法分析和語法分析,那麼如何用呢?使用有以下兩種方式:

  1. 用grun工具測試
  2. 用Java代碼調用詞法分析器和語法分析器,編寫完整的編譯器

如今先來講說grun工具。其實並無grun這個東西,grun是一個別名,真實的工具在是antlr-4.8-complete.jar中的 org.antlr.v4.gui.TestRig類,在macOS或Linux下,可使用alias命令起一個別名,官方叫grun,因此這裏就沿用了官方的叫法。若是在windows下,能夠建立一個grun.cmd文件。

起別名的完整命令以下:

alias grun='java -classpath .:/System/Volumes/Data/sdk/compilers/antlr4-4.8/antlr-4.8-complete.jar org.antlr.v4.gui.TestRig'

如今就可使用grun測試咱們的程序了。

首先要說明一點,grun測試的是.class文件,不是.java文件,因此在測試以前,要在終端中切換到.class文件所在的目錄。Intellij IDEA CE默認的.class目錄是out/production目錄,以下圖所示。在一開始,前面生成的.java文件並無編譯,讀者能夠隨便找個Java程序運行下,這時Intellij IDEA CE會編譯全部尚未編譯的.java文件,咱們會發現,剛纔生成的全部.java文件都生成了同名的.class文件。
打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

讀者能夠直接在操做系統的終端進入.class所在的目錄,或者經過Intellij IDEA CE下方的Terminal也能夠輸入命令行,以下圖所示。

打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

如今來作咱們的第一個測試:

首先輸入下面的命令(先不須要管命令是什麼意思):
grun Hello r -tokens

而後輸入下面的內容:
hello world

若是讀者在macOS或Linux下,按Ctrl+D,若是在Windows下,按Ctrl+Z輸入結束符號,會輸出以下圖的內容:

打破國外壟斷,開發中國人本身的編程語言(1):編寫解析表達式的計算器

如今來解釋一下grun Hello r -tokens是什麼意思。Hello表示Hello.g4中grammar後面的部分,也就是Hello。r是文法產生式等號左側的符號(非終結符),也就是r : 'hello' ID ;中的r。 -tokens表示列出全部的tokens。

那麼什麼是token呢? 其實token是詞法分析器的輸出,同時,token將做爲語法分析器的輸入,而AST(抽象語法樹)則是語法分析器的輸出。

token就是編程語言中不可再分的單元,至關於編程語言的原子。看下面的程序:

if(i == 10) {
}

這是一個很是簡單的條件語句,那麼在這兩行代碼中,有多少個token呢?根據token不可分割的原則,包含以下的token:
if,(,i,==,10,),{,}

上面用逗號(,)分隔的符號都是token,例如,if是關鍵字,將做爲一個總體對待,在解析代碼時,確定不會將if拆開,10是一個整數,也將做爲一個總體對待,確定不會將其拆成1和0。

那麼Hello的輸出結果意味着什麼呢?咱們輸入了hello world,根據語法規則。任何字符串都須要以hello開頭,因此hello將做爲一個token(至關於前面條件語句的if關鍵字,這裏hello是一個關鍵字)。然後面能夠是任意字符串,但與hello之間至少要有一個空格。因此hello world符合Hello的語法規則,hello abc也一樣符合,而helloabc就不符合了,由於hello和abc之間沒有任何分隔符,根據最長匹配原則,Antlr4會選擇最長的字符串進行匹配,因此匹配的是helloabc,而不是hello。

如今咱們的實驗也作完了,可能不少讀者仍是一頭霧水,不過沒關係,咱們再詳細講一下Antlr4究竟是怎麼分析的。

Antlr4採用了自頂向下遞歸的分析方式。自頂向下就是先將整個編程語言源文件當作一個總體,這就是入口點,也就是Hello.g4中的r。這個入口點起任何名字均可以,只要不和其餘的文法標識重名便可。而後從這個入口點開始,就能夠用遞歸的方式寫文法了。文法用於從上到下推導,左側是文法標識,右側是文法的產生式。例如,要識別下面一組字符串:

hello world
hello abc
hello Bill
hello 李寧

很明顯,這4行文本都是以hello開頭,後面跟着任意的字符串,中間用空格分隔。因此咱們的文法應該是以hello開頭,後面跟一個標識,用ID表示。文法以下:
r : 'hello' ID;

在Antlr4中,每個文法都要用分號(;)結尾,若是是固定的字符串,如關鍵字,用單引號括起來。如'hello'。

ID表示任意的標識符,也是終結符。所謂終結符,是指不能再繼續往下推導的符號(至關於樹的葉子節點)。在Antlr4中,終結符標識用由首字母大寫的字符串表示,如ID。而非終結符(能夠繼續往下推導)用首字母小寫的字符串表示,如r。
如今是自頂向下分析的第1步,第2步是處理ID。文法以下:
ID : [a-z]+ ;

ID的產生式不包含任何的非終結符,也就是再也沒法繼續推導了。[a-z]是一種簡寫,也就是a到z共26個小寫字母中的任何一個,後面的加號(+)表示至少要有一個小寫字母。

到如今爲止,自頂向下分析的過程已經完成了,分爲兩步,第一步將整個字符串看作一個總體,而且將其分解爲hello和後面的任意字符串。第二部來處理這個任意字符串。這裏規定,這個任意字符串只能由小寫字母組成。

不過如今還有一個問題,Antlr4怎麼知道hello和world之間須要有空格或其餘空白符分隔呢?其實這就涉及到Hello.g4的最後一行代碼了:WS : [ \t\r\n]+ -> skip ; 這行代碼設置了一個skip通道(通道會在後面的文章中詳細講解),用於忽略指定的字符,這些被忽略的字符,將做爲token的分隔符,這裏面指定了4個分隔符:空格、製表符(\t)、回車符(\r)、換行符(\n)。也就是說,下面的形式也是能夠的:

hello
world

ok,如今Hello.g4的語法規則已經講的差很少了,裏面涉及到了一些概念,在後面的文章中會詳細講解。如今來總結一下:

Antlr4的文法文件是以g4做爲擴展名,第一行代碼必須以grammar開頭,後面跟着語法名,如Hello,該名字必須與g4文件名一致。每一行代碼都必須用分號(;)分隔。而後就是若干文法產生式了。例如,Ori語言的最頂端文法是這樣的。

grammar Ori;
program : sourceElements? EOF
sourceElement   : statement
statement
:
    importStatement
    | sqlStatement
    | dollarMemberStatement        
    |  classDeclarationStatement
    |  interfaceDeclarationStatement
    | functionDeclarationStatement
    | variableStatement     
    | ifStatement                
    | iterationStatement         
    | continueStatement           
    | breakStatement       
    | returnStatement             
    | withStatement             
    | switchStatement           
    | throwStatement           
    | tryStatement                 
    | blockStatement  
    | expressionStatement
    | commentStatement
    ;

program是Ori語言的入口點,而後Ori語言將整個語言分紅若干源代碼元素(sourceElements?),後面的問號表示可選,也就是說,Ori語言的源代碼文件能夠是空文件。EOF是文件結束符。這裏講每個源代碼元素對應一條statement(語句),這裏之因此不直接使用statement,而是使用sourceElement,是由於之後可能會進行擴展,這時只須要修改sourceElement便可(目前sourceElement等於statement),而一條語句包括多種,如ImportStatement、sqlStatement(內嵌SQL)、classDeclarationStatement(類聲明)等。而後就繼續往下分,如sqlStatement還會包含sqlInsert、sqlUpdate等。以此類推,直到不可再分爲止。這就是自頂向下分析的基本方法,其實這就是分治法的一種表現,儘管編程語言看着很複雜,一個大型系統可能會有上百萬甚至更多行代碼,但若是將編程語言從頂向下分析,涉及到的語句種類也不過幾十種而已。Ori語言的文法文件也就1000多行,包括詞法文件部分,也就2000行出頭。用2000行代碼,就能夠徹底描述一種圖靈完備的編程語言,真是perfect。而這2000行代碼,生成的Go語言代碼超過了60000行。

如今再回到grun工具上來。其實grun的功能很強大,除了能夠做爲測試工具外,還能夠顯示Antlr4生成的AST,看一下自頂向下分析的流程。

首先準備一個hello.txt文件,並輸入hello world。而後在終端輸入下面的命令(讀者要將hello.txt文件的路徑改爲本身機器上的路徑):

grun Hello r -gui < /MyStudio/java/java_knowledge/antlr/test/hello.txt

而後就會彈出以下圖的窗口,右側顯示了AST的樹狀結構。Antlr4製做編譯器的過程就是先根據源代碼生成AST,而後對AST進行遍歷(根據語言的特性,會遍歷1到n遍),遍歷完後,就會生成中間代碼、以及最終的二進制文件。因此AST起到了承前啓後的做用。

6. 如何用程序進行詞法和語法分析

儘管已經瞭解了Antlr4的基本使用方法,但到如今爲止,尚未用Java編寫過一行代碼呢?如今我就來演示如何用Java調用上一節生成的詞法分析器和語法分析器。

下面先給出實現代碼:

首先建立一個MyHelloVisitor.java文件,並輸入下面的代碼:

import org.antlr.v4.runtime.tree.AbstractParseTreeVisitor;

public class MyHelloVisitor extends AbstractParseTreeVisitor<String> implements HelloVisitor<String> {

    @Override public String visitR(HelloParser.RContext ctx) {
        System.out.println(ctx.getText());
        System.out.println(ctx.ID().getText());
        return visitChildren(ctx);
    }
}

而後再建立一個Hello.java文件,並輸入下面的代碼:

import org.antlr.v4.runtime.CharStream;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.tree.ParseTree;

public class Hello {
    public static void main(String[] args) throws Exception  {
         // 讀取源代碼文件,這裏選擇直接從字符串讀取
         CharStream input = CharStreams.fromString("hello world");
        // 建立詞法分析器對象 
        HelloLexer lexer = new HelloLexer(input);
        // 獲取詞法分析器輸出的tokens
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        // 建立語法分析器對象,並將詞法分析器輸出的tokens做爲語法分析器的輸入
        HelloParser parser = new HelloParser(tokens);
        // 開始分析程序,這也是生成AST的過程
        ParseTree tree = parser.r();    // 文法的入口點r會轉換爲一個方法,調用該方法,就會自頂向下遞歸分析源代碼
        // 建立Visitor對象
        MyHelloVisitor hello = new MyHelloVisitor();
        // 開始遍歷AST
        hello.visit(tree);
    }
}

如今運行Hello.java,若是在Run窗口輸出以下圖的內容,說明運行成功了。

如今來解釋一下前面的代碼。這裏先要知道Antlr4是如何遍歷AST的。Antlr4有以下兩種方式遍歷AST:
(1)listener
(2)visitor

第一種方式更靈活,但不容易使用。visitor不靈活,但容易使用。本例使用了第2種方式來遍歷AST,但本系列文章的大多數代碼主要使用listener來遍歷AST。listener方式會在後面的文章中詳細介紹,這裏主要介紹visitor。其實這兩種遍歷AST的方式的原理相似,都是遇到了一個節點,就會調用相應的回調方法,而後將必要的信息做爲參數傳入回調方法,用戶能夠在回調方法中完成代碼生成、數據處理、中間代碼優化等工做。那麼這些回調方法放在哪裏呢?這就要說到前面建立的MyHelloVisitor類。該類實現了HelloVisitor接口,該接口是根據Hello.g4文件自動生成的,代碼以下:

import org.antlr.v4.runtime.tree.ParseTreeVisitor;
public interface HelloVisitor<T> extends ParseTreeVisitor<T> {  
    T visitR(HelloParser.RContext ctx);
}

咱們能夠看到,該接口中只有一個方法,就是visitR,該方法是遍歷到r節點調用的回調方法。

若是文法文件很大時,會生成至關多的回調方法,例如,Ori語言的文法就生成了數百個回調方法,這些回調方法並不必定都用到,在這種狀況下,並不須要實現全部的回調方法,因此Antlr4在生成回調接口文件的同時,還生成了一個默認實現類,如本例的HelloBaseVisitor,默認實現類已經默認實現了全部的回調方法,咱們的Visitor類只須要從該類繼承,就只須要實現必要的回調方法便可。

import org.antlr.v4.runtime.tree.AbstractParseTreeVisitor;

public class HelloBaseVisitor<T> extends AbstractParseTreeVisitor<T> implements HelloVisitor<T> {

    @Override public T visitR(HelloParser.RContext ctx) { return visitChildren(ctx); }
}

本例的MyHelloVisitor類繼承了HelloBaseVisitor類,並覆蓋了visitR方法,輸出了r節點的文本和ID的文本。

對於Hello類來講,就是最終的調用代碼了。一般一個用Antlr4實現的編譯器,須要通過以下幾步:

(1)讀取源代碼文件(或直接從字符串獲取源代碼)
(2)建立詞法分析器(輸入是單個字符、輸出是tokens)
(3)建立語法分析器(輸入是tokens、輸出是AST)
(4)開始遍歷AST

這4步已經在Hello類中作了詳細的註釋,你們能夠自行查看。

7. 弄一個能夠解析表達式的計算器

前面已經給出了一個完整的Antlr4案例,不過這個案例太簡單了,沒什麼實際的用途,本節會利用Antlr4實現一個有實際價值的計算器程序。該程序能夠解析過個表達式,表達式包含加減乘除運算,每個表達式佔一行,用分號(;)結尾。

先給出文法:Calc.g4

grammar Calc;
// 下面是語法
prog:   stat+ ;

stat:   expr ';'                # printExpr
    |   ID '=' expr ';'         # assign
    |   NEWLINE                 # blank
    ;

expr:   expr op=('*'|'/') expr      # MulDiv
    |   expr op=('+'|'-') expr      # AddSub
    |   INT                         # int
    |   ID                          # id
    |   '(' expr ')'                # parens
    ;
// 下面是詞法
MUL :   '*' ;
DIV :   '/' ;
ADD :   '+' ;
SUB :   '-' ;
ID  :   [a-zA-Z]+ ;      // 匹配標識符
INT :   [0-9]+ ;         // 匹配整數
WS  :   [ \t]+ -> skip ; // 忽略空白符
NEWLINE:'\r'? '\n' ;     // 空行

如今生成Calc.g4 的相關文件。先看一下生成的CalcVisitor.java文件,代碼以下:

import org.antlr.v4.runtime.tree.ParseTreeVisitor;
public interface CalcVisitor<T> extends ParseTreeVisitor<T> {
    T visitProg(CalcParser.ProgContext ctx);    
    T visitPrintExpr(CalcParser.PrintExprContext ctx);
    T visitAssign(CalcParser.AssignContext ctx);
    T visitBlank(CalcParser.BlankContext ctx);
    T visitParens(CalcParser.ParensContext ctx);
    T visitMulDiv(CalcParser.MulDivContext ctx);
    T visitAddSub(CalcParser.AddSubContext ctx);
    T visitId(CalcParser.IdContext ctx);
    T visitInt(CalcParser.IntContext ctx);
}

CalcVisitor有9個回調方法,從文法上看,有多少個文法,就應該有多少個回調方法。在Calc.g4中,除了第一個文法(prog:stat+;)外,其餘的文法都起了別名,如printExpr,assign等。因此這些文法對應的回調方法都是以別名做爲後綴的,而後前面加上visit。其實這9個方法,分別通過了AST的9個非葉子節點後(若是有的話),被分別調用。
例如,如今測試這個表達式(將表達式放置expr.calc文件中):1+3 * 4 - 12 /5;

grun Calc prog -gui &lt; /MyStudio/java/java_knowledge/antlr/Calc/expr.calc

執行上面的命令,會顯示以下圖的AST。

要計算上述表達式,就須要遍歷這棵AST。例如,當遍歷到prog節點時,就會調用visitProg方法,經過該方法的參數能夠獲取prog節點的直接子節點的信息(就是左右兩個stat節點)。當遇到減法表達式時,就會調用visitAddSub方法,以此類推。

如今看一下EvalVisitor類的實現。該類的實現原理是當直接計算兩個值時,如3 * 五、4 - 1,就分別由visitMulDivhe visitAddSub方法計算,並經過返回值返回計算結果。若是遇到變量(Calc支持變量),須要首先將變量放到一個Map中,而後在獲取該變量時,會從Map讀取。Map至關於一個符號表。

import java.util.HashMap;
import java.util.Map;

public class EvalVisitor extends CalcBaseVisitor<Integer> {
    /** "memory" for our calculator; variable/value pairs go here */
    Map<String, Integer> memory = new HashMap<String, Integer>();
    boolean error = false;

    /** ID '=' expr NEWLINE */
    // 初始化變量的操做(賦值操做)
    @Override
    public Integer visitAssign(CalcParser.AssignContext ctx) {
        String id = ctx.ID().getText();  // id is left-hand side of '='
        int value = visit(ctx.expr());   // compute value of expression on right
        memory.put(id, value);           // store it in our memory
        return value;
    }

    /** expr NEWLINE */
    // 輸出表達式的計算結果
    @Override
    public Integer visitPrintExpr(CalcParser.PrintExprContext ctx) {
        Integer value = visit(ctx.expr()); // evaluate the expr child
        System.out.println(value);         // print the result
        return 0;                          // return dummy value
    }

    /** INT */
    // 將字符串形式的整數轉換爲整數類型
    @Override
    public Integer visitInt(CalcParser.IntContext ctx) {
        return Integer.valueOf(ctx.INT().getText());
    }

    /** ID */
    @Override
    public Integer visitId(CalcParser.IdContext ctx) {
        String id = ctx.ID().getText();
        // 從Map中獲取變量的值 
        if ( memory.containsKey(id) ) {
            return memory.get(id);
        } else {
            // 引用了不存在的變量,輸出錯誤信息 
            System.err.println(String.format("變量<%s> 不存在!",id));
            error = true;

        }
        return 0;
    }

    /** expr op=('*'|'/') expr */
    // 計算乘法和除法
    @Override
    public Integer visitMulDiv(CalcParser.MulDivContext ctx) {

        int left = visit(ctx.expr(0));  // get value of left subexpression
        int right = visit(ctx.expr(1)); // get value of right subexpression

        if ( ctx.op.getType() == CalcParser.MUL ) return left * right;
        return left / right; // must be DIV
    }

    // 計算加法和減法
    /** expr op=('+'|'-') expr */
    @Override
    public Integer visitAddSub(CalcParser.AddSubContext ctx) {
        int left = visit(ctx.expr(0));  // get value of left subexpression
        int right = visit(ctx.expr(1)); // get value of right subexpression
        if ( ctx.op.getType() == CalcParser.ADD ) return left + right;
        return left - right; // must be SUB
    }

    /** '(' expr ')' */
    // 處理括號表達式
    @Override
    public Integer visitParens(CalcParser.ParensContext ctx) {
        return visit(ctx.expr()); // return child expr's value
    }
}

最後看一下主程序(MarvelCalc)的源代碼。

import org.antlr.v4.runtime.ANTLRInputStream;
import org.antlr.v4.runtime.CharStream;
import org.antlr.v4.runtime.CharStreams;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.tree.ParseTree;
import java.io.FileInputStream;
import java.io.InputStream;

public class MarvelCalc {
    public static void main(String[] args) throws Exception  {
        // 從文件讀取源代碼 
        String inputFile = null;
        if ( args.length>0 ) {
            inputFile = args[0];
        } else {
            System.out.println("語法格式:MarvelCalc inputfile");
            return;
        }
        InputStream is = System.in;
        if ( inputFile!=null ) is = new FileInputStream(inputFile);

        CharStream input = CharStreams.fromStream(is);

        CalcLexer lexer = new CalcLexer(input);
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        CalcParser parser = new CalcParser(tokens);
        ParseTree tree = parser.prog(); // 分析源代碼

        EvalVisitor eval = new EvalVisitor();

        eval.visit(tree);
    }
}

在expr.calc文件中輸入下面的內容:

1+3 * 4 - 12 /6;
x = 40;
y = 13;
x * y + 20 - 42/6;
z = 12;
x + 5 * z - y;

並使用下面的命令行執行計算器程序,或在IDE中將expr.calc做爲參數容許MarvelCalc。

java MarvelCalc expr.calc

會獲得下面的結果:

11
533
87

咱們能夠看到,在expr.calc文件中,有3個能夠計算的表達式,其中最後兩個表達式使用了變量,而輸出結果就是這3個表達式的計算結果。從Calc.g4中也能夠看出。語句一共有以下3種:
(1) 輸出表達式(包括運算、id和常量)
(2)賦值表達式(建立變量)
(3)空行

從EvalVisitor類的實現能夠看出,只有輸出表達式纔會輸出結果,其餘的表達式只是在內部計算,生成內部結果,如向Map中存儲變量和值。

OK,到如今爲止,咱們已經建立了一個很是實用的計算器程序,不過這個程序仍然很簡單,在後面的文章中,將會不斷利用新學到的知識完成更復雜的編譯器程序,直到能夠實現Marvel語言爲止。

相關文章
相關標籤/搜索