跟vczh看實例學編譯原理——一:Tinymoe的設計哲學

自從《序》胡扯了快一個月以後,終於迎來了正片。之因此係列文章叫《看實例學編譯原理》,是由於整個系列會經過帶你們一步一步實現Tinymoe的過程,來介紹編譯原理的一些知識點。git

 

可是第一個系列還沒到開始處理Tinymoe源代碼的時候,首先的跟你們講一講我設計Tinymoe的故事。爲何這種東西要等到如今纔講呢,由於以前沒有文檔,將了也是白講啊。Tinymoe在github的wiki分爲兩部分,一部分是介紹語法的,另外一部分是介紹一個最小的標準庫是如何實現出來的,地址在 https://github.com/vczh/tinymoe/wiki 不帶問號的那些都是寫完了的。程序員

系列文章的目標

在介紹Tinymoe以前,先說一下這個系列文章的目標。Ideally,只要一我的看完了這個系列,他就能夠在下面這些地方獲得入門github

  • 詞法分析
  • 歧義與不歧義的語法分析
  • 語義分析
  • 符號表
  • 全文CPS變換
  • 編譯生成高效的其餘語言的代碼
  • 編譯生成本身的指令集
  • 帶GC的虛擬機
  • 類型推導(intersection type,union type,concept mapping)
  • 跨過程分析(inter-procedural analyzing)

 

固然,這並不能讓你成爲一個大牛,可是至少本身作作實驗,搞一點高大上的東西騙師妹們是沒有問題了。算法

Tinymoe設計的目標

雖然想法不少年前就已經有了,可是此次我想把它實現出來,是爲了完成《如何設計一門語言》的後續。光講大道理是沒有意義的,至少得有一個例子,讓你們知道這些事情究竟是什麼樣子的。所以Tinymoe有一點教學的意義,無論是使用它仍是實現它。spring

 

首先,處理Tinymoe須要的知識點多,用於編譯原理教學。既然是爲了展現編譯原理的基礎知識,所以語言自己不多是那種爛大街的C系列的東西。固然除了知識點之外,還會讓你們深入的理解到,難實現和難用,是徹底沒有關係的!Tinymoe用起來可爽了,啊哈哈哈哈哈。數組

 

其次,Tinymoe容易嵌入其餘語言的程序,做爲DSL使用,能夠調用宿主程序提供的功能。這嚴格的來說不算語言自己的功能,而是實現自己的功能。就算是C++也能夠設計爲嵌入式,lua也能夠被設計爲編譯成exe的。一個語言自己的設計並不會對如何使用它有多大的限制。爲了讓你們看了這個系列以後,能夠寫出至少可用的東西,而不只僅是寫玩具,所以這也是設計的目標之一。數據結構

 

第三,Tinymoe語法優化於描述複雜的邏輯,而不是優化與複雜的數據結構和算法(雖然也能夠)。Tinymoe自己是不存在任何細粒度控制內存的能力的,並且雖然能夠實現複雜的數據結構和算法,可是自己描述這些東西最多也就跟JavaScript同樣容易——其實就是不容易。可是Tinymoe設計的時候,是爲了讓你們把Tinymoe當成是一門能夠設計DSL的語言,所以對複雜邏輯的描述能力特別強。惟一的前提就是,你懂得如何給Tinymoe寫庫。很好的使用和很好地實現一個東西是相輔相成的。我在設計Tinymoe之初,不少pattern我也不知道,只是由於設計Tinymoe遵循了科學的方法,所以最後我發現Tinymoe居然具備如此強大的描述能力。固然對於讀者們自己,也會在閱讀系列文章的有相似的感受。閉包

 

最後,Tinymoe是一個動態類型語言。這純粹是個人我的愛好了。對一門動態類型語言作靜態分析那該多有趣啊,啊哈哈哈哈哈哈。app

Tinymoe的設計哲學

固然我並不會爲了寫文章就無線提升Tinymoe的實現難度的。爲了把他控制在一個正常水平,所以設計Tinymoe的第一條就是:數據結構和算法

 

1、小規模的語言核心+大規模的標準庫

 

其實這跟C++差很少。可是C++因爲想作的事情實在是太多了,譬如說視圖包涵全部範式等等,所以就算這麼作,仍然讓C++自己包含的東西過於巨大(其實我仍是以爲C++不難怎麼辦)。

 

語言核心小,實現起來固然容易。可是你並不能爲了讓語言核心小就犧牲什麼功能。所以精心設計一個核心是必須的,由於全部你想要可是不想加入語言的功能,今後就能夠用庫來實現了。

 

譬如說,Tinymoe經過有條件地暴露continuation,要求編譯器在編譯Tinymoe的時候作一次全文CPS變換。這個東西說容易也不是那麼容易,可是至少比你作分支循環異常處理什麼的所有加起來要簡單多了吧。因此我只提供continuation,剩下的控制流所有用庫來作。這樣有三個好處:

  1. 語言簡單,實現難度下降
  2. 爲了讓庫能夠發揮應有的做用,語言的功能的選擇十分的正交化。不過這仍然在必定的程度上提升了學習的難度。可是並非全部人都須要寫庫對吧,不少人只須要會用庫就夠了。經過一點點的犧牲,正交化能夠充分發揮程序員的想象能力。這對於以DSL爲目的的語言來講是不可或缺的。
  3. 標準庫自己能夠做爲編譯器的測試用例。你只須要準備足夠多的測試用例來運行標準庫,那麼你只要用C++(假設你用C++來實現Tinymoe)來跑他們,那全部的標準庫都會獲得運行。運行結果若是對,那你對編譯器的實現也就有信心了。爲何呢,由於標準庫大量的使用了語言的各類功能,並且是無節操的使用。若是這樣都能過,那普通的程序就更能過了。

 

說了這麼多,那到底什麼是小規模的語言核心呢?這在Tinymoe上有兩點體現。

 

第一點,就是Tinymoe的語法元素少。一個Tinymoe表達式無非就只有三類:函數調用、字面量和變量、操做符。字面量就是那些數字字符串什麼的。當Tinymoe的函數的某一個參數指定爲不定個數的時候你還得提供一個tuple。委託(在這裏是函數指針和閉包的統稱)和數組雖然也是Tinymoe的原生功能之一,可是對他們的操做都是經過函數調用來實現的,沒有特殊的語法。

 

簡單地講,就是除了下面這些東西之外你不會見到別的種類的表達式了:

1

"text"

sum from 1 to 100

sum of (1, 2, 3, 4, 5)

(1+2)*(3+4)

true

 

一個Tinymoe語句的種類就更少了,要麼是一個函數調用,要麼是block,要麼是連在一塊兒的幾個block:

do something bad

 

repeat with x from 1 to 100

    do something bad with x

end

 

try

    do something bad

catch exception

    do something worse

end

 

有人可能會說,那repeat和try-catch就不是語法元素嗎?這個真不是,他們是標準庫定義好的函數,跟你本身聲明的函數沒有任何特殊的地方。

 

這裏其實還有一個有意思的地方:"repeat with x from 1 to 100"的x實際上是循環體的參數。Tinymoe是如何給你自定義的block開洞的呢?不只如此,Tinymoe的函數還能夠聲明"引用參數",也就是說調用這個函數的時候你只能把一個變量放進去,函數裏面能夠讀寫這個變量。這些都是怎麼實現的呢?學下去就知道了,啊哈哈哈哈。

 

Tinymoe的聲明也只有兩種,第一種是函數,第二種是符號。函數的聲明可能會略微複雜一點,不過除了函數頭之外,其餘的都是相似配置同樣的東西,幾乎都是用來定義"catch函數在使用的時候必須是連在try函數後面"啊,"break只能在repeat裏面用"啊,諸如此類的信息。

 

Tinymoe的符號十分簡單,譬如說你要定義一年四季的符號,只須要這麼寫:

symbol spring

symbol summer

symbol autumn

symbol winter

 

symbol是一個"不同凡響的值",也就是說你在兩個module下面定義同名的symbol他們也是不同的。全部symbol之間都是不同的,能夠用=和<>來判斷。symbol就是靠"不同"來定義其自身的。

 

至於說,那爲何不用enum呢?由於Tinymoe是動態類型語言,enum的類型自己是根本沒有用武之地的,因此乾脆就設計成了symbol。

 

第二點,Tinymoe除了continuation和select-case之外,沒有其餘原生的控制流支持

 

這基本上歸功於先輩發明continuation passing style transformation的功勞,細節在之後的系列裏面會講。心急的人能夠先看 https://github.com/vczh/tinymoe/blob/master/Development/Library/StandardLibrary.txt 。這個文件暫時包含了Tinymoe的整個標準庫,裏面定義了不少if-else/repeat/try-catch-finally等控制流,甚至連coroutine均可以用continuation、select-case和遞歸來作。

 

這也是小規模的語言核心+大規模的標準庫所要表達的意思。若是能夠提供一個feature A,經過他來完成其餘必要的feature B0, B1, B2…的同時,未來說不定還有人能夠出於本身的需求,開發DSL的時候定義feature C,那麼只有A須要保留下來,全部的B和C都將使用庫的方法來實現。

 

這麼作並非徹底有益無害的,只是壞處很小,在"Tinymoe的實現難點"裏面會詳細說明。

 

2、擴展後的東西跟原生的東西外觀一致

 

這是很重要的。若是擴展出來的東西跟原生的東西長得不同,用起來就以爲很傻逼。Java的string不能用==來判斷內容就是這樣的一個例子。雖然他們有的是理由證實==的反直覺設計是對的——可是反直覺就是反直覺,就是一個大坑。

 

這種例子還有不少,譬如說go的數組和表的類型啦,go自己若是不要數組和表的話,是寫不出長得跟原生數組和表同樣的數組和表的。其實這也不是一個大問題,問題是go給數組和表的樣子搞特殊化,還有那個反直覺的slice的賦值問題(會合法溢出!),相似的東西實在是太多了。一個東西特例太多,坑就沒法避免。因此其實在我看來,go還不如給C語言加上erlang的actor功能了事。

 

反而C++在這件事情上就作得很好。若是你對C++不熟悉的話,有時候根本分不清什麼是編譯器乾的,什麼是標準庫乾的。譬如說static_cast和dynamic_cast長得像一個模板函數,所以boost就能夠用相似的手法加入lexical_cast和針對shared_ptr的static_pointer_cast和dynamic_pointer_cast,整個標準庫和語言自己渾然一體。這樣子作的好處是,當你在培養對語言自己的直覺的時候,你也在培養對標準庫的直覺,培養直覺這件事情你不用作兩次。你對一個東西的直覺越準,學習新東西的速度就越快。因此C++的設計恰好可讓你在熬過第一個階段的學習以後,後面都以爲無比的輕鬆。

 

不過具體到Tinymoe,由於Tinymoe自己的語法元素太少了,因此這個作法在Tinymoe身上體現得不明顯。

Tinymoe的實現難點

首先,語法分析須要對Tinymoe程序處理三遍。Tinymoe對於語句設計使得對一個Tinymoe程序作語法分析不是那麼直接(雖然比C++什麼的仍是容易多了)。舉個例子:

module hello world

 

phrase sum from (lower bound) to (upper bound)

end

 

sentence print (message)

end

 

phrase main

    print sum from 1 to 100

end

 

第一遍分析是詞法分析,這個時候得把每個token的行號記住。第二遍分析是不帶歧義的語法分析,目標是把全部的函數頭抽取出來,而後組成一個全局符號表。第三遍分析就是對函數體裏面的語句作帶歧義的語法分析了。由於Tinymoe容許你定義變量,因此符號表確定是一邊分析一邊修改的。因而對於"print sum from 1 to 100"這一句,若是你沒有發現"phrase sum from (lower bound) to (upper bound)"和"sentence print (message)",那根本無從下手。

 

還有另外一個例子:

module exception handling

 

 

phrase main

    try

        do something bad

    catch

        print "bad thing happened"

    end

end

 

當語法分析作到"try"的時候,由於發現存在try函數的定義,因此Tinymoe知道接下來的"do something bad"屬於調用try這個塊函數所需提供的代碼塊裏面的代碼。接下來是"catch",Tinymoe怎麼知道catch是接在try後面,而不是放在try裏面的呢?這仍然是因爲catch函數的定義告訴咱們的。關於這方面的語法知識能夠點擊這裏查看

 

正由於如此,咱們須要首先知道函數的定義,而後才能分析函數體裏面的代碼。雖然這在必定程度上形成了Tinymoe的語法分析複雜度的提高,可是其複雜度自己並不高。比C++簡單就不說了,就算是C、C#和Java,因爲其語法元素太多,致使不須要屢次分析所下降的複雜度被徹底的抵消,結果跟實現Tinymoe的語法分析器的難度不相上下。

 

其次,CPS變換後的代碼須要特殊處理,不然直接執行容易致使call stack積累的沒用的東西過多。由於Tinymoe能夠自定義操做符,因此操做符跟C++同樣在編譯的時候被轉換成了函數調用。每個函數調用都是會被CPS變換的。儘管每一行的函數調用次數很少,可是若是你的程序油循環,循環是經過遞歸來描述(而不是實現,因爲CPS變換後Tinymoe作了優化,因此不存在實際上的遞歸)的,若是直接執行CPS變換後的代碼,算一個1加到1000都會致使stack overflow。可見其call stack裏面堆積的closure數量之巨大。

 

我在作Tinymoe代碼生成的實驗的時候,爲了簡單我在單元測試裏面直接產生了對應的C#代碼。一開始沒有處理CPS而直接調用,程序不只慢,並且容易stack overflow。可是咱們知道(其實大家之後纔會知道),CPS變換後的代碼裏面幾乎全部的call stack項都是浪費的,所以我把整個在生成C#代碼的時候修改爲,若是須要調用continuation,就返回調用continuation的語句組成的lambda表達式,在最外層用一個循環去驅動他直到返回null爲止。這樣作了以後,就算Tinymoe的代碼有遞歸,call stack裏面也不會由於遞歸而積累call stack item了。因而生成的C#代碼執行飛快,並且不管你怎麼遞歸也永遠不會形成stack overflow了。這個美妙的特性幾乎全部語言都作不到,啊哈哈哈哈哈。

 

固然這也是有代價的,由於本質上我只是把保存在stack上的context轉移到heap上。不過多虧了.net 4.0的強大的background GC,這樣作絲毫沒有多餘的性能上的損耗。固然這也意味着,一個高性能的Tinymoe虛擬機,須要一個牛逼的垃圾收集器做爲靠山。context產生的closure在函數體真的被執行完以後就會被很快地收集,因此CPS加上這種作法並不會對GC產生額外的壓力,全部的壓力仍然來源於你本身所建立的數據結構。

 

第三,Tinymoe須要動態類型語言的類型推導。固然你不這麼作而把Tinymoe的程序當JavaScript那樣的程序處理也沒有問題。可是咱們知道,正是由於V8對JavaScript的代碼進行了類型推導,才產生了那麼優異的性能。所以這算是一個優化上的措施。

 

最後,Tinymoe還須要跨過程分析和對程序的控制流的化簡(譬如continuation轉狀態機等)。目前具體怎麼作我還在學習當中。不過咱們想,既然repeat函數是經過遞歸來描述的,那咱們能不能經過對全部代碼進行inter-procedural analyzing,從而發現諸如

repeat 3 times

    do something good

end

就是一個循環,從而生成用真正的循環指令(譬如說goto)呢?這個問題是個頗有意思的問題,我以爲我若是能夠經過學習靜態分析從而解決它,不進個人能力會獲得提高,我對大家的科普也會作得更好。

後記

雖然還不到五千字,可是總以爲寫了好多的樣子。總之我但願讀者在看完《零》和《一》以後,對接下來須要學習的東西有一個較爲清晰的認識。

相關文章
相關標籤/搜索