Clojure 運行原理之編譯器剖析 | Keep Writing Codes
Clojure is a compiled language, yet remains completely dynamic — every feature supported by Clojure is supported at runtime.
Rich Hickey https://clojure.org/
這里的 runtime 指的是 JVM,JVM 之初是為運行 Java 語言而設計,而現在已經發展成一重量級平臺,除了 Clojure 之外, 很多動態語言 也都選擇基于 JVM 去實現。
為了更加具體描述 Clojure 運行原理,會分兩篇文章來介紹。
本文為第一篇,涉及到的主要內容有:編譯器工作流程、Lisp 的宏機制。
第二篇將主要分析 Clojure 程序編譯成的 bytecode 如何保證動態語言的特性以及如何加速 Clojure 程序執行速度,這會涉及到 JVM 的類加載機制、反射機制。
編譯型 VS. 解釋型
SO 上有個問題 Is Clojure compiled or interpreted ,根據本文開始部分的官網引用,說明 Clojure 是門編譯型語言,就像 Java、Scala。但是 Clojure 與 Java 不一樣的地方在于,Clojure 可以在運行時進行編譯然后加載,而 Java 明確區分編譯期與運行期。
編譯器工作流程
與解釋型語言里的解釋器類似,編譯型語言通過編譯器(Compiler)來將源程序編譯為字節碼。一般來說,編譯器包括 兩個部分 :
- 前端: 詞法分析 —> 語法分析 —> 語義分析
- 后端: 分析、優化 —> 目標代碼生成
Clojure 的編譯器也遵循這個模式,大致可以分為以下兩個模塊:
- 讀取 Clojure 源程序 —> 分詞 —> 構造 S-表達式,由 LispReader.java 類實現
- 宏擴展 —> 語義分析 —> 生成 JVM 字節碼,由 Compiler.java 類實現
上圖給出了不同階段的輸入輸出,具體實現下面一一講解。
LispReader.java
一般來說,具有復雜語法的編程語言會把詞法分析與語法分析分開實現為 Lexer 與 Parser,但在 Lisp 家族中,源程序的語法就已經是 AST 了,所以會把 Lexer 與 Parser 合并為一個過程 Reader, 核心代碼 實現如下:
for (; ; ) {
if (pendingForms instanceof List && !((List) pendingForms).isEmpty())
return ((List) pendingForms).remove(0);
int ch = read1(r);
while (isWhitespace(ch))
ch = read1(r);
if (ch == -1) {
if (eofIsError)
throw Util.runtimeException("EOF while reading");
return eofValue;
}
if (returnOn != null && (returnOn.charValue() == ch)) {
return returnOnValue;
}
if (Character.isDigit(ch)) {
Object n = readNumber(r, (char) ch);
return n;
}
IFn macroFn = getMacro(ch);
if (macroFn != null) {
Object ret = macroFn.invoke(r, (char) ch, opts, pendingForms);
//no op macros return the reader
if (ret == r)
continue;
return ret;
}
if (ch == '+' || ch == '-') {
int ch2 = read1(r);
if (Character.isDigit(ch2)) {
unread(r, ch2);
Object n = readNumber(r, (char) ch);
return n;
}
unread(r, ch2);
}
String token = readToken(r, (char) ch);
return interpretToken(token);
}
Reader 的行為是由內置構造器(目前有數字、字符、Symbol 這三類)與一個稱為 read table 的擴展機制(getMacro)驅動的, read table 里面每項記錄提供了由特性符號(稱為 macro characters )到特定讀取行為(稱為 reader macros )的映射。
與 Common Lisp 不同,普通用戶無法擴展 Clojure 里面的 read table 。關于擴展 read table 的好處,可以參考 StackOverflow 上的 What advantage does common lisp reader macros have that Clojure does not have? 。Rich Hickey 在 一 Google Group 里面有闡述不開放 read table 的理由,這里摘抄如下:
I am unconvinced that reader macros are needed in Clojure at this
time. They greatly reduce the readability of code that uses them (by
people who otherwise know Clojure), encourage incompatible custom mini-
languages and dialects (vs namespace-partitioned macros), and
complicate loading and evaluation.
To the extent I’m willing to accommodate common needs different from
my own (e.g. regexes), I think many things that would otherwise have
forced people to reader macros may end up in Clojure, where everyone
can benefit from a common approach.
Clojure is arguably a very simple language, and in that simplicity
lies a different kind of power.
I’m going to pass on pursuing this for now,
截止到 Clojure 1.8 版本,共有如下九個 macro characters :
Quote (')
Character (\)
Comment (;)
Deref (@)
Metadata (^)
Dispatch (#)
Syntax-quote (`)
Unquote (~)
Unquote-splicing (~@)
Compiler.java
Compiler 類主要有三個入口函數:
- compile ,當調用 clojure.core/compile 時使用
- load ,當調用 clojure.core/require 、 clojure.core/use 時使用
- eval ,當調用 clojure.core/eval 時使用
這三個入口函數都會依次調用 macroexpand 、 analyze 方法,生成 Expr 對象,compile 函數還會額外調用 emit 方法生成 bytecode。
macroexpand
Macro 毫無疑問是 Lisp 中的屠龍刀,可以在 編譯時 自動生成代碼:
static Object macroexpand(Object form) {
Object exf = macroexpand1(form);
if (exf != form)
return macroexpand(exf);
return form;
}
macroexpand1 函數進行主要的擴展工作,它會調用 isMacro 判斷當前 Var 是否為一個宏,而這又是通過檢查 var 是否為一個函數,并且元信息中 macro 是否為 true 。
Clojure 里面通過 defmacro 函數創建宏,它會調用 var 的 setMacro 函數來設置元信息 macro 為 true 。
analyze
interfaceExpr{
Object eval();
void emit(C context, ObjExpr objx, GeneratorAdapter gen);
boolean hasJavaClass();
Class getJavaClass();
}
private static Expr analyze(C context, Object form, String name)
analyze 進行主要的語義分析, form 參數即是宏展開后的各種數據結構(String/ISeq/IPersistentList 等),返回值類型為 Expr ,可以猜測出, Expr 的子類是程序的主體,遵循模塊化的編程風格,每個子類都知道如何對其自身求值(eval)或輸出 bytecode(emit)。
emit
這里需要明確一點的是,Clojure 編譯器并沒有把 Clojure 代碼轉為相應的 Java 代碼,而是借助 bytecode 操作庫 ASM 直接生成可運行在 JVM 上的 bytecode。
根據 JVM bytecode 的規范,每個 .class 文件都必須由類組成,而 Clojure 作為一個函數式語言,主體是函數,通過 namespace 來封裝、隔離函數,你可能會想當然的認為每個 namespace 對應一個類,namespace 里面的每個函數對應類里面的方法,而實際上并不是這樣的,根據 Clojure 官方文檔 ,對應關系是這樣的:
- 每個文件、函數、 gen-class 都會生成一個 .class 文件
- 每個文件生成一個 <filename>__init 的加載類
- gen-class 生成固定名字的類,方便與 Java 交互
生成的 bytecode 會在本系列第二篇文章中詳細介紹,敬請期待。
eval
每個 Expr 的子類都有 eval 方法的相應實現。下面的代碼片段為 LispExpr.eval 的實現,其余子類實現也類似,這里不在贅述。
public Object eval() {
IPersistentVector ret = PersistentVector.EMPTY;
for (int i = 0; i < args.count(); i++)
// 這里遞歸的求列表中每項的值
ret = (IPersistentVector) ret.cons(((Expr) args.nth(i)).eval());
return ret.seq();
}
總結
之前看 SICP 后實現過幾個解釋器,但是相對來說都比較簡單,通過分析 Clojure 編譯器的實現,加深了對eval-apply 循環的理解,還有一點就是揭開了宏的真實面貌,之前一直認為宏是個很神奇的東西,其實它只不過是 編譯時運行的函數 而已,輸入與輸出的內容既是構成程序的數據結構,同時也是程序內在的 AST。
參考
- Decompiling Clojure II, the Compiler
- Clojure Compilation: Parenthetical Prose to Bewildering Bytecode
- The ClojureScript Compilation Pipeline
- Ahead-of-time Compilation and Class Generation
- The Reader
來自:http://liujiacai.net/blog/2017/02/05/clojure-compiler-analyze/