Clojure 运行原理之编译器剖析 | Keep Writing Codes

karoizi 3年前
   <p>Clojure is a compiled language, yet remains completely dynamic — every feature supported by Clojure is supported at runtime.</p>    <p>Rich Hickey https://clojure.org/</p>    <p>这里的 runtime 指的是 JVM,JVM 之初是为运行 Java 语言而设计,而现在已经发展成一重量级平台,除了 Clojure 之外, 很多动态语言 也都选择基于 JVM 去实现。</p>    <p>为了更加具体描述 Clojure 运行原理,会分两篇文章来介绍。</p>    <p>本文为第一篇,涉及到的主要内容有:编译器工作流程、Lisp 的宏机制。</p>    <p>第二篇将主要分析 Clojure 程序编译成的 bytecode 如何保证动态语言的特性以及如何加速 Clojure 程序执行速度,这会涉及到 JVM 的类加载机制、反射机制。</p>    <h2>编译型 VS. 解释型</h2>    <p>SO 上有个问题 Is Clojure compiled or interpreted ,根据本文开始部分的官网引用,说明 Clojure 是门编译型语言,就像 Java、Scala。但是 Clojure 与 Java 不一样的地方在于,Clojure 可以在运行时进行编译然后加载,而 Java 明确区分编译期与运行期。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/f457ac8d72b2e50a21418395ca9d5e9f.png"> <img src="https://simg.open-open.com/show/3d94680e5fc6b478c2f7bc90b76124ff.png"></p>    <h2>编译器工作流程</h2>    <p>与解释型语言里的解释器类似,编译型语言通过编译器(Compiler)来将源程序编译为字节码。一般来说,编译器包括 <a href="/misc/goto?guid=4959736053537168920" rel="nofollow,noindex">两</a>个部分 :</p>    <ul>     <li>前端: 词法分析 —> 语法分析 —> 语义分析</li>     <li>后端: 分析、优化 —> 目标代码生成</li>    </ul>    <p>Clojure 的编译器也遵循这个模式,大致可以分为以下两个模块:</p>    <ul>     <li>读取 Clojure 源程序 —> 分词 —> 构造 S-表达式,由 LispReader.java 类实现</li>     <li>宏扩展 —> 语义分析 —> 生成 JVM 字节码,由 Compiler.java 类实现</li>    </ul>    <p style="text-align:center"><img src="https://simg.open-open.com/show/88c431c2680440d1f4586b0f78218039.png"></p>    <p>上图给出了不同阶段的输入输出,具体实现下面一一讲解。</p>    <h3><a href="/misc/goto?guid=4959736053631131655" rel="nofollow,noindex">LispReader.java</a></h3>    <p>一般来说,具有复杂语法的编程语言会把词法分析与语法分析分开实现为 Lexer 与 Parser,但在 Lisp 家族中,源程序的语法就已经是 AST 了,所以会把 Lexer 与 Parser 合并为一个过程 Reader, 核心代码 实现如下:</p>    <pre>  <code class="language-java">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);  }  </code></pre>    <p>Reader 的行为是由内置构造器(目前有数字、字符、Symbol 这三类)与一个称为 read table 的扩展机制(getMacro)驱动的, read table 里面每项记录提供了由特性符号(称为 macro characters )到特定读取行为(称为 reader macros )的映射。</p>    <p>与 Common Lisp 不同,普通用户无法扩展 Clojure 里面的 read table 。关于扩展 read table 的好处,可以参考 StackOverflow 上的 What advantage does common lisp reader macros have that Clojure does not have? 。Rich Hickey 在 <a href="/misc/goto?guid=4959736053712781808" rel="nofollow,noindex">一 Google Group</a> 里面有阐述不开放 read table 的理由,这里摘抄如下:</p>    <p>I am unconvinced that reader macros are needed in Clojure at this</p>    <p>time. They greatly reduce the readability of code that uses them (by</p>    <p>people who otherwise know Clojure), encourage incompatible custom mini-</p>    <p>languages and dialects (vs namespace-partitioned macros), and</p>    <p>complicate loading and evaluation.</p>    <p>To the extent I’m willing to accommodate common needs different from</p>    <p>my own (e.g. regexes), I think many things that would otherwise have</p>    <p>forced people to reader macros may end up in Clojure, where everyone</p>    <p>can benefit from a common approach.</p>    <p>Clojure is arguably a very simple language, and in that simplicity</p>    <p>lies a different kind of power.</p>    <p>I’m going to pass on pursuing this for now,</p>    <p>截止到 Clojure 1.8 版本,共有如下九个 macro characters :</p>    <pre>  <code class="language-java">Quote (')  Character (\)  Comment (;)  Deref (@)  Metadata (^)  Dispatch (#)  Syntax-quote (`)  Unquote (~)  Unquote-splicing (~@)  </code></pre>    <h3><a href="/misc/goto?guid=4959736053800414934" rel="nofollow,noindex">Compiler.java</a></h3>    <p>Compiler 类主要有三个入口函数:</p>    <ul>     <li><a href="/misc/goto?guid=4959736053877272022" rel="nofollow,noindex">compile</a> ,当调用 clojure.core/compile 时使用</li>     <li><a href="/misc/goto?guid=4959736053970756095" rel="nofollow,noindex">load</a> ,当调用 clojure.core/require 、 clojure.core/use 时使用</li>     <li><a href="/misc/goto?guid=4959736054056537913" rel="nofollow,noindex">eval</a> ,当调用 clojure.core/eval 时使用</li>    </ul>    <p style="text-align:center"><img src="https://simg.open-open.com/show/fc1510938a62e8ba485217d3b2071a86.png"></p>    <p>这三个入口函数都会依次调用 macroexpand 、 analyze 方法,生成 Expr 对象,compile 函数还会额外调用 emit 方法生成 bytecode。</p>    <p>macroexpand</p>    <p>Macro 毫无疑问是 Lisp 中的屠龙刀,可以在 编译时 自动生成代码:</p>    <pre>  <code class="language-java">static Object macroexpand(Object form) {      Object exf = macroexpand1(form);      if (exf != form)          return macroexpand(exf);      return form;  }  </code></pre>    <p>macroexpand1 函数进行主要的扩展工作,它会调用 isMacro 判断当前 Var 是否为一个宏,而这又是通过检查 var 是否为一个函数,并且元信息中 macro 是否为 true 。</p>    <p>Clojure 里面通过 defmacro 函数创建宏,它会调用 var 的 setMacro 函数来设置元信息 macro 为 true 。</p>    <p>analyze</p>    <pre>  <code class="language-java">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)  </code></pre>    <p>analyze 进行主要的语义分析, form 参数即是宏展开后的各种数据结构(String/ISeq/IPersistentList 等),返回值类型为 Expr ,可以猜测出, Expr 的子类是程序的主体,遵循模块化的编程风格,每个子类都知道如何对其自身求值(eval)或输出 bytecode(emit)。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/a2bc3a902b7ebeeea6d9e23867bcd572.png"></p>    <p>emit</p>    <p>这里需要明确一点的是,Clojure 编译器并没有把 Clojure 代码转为相应的 Java 代码,而是借助 bytecode 操作库 ASM 直接生成可运行在 JVM 上的 bytecode。</p>    <p>根据 JVM bytecode 的规范,每个 .class 文件都必须由类组成,而 Clojure 作为一个函数式语言,主体是函数,通过 namespace 来封装、隔离函数,你可能会想当然的认为每个 namespace 对应一个类,namespace 里面的每个函数对应类里面的方法,而实际上并不是这样的,根据 Clojure 官方文档 ,对应关系是这样的:</p>    <ul>     <li>每个文件、函数、 gen-class 都会生成一个 .class 文件</li>     <li>每个文件生成一个 <filename>__init 的加载类</li>     <li>gen-class 生成固定名字的类,方便与 Java 交互</li>    </ul>    <p>生成的 bytecode 会在本系列第二篇文章中详细介绍,敬请期待。</p>    <p>eval</p>    <p>每个 Expr 的子类都有 eval 方法的相应实现。下面的代码片段为 LispExpr.eval 的实现,其余子类实现也类似,这里不在赘述。</p>    <pre>  <code class="language-java">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();  }  </code></pre>    <h2>总结</h2>    <p>之前看 SICP 后实现过几个解释器,但是相对来说都比较简单,通过分析 Clojure 编译器的实现,加深了对eval-apply 循环的理解,还有一点就是揭开了宏的真实面貌,之前一直认为宏是个很神奇的东西,其实它只不过是 编译时运行的函数 而已,输入与输出的内容既是构成程序的数据结构,同时也是程序内在的 AST。</p>    <h2>参考</h2>    <ul>     <li><a href="/misc/goto?guid=4959736054142805256" rel="nofollow,noindex">Decompiling Clojure II, the Compiler</a></li>     <li><a href="/misc/goto?guid=4959736054224445808" rel="nofollow,noindex">Clojure Compilation: Parenthetical Prose to Bewildering Bytecode</a></li>     <li><a href="/misc/goto?guid=4959736054307735839" rel="nofollow,noindex">The ClojureScript Compilation Pipeline</a></li>     <li><a href="/misc/goto?guid=4959736054389173438" rel="nofollow,noindex">Ahead-of-time Compilation and Class Generation</a></li>     <li><a href="/misc/goto?guid=4959736054480327010" rel="nofollow,noindex">The Reader</a></li>    </ul>    <p> </p>    <p> </p>    <p>来自:http://liujiacai.net/blog/2017/02/05/clojure-compiler-analyze/</p>    <p> </p>