Q:代码是如何运行的?
代码是由CPU执行的,而目前的CPU并不能直接执行诸如if…else
之类的语句,它只能执行二进制指令。但是二进制指令对人类实在是太不友好了:我们很难快速准确的判断一个二进制指令1000010010101001代表什么?所以科学家们发明汇编语言(实际上就是二进制指令的助记符)。
假设10101010代表读取内存操作,内存地址是10101111,寄存器地址是11111010,那么完整的操作101010101010111111111010就代表读取某个内存地址的值并装载到寄存器,而汇编语言并没有改变这种操作方式,它只是二进制指令的映射:
LD:10101010
id:10101111
R:11111010
这样上述指令就可以表达为LD id R
,大大增强了代码的可读性。
但是这样还不够友好,CPU只能执行三地址表达式,和人的思考方式、语言模式相距甚远。所以伟大的科学家们又发明了高级语言。
高级语言之所以称之为“高级”,就是因为它更加符合我们的思维和阅读习惯。if…else
这种语句看起来要比1010101010舒服的多了。但是计算机并不能直接执行高级语言,所以还需要把高级语言转化为汇编语言/机器指令才能执行。这个过程就是编译。
JavaScript是什么类型的语言?
- JavaScript 动态类型的动态语言;
在运行时代码可以根据某些条件改变自身结构,如JavaScript在运行时新的函数、对象、甚至代码都可以被引进(eval),因此JavaScript是动态语言。JavaScript数据类型不是在编译阶段确定,而是在运行时确定,所以JavaScript是动态类型的语言。 - JavaScript是 解释型语言且弱类型,JavaScript 代码需要在机器(node 或者浏览器)上安装一个工具(JS 引擎)才能执行,这是解释型语言所需要的。在生成 AST 之后,就开始一边解释,一边执行。
Q:JavaScript需要编译吗?
与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。但是JavaScript引擎进行编译的步骤和传统的编译语言非常相似,在某些环节可能比预想的要复杂,具体表现在:
- JavaScript引擎在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等。
- 与其他编译语言不同,JavaScript的编译过程不是发生在构建之前的,因此JavaScript引擎不会有大量的时间进行优化。
- 对于JavaScript,大部分情况下编译发生在代码执行前的几微秒(甚至更短)。
- JavaScript引擎用尽了各种办法(如JIT,可以延迟编译甚至实施重编译)来保证性能最佳。
JavaScript是如何运行的
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历一系列步骤,统称为“编译”。
常见编译型语言(例如:Java)来说,编译步骤分为:词法分析->语法分析->语义检查->代码优化和字节码生成。
对于解释型语言(例如 JavaScript)来说,通过词法分析 -> 语法分析 -> 语法树,生成 AST 之后,就开始一边解释,一边执行。
在JavaScript的执行过程主要有以下几个关键角色:
- 引擎:从头到尾负责整个JavaScript程序的编译及执行过程;
- 编译器:负责语法分析以及代码生成等;
- 作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。作用域本质上就是程序存储和访问变量的规则。
我们带着Q:变量住在哪里?它们储存在哪里?程序需要时如何找到它们?一起看看JavaScript的具体执行过程:
- 分词/词法分析(Tokenizing/Lexing)
编译器将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。如果是有状态的解析,还会赋予单词语义。
词法单元生成器在判断a是一个独立的词法单元还是其他词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法分析。
For example
如程序var a = 2;
通常会被分解为词法单元:var、a、=、2、; 具体如下图所示。并且给它们加上标注,代表这是一个变量还是一个操作。空格是否会被当作词法单元,取决于空格在这门语言中是否具有意义。
- 解析/语法分析(Parsing)
语法分析的任务是在词法分析的基础上将单词序列组合成各类语法短语,如“程序”,“语句”,“表达式”等等。
语法分析程序判断源程序在结构上是否正确。如var str ='s ;
这就是典型的语法错误,这种代码无法生成AST,在词法分析阶段就会报错。通常我们这么写代码,IDE 就会报错。这是IDE的优化工作,和词法分析相关。
将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
For example
上述例子var a = 2;
被分解的词法单元在语法分析阶段会被转换成如下结构:
- 预编译(开放内存空间,存放变量和函数)
当JavaScript引擎解析脚本时,它会先在预编译期对所有声明的变量和函数进行处理!编译阶段的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来,因此这个过程编译器和作用域会进行如下互动:
⚠️ 预编译阶段没有初始化行为(赋值),匿名函数不参与预编译。只有在解释执行阶段才会进行变量初始化。
JavaScript的作用域才有词法作用域工作模型,JavaScript 的变量和函数作用域是在定义时决定的,而不是执行时决定的。
例:看一个简单的声明语句var name = 'bubble'
;在JS引擎眼里,它包含两个声明,其中
-
var name
在编译时(此步骤由编译器)处理, -
name=bubble
在运行时处理,即第4步(解释执行由JS引擎处理)。
- 解释执行
在执行过程中,JavaScript 引擎是严格按着作用域机制(scope)来执行的。引擎在运行时会完成对变量的赋值操作,因此和作用域有如下互动:
作用域套作用域,就有了作用域链:
JavaScript 引擎通过作用域链(scope chain)把多个嵌套的作用域串连在一起,并借助这个链条帮助 JavaScript 解释器检索变量的值。这个作用域链相当于一个索引表,并通过编号来存储它们的嵌套关系。当 JavaScript 解释器检索变量的值,会按着这个索引编号进行快速查找,直到找到全局对象(global object)为止,如果没有找到值,则传递一个特殊的 undefined 值。
var scope = "global";
scopeTest();
function scopeTest(){
console.log(scope);
var scope = "local";
console.log(scope);
}
打印结果:undefined,local;
而引擎查找变量的方式会直接影响到查找的结果,尤其在变量未声明的情况下:
总结一下,任何JavaScript代码片段在执行前都要进行编译(通常就在执行前)。因此,JavaScript编译器首先会对var a = 2;
这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它。
让我们看看引擎对下面这段代码做了什么吧!
<script>
var a = 1; // 变量声明
function b(y){ //函数声明
var x = 1;
console.log('so easy');
};
var c = function(){ //变量声明
//...
}
b(100);
</script>
<script>
var d = 0;
</script>
- 页面产生便创建了GO全局对象(Global Object),也就是window对象;
- 第一个script脚本文件加载;
- 脚本文件加载后,分析语法是否合法;
- 开始预编译:
- 查找变量声明,作为GO属性,值赋予
undefined
; - 查找函数声明,作为GO属性,值赋予函数体;
GO/window = {
//页面加载创建GO同时,创建了document、screen等属性
a: undefined,
c: undefined,
b: function(y){
var x = 1;
console.log('so easy');
}
}
- 解释执行代码,找到变量并赋值(直到执行函数b)
GO/window = {
a: 1,
c: function(){ },
b: function(y){
var x = 1;
console.log('so easy');
}
}
- 执行函数b之前,发生预编译:
- 创建AO活动对象(Active Object)
- 查找函数形参及函数内变量声明,形参名及变量名作为AO对象的属性,值为undefined
- 实参值赋给形参
AO = {
//创建AO同时,创建了arguments等等属性,此处省略
y: 100,
x: undefined
}
- 解释执行函数中的代码;
x=1
输出so easy - 第一个脚本文件执行完毕,加载第二个脚本文件
- 第二个脚本文件加载完毕后,进行语法分析
- 语法分析完毕,开始预编译
重复最开始的预编译步骤……
测试
- 例1
function foo() {
console.log(a);
a = 1;
}
foo(); // Uncaught ReferenceError: a is not defined
function bar() {
a = 1;
console.log(a);
}
bar(); // 1
这是因为函数中的 "a" 并没有通过 var 关键字声明,所有不会被存放在 AO 中。没有 a 的值,然后就会到全局去找,全局也没有,所以会报错。
- 例2
console.log(foo);
function foo(){
console.log("foo");
}
var foo = 1;
会打印函数,而不是 undefined 。
这是因为在进入执行上下文时,首先会处理函数声明,其次会处理变量声明,如果如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。
下方开始大量扩展知识
编程语言的分类
与硬件的距离
- 比较低级 Low-level 语言
最低级的语言就是机器语言,由0和1构成,通过面板、打孔卡输入。
接下来是汇编语言,它对硬件指令做了简单的封装,一些操作可以用ADD、MOVE等英文单词来表示。目前在内核/驱动中会被用到。 - 比较高级 High-level 语言
除了上面两种,其他语言都是高级语言,将很多细节交由计算机(编译器)把控,同时变得更加抽象。高级语言中也有相对低级和高级的。如C属于非常低级的高级语言,因为C语言中也还是时不时的会用到硬件知识。而类似Ruby或JS这样的脚本语言就基本不用操心硬件的事了。
一般来讲,跟硬件离的越近,就越能通过打磨去挖掘硬件潜力,写成的程序执行效率就会越高,但是开发效率肯定也就越低。
是否需要编译
所有语言最终都需要转变为机器码,基于其转换为机器码的方式,高级语言可大致分为编译型和解释型两类(汇编语言无须编译或解释,仅需汇编成机器码)
编译型语言(Compiled Language)
利用编译器先将代码编译为机器码,再加以运行。如C++代码在Windows上会编译成.obj文件,而在Linux上则生成.o文件。解释型语言(Interpreted Language)
利用解释器,在运行期间,动态将代码逐行解释(Interpret)为机器代码执行。
有时也叫脚本语言(Scripting Language)。如Python,Ruby、BASIC、JavaScript,写好之后无需编译,直接运行于自己的解释器之上。
编译型语言的运行速度更快(因为已经预先编译好,运行时无须执行解释这一步骤),而因此,编译型语言的开发/调试时间也较长,因为每次调试之前都需要编译一次。而解释型语言则可以快速的测试和调试,因为跟硬件隔了一层,所以效率上一般是比较低的,但功能上可以更为灵活。
-
半解释半编译
Java就是两种类型的结合典型。无论是在什么操作系统上.java文件编译出的都是.class文件(这就是字节码文件,一种中间形态的目标代码)。然后Java对不同的系统提供不同的Java虚拟机用于解释执行字节码文件。解释执行并不生成目标代码,但其最终还是要转为汇编/二进制指令来给计算机执行的。
Java采用半解释半编译的好处就是大大提升了开发效率,然而相应的则降低了代码的执行效率,毕竟虚拟机是有性能损失的。
编程范式(Programming Paradigms)
也叫编程范型、编程典范,基于编程语言的特点而进行分类的方式,一种语言可以支持超过一种编程范型,常见范式如下:
命令式和声明式
这是两个相对/并列的范式,命令式编程描述过程 ,声明式编程描述目标。
- 命令式编程(Imperative programming)
命令式编程描述计算所需作出的行为。几乎所有的计算机硬件都是命令式的。
子范式:过程式和面向对象式,过程式更靠近机器,面向对象式更贴近程序员。
-
过程式编程(Procedural programming)
把操作转换成语句一步步的去做,主要使用顺序、条件选择、循环三种基本结构来编写程序。
来源于结构化编程,其概念基于过程(procedure、routine、subroutine、function),过程由一系列可执行可计算的步骤构成。
Fortran、ALGOL、COBOL、BASIC、Pascal和C等语言采用过程式编程。 -
面向对象式编程(Object-oriented programming)
具有对象概念的编程范式,先把数据封装成对象,通过对象之间的交互来实现功能。
重要的面向对象编程语言包括Common Lisp、Python、C++、Java、C#、Perl、PHP、Ruby、Swift等。
- 声明式编程(Declarative programming)
声明式编程描述目标的性质,让计算机明白目标,而非流程。声明式编程通常被定义为所有的“非命令式编程”。
声明式编程包括数据库查询语言(SQL)、正则表达式、逻辑式编程、函数式编程和configuration management。声明式编程通常用作解决人工智能和约束满足问题。
子范型:函数式编程、逻辑式编程、约束式编程、数据流式编程
-
函数式编程(Functional programming)
又称泛函编程,它将计算视为数学上的函数运算,避免变量或状态(只有函数及其参数)。其最重要的基础是λ演算(lambda calculus),λ演算的函数可以接受函数当作输入和输出。
分为纯函数式编程(Purely functional programming)和函数逻辑式编程(Functional logic programming,函数式编程和逻辑式编程的组合) -
逻辑式编程(Logic programming)
逻辑式编程基于数理逻辑,它设置答案所须匹配的规则来解决问题,而非设置步骤来解决问题。过程为:事实+规则=结果。
最常用的逻辑式编程语言是Prolog,Mercury则较适用于大型方案。 -
约束式编程(Constraint programming)
在这种范式中,变量之间的关系是以约束的形式陈述的,它们并非明确说明了要去执行步骤的某一步,而是规范其解的一些属性。 -
数据流式编程(Dataflow programming)
将程序建模为一个描述数据流的有向图。例如BLODI。
结构化和非结构化
这是两个相对的范式,非结构化编程是最早的编程范式,现今的计算机科学家都同意结构化编程的好处。
- 结构化编程(Structured programming)
通过子程序、代码块、for循环、while循环等结构来取代之前的goto语句,以提高代码的清晰程度,获得更好的可读性。现今的大部分高级语言都是结构化的。
结构化编程的流程包括顺序、选择(if, else, switch)、循环(for, while)几类。 - 非结构化编程(Non-structured programming)
是最早的编程范式,相对于结构化编程,特点是其控制流是通过(容易引起混乱的)goto语句跳转实现的。非结构化编程包括机器语言、汇编语言、MS-DOS batch、以及早期的BASIC及Fortran等等。
- 泛型编程(Generic programming)
泛型允许程序员在用强类型语言编写代码时使用一些以后才指定的类型。
Ada、Delphi、C#、Java、Swift称之为泛型(generics),Scala和Haskell称之为参数多态(parametric polymorphism),C++称之为模板。
动态or静态分类
动态语言(Dynamic programming language)在运行时可以改变自身结构:新的函数、对象甚至代码可以被引进,已有的函数可以被删除或有其他结构上的变化。JavaScript、PHP、Python、Ruby属于动态语言,而C和C++则不属于动态语言。
大部分动态语言都使用动态类型,但也有些不是。
语言类型系统(Type system)分类
- 动态和静态类型检查
- 静态类型检查
对类型的检查发生在编译时。编译语言通常使用静态类型检查。 - 动态类型检查
对类型的检查发生在运行时。动态类型检查经常出现在脚本语言和解释型语言中。
大部分动态语言都使用动态类型,但也有些不是。
- 强弱类型
按照编程语言对于混入不同数据类型的值进行运算时的处理方式不同分为强类型和弱类型。
- 强类型(Strongly typed)
强类型的语言遇到函数形参和实参的类型不匹配时通常会失败。 - 弱类型(Weakly/Loosely typed)
弱类型的语言常常会进行隐式的转换(并可能造成不可知的后果)。
- 类型安全和内存安全
按照类型运算和转换的安全性不同分为类型安全和内存安全。通常来说,类型安全和内存安全是同时存在的。
- 类型安全
它不允许导致不正确的情况的运算或转换,计算机科学就认为该语言是类型安全的。 - 内存安全
指程序不被允许访问没有分配给它的内存,比如:内存安全语言会做数组边界检查。
比如以下例子:
var x:= 5
var y:= “37”
var z:= x + y
上例中的z的值为42,不管编写者有没有这个意图,该语言定义了明确的结果,且程序不会就此崩溃,或将不明定义的值赋给z。就这方面而言,这样的语言就是类型安全的。
再比如:
int x = 5
char y[] = “37”
char* z = x + y
在这个例子中,z将会指向一个超过y地址5个字节的存储器地址,相当于指向y字符串的指针之后的两个空字符之处。这个地址的内容尚未定义,且有可能超出存储器的定址界线,这就是一个类型不安全/内存不安全的语言。
- 显式声明和隐式暗示
许多静态类型系统,如C和Java,要求变量声明类型:编写者必须以指定类型明确地关系到每一个变量上。其它的,如Haskell,则进行类型推断:编译器根据编写者如何运用这些变量,以草拟出关于这个变量的类型的结论。
例如,给定一个函数f(x,y),它将x和y加起来,编译器可以推断出x和y必须是数字——因为加法仅定义给数字。因此,任何在其它地方以非数值类型(如字符串或链表)作为参数来调用f的话,将会发出一个错误。
在代码中数值、字符串常量以及表达式,经常可以在详细的前后文中暗示类型。例如,一个表达式3.14可暗示浮点数类型;而[1, 2, 3]则可暗示一个整数的链表;通常是一个数组。