一門語言的作用域和函數(shù)調(diào)用是如何實現(xiàn)的
前言
上次利用 Antlr 重構(gòu)一版??用 Antlr 重構(gòu)腳本解釋器??之后便著手新增其他功能,也就是現(xiàn)在看到的支持了作用域以及函數(shù)調(diào)用。
int b= 10;
int foo(int age){
for(int i=0;i<10;i++){
age++;
}
return b+age;
}
int add(int a,int b) {
int e = foo(10);
e = e+10;
return a+b+3+e;
}
add(2,20);
// Output:65
整個語法規(guī)則大部分參考了 Java,現(xiàn)階段支持了:
- 函數(shù)聲明與調(diào)用。
- 函數(shù)調(diào)用的入棧和出棧,保證了函數(shù)局部變量在函數(shù)退出時銷毀。
- 作用域支持,內(nèi)部作用域可以訪問外部作用域的變量。
- 基本的表達式語句,如i++, !=,==
這次實現(xiàn)的重點與難點則是作用域與函數(shù)調(diào)用,實現(xiàn)之后也算是滿足了我的好奇心,不過在講作用域與函數(shù)調(diào)用之前先來看看一個簡單的變量聲明與訪問語句是如何實現(xiàn)的,這樣后續(xù)的理解會更加容易。
變量聲明
int a=10;
a;
由于還沒有實現(xiàn)內(nèi)置函數(shù),比如控制臺輸出函數(shù) print(),所以這里就直接訪問變量也能拿到數(shù)據(jù)
運行后結(jié)果如下:
首先看看變量聲明語句的語法:
variableDeclarators
: typeType variableDeclarator (',' variableDeclarator)*
;
variableDeclarator
: variableDeclaratorId ('=' variableInitializer)?
;
typeList
: typeType (',' typeType)*
;
typeType
: (functionType | primitiveType) ('[' ']')*
;
primitiveType
: INT
| STRING
| FLOAT
| BOOLEAN
;
只看語法不太直觀,直接看下生成的 AST 樹就明白了:
編譯期 左邊這棵 ?BlockVardeclar? 樹對應(yīng)的就是 int a=10;?,右邊的 blockStm? 對應(yīng)的就是變量訪問 a。
整個程序的運行過程分為編譯期和運行期,對應(yīng)的流程:
遍歷 AST 樹,做語義分析,生成對應(yīng)的符號表、類型表、引用消解、還有一些語法校驗,比如變量名、函數(shù)名是否重復(fù)、是否能訪問私有變量等。
運行期:從編譯期中生成的符號表、類型表中獲取數(shù)據(jù),執(zhí)行具體的代碼邏輯。
訪問 AST
對于剛才提到的編譯期和運行期其實分別對應(yīng)兩種訪問 AST? 的方式,這也是 Antlr 所提供兩種方式。
Listener 模式
第一種是 Listener 模式,就這名字也能猜到是如何運行的;我們需要實現(xiàn) Antlr 所提供的接口,這些接口分別對應(yīng) AST 樹中的不同節(jié)點。
接著 Antlr 會自動遍歷這棵樹,當訪問和退出某個節(jié)點時變會回調(diào)我們自定義的方法,這些接口都是沒有返回值的,所以我們需要將遍歷過程中的數(shù)據(jù)自行存放起來。
這點非常適合上文提到的編譯期,遍歷過程中產(chǎn)生的數(shù)據(jù)自然就會存放到符號表、類型表這些容器中。
以這段代碼為例,我們實現(xiàn)了程序根節(jié)點、for循環(huán)節(jié)點的進入和退出 Listener,當 Antlr 運行到這些節(jié)點時便會執(zhí)行其中的邏輯。
https://github.com/crossoverJie/gscript/blob/main/resolver/type_scope_resolver.go
Visitor 模式
Visitor? 模式正好和 Listener 相反,這是由我們自行控制需要訪問哪個 AST 節(jié)點,同時需要在每次訪問之后返回數(shù)據(jù),這點非常適合來做程序運行期。
配合在編譯期中存放的數(shù)據(jù),便可以實現(xiàn)各種特性了。
以上圖為例,在訪問 Prog 節(jié)點時便可以從編譯期中拿到當前節(jié)點所對應(yīng)的作用域 scope?,同時我們可以自行控制訪問下一個節(jié)點 VisitBlockStms,訪問其他節(jié)點當然也是可以的,不過通常我們還是按照語法中定義的結(jié)構(gòu)進行訪問。
作用域
即便是同一個語法生成的 AST 是相同的,但我們在遍歷 AST 時實現(xiàn)不同也就會導致不同的語義,這就是各個語言語義分析的不同之處。
比如 Java 不允許在子作用域中聲明和父作用域中相同的變量,但 JavaScript 卻是可以的。
有了上面的基礎(chǔ)下面我們來看看作用域是如何實現(xiàn)的。
int a=10;
a;
還是以這段代碼為例:
這里我簡單畫了下流程:
在編譯期間會會為當前節(jié)點寫入一個 scope?,以及在 scope? 中寫入變量 “a”。
這里的寫入 scope 和寫入變量是分為兩次 Listener 進行的,具體代碼實現(xiàn)在下面查看源碼。
第一次:https://github.com/crossoverJie/gscript/blob/main/resolver/type_scope_resolver.go#L21
第二次:https://github.com/crossoverJie/gscript/blob/main/resolver/type_resolver.go#L59
接著是運行期,從編譯期中生成的數(shù)據(jù)拿到 scope? 以及其中的變量,獲取變量時有一個細節(jié):當前 scope 中如果獲取不到需要嘗試從父級 scope 中獲取,比如如下情況:
int b= 10;
int foo(){
return b;
}
這里的 b 在當前函數(shù)作用域中是獲取不到的,只能在父級 scope 中獲取。
父級 scope 的關(guān)系是在創(chuàng)建 scope 的時候維護進去的,默認當前 scope 就是寫入時 scope 的父級。
關(guān)鍵代碼試下如下圖:
第四步獲取變量的值也是需要訪問到 AST 中的字面量節(jié)點獲取值即可,核心代碼如下:
函數(shù)
函數(shù)的調(diào)用最核心的就是在運行時需要把當前函數(shù)中的所有數(shù)據(jù)入棧,訪問完畢后出棧,這樣才能實現(xiàn)函數(shù)退出后自動釋放函數(shù)體類的數(shù)據(jù)。
核心代碼如下:
int b= 10;
int foo(){
return b;
}
int func(int a,int b) {
int e = foo();
return a+b+3+e;
}
func(2,20);
即便是有上面這類函數(shù)類調(diào)其他函數(shù)情況也不必擔心,無非就是在執(zhí)行函數(shù)體的時候再往棧中寫入數(shù)據(jù)而已,函數(shù)退出后會依次退出棧幀。
有點類似于匹配括號的算法 {[()]},本質(zhì)上就是遞歸調(diào)用。
總結(jié)
限于篇幅其中的許多細節(jié)沒有仔細討論,感興趣的朋友可以直接跑跑單測,debug 試試。
https://github.com/crossoverJie/gscript/blob/main/compiler_test.go
目前的版本還比較初級,比如基本類型還只有 int,也沒有一些常用的內(nèi)置函數(shù)。
后續(xù)會逐步完善,比如新增:
- 函數(shù)多返回值。
- 自定義類型
- 閉包
等特性,這個坑會一直填下去,希望在年底可以用 gscript? 寫一個 web 服務(wù)端那就算是里程碑完成了。
現(xiàn)階段也實現(xiàn)了一個簡易的 REPL? 工具,大家可以安裝試用:
源碼地址:https://github.com/crossoverJie/gscript