使用Go語言構建一個解釋型語言

oschina發表於2015-04-04

我目前正參與我們的一個大專案,Alloy。Alloy 是一種編譯型的程式語言。我目前在計算機及程式設計領域最喜歡的一個愛好就是語言。事實上,我認為每個程式設計師都應該對程式語言是如何工作的有個基本的瞭解,這就是我寫這個系列的原因。

這是系列文章中的第一篇文章。該系列將描述我已經寫過的程式碼,來向你展示如何製作自己的程式語言。這裡注意一下,本文假設你對編譯器/直譯器的理論/實踐有已有很少或沒有過往經驗。還有要注意的是,這一系列的文章不是介紹程式設計或Go程式設計的。

什麼是直譯器(interpreter)?

直譯器會直接執行或表現寫在某特定指令碼語言中的指令。這可以是一種已存在的指令碼語言,像 Python或者 Ruby。它也可以是一種你自己創造的指令碼語言,這將是我們在這裡要做的。這一系列將從Go的基礎開始指導你實現自己的指令碼語言/直譯器“玩具”

為什麼是“玩具”指令碼語言/直譯器?

直譯器可以是極其複雜的。現代直譯器(比如Ruby或Python)十分龐大,包括成百上千行,甚至多達百萬的程式碼量。這對一個新手來說不太容易理解。玩具語言是個更為簡化的版本,它們常常跳過或者省去一些短語(在這裡我們將不考慮優化)。製造一種玩具語言是一個理解它們如何工作的有效方法,當開始使用它們時,它們將實實在在地幫助你理解,即使你不是在一個已經存在的直譯器(如Rust)上工作。

程式語言

你可以用任意一種你喜歡的語言構建一個直譯器。在這個案例中,我將使用Go。這之前我還沒寫過許多Go,所以對我來說這也是一個學習的經歷!然而如果你不習慣用Go寫,你可以用如下任一種語言製作你的直譯器,可以是 C,Java,或者甚至是 JavaScript。

小結

由於在當今世界有如此多的直譯器和編譯器,因此有許多工具可以來幫助你製作它們。你需要決定是否考慮偷偷使用一個外部工具,或者你想要自己寫所有的程式碼。我更喜歡後者,因為我覺得如果我使用某個外部工具來代勞,我就學不會它如何工作。不過這完全取決於你自己。在直譯器環境中,你是否使用這些工具會在編譯器/直譯器社群引起非常強烈的爭論。一些人會告訴你如果你不用ANTLR,BISON或者其它一些工具那麼你會出錯。另一些人會說完成它的唯一方法就是親手寫你自己的詞彙分析器(lexer)和語法分析器(parser)。最後,這是你的選擇,但在這一系列文章中,我會至少會涵蓋如何構建詞彙分析器(Lexer)和語法分析器(Parser)。

理論

在深入之前,我們需要講解一下理論。

什麼是詞彙分析器和語法分析器

如果你看到這一段落,並困惑於我所指的詞彙分析器和語法分析器,那麼不用擔心。典型做法是把這個分析過程分成不同的階段。有些階段是可選的,換句話叫做優化階段。但是大部分現代解析器幾乎處理所有階段。讓我們深入去看看這些階段吧。

詞法分析

第一階段是語法分析,基本上就是一個分詞器。詞法分析、解析器或者語法分析把字元或者輸入流分割成標記。這些標記以列表或者容器等資料結構存成標記流。解析器通過歸類這些詞(輸入流中的符號字串),給於特定的標記某種含義。例如,*,=,+等詞可以歸為操作符,tost 和bacon可以歸為字串常亮,而’a‘和’b‘則是字元。

解析

解析器是一個翻譯元件,它用來接收資料的輸入,一個詞法分析器產生令牌列表,併產生一個表示式,通常是一種抽象的語法樹,或其他結構。直譯器遵循的規則被叫做語法,它是你定義的一種語言的方式,這些語法諸如Extended Backus- Naur Form (EBNF)和 BNF (Backus-Naur Form),它們被用來描述一種語言的語法。下面是一個被寫成EBNF語法的例子:

letter = "A" | "a" | ... "Z" | "z" | "_";
digit = { "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" };
identifier = letter { letter | digit };

這可能對你沒有任何意義。你可能認識這些在程式語言中的符號,例如管道|,花括號{}。所有的符號都有特殊的含義:

{ }   - denotes repetition  
|     - denotes an option, similar to OR
[...] - optional terminal/nonterminal
;     - termination  
=     - definition  
...   - sequence
"..." - terminal string

我們在後面將著眼於更多的一些符號. 上面的示例定義了一個“生產規則”. 一個生產規則可能包含兩個詞彙元素: 非終端和終端. 終端是不能使用語法規則不能被改變的文字. 非終端則是可以被替換的符號, 可以把它看做是一個佔位符或者一個變數. 它們有時會被稱為“語法變數”. 在上面的示例中, 識別符號,字母和數字都是非終端符號. 而 “Z”, ”0″, ”1″, 都是終端符號的例子,它們是常量字元,也就是說它們不能被改變.

現在來看,上面的語法中所有的符號都意味著什麼呢? 一個字母的定義是:

letter = "A" | "a" | ... "Z" | "z" | "_";

為了能夠理解,要像讀英語一樣閱讀它,例如,上面的語法被讀作 “A” 或者 “a” 到 “Z” 或者 “z” 或者 “_”. 因此一個字母可以是任何從 a-Z 或者一個下劃線的東西.

我們如此定義一個數字:

digit = { "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" };

這意味著一個數字可以是 “0” 或者 “1” 或者 “2” … 你理解到點子上了. 不過, 要注意這裡的大括號. 如果你還記得我們在上面提供的列表, 括弧代表了重複, 指定重複 0 到 n 次, 這裡的n可以是任何一個數字. 這就意味著一個數字可以是 0 – 9 總共重複 n 多次, 因此 123 是對的, 5123 也是對的.

最後是識別符號:

identifier = letter { letter | digit };

目前我們理解了字母(letter)和數字(digit)的意思, 我們現在就能夠理解這個小的生產規則. 基本上,一個識別符號必須以一個字元開頭,其後可以是0個或者更多個不同字母或者數字的重複. 例如,a_, a_a, a_1, a__, 等等都是正確的識別符號.

這兩個階段的詞法和語法解析通常指的是作為前端的編譯器和直譯器。現在,讓我們開始寫一些程式碼,我將使用GO來編寫。所有的原始碼將公佈在我的 Github頁面上。如果你接著使用GO來編寫,首先為你的專案建立一個新的目錄並且設定好你的main go檔案。剛好,我編寫了一個簡單的Hello World檔案來進行測試。GO擁有一個神奇的工作空間系統,因此一開始,你就需要建立你的工作空間,我一直使用Linux來作為我的工作空間,因此我使用GO設定$HOME/go 的環境變數
。為方便起見,GO推薦我們增加這個設定到達我們的路徑:

mkdir $HOME/go 
export PATH=$PATH:$GOPATH/bin

我的專案的基本路徑是在 github.com/felixangell。

你可以找到你想要的,或者你的 github 使用者名稱:

mkdir -p $GOPATH/src/github.com/yourusername

現在開始設定我們的直譯器程式,我們在個人目錄下建立一個資料夾,名字可以是你給這個直譯器起的任何名字,我叫它 vident。我們進入這個目錄。

mkdir $GOPATH/src/github.com/felixangell/vident
cd $GOPATH/src/github.com/felixangell/vident

然後我們建立一個簡單的檔案作為測試用,可以直接拷貝這一部分:

package main

import "fmt"

func main() {
  fmt.Printf("hello, world/n");
}

把他儲存到我們剛剛建立的資料夾 vident 中,名字為 main.go。現在我們便以並執行它:

go install
vident

因為我們工程目錄結構系統,我們需要新增 bin 目錄到我們的目錄,然後簡單的執行上面的程式碼。當你執行時,你應該可以看到輸出了“hello, world”。

那麼接下來我們要定義我們的語言。Vident 是一門簡單的語言,我們從一些小的特性入手,然後我們再轉移到複雜的示例。下面是 Vident 的一個程式碼例項:

let x = 5 + 5
print: x, "hello", x

我需要把->改為:,否則熟悉 Tumblr 格式的人對它很多抱怨,抱歉!我們語言的 EBNF 語法:

letter = "A" | "a" | ... "Z" | "z" | "_";
digit = { "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" };
identifier = letter { letter | digit }; 
number_literal = digit | [ "." digit ];
string_literal = """ letter { letter } """;
char_literal = "'" letter "'";
literal = number_literal | string_literal | char_literal;
binaryOp = "+" | "-" | "/" | "*";
binary_expr = expression binaryOp expression;
expression = binary_expr | function_call | identifier | literal;
let_stat = "let" identifier [ "=" expression ];
arguments = { expression "," };
function_call = identifier [ ":" arguments ];
statement = let_stat | function_call;

目前我們已經為這門語言引入了一些東西,最明顯的是方括號。方括號表示一個可選值,例如:

let_stat = "let" identifier [ "=" expression ];

這代表 let x 和 let x = 5 + 5 都是有效的,第一個是一個定義,比如定義變數,第二是顯示的變數宣告,即定義變數並宣告值。

現在看上面的語法可能會有點複雜,但如果你一點點的靠近去理解它,它就會比你想象的更加簡單. 注意,我們不會一下就全部實現它,而是按階段分部分去著重於語法的每一個部分並進行實現!

不管怎麼樣,如上就是第一部分! 敬請關注接下來的章節,我們將會編寫詞法分析器,而我們也會討論更多有關直譯器後端的內容.

相關文章