譯者 | 劉汪洋
審校 | 重樓
“這本書是經典之作,要好好拜讀?!?/p>
大約 15 年前,當我剛開始職業(yè)生涯并偶然踏入編譯器構建領域時,我的團隊架構師遞給我一本 《龍書》,并強調這是一部經典之作,需要倍加珍惜。不過不幸的是,有一天晚上我閱讀時不慎睡著,書本從手中滑落,重重地落在地板上。還書的時候,我非常希望他沒注意到封面上的那個小凹痕。
《龍書》首版發(fā)行于 1986 年,那時構建編譯器是一項極具挑戰(zhàn)性的任務,它集計算機科學和編程技術、藝術之大成。近四十年后,我再次面對這一挑戰(zhàn)。如今,這項任務的難度又是怎樣的呢?接下來,讓我們深入探討創(chuàng)建一種新語言所涉及的內容,以及現代工具如何簡化這一過程。
目標語言
為了更明確我們的目標,我們會構建一個具體的語言。我發(fā)現用實際案例來說明,比理論模型更有效。因此,我選擇了我們在 ZenStack 開發(fā)的 ZModel 語言作為例子。ZModel 是一種用于建模數據庫表和訪問控制規(guī)則的領域特定語言(DSL)。為了保持文章的簡潔,我只展示其中的部分功能。我們的目標是編譯下面的代碼:
model User {
id Int
name String
posts Post[]
}
model Post {
id Int
title String
author User
published Boolean
@@allow('read', published == true)
}
這里簡要說明幾點:
- model 關鍵字用于定義一個數據庫表,其字段對應表中的列。
- 模型可以相互引用,構建關系。在此例中,User 和 Post 模型構成了一對多關系。
- @@allow 關鍵字用于定義訪問控制規(guī)則。它接受兩個參數:一個是訪問類型(“create”、“read”、“update”、“delete” 或 “all”),另一個是布爾表達式,用于判定是否允許該操作。
讓我們開始動手編譯這段代碼吧!
注:ZModel 是 Prisma Schema Language 的擴展版本。
六個步驟構建編程語言
第 1 步:從文本到語法樹
盡管多年來編譯器的構建步驟基本保持不變,但一些高級語言構建工具已經能夠簡化這些步驟。這些工具可以直接將文本轉換成語法樹。構建過程首先需要一個詞法分析器(lexer),它將文本分解成標記(tokens)。然后,解析器(parser)會將這些標記組織成解析樹(parse tree)。現代工具往往可以將這兩個步驟整合,直接從文本生成語法樹。
我們采用了 Langium,這是一個基于 TypeScript 的開源軟件工具包,專門用于語言構建。Langium 提供了直觀的領域特定語言(DSL),讓我們能夠定義詞法和解析規(guī)則。
值得一提的是,Langium DSL 本身也是用 Langium 構建的。這種自我遞歸的過程,在編譯器領域被稱為自舉(bootstrapping)。通常,編譯器的最初版本需要用另一種語言或工具來編寫。
下面是我們的 ZModel 語言的正式語法定義:
grammar ZModel
entry Schema:
(models+=Model)*;
Model:
'model' name=ID '{'
(fields+=Field)+
(rules+=Rule)*
'}';
Field:
name=ID type=(Type | ModelReference) (isArray?='[' ']')?;
ModelReference:
target=[Model];
Type returns string:
'Int' | 'String' | 'Boolean';
Rule:
'@@allow' '('
accessType=STRING ',' condition=Condition
')';
Condition:
field=SimpleExpression '==' value=SimpleExpression;
SimpleExpression:
FieldReference | Boolean;
FieldReference:
target=[Field];
Boolean returns boolean:
'true' | 'false';
hidden terminal WS: /\s+/;
terminal ID: /[_a-zA-Z][\w_]*/;
terminal STRING: /"(\\.|[^"\\])*"|'(\\.|[^'\\])*'/;
這個語法定義清晰且易于理解,包含兩個主要部分:
- 詞法規(guī)則
底部的終結符規(guī)則定義了如何將源文本分解為標記。我們的簡單語言包含標識符(ID)和字符串(STRING)兩種標記類型??崭褡址谶@里被忽略。 - 解析規(guī)則
其他部分是解析規(guī)則,決定了如何將標記流組織成一棵樹。解析規(guī)則中也包含關鍵字(如 Int、@@allow),這些關鍵字同樣參與詞法分析。在更復雜的語言中,可能會出現遞歸的解析規(guī)則(例如,嵌套表達式),這需要特別注意設計,但我們的示例語言結構較為簡單,暫不涉及此類情況。
通過定義好的語言規(guī)則,我們可以利用 Langium 的 API 將示例代碼轉換成如下解析樹:
第 2 步:從語法樹構建鏈接樹
解析樹極大地幫助我們理解源代碼的語義。但為了進一步完善解析樹,我們還需要進行一些額外的步驟。
在我們的 ZModel 語言中,出現了“循環(huán)引用”的情況。例如,User 模型的字段被 Post 模型的 author 字段引用。當我們?yōu)g覽解析樹時,會遇到引用“Post”這個名字的節(jié)點,但無法直接得知“Post”具體指代什么。雖然可以進行特定的搜索來找到匹配的模型名稱,但更系統(tǒng)的做法是執(zhí)行一次“鏈接”操作,將這些引用解析并鏈接到它們的目標節(jié)點。完成這一鏈接后,我們的解析樹將轉變?yōu)槿缦滤荆榱撕喕故?,這里只展示樹的一部分):
從技術角度看,此時的結構更像是圖而非樹,但我們依舊習慣性地稱之為解析樹。
Langium 在這方面有顯著優(yōu)勢,它能自動完成大部分鏈接工作。這個工具遵循解析節(jié)點的嵌套層次,利用這一結構構建“作用域”。它解析遇到的名稱,并將它們鏈接到正確的目標節(jié)點。在語言較為復雜的場景中,可能會遇到需要特別處理的情況。Langium 允許用戶自定義實現幾個服務,從而簡化這個過程,讓鏈接操作更加高效。
第 3 步:從鏈接樹到語義正確性檢查
如果源文件包含詞法或解析錯誤,編譯器會報錯并終止處理。
例如:
model {
id
title String
}
編譯器可能會報如下錯誤:
期望的是 'ID' 類型的標記,但實際發(fā)現 `{`。[第1行,第7列]
但是,代碼即使沒有詞法或解析錯誤,也不一定在語義上正確。以以下代碼為例,它在語法上是有效的,但在語義上卻是錯誤的。原因是將 title 與 true 進行比較是無意義的操作。
model Post {
id Int
title String
author User
published Boolean
@@allow('read', title == true) // <- 這類比較應當是無效的
}
語義規(guī)則通常是特定于語言的,并且工具很難自動處理這些規(guī)則。Langium 解決這個問題的方法是,為不同節(jié)點類型提供驗證鉤子。
例如:
export function registerValidationChecks(services: ZModelServices) {
const registry = services.validation.ValidationRegistry;
const validator = services.validation.ZModelValidator;
const checks: ValidationChecks<ZModelAstType> = {
SimpleExpression: validator.checkExpression,
};
registry.register(checks, validator);
}
export class ZModelValidator {
checkExpression(expr: SimpleExpression, accept: ValidationAcceptor): void {
if (isFieldReference(expr) && expr.target.ref?.type !== 'Boolean') {
accept('error', '條件中只允許使用布爾字段', {
node: expr,
});
}
}
}
現在,我們可以針對語義問題,得到更準確的錯誤提示:
條件中只允許使用布爾字段 [第7行,第19列]
與詞法分析、解析和鏈接過程不同,語義檢查通常不是非常聲明式或系統(tǒng)化的。對于復雜的語言,你可能需要編寫許多命令式代碼規(guī)則。
圖片引用自 《特征工程的方法和原理》
第 4 步:優(yōu)化開發(fā)者體驗
在當今軟件開發(fā)領域,為開發(fā)者提供優(yōu)秀的工具體驗非常重要。一個成功的開發(fā)工具不僅要運行良好,還要提供出色的用戶體驗。在語言和編譯器開發(fā)中,關注開發(fā)者體驗(DX)主要涉及以下幾個方面:
- IDE 支持
優(yōu)秀的集成開發(fā)環(huán)境(IDE)支持,如語法高亮、代碼格式化、自動補全等,可以顯著降低學習曲線,提高開發(fā)者的工作效率。Langium 的一個重要優(yōu)勢是支持 Language Server Protocol(語言服務器協(xié)議)。這意味著你的解析規(guī)則和驗證檢查可以自動轉換為一個基礎的 LSP 實現,兼容 Visual Studio Code 和最新的JetBrains IDEs(有限制)。但要提供卓越的 IDE 體驗,你還需擴展 Langium 默認的 LSP 相關服務,進行深度優(yōu)化。
- 錯誤報告
你的驗證邏輯會在多種情況下生成錯誤消息。這些消息的準確性和實用性極大地影響了開發(fā)者理解和修復錯誤的速度。
- 調試
如果你的語言支持“執(zhí)行”(這一點我們將在下一節(jié)詳細討論),提供調試工具就非常重要。調試的具體內容取決于語言特性。對于包含語句和控制流的命令式語言,可能需要支持步進調試和狀態(tài)檢查。而對于聲明式語言,調試可能意味著提供可視化工具,幫助理解復雜結構,如規(guī)則和表達式等。
第 5 步:發(fā)揮實際應用價值
解析出一個無錯誤的解析樹是非常有趣的,但這本身并不足以產生實際應用價值。從這一步開始,你有幾個選項來生成實際的應用價值:
1.就此停止
你可以選擇在此階段停止,將解析樹作為最終成果,并讓用戶決定如何使用它。
2.轉換成其他語言
通常一種語言會有一個“后端”來將解析樹轉換成更低級的語言。舉個例子,Java 編譯器的后端生成 JVM 字節(jié)碼,TypeScript 的后端生成 JavaScript 代碼。在 ZenStack 中,我們將 ZModel 轉換成 Prisma Schema Language,并由目標語言的工具或運行時進行處理。
3.實現可插拔的轉換機制
另一種選擇是實現一個插件機制。使用這樣語言的用戶就可以提供他們自己的后端轉換。與僅提供解析樹相比,這是一種更有結構的方法。
4.構建一個執(zhí)行解析樹的運行時
這可能是構建語言的最全面方法。你可以實現一個解釋器來“運行”解析后的代碼。具體的“運行”意義取決于你的定義。在 ZenStack 中,我們不僅將 ZModel 轉換成 Prisma Schema Language,還實現了一個運行時。這個運行時解釋和執(zhí)行訪問控制規(guī)則,在數據訪問期間生效。
第 6 步:推廣使用
恭喜你!你已經完成了創(chuàng)建新語言工作的20%。就像大多數創(chuàng)新一樣,最具挑戰(zhàn)性的部分通常是推廣它——即使它是免費的。如果這種語言僅供你自己或團隊內部使用,那不如不做。但如果你的語言面向公眾,那么你需要投入大量努力進行市場推廣。這通常占據了剩余的 80% 工作量??。
最后的思考
考慮到軟件工程在過去幾十年的迅速發(fā)展,編譯器構建似乎成為了一門古老的藝術。然而,我認為這是每個認真的開發(fā)者都應該嘗試的事情。它能帶來獨特的經驗,并很好地反映了編程的二元性——美學與實用主義的結合。一個優(yōu)秀的軟件系統(tǒng)通?;谝粋€優(yōu)雅的概念模型,但在其表面之下,你會發(fā)現許多實際操作中的不太完美之處。
你應該嘗試構建一種語言,這是一個值得挑戰(zhàn)的有趣項目。
譯者介紹
劉汪洋,51CTO社區(qū)編輯,昵稱:明明如月,一個擁有 5 年開發(fā)經驗的某大廠高級 Java 工程師,擁有多個主流技術博客平臺博客專家稱號。
原文標題:How Much Work Does It Take to Build a Programming Language?,作者:ymc9