Clojure 執行原理之編譯器剖析

jiacai2050發表於2017-02-05

Clojure is a compiled language, yet remains completely dynamic -- every feature supported by Clojure is supported at runtime.

Rich Hickey 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 明確區分編譯期與執行期。

Clojure 執行原理之編譯器剖析

Clojure 執行原理之編譯器剖析

編譯器工作流程

與解釋型語言裡的直譯器類似,編譯型語言通過編譯器(Compiler)來將源程式編譯為位元組碼。一般來說,編譯器包括兩個部分

Clojure 的編譯器也遵循這個模式,大致可以分為以下兩個模組:

  • 讀取 Clojure 源程式 --> 分詞 --> 構造 S-表示式,由 LispReader.java 類實現
  • 巨集擴充套件 --> 語義分析 --> 生成 JVM 位元組碼,由 Compiler.java 類實現

Clojure 執行原理之編譯器剖析
Clojure 編譯器工作流

上圖給出了不同階段的輸入輸出,具體實現下面一一講解。

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 (~@)複製程式碼

它們的具體含義可參考官方文件 reader#macrochars

Compiler.java

Compiler 類主要有三個入口函式:

  • compile,當呼叫clojure.core/compile時使用
  • load,當呼叫clojure.core/requireclojure.core/use時使用
  • eval,當呼叫clojure.core/eval時使用

Clojure 執行原理之編譯器剖析
Compiler 類的 UML

這三個入口函式都會依次呼叫 macroexpandanalyze 方法,生成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函式建立巨集,它會呼叫varsetMacro函式來設定元資訊macrotrue

analyze

interface Expr {
    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)。


Clojure 執行原理之編譯器剖析
Expr 類繼承關係(部分)

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。

參考

Clojure 執行原理之編譯器剖析
KeepWritingCodes 微信公眾號

PS: 微信公眾號,頭條,掘金等平臺均有我文章的分享,但我的文章會隨著我理解的加深不定期更新,建議大家最好去我的部落格 liujiacai.net 閱讀最新版。

相關文章