时间:2018-5-24来源:本站原创作者:佚名

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

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

什么是编译器,它以什么为分界线,分为前端和后端?

Java是编译型语言还是解释型语言,Python呢?

C语言的编译器也是C语言,那它怎么被编译的?

目标文件的格式是什么样的,段表、符号表、重定位表有什么作用?

Swift是静态语言,为什么还有运行时库?

什么是ABI,ABI不稳定有什么问题?

什么是WebAssembly,为什么要推出这门技术,用C++代替JavaScript可行么?

JavaScript和DOMAPI是什么关系,JavaScript可以读写文件么?

C++代码可以自动转换成Java代码么,任意两种语言是否可以互转?

为什么说Python是胶水语言,它可以用来开发iOS/Android么?

编译原理

就像数学是一个公理体系,从简单的公理就能推导出各种高阶公式一样,我们从最基本的C语言和编译说起。

intmain(void){inta=strlen("Helloworld");//字符串的长度是11return0;}

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

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

词法分析

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

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

intt表示一个整数,而intt只是一个变量名。

inta()表示一个函数而非整数a,inta()也是一个函数。

a=没有具体价值,它可以是一个赋值语句,还可以是a==1的前缀,表示一个判断。

词法分析的主要难点在于,前缀无法决定一个完整字符串的含义,通常需要看完整句以后才知道每个单词的具体含义。同时,C语言的语法也不简单,各种关键字,括号,逗号,语法等等都会给词法分析的实现增加难度。

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

如果自己实现的话,思路也不难。外面包一个循环,然后各种switch...case就完事了。词法分析应该算是最简单的一节。

语法分析

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

inta=10;

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

类型变量名=常量;

所以inta=10;当然可以匹配上这种模式。同理,它不可能匹配类型函数名(参数);这种函数定义模式,因为两者结构不一致,等号无法被匹配。

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

在匹配模式的时候,另一个问题在于上述的名词,比如类型、参数,很难界定。比如int是类型,longlong也是类型,unsignedlonglong也是类型。(inta)可以是参数,(inta,intb)也是参数,(unsignedlonglonga,longlongdoubleb,int*p)看起来能把人逼疯。

下面举一个简单的例子来解释inta=10是如何被解析的,总的思路是归纳与分解。我们把一个复杂的式子分割成若干部分,然后分析各个部分,这样可以简化复杂度。对于inta=10来说,他是一个声明,声明由两部分组成,分别是声明说明符和初始声明符列表。

声明声明说明符初始声明符列表inta=10inta=10intfun(inta)intfun(inta)intarray[5]intarray[5]

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

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

而且我们知道若干个类型连在一起又变成了声明说明符,所以上述等式等价于:

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

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

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

成功解析语法以后,我们会得到抽象语法树(AST:AbstractSyntaxTree)。以这段代码为例:

intfun(inta,intb){intc=0;c=a+b;returnc;}

它的语法树如下:

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

生成中间代码

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

语法树转高端gimple

高端gimple转低端gimple

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

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

语法树转高端gimple

这一步主要是处理寄存器和栈,比如c=a+b并没有直接的汇编代码和它对应,一般来说需要把a+b的结果保存到寄存器中,然后再把寄存器赋值给c。所以这一步如果用C语言来表示其实是:

inttemp=a+b;//temp其实是寄存器c=temp;

另外,调用一个新的函数时会进入到函数自己的栈,建栈的操作也需要在gimple中声明。

高端gimple转低端gimple

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

inta=1;a++;intb=1;

会被处理成:

inta=1;intb=1;a++;

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

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

if(10){return1;}else{return0;}

会被处理成:

if(10){gotoa;}else{gotob;}a:return1;b:return0;低端gimple经过cfa转ssa再转中间代码

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

中间代码的意义

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

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

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

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

definei32

square_unsigned(i32%a){%1=muli32%a,%areti32%1}生成目标代码

目标代码也可以叫做汇编代码。由于中间代码已经非常接近于实际的汇编代码,它几乎可以直接被转化。主要的工作量在于兼容各种CPU以及填写模板。在最终生成的汇编代码中,不仅有汇编命令,也有一些对文件的说明。比如:

.file"test.c"#文件名称.globalm#全局变量m.data#数据段声明.align4#4字节对齐.typem,

objc.sizem,4m:.long10#m的值是10.text.globalmain.typemain,

functionmain:pushl%ebpmovl%esp,%ebp...汇编

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

分段可以将数据和代码区分开。其中代码只读,数据可写,方便权限管理,避免指令被改写,提高安全性。

现代CPU一般有自己的数据缓存和指令缓存,区分存储有助于提高缓存命中率。

当多个进程同时运行时,他们的指令可以被共享,这样能节省内存。

段分离我们并不遥远,比如命令行中的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(GNUCompileCollection)则是一个套装,包揽了前后端的所有任务。前端主要负责预处理、词法分析、语法分析,最终生成语言无关的中间代码。后端主要负责目标代码的生成和优化。

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

Java

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

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

解释型语言

一般来说我们也把解释型语言叫做脚本语言,比如Python、Ruby、JavaScript等等。这类语言的特点是,不需要编译,直接由解释器执行。换言之,运行流程变成了:

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

需要注意的是,这里的解释器只是一个黑盒,它的实现方式可以是多种多样的。举个例子,它的实现可以非常类似于Java的执行过程。解释器里面可以包含一个编译器和虚拟机,编译器把源码转化成AST或者字节码(中间代码)然后交给虚拟机执行,比如Ruby1.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这个动态链接库。包括但不限于字符串处理(strlen、strcpy)、信号处理、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初始地址ab

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

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

模块A初始地址cab

如果调用方还是使用相同的偏移量,可以想见,这次拿到的就是变量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的能力就会发生变化。它不再具有DOMAPI,但多了读写文件等能力。这时候,Node.js就更像是一个标准的JavaScript解析器了。这也是为什么Node.js让JavaScript可以编写后端应用的原因。

JIT优化

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

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

functionadd(a,b){returna+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解释器都可以解释它:

functionadd(a,b){a=a

0//任何整数和自己做按位或运算的结果都是自己b=b

0//所以这个标记不改变运算结果,但是可以提示编译器a、b都是整数returna+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字节码都可以反编译成源代码,这种从中间代码倒推回源代码的技术也被叫做反编译(de







































北京最专业治疗白癜风医院
中医治疗白癜风的方法

转载请注明原文网址:http://www.gzdatangtv.com/bcyykf/10886.html

------分隔线----------------------------