Clojure 運行原理之編譯器剖析

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

Rich Hickey clojure.org/html

這裏的 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 明確區分編譯期與運行期。java


編譯器工做流程

與解釋型語言裏的解釋器相似,編譯型語言經過編譯器(Compiler)來將源程序編譯爲字節碼。通常來講,編譯器包括兩個部分git

Clojure 的編譯器也遵循這個模式,大體能夠分爲如下兩個模塊:github

  • 讀取 Clojure 源程序 --> 分詞 --> 構造 S-表達式,由 LispReader.java 類實現
  • 宏擴展 --> 語義分析 --> 生成 JVM 字節碼,由 Compiler.java 類實現

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時使用

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)。


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。

參考

KeepWritingCodes 微信公衆號

PS: 微信公衆號,頭條,掘金等平臺均有我文章的分享,但個人文章會隨着我理解的加深不按期更新,建議你們最好去個人博客 liujiacai.net 閱讀最新版。

相關文章
相關標籤/搜索