大前端开发者须要了解的基础编译原理和语言知识

在我刚刚进入大学,从零开始学习 C 语言的时候,我就不断的从学长的口中听到一个又一个语言,好比 C++、Java、Python、JavaScript 这些大众的,也有 Lisp、Perl、Ruby 这些相对小众的。通常来讲,当程序员讨论一门语言的时候,默认的上下文常常是:“用 xxx 语言来完成 xxx 任务”。因此一直困扰着的个人一个问题就是,为何完成某个任务,必定要选择特定的语言,好比安卓开发是 Java,前端要用 JavaScript,iOS 开发使用 Objective-C 或者 Swift。这些问题的答案很是复杂,有的是技术缘由,有的是历史缘由,有的会考虑成本,很可贵出统一的结论,只能 case-by-case 的分析。这篇文章并不是专门解答上述问题,而是但愿经过介绍一些通用的概念,帮助读者掌握分析问题的能力,若是这个概念在实际编程中用获得,我也会举一些具体的例子。javascript

在阅读本文前,不妨思考一下这几个问题,若是没有头绪,建议看完文章之后再思考一遍。若是以为答案显而易见,恭喜你,这篇文章并不是为你准备的:html

  1. 什么是编译器,它以什么为分界线,分为前端和后端?
  2. Java 是编译型语言仍是解释型语言,Python 呢?
  3. C 语言的编译器也是 C 语言,那它怎么被编译的?
  4. 目标文件的格式是什么样的,段表、符号表、重定位表有什么做用?
  5. Swift 是静态语言,为何还有运行时库?
  6. 什么是 ABI,ABI 不稳定有什么问题?
  7. 什么是 WebAssembly,为何要推出这门技术,用 C++ 代替 JavaScript 可行么?
  8. JavaScript 和 DOM API 是什么关系,JavaScript 能够读写文件么?
  9. C++ 代码能够自动转换成 Java 代码么,任意两种语言是否能够互转?
  10. 为何说 Python 是胶水语言,它能够用来开发 iOS/Android 么?

编译原理

就像数学是一个公理体系,从简单的公理就能推导出各类高阶公式同样,咱们从最基本的 C 语言和编译提及。前端

int main(void) {
    int a = strlen("Hello world");  // 字符串的长度是 11
    return 0;
}复制代码

相关的介绍编译过程的文章不少,读者应该都很是熟悉了,整个流程包括预处理词法分析语法分析生成中间代码生成目标代码汇编连接 等。已有的文章大多分析了每一步的逻辑,但不多谈实现思路,我会尽可能用简单的语言来描述每一步的实现思路,相信这样有助于加深记忆。因为主要谈的概念和思路,不免会有一些不够准确的抽象,读者学会抓重点就行。java

预处理是一个独立的模块,它放在最后介绍,咱们先看词法分析。python

词法分析

最早登场的是编译器,它负责前五个步骤,也就是说编译器的输入是源代码,输出是中间代码。ios

编译器不能像人同样,一眼就看明白源代码的内容,它只能比较傻的逐个单词分析。词法分析要作的就是把源代码分割开,造成若干个单词。这个过程并不像想象的那么简单。好比举几个例子:git

  1. int t 表示一个整数,而 intt 只是一个变量名。
  2. int a() 表示一个函数而非整数 a,int a () 也是一个函数。
  3. a = 没有具体价值,它能够是一个赋值语句,还能够是 a == 1 的前缀,表示一个判断。

词法分析的主要难点在于,前缀没法决定一个完整字符串的含义,一般须要看完整句之后才知道每一个单词的具体含义。同时,C 语言的语法也不简单,各类关键字,括号,逗号,语法等等都会给词法分析的实现增长难度。程序员

词法分析的主要实现原理是状态机,它逐个读取字符,而后根据读到的字符的特色转换状态。好比这是 GCC 的词法分析状态机(引用自《编译系统透视》):github

若是本身实现的话,思路也不难。外面包一个循环,而后各类 switch...case 就完事了。词法分析应该算是最简单的一节。web

语法分析

通过词法分析之后,编译器已经知道了每一个单词,但这些单词组合起来表示的语法还不清楚。一个简单的思路是模板匹配,好比有这样的语句:

int a = 10;复制代码

它其实表示了这么一种通用的语法格式:

类型 变量名 = 常量;

因此 int a = 10; 固然能够匹配上这种模式。同理,它不可能匹配 类型 函数名(参数); 这种函数定义模式,由于二者结构不一致,等号没法被匹配。

语法分析比词法分析更复杂,由于全部 C 语言支持的语法特性都必须被语法分析器正确的匹配,这个难度比纯新手学习 C 语言语法难上不少倍。不过这个属于业务复杂性,不管采用哪一种解决方案都不可避免,由于语法规则的数量就是这么多。

在匹配模式的时候,另外一个问题在于上述的名词,好比 类型参数,很难界定。好比 int 是类型,long long 也是类型,unsigned long long 也是类型。(int a) 能够是参数,(int a, int b) 也是参数,(unsigned long long a, long long double b, int *p) 看起来能把人逼疯。

下面举一个简单的例子来解释 int a = 10 是如何被解析的,总的思路是概括与分解。咱们把一个复杂的式子分割成若干部分,而后分析各个部分,这样能够简化复杂度。对于 int a = 10 来讲,他是一个声明,声明由两部分组成,分别是声明说明符和初始声明符列表。

声明 声明说明符 初始声明符列表
int a = 10 int a = 10
int fun(int a) int fun(int a)
int array[5] int array[5]

声明说明符比较简单,它实际上是若干个类型的串联:

声明说明符 = 类型 + 类型的数组(长度能够为 0)

并且咱们知道若干个类型连在一块儿又变成了声明说明符,因此上述等式等价于:

声明说明符 = 类型 + 声明说明符(可选)

再严谨一些,声明说明符还能够包括 const 这样的限定说明符,inline 这样的函数说明符,和 _Alignas 这样的对齐说明符。借用书中的公式,它的完整表达以下:

这才仅仅是声明语句中最简单的声明说明符,仅仅是几个类型和关键字的组合而已。后面的初始声明符列表的解析更复杂。若是有能力作完这些解析,恭喜你,成功的解析了声明语句。你会发现什么定义语句啦,调用语句啦,正妩媚的向你招手╮(╯▽╰)╭。

成功解析语法之后,咱们会获得抽象语法树(AST: Abstract Syntax Tree)。以这段代码为例:

int fun(int a, int b) {
    int c = 0;
    c = a + b;
    return c;
}复制代码

它的语法树以下:

语法树将字符串格式的源代码转化为树状的数据结构,更容易被计算机理解和处理。但它距离中间代码还有必定的距离。

生成中间代码

以 GCC 为例,生成中间代码能够分为三个步骤:

  1. 语法树转高端 gimple
  2. 高端 gimple 转低端 gimple
  3. 低端 gimple 通过 cfa 转 ssa 再转中间代码

简单的介绍一下每一步都作了什么。

语法树转高端 gimple

这一步主要是处理寄存器和栈,好比 c = a + b 并无直接的汇编代码和它对应,通常来讲须要把 a + b 的结果保存到寄存器中,而后再把寄存器赋值给 c。因此这一步若是用 C 语言来表示实际上是:

int temp = a + b; // temp 实际上是寄存器
c =  temp;复制代码

另外,调用一个新的函数时会进入到函数本身的栈,建栈的操做也须要在 gimple 中声明。

高端 gimple 转低端 gimple

这一步主要是把变量定义,语句执行和返回语句区分存储。好比:

int a = 1;
a++;
int b = 1;复制代码

会被处理成:

int a = 1;
int b = 1;
a++;复制代码

这样作的好处是很容易计算一个函数到底须要多少栈空间。

此外,return 语句会被统一处理,放在函数的末尾,好比:

if (1 > 0) {
    return 1;
}
else {
    return 0;
}复制代码

会被处理成:

if (1 > 0) {
    goto a;
}
else {
    goto b;
}
a:
    return 1;
b:
    return 0;复制代码

低端 gimple 通过 cfa 转 ssa 再转中间代码

这一步主要是进行各类优化,添加版本号等,我不太了解,对于普通开发者来讲也没有学习的必要。

中间代码的意义

其实中间代码能够被省略,抽象语法树能够直接转化为目标代码(汇编代码)。然而,不一样的 CPU 的汇编语法并不一致,好比 AT&T与Intel汇编风格比较 这篇文章所提到的,Intel 架构和 AT&T 架构的汇编码中,源操做数和目标操做数位置刚好相反。Intel 架构下操做数和当即数没有前缀但 AT&T 有。所以一种比较高效的作法是先生成语言无关,CPU 也无关的中间代码,而后再生成对应各个 CPU 的汇编代码。

生成中间代码是很是重要的一步,一方面它和语言无关,也和 CPU 与具体实现无关。能够理解为中间代码是一种很是抽象,又很是普适的代码。它客观中立的描述了代码要作的事情,若是用中文、英文来分别表示 C 和 Java 的话,中间码某种意义上能够被理解为世界语。

另外一方面,中间代码是编译器前端和后端的分界线。编译器前端负责把源码转换成中间代码,编译器后端负责把中间代码转换成汇编代码。

LLVM IR 是一种中间代码,它长成这样:

define i32 @square_unsigned(i32 %a) {
  %1 = mul i32 %a, %a
  ret i32 %1
}复制代码

生成目标代码

目标代码也能够叫作汇编代码。因为中间代码已经很是接近于实际的汇编代码,它几乎能够直接被转化。主要的工做量在于兼容各类 CPU 以及填写模板。在最终生成的汇编代码中,不只有汇编命令,也有一些对文件的说明。好比:

.file       "test.c"      # 文件名称
    .global     m             # 全局变量 m
    .data                     # 数据段声明
    .align      4             # 4 字节对齐
    .type       m, @objc
    .size       m, 4
m:
    .long       10            # m 的值是 10
    .text
    .global     main
    .type       main, @function
main:
    pushl   %ebp
    movl    %esp,   %ebp
    ...复制代码

汇编

汇编器会接收汇编代码,将它转换成二进制的机器码,生成目标文件(后缀是 .o),机器码能够直接被 CPU 识别并执行。从目标代码能够猜出来,最终的目标文件(机器码)也是分段的,这主要有如下三个缘由:

  1. 分段能够将数据和代码区分开。其中代码只读,数据可写,方便权限管理,避免指令被改写,提升安全性。
  2. 现代 CPU 通常有本身的数据缓存和指令缓存,区分存储有助于提升缓存命中率。
  3. 当多个进程同时运行时,他们的指令能够被共享,这样能节省内存。

段分离咱们并不遥远,好比命令行中的 objcopy 能够自行添加自定义的段名,C 语言的 __attribute((section(段名)))__ 能够把变量定义在某个特定名称的段中。

对于一个目标文件来讲,文件的最开头(也叫做 ELF 头)记录了目标文件的基本信息,程序入口地址,以及段表的位置,至关因而对文件的总体描述。接下来的重点是段表,它记录了每一个段的段名,长度,偏移量。比较经常使用的段有:

  • .strtab 段: 字符串长度不定,分开存放浪费空间(由于须要内存对齐),所以能够统一放到字符串表(也就是 .strtab 段)中进行管理。字符串之间用 \0 分割,因此凡是引用字符串的地方用一个数字就能够表明。
  • .symtab: 表示符号表。符号表统一管理全部符号,好比变量名,函数名。符号表能够理解为一个表格,每行都有符号名(数字)、符号类型和符号值(存储地址)
  • .rel 段: 它表示一系列重定位表。这个表主要在连接时用到,下面会详细解释。

连接

在一个目标文件中,不可能全部变量和函数都定义在文件内部。好比 strlen 函数就是一个被调用的外部函数,此时就须要把 main.o 这个目标文件和包含了 strlen 函数实现的目标文件连接起来。咱们知道函数调用对应到汇编实际上是 jump 指令,后面写上被调用函数的地址,但在生成 main.o 的过程当中,strlen() 函数的地址并不知道,因此只能先用 0 来代替,直到最后连接时,才会修改为真实的地址。

连接器就是靠着重定位表来知道哪些地方须要被重定位的。每一个可能存在重定位的段都会有对应的重定位表。在连接阶段,连接器会根据重定位表中,须要重定位的内容,去别的目标文件中找到地址并进行重定位。

有时候咱们还会听到动态连接这个名词,它表示重定位发生在运行时而非编译后。动态连接能够节省内存,但也会带来加载的性能问题,这里不详细解释,感兴趣的读者能够阅读《程序员的自我修养》这本书。

预处理

最后简单描述一下预处理。预处理主要是处理一些宏定义,好比 #define#include#if 等。预处理的实现有不少种,有的编译器会在词法分析前先进行预处理,替换掉全部 # 开头的宏,而有的编译器则是在词法分析的过程当中进行预处理。当分析到 # 开头的单词时才进行替换。虽然先预处理再词法分析比较符合直觉,但在实际使用中,GCC 使用的倒是一边词法分析,一边预处理的方案。

编译 VS 解释

总结一下,对于 C 语言来讲,从源码到运行结果大体上须要经历编译、汇编和连接三个步骤。编译器接收源代码,输出目标代码(也就是汇编代码),汇编器接收汇编代码,输出由机器码组成的目标文件(二进制格式,.o 后缀),最后连接器将各个目标文件连接起来,执行重定位,最终生成可执行文件。

编译器以中间代码为界限,又能够分前端和后端。好比 clang 就是一个前端工具,而 LLVM 则负责后端处理。另外一个知名工具 GCC(GNU Compile Collection)则是一个套装,包揽了先后端的全部任务。前端主要负责预处理、词法分析、语法分析,最终生成语言无关的中间代码。后端主要负责目标代码的生成和优化。

关于编译原理的基础知识虽然枯燥,但掌握这些知识有助于咱们理解一些有用的,但不太容易理解的概念。接下来,咱们简单看一下别的语言是如何运行的。

Java

在 Java 代码的执行过程当中,能够简单分为编译和执行两步。Java 的编译器首先会把 .java 格式的源码编译成 .class 格式的字节码。字节码对应到 C 语言的编译体系中就是中间码,Java 虚拟机执行这些中间码获得最终结果。

回忆一下上文对中间码的解释,一方面它与语言无关,仅仅描述客观事实。另外一方面它和目标代码的差距并不大,已经包括了对寄存器和栈的处理,仅仅是抽象了 CPU 架构而已,只要把它具体化成各个平台下的目标代码,就能够交给汇编器了。

解释型语言

通常来讲咱们也把解释型语言叫作脚本语言,好比 Python、Ruby、JavaScript 等等。这类语言的特色是,不须要编译,直接由解释器执行。换言之,运行流程变成了:

源代码 -> 解释器 -> 运行结果

须要注意的是,这里的解释器只是一个黑盒,它的实现方式能够是多种多样的。举个例子,它的实现能够很是相似于 Java 的执行过程。解释器里面能够包含一个编译器和虚拟机,编译器把源码转化成 AST 或者字节码(中间代码)而后交给虚拟机执行,好比 Ruby 1.9 之后版本的官方实现就是这个思路。

至于虚拟机,它并非什么黑科技,它的内部能够编译执行,也能够解释执行。若是是编译执行,那么它会把字节码编译成当前 CPU 下的机器码而后统一执行。若是是解释执行,它会逐条翻译字节码。

有意思的是,若是虚拟机是编译执行的,那么这套流程和 C 语言几乎同样,都知足下面这个流程:

源代码 -> 中间代码 -> 目标代码 -> 运行结果

下面是重点!!!
下面是重点!!!
下面是重点!!!

所以,解释型语言和编译型语言的根本区别在于,对于用户来讲,究竟是直接从源码开始执行,仍是从中间代码开始执行。以 C 语言为例,全部的可执行程序都是二进制文件。而对于传统意义的 Python 或者 JavaScript,用户并无拿到中间代码,他们直接从源码开始执行。从这个角度来看, Java 不多是解释型语言,虽然 Java 虚拟机会解释字节码,可是对于用户来讲,他们是从编译好的 .class 文件开始执行,而非源代码。

实际上,在 x86 这种复杂架构下,二进制的机器码也不能被硬件直接执行,CPU 会把它翻译成更底层的指令。从这个角度来讲,咱们眼中的硬件其实也是一个虚拟机,执行了一些“抽象”指令,但我相信不会有人认为 C 语言是解释型语言。所以,有没有虚拟机,虚拟机是否是解释执行,会不会生成中间代码,这些都不重要,重要的是若是从中间代码开始执行,并且 AST 已经事先生成好,那就是编译型的语言。

若是更本质一点看问题,根本就不存在解释型语言或者编译型语言这种说法。已经有人证实,若是一门语言是能够解释的,必然能够开发出这门语言的编译器。反过来讲,若是一门语言是可编译的,我只要把它的编译器放到解释器里,把编译推迟到运行时,这么语言就能够是解释型的。事实上,早有人开发出了 C 语言的解释器:

C 源代码 -> C 语言解释器(运行时编译、汇编、连接) -> 运行结果

我相信这一点很容易理解,规范和实现是两套分离的体系。咱们日常说的 C 语言的语法,其实是一套规范。理论上来讲每一个人均可以写出本身的编译器来实现 C 语言,只要你的编译器可以正确运行,最终的输出结果正确便可。而编译型和解释型说的实际上是语言的实现方案,是提早编译以得到最大的性能提升,仍是运行时去解析以得到灵活性,每每取决于语言的应用场景。因此说一门语言是编译型仍是解释型的,这会很是好笑。一个标准怎么可能会有固定的实现呢?之因此给你们留下了 C 语言是编译型语言,Python 是解释型语言的印象,每每是由于这门语言的应用场景决定了它是主流实现是编译型仍是解释型。

自举

不知道有没有人思考过,C 语言的编译器是如何实现的?实际上它仍是用 C 语言实现的。这种本身能编译本身的神奇能力被称为自举(Bootstrap)。

乍一看,自举是不可能的。由于 C 语言编译器,好比 GCC,要想运行起来,一定须要 GCC 的编译器将它编译成二进制的机器码。然而 GCC 的编译器又如何编译呢……

解决问题的关键在于打破这个循环,咱们能够先用一个比 C 语言低级的语言来实现一个 C 语言编译器。这件事是可能作到的,由于这个低级语言必然会比 C 语言简单,好比咱们能够直接用汇编代码来写 C 语言的编译器。因为越低级的语言越简单,但表达能力越弱,因此用汇编来写可能太复杂。这种状况下咱们能够先用一个比 C 语言低级但比汇编高级的语言来实现 C 语言的编译器,同时用汇编来实现这门语言的编译器。总之就是不断用低级语言来写高级语言的编译器,虽然语言越低级,它的表达能力越弱,可是它要解析的语言也在不断变简单,因此这件事是能够作到的。

有了低级语言写好的 C 语言编译器之后,这个编译器是二进制格式的。此时就能够删掉全部的低级语言,只留一个二进制格式的 C 语言编译器,接下来咱们就能够用 C 语言写编译器,再用这个二进制格式的编译器去编译 C 语言实现的 C 语言编译器了,因而完成了自举。

以上逻辑描述起来比较绕,但我想多读几遍应该能够理解。若是实在不理解也不要紧,咱们只要明白 C 语言能够自举是由于它能够编译成二进制机器码,只要用低级语言生成这个机器码,就再也不须要低级语言了,由于机器码能够直接被 CPU 执行。

从这个角度来看,解释型语言是不可能自举的。以 Python 为例,自举要求它能用 Python 语言写出来 Python 的解释器,然而这个解释器如何运行呢,最终仍是须要一个解释器。而解释器体系下, Python 都是从源码通过解释器执行,又不能留下什么能够直接被硬件执行的二进制形式的解释器文件,天然是没办法自举的。然而,就像前面说的,Python 彻底能够实现一个编译器,这种状况下它就是能够自举的。

因此一门语言能不能自举,主要取决于它的实现形式可否被编译并留下二进制格式的可执行文件。

运行时

本文的读者若是是使用 Objective-C 的 iOS 开发者,想必都有过在面试时被 runtime 支配的恐惧。然而,runtime 并不是是 Objective-C 的专利,绝大多数语言都有这个概念。因此有人说 Objective-C 具备动态性是由于它有 runtime,这种说法并不许确,我以为要把 Objective-C 的 runtime 和通常意义的运行时库区分开,认识到它仅仅是运行时库的一个组成部分,同时仍是要深刻到方法调用的层面来谈。

运行时库的基本概念

以 C 语言为例,有很是多的操做最终都依赖于 glibc 这个动态连接库。包括但不限于字符串处理(strlenstrcpy)、信号处理、socket、线程、IO、动态内存分屏(malloc)等等。这一点很好理解,若是回忆一下以前编译器的工做原理,咱们会发现它仅仅是处理了语言的语法,好比变量定义,函数声明和调用等等。至于语言的功能, 好比内存管理,內建的类型,一些必要功能的实现等等。若是要对运行时库进行分类,大概有两类。一种是语言自身功能的实现,好比一些內建类型,内置的函数;另外一种则是语言无关的基础功能,好比文件 IO,socket 等等。

因为每一个程序都依赖于运行时库,这些库通常都是动态连接的,好比 C 语言的 (g)libc。这样一来,运行时库能够存储在操做系统中,节省内存占用空间和应用程序大小。

对于 Java 语言来讲,它的垃圾回收功能,文件 IO 等都是在虚拟机中实现,并提供给 Java 层调用。从这个角度来看,虚拟机/解释器也能够被看作语言的运行时环境(库)。

swift 运行时库

通过这样的解释,相信 swift 的运行时库就很容易理解了。一方面,swift 是绝对的静态语言,另外一方面,swift 毫无疑问的带有本身的运行时库。举个最简单的例子,若是阅读 swift 源码就会发现某些类型,好比字符串(String),或者数组,再或者某些函数(print)都是用 swift 实现的,这些都是 swift 运行时库的一部分。按理说,运行时库应该内置于操做系统中而且和应用程序动态连接,然而坑爹的 Swift 在本文写做之时依然没有稳定 ABI,致使每一个程序都必须自带运行时库,这也就是为何目前 swift 开发的 app 广泛会增长几 Mb 包大小的缘由。

说到 ABI,它其实就是一个编译后的 API。简单来讲,API 是描述了在应用程序级别,模块之间的调用约定。好比某个模块想要调用另外一个模块的功能,就必须根据被调用模块提供的 API 来调用,由于 API 中规定了方法名、参数和返回结果的类型。而当源码被编译成二进制文件后,它们之间的调用也存在一些规则和约定。

好比模块 A 有两个整数 a 和 b,它们的内存布局以下:

模块 A
初始地址
a
b

这时候别的模块调用 A 模块的 b 变量,能够经过初始地址加偏移量的方式进行。

若是后来模块 A 新增了一个整数 c,它的内存布局可能会变成:

模块 A
初始地址
c
a
b

若是调用方仍是使用相同的偏移量,能够想见,此次拿到的就是变量 a 了。所以,每当模块 A 有更新,全部依赖于模块 A 的模块都必须从新编译才能正确工做。若是这里的模块 A 是 swift 的运行时库,它内置于操做系统并与其余模块(应用程序)动态连接会怎么样呢?结果就是每次更新系统后,全部的 app 都没法打开。显然这是没法接受的。

固然,ABI 稳定还包括其余的一些要求,好比调用和被调用者遵照相同的调用约定(参数和返回值如何传递)等。

JavaScript 那些事

咱们继续刚才有关运行时的话题,先从 JavaScript 的运行时聊起,再介绍 JavaScript 的相关知识。

JavaScript 是如何运行的

JavaScript 和其余语言,不管是 C 语言,仍是 Python 这样的脚本语言,最大的区别在于 JavaScript 的宿主环境比较奇怪,通常来讲是浏览器。

不管是 C 仍是 Python,他们都有一个编译器/解释器运行在操做系统上,直接把源码转换成机器码。而 JavaScript 的解释器通常内置在浏览器中,好比 Chrome 就有一个 V8 引擎能够解析并执行 JavaScript 代码。所以 JavaScript 的能力实际上会受到宿主环境的影响,有一些限制和增强。

首先来看看 DOM 操做,相关的 API 并无定义在 ECMAScript 标准中,所以咱们经常使用的 window.xxx 还有 window.document.xxx 并不是是 JavaScript 自带的功能,这一般是由宿主平台经过 C/C++ 等语言实现,而后提供给 JavaScript 的接口。一样的,因为浏览器中的 JavaScript 只是一个轻量的语言,没有必要读写操做系统的文件,所以浏览器引擎通常不会向 JavaScript 提供文件读写的运行时组件,它也就不具有 IO 的能力。从这个角度来看,整个浏览器均可以看作 JavaScript 的虚拟机或者运行时环境。

所以,当咱们换一个宿主环境,好比 Node.js,JavaScript 的能力就会发生变化。它再也不具备 DOM API,但多了读写文件等能力。这时候,Node.js 就更像是一个标准的 JavaScript 解析器了。这也是为何 Node.js 让 JavaScript 能够编写后端应用的缘由。

JIT 优化

解释执行效率低的主要缘由之一在于,相同的语句被反复解释,所以优化的思路是动态的观察哪些代码是常常被调用的。对于那些被高频率调用的代码,能够用编译器把它编译成机器码而且缓存下来,下次执行的时候就不用从新解释,从而提高速度。这就是 JIT(Just-In-Time) 的技术原理。

但凡基于缓存的优化,必定会涉及到缓存命中率的问题。在 JavaScript 中,即便是同一段代码,在不一样上下文中生成的机器码也不必定相同。好比这个函数:

function add(a, b) {
    return a + b;
}复制代码

若是这里的 a 和 b 都是整数,能够想见最终的代码必定是汇编中的 add 命令。若是相似的加法运算调用了不少次,解释器可能会认为它值得被优化,因而编译了这段代码。但若是下一次调用的是 add("hello", "world"),以前的优化就无效了,由于字符串加法的实现和整数加法的实现彻底不一样。

因而优化后的代码(二进制格式)还得被还原成原先的形式(字符串格式),这样的过程被称为去优化。反复的优化 -> 去优化 -> 优化 …… 很是耗时,大大下降了引入 JIT 带来的性能提高。

JIT 理论上给传统的 JavaScript 带了了 20-40 倍的性能提高,但因为上述去优化的存在,在实际运行的过程当中远远达不到这个理论上的性能天花板。

WebAssembly

前文说过,JavaScript 其实是由浏览器引擎负责解析并提供一些功能的。浏览器引擎多是由 C++ 这样高效的语言实现的,那么为何不用 C++ 来写网页呢?实际上我认为从技术角度来讲并不存在问题,直接下发 C++ 代码,而后交给 C++ 解释器去执行,再调用浏览器的 C++ 组件,彷佛更加符合直觉一些。

之因此选择 JavaScript 而不是 C++,除了主流浏览器目前都只支持 JavaScript 而不支持 C++ 这个历史缘由之外,更重要的一点是一门语言的高性能和简单性不可兼得。JavaScript 在运行速度方面作出了牺牲,但也具有了简单易开发的优势。做为通用编程语言,JavaScript 和 C++ 主要的性能差距就在于缺乏类型标注,致使没法进行有效的提早编译。以前说过 JIT 这种基于缓存去猜想类型的方式存在瓶颈,那么最精确的方式确定仍是直接加上类型标注,这样就能够直接编译了,表明性的做品有 Mozilla 的 Asm.js

Asm.js 是 JavaScript 的一个子集,任何 JavaScript 解释器均可以解释它:

function add(a, b) {
    a = a | 0  // 任何整数和本身作按位或运算的结果都是本身
    b = b | 0  // 因此这个标记不改变运算结果,可是能够提示编译器 a、b 都是整数
    return a + b | 0
}复制代码

若是有 Asm.js 特定的解释器,彻底能够把它提早编译出来。即便没有也不要紧,由于它彻底是 JavaScript 语法的子集,普通的解释器也能够解释。

然而,回顾一下咱们最初对解释器的定义: 解释器是一个黑盒,输入源码,输出运行结果。Asm.js 实际上是黑盒内部的一个优化,不一样的黑盒(浏览器)没法共享这一优化。换句话说 Asm.js 写成的代码放到 Chrome 上面和普通的 JavaScript 毫无区别。

因而,包括微软、谷歌和苹果在内的各大公司以为,是时候搞个标准了,这个标准就是 WebAssembly 格式。它是介于中间代码和目标代码之间的一种二进制格式,借用 WebAssembly 系列(四)WebAssembly 工做原理 一文的插图来表示:

一般从中间代码到机器码,须要通过平台具体化(转目标代码)和二进制化(汇编器把汇编代码变为二进制机器码)这两个步骤。而 WebAssembly 首先完成了第二个步骤,即已是二进制格式的,但只是一系列虚拟的通用指令,还须要转换到各个 CPU 架构上。这样一来,从 WebAssembly 到机器码实际上是透明且统一的,各个浏览器厂商只须要考虑如何从中间代码转换 WebAssembly 就好了。

因为编译器的前端工具 Clang 能够把 C/C++ 转换成中间代码,所以理论上它们均可以用来开发网页。然而谁会这么这么作呢,放着简单快捷,如今又高效的 JavaScript 不写,非要去啃 C++?

跨语言那些事儿

C++ 写网页这个脑洞虽然比较大,但它启发我思考一个问题:“对于一个常见的能够由某个语言完成的任务(好比 JavaScript 写网页),能不能换一个语言来实现(好比 C++),若是不能,制约因素在哪里”。

因为绝大多数主流语言都是图灵完备的,也就是说一切可计算的问题,在这些语言层面都是等价的,均可以计算。那么制约语言能力的因素也就只剩下了运行时的环境是否提供了相应的功能。好比前文解释过的,虽然浏览器中的 JavaScript 不能读写文件,不能实现一个服务器,但这是浏览器(即运行时环境)不行,不是 JavaScript 不行,只要把运行环境换成 Node.js 就好了。

直接语法转换

大部分读者应该接触过简单的逆向工程。好比编译后的 .o 目标文件和 .class 字节码均可以反编译成源代码,这种从中间代码倒推回源代码的技术也被叫作反编译(decompile),反编译器的工做流程基本上是编译器的倒序,只不过完美的反编译通常来讲比较困难,这取决于中间代码的实现。像 Java 字节码这样的中间代码,因为信息比较全,因此反编译就相对容易、准确一些。C 代码在生成中间代码时丢失了不少信息,所以就几乎不可能 100% 准确的倒推回去,感兴趣的读者能够参考一下知名的反编译工具 Hex-Rays 的一篇博客

前文说过,编译器前端能够对多种语言进行词法分析和语法分析,而且生成一套语言无关的中间代码,所以理论上来讲,若是某个编译器前端工具支持两个语言 A 和 B 的解析,那么 A 和 B 是能够互相转换的,流程以下:

A 源码 <--> 语言无关的中间代码 <--> B 源码

其中从源码转换到中间代码须要使用编译器,从中间代码转换到源码则使用反编译器。

但在实际状况中,事情会略复杂一些,这是由于中间代码虽然是一套语言无关、CPU 也无关的指令集,但不表明不一样语言生成的中间代码就能够通用。好比中间代码共有 一、二、三、……、6 这六个指令。A 语言生成的中间代码仅仅是全部指令的一个子集,好比是 1-5 这 5 个指令;B 语言生成的中间代码多是全部指令的另外一个子集,好比 2-6。这时候咱们说的 B 语言的反编译器,其实是从 2-6 的指令子集推导出 B 语言源码,它对指令 1 可能无能为力。

以 GCC 的中间代码 RTL: Register Transfer Language 为例,官方文档 在对 RTL 的解释中,就明确的把 RTL 树分为了通用的、C/C++ 特有的、Java 特有的等几个部分。

具体来讲,咱们知道 Java 并不能直接访问内存地址,这一点和浏览器上的 JavaScript 不能读写文件很相似,都是由于它们的运行环境(虚拟机)具有这种能力,但没有在语言层面提供。所以,含有指针四则运算的 C 代码没法直接被转换成 Java 代码,由于 Java 字节码层面并无定义这样的抽象,一种简单的方案是申请一个超大的数组,而后本身模拟内存地址。

因此,即便编译器前端同时支持两种语言的解析,要想进行转换,还必须处理两种语言在中间代码层面的一些小差别,实际流程应该是:

A 源码 <--> 中间代码子集(A) <--适配器--> 中间代码子集(B) <--> B 源码

这个思路已经不只仅停留在理论上了,好比 Github 上有一个库: emscripten 就实现了将任何 Clang 支持的语言(好比 C/C++ 等)转换成 JavaScript,再好比 lljvm 实现了 C 到 Java 字节码的转换。

然而前文已经解释过,实现单纯语法的转换意义并不大。一方面,对于图灵完备的语言来讲,换一种表示方法(语言)去解决相同的问题并无意义。另外一方面,语言的真正功能毫不仅仅是语法自己,而在于它的运行时环境提供了什么样的功能。好比 Objective-C 的 Foundation 库提供了字典类型 NSDictionary,它若是直接转换成 C 语言,将是一个找不到的符号。由于 C 语言的运行时环境根本就不提供对这种数据结构的支持。所以凡是在语言层面进行强制转换的,要么利用反编译器拿到一堆格式正确但没法运行的代码,要么就自行解析语法树并为转换后的语言添加对应的能力,来实现转换前语言的功能。

好比图中就是一个 C 语言转换 Java 的工具,为了实现 C 语言中的字符串申请和释放内存,这个工具不得不本身实现了 com.mtsystems.coot.String8 类。这样巨大的成本,显然不够普适,应用场景相对有限。

总之,直接的语法转换是一个美好的想法,但实现起来难度大,收益有限,一般是为了移植已经用某个语言写好的框架,或者开个脑洞用于学习,但实际应用场景并很少。

胶水语言 Python

Python 一个很强大的特色是胶水语言,能够把 Python 理解为各类语言的粘合剂。对于 Python 能够处理的逻辑,用 Python 代码便可完成。若是追求极致的性能或者调用已经实现的功能,也可让 Python 调用已经由别的语言实现的模块,以 Python 和 C 语言的交互解释一下。

首先,若是是 C 语言要执行 Python 代码,显然须要一个 Python 的解释器。因为在 Mac OS X 系统上,Python 解释器是一个动态连接库,因此只要导入一下头文件便可,下面这段代码能够成功输出 “Hello Python!!!”:

#include <stdio.h>
#import <Python/Python.h>

int main(int argc, const char * argv[]) {
    Py_SetProgramName(argv[0]);
    Py_Initialize();
    PyRun_SimpleString("print 'Hello Python!!!'\n");
    Py_Finalize();
    return 0;
}复制代码

若是是在 iOS 应用里,因为 iOS 系统没有对应的动态库,因此须要把 Python 的解释器打包成一个静态库而且连接到应用中,网上已经有人作好了: python-for-iphone,这就是为何咱们看到一些教育类的应用模拟了 Python 解释器,容许用户编写 Python 代码并获得输出。

Python 调用 Objective-C/C 也不复杂,只须要在 C 代码中指定要暴露的模块 A 和要暴露的方法 a,而后 Python 就能够直接调用了:

import A
A.a()复制代码

详细的教程能够看这里: 如何实现 C/C++ 与 Python 的通讯?

有时候,若是能把本身熟悉的语言应用到一个陌生的领域,无疑会大大下降上手的难度。以 iOS 开发为例,开发者的平常实际上是利用 Objective-C 语法来描述一些逻辑,最终利用 UIKit 等框架完成和应用的交互。 一种很天然而然的想法是,能不能用 Python 来实现逻辑,而且调用 Objective-C 的接口,好比 UIKit、Foundation 等。实际上前者是彻底能够实现的,可是 Python 调用 Objective-C 远比调用 C 语言要复杂得多。

一方面从以前的分析中也能看出,并非全部的源码编译成目标文件均可以被 Python 引用;另外一方面,最重要的是 Objective-C 方法调用的特性。咱们知道方法调用实际上会被编译成 msg_Send 并交给 runtime 处理,最终找到函数指针并调用。这里 Objective-C 的 runtime 实际上是一个用 C 语言实现动态连接库,它能够理解为 Objective-C 运行时环境的一部分。换句话说,没有 runtime 这个库,包含方法调用的 Objective-C 代码是不可能运行起来的,由于 msg_Send 这个符号没法被重定向,运行时将找不到 msg_Send 函数的地址。就连原生的 Objective-C 代码都须要依赖运行时,想让 Python 直接调用某个 Objective-C 编译出来的库就更不可能了。

想用 Python 写开发 iOS 应用是有可能的,好比: PyObjc,但最终仍是要依赖 Runtime。大概的思路是首先用 Python 拿到 runtime 这个库,而后经过这个库去和 runtime 交互,进而具有了调用 Objective-C 和各类框架的能力。好比我要实现 Python 中的 UIView 这个类,代码会变成这样:

import objc

# 这个 objc 是动态加载 libobjc.dylib 获得的
# Python 会对 objc 作一些封装,提供调用 runtime 的能力
# 实际的工做仍是交给 libobjc.dylib 完成

class UIView:
    def __init__(self, param):
        objc.msgSend("UIView", "init", param)复制代码

这么作的性价比并不高,若是和 JSPatch 相比,JSPatch 使用了内置的 JavaScriptCore 做为 JavaScript 的解析器,而 PyObjc 就得本身带一个 libPython.a 解释器。此外,因为 iOS 系统的沙盒限制,非越狱机器并不能拿到 libobjc 库,因此这个工具只能在越狱手机上使用。

OCS

既然说到了 JSPatch 这一类动态化的 iOS 开发工具,我就斗胆猜想一下腾讯 OCS 的实现原理,目前介绍 OCS 的文章寥寥无几,因为苹果公司的要求,原文已经被删除,重新浪博客上摘录了一份: OCS ——史上最疯狂的 iOS 动态化方案。若是用一句话来概述,那么就是 OCS 是一个 Objective-C 解释器。

首先,OCS 基于 clang 对下发的 Objective-C 代码作词法、语法分析,生成 AST 而后转化成自定义的一套中间码(OSScript)。固然,原生的 Objective-C 能够运行,毫不仅仅是编译器的功劳。就像以前反复强调的那样,运行时环境也必不可少,好比负责 GCD 的 libdispatch 库,还有内存管理,多线程等等功能。这些功能原来都由系统的动态库实现,但如今必须由解释器实现,因此 OCS 的作法是开发了一套本身的虚拟机去解释执行中间码。这个运行原理就和 JVM 很是相似了。

固然,最终仍是要和 Objective-C 的 Runtime 打交道,这样才能调用 UIKit 等框架。因为对虚拟机的实现原理并不清楚,这里就不敢多讲了,但愿在学习完 JVM 之后再作分享。

参考资料

  1. AT&T与Intel汇编风格比较
  2. glibc
  3. WebAssembly 系列(一)生动形象地介绍 WebAssembly
  4. Decompilers and beyond
  5. python-for-iphone
  6. 如何实现 C/C++ 与 Python 的通讯?
  7. WebAssembly 系列(四)WebAssembly 工做原理
  8. 扯淡:大白话聊聊编译那点事儿
  9. rubicon-objc
  10. OCS ——史上最疯狂的 iOS 动态化方案
  11. 虚拟机随谈(一):解释器,树遍历解释器,基于栈与基于寄存器,大杂烩
  12. JavaScript的功能是否是都是靠C或者C++这种编译语言提供的?
  13. 计算机编程语言必须可以自举吗?
  14. 如何评论浏览器最新的 WebAssembly 字节码技术?
  15. Objective-C Runtime —— From Build To Did Launch
  16. 10 GENERIC
  17. 写个编译器,把C++代码编译到JVM的字节码可不可行?
相关文章
相关标签/搜索