lufei's Studio.

语义分析

字数统计: 5.2k阅读时长: 17 min
2019/12/04 Share

前言

学习了*词法分析(Lexical Analysis)语法分析(Syntactic Analysis, or Parsing)*,基本上就可以用自己熟悉的语言来实现一门简单的脚本语言了,可以为自己的语言实现想要的操作符和关键字,简单的运算,也可以处理语法错误,我给自己的语言取名叫njscript,并且实现REPL交互环境。

当然,这玩意还是连玩具都算不上,因为我的算法能力太差,脑袋太笨,所以在njscript涉及到算法的时候,就打了退堂鼓,所以就跳过开始学习语义分析(Semantic Analysis)

开始

简单来说,语义分析就是要让计算机理解我们的真实意图,把一些模棱两可的地方消除掉。

语义分析的职责:

  • 某个表达式的计算结果是什么数据类型?如果有数据类型不匹配的情况,是否要做自动转换?

  • 如果在一个代码块的内部和外部有相同名称的变量,我在执行的时候到底用哪个? 就像“我喜欢又聪明又勇敢的你”中的“你”,到底指的是谁,需要明确。

  • 在同一个作用域内,不允许有两个名称相同的变量,这是唯一性检查。你不能刚声明一个变量 a,紧接着又声明同样名称的一个变量 a,这就不允许了。

语义分析基本上就是做这样的事情,也就是根据语义规则进行分析判断。

语义分析工作的某些成果,会作为属性标注在抽象语法树上。

在这个树上还可以标记很多属性,有些属性是在之前的两个阶段就被标注上了,比如所处的源代码行号,这一行的第几个字符。这样,在编译程序报错的时候,就可以比较清楚地了解出错的位置。

做了这些属性标注以后,编译器在后面就可以依据这些信息生成目标代码了。

作用域 (Scope)

作用域是指计算机语言中变量、函数、类等起作用的范围。

C语言的作用域的规律大概是这样:

  • 变量的作用域有大有小,外部变量在函数内可以访问,而函数中的本地变量,只有本地才可以访问。

  • 变量的作用域,从声明以后开始。

  • 在函数里,我们可以声明跟外部变量相同名称的变量,这个时候就覆盖了外部变量。

C语言里还有块作用域的概念,就是用花括号包围的语句,if 和 else 后面就跟着这样的语句块。块作用域的特征跟函数作用域的特征相似,都可以访问外部变量,也可以用本地变量覆盖掉外部变量。

不过,各个语言在这方面的设计机制是不同的,比如javascript是没有块作用域的,在块里和 for 语句试图重新定义变量 b,语法上是允许的,但我们每次用到的其实是同一个变量。

虽然各门语言设计上的特性是不同的,但在运行期的机制都很相似,比如都会用到栈和堆来做内存管理。

生存期 (Extent)

生存期是变量可以访问的时间段,也就是从分配内存给它,到收回它的内存之间的时间。

本地变量是用栈来管理的,所以它的作用域和生存期是一致的,出了作用域,生存期也就结束了,变量所占用的内存也就被释放了。

但也有一些情况,变量的生存期跟语法上的作用域不一致,比如在堆中申请的内存,退出作用域以后仍然会存在。

面向对象的语义特征

从类型角度

类型处理是语义分析时的重要工作。现代计算机语言可以用自定义的类来声明变量,这是一个巨大的进步。因为早期的计算机语言只支持一些基础的数据类型,比如各种长短不一的整型和浮点型,像字符串这种我们编程时离不开的类型,往往是在基础数据类型上封装和抽象出来的。所以,我们要扩展语言的类型机制,让程序员可以创建自己的类型。

从作用域角度

首先是类的可见性。作为一种类型,它通常在整个程序的范围内都是可见的,可以用它声明变量。当然,一些像 Java 的语言,也能限制某些类型的使用范围,比如只能在某个命名空间内,或者在某个类内部。

对象的成员的作用域是怎样的呢?我们知道,对象的属性(“属性”这里指的是类的成员变量)可以在整个对象内部访问,无论在哪个位置声明。也就是说,对象属性的作用域是整个对象的内部,方法也是一样。这跟函数和块中的本地变量不一样,它们对声明顺序有要求,像 C 和 Java 这样的语言,在使用变量之前必须声明它。

从生存期的角度

对象的成员变量的生存期,一般跟对象的生存期是一样的。在创建对象的时候,就对所有成员变量做初始化,在销毁对象的时候,所有成员变量也随着一起销毁。当然,如果某个成员引用了从堆中申请的内存,这些内存需要手动释放,或者由垃圾收集机制释放。

但还有一些成员,不是与对象绑定的,而是与类型绑定的,比如 Java 中的静态成员。静态成员跟普通成员的区别,就是作用域和生存期不同,它的作用域是类型的所有对象实例,被所有实例共享。生存期是在任何一个对象实例创建之前就存在,在最后一个对象销毁之前不会消失。

闭包

想实现闭包,有两个前提:

  • 函数要变成一等公民。也就是要能把函数像普通数值一样赋值给变量,可以作为参数传递给其他函数,可以作为函数的返回值。

  • 要让内层函数一直访问它环境中的变量,不管外层函数退出与否。

其实,只要函数能作为值传来传去,就一定会产生作用域不匹配的情况,这样的内在矛盾是语言设计时就决定了的。闭包是为了让函数能够在这种情况下继续运行所提供的一个方案。

静态作用域(Static Scope)

如果一门语言的作用域是静态作用域,那么符号之间的引用关系能够根据程序代码在编译时就确定清楚,在运行时不会变。某个函数是在哪声明的,就具有它所在位置的作用域。它能够访问哪些变量,那么就跟这些变量绑定了,在运行时就一直能访问这些变量。

动态作用域(Dynamic Scope)就是变量引用跟变量声明不是在编译时就绑定死了的。在运行时,它是在运行环境中动态地找一个相同名称的变量。在 macOS 或 Linux 中用的 bash 脚本语言,就是动态作用域的。

静态作用域可以由程序代码决定,在编译时就能完全确定,所以又叫做词法作用域(Lexcical Scope)。

不过这个词法跟词法分析时说的词法不大一样。这里,跟 Lexical 相对应的词汇可以认为是 Runtime,一个是编写时,一个是运行时。

用静态作用域的概念描述一下闭包:因为我们的语言是静态作用域的,它能够访问的变量,需要一直都能访问,为此,需要把某些变量的生存期延长。

当然,我们学习使用的大多数语言都是采用静态作用域的(所以我们下面的思路也是基于静态作用域)。

实现闭包思路

闭包的内在矛盾是运行时的环境和定义时的作用域之间的矛盾。那么我们把内部环境中需要的变量,打包交给闭包函数,它就可以随时访问这些变量了。

实现了闭包的机制,函数也变成了一等公民,就是在一定程度上支持了函数式编程(functional programming)。

函数式编程的一个典型特点就是高阶函数(High-order function)功能,高阶函数是这样一种函数,它能够接受其他函数作为自己的参数。

闭包小结

闭包就是把函数在静态作用域中所访问的变量的生存期拉长,形成一份可以由这个函数单独访问的数据。正因为这些数据只能被闭包函数访问,所以也就具备了对信息进行封装、隐藏内部细节的特性。

“封装,把数据和对数据的操作封在一起”,这是相当面向对象的理解,所以,一个闭包可以看做是一个对象。反过来看,一个对象是不是也可以看做一个闭包,对象的属性,也可以看做被方法所独占的环境变量,其生存期也必须保证能够被方法一直正常的访问。

类型系统

在做语法分析时我们可以得到一棵语法树,而基于这棵树能做什么,是语义的事情。比如,+ 号的含义是让两个数值相加,并且通常还能进行缺省的类型转换。所以,如果要区分不同语言的差异,不能光看语言的语法。比如 Java 语言和 JavaScript 在代码块的语法上是一样的,都是用花括号,但在语义上是不同的,一个有块作用域,一个没有。

相比词法和语法的设计与处理,语义设计和分析要复杂很多。

这一部分的重点是类型系统。

围绕类型系统产生过一些争论,有的程序员会拥护动态类型语言,有的会觉得静态类型语言好。要想探究这个问题,我们需要对类型系统有个清晰的了解,最直接的方式,就是建立一个完善的类型系统。

其实,类型系统是一门语言所有的类型的集合,操作这些类型的规则,以及类型之间怎么相互作用的(比如一个类型能否转换成另一个类型)。如果要建立一个完善的类型系统,形成对类型系统比较完整的认知,需要从两个方面出发:

  • 根据领域的需求,设计自己的类型系统的特征。

  • 在编译器中支持类型检查、类型推导和类型转换。

设计类型系统的特征

事实上,在机器代码这个层面,其实是分不出什么数据类型的。在机器指令眼里,那就是 0101,它并不对类型做任何要求,不需要知道哪儿是一个整数,哪儿代表着一个字符,哪儿又是内存地址。你让它做什么操作都可以,即使这个操作没有意义,比如把一个指针值跟一个字符相加。

那么高级语言为什么要增加类型这种机制呢?

对类型做定义很难,但大家公认的有一个说法:类型是针对一组数值,以及在这组数值之上的一组操作。比如,对于数字类型,你可以对它进行加减乘除算术运算,对于字符串就不行。

所以,类型是高级语言赋予的一种语义,有了类型这种机制,就相当于定了规矩,可以检查施加在数据上的操作是否合法。因此类型系统最大的好处,就是可以通过类型检查降低计算出错的概率。所以,现代计算机语言都会精心设计一个类型系统,而不是像汇编语言那样完全不区分类型。

不过,类型系统的设计有很多需要取舍和权衡的方面,比如:

  • 面向对象的拥护者希望所有的类型都是对象,而重视数据计算性能的人认为应该支持非对象化的基础数据类型;

  • 你想把字符串作为原生数据类型,还是像 Java 那样只是一个普通的类?

  • 是静态类型语言好还是动态类型语言好?

虽然类型系统的设计有很多需要取舍和权衡的方面,但它最需要考虑的是,是否符合这门语言想解决的问题,我们用静态类型语言和动态类型语言分析一下。

根据类型检查是在编译期还是在运行期进行的,我们可以把计算机语言分为两类:

  • 静态类型语言(全部或者几乎全部的类型检查是在编译期进行的)。

  • 动态类型语言(类型的检查是在运行期进行的)。

静态类型语言好处:因为编译期做了类型检查,所以程序错误较少,运行期不用再检查类型,性能更高,在编译时就对类型做很多处理,包括检查类型是否匹配,以及进行缺省的类型转换,大大降低了程序出错的可能性,还能让程序运行效率更高,因为不需要在运行时再去做类型检查和转换。

动态类型语言好处:不要一遍遍的编译,方便进行快速开发。

客观地讲,这些说法都有道理。目前的趋势是,某些动态类型语言在想办法增加一些机制,在编译期就能做类型检查,比如用 TypeScript 代替 JavaScript 编写程序,做完检查后再输出成 JavaScript。而某些静态语言呢,却又发明出一些办法,部分地绕过类型检查,从而提供动态类型语言的灵活性。

再延伸一下,跟静态类型和动态类型概念相关联的,还有强类型和弱类型。强类型语言中,变量的类型一旦声明就不能改变,弱类型语言中,变量类型在运行期时可以改变。二者的本质区别是,强类型语言不允许违法操作,因为能够被检查出来,弱类型语言则从机制上就无法禁止违法操作,所以是不安全的。比如你写了一个表达式 a*b。如果 a 和 b 这两个变量是数值,这个操作就没有问题,但如果 a 或 b 不是数值,那就没有意义了,弱类型语言可能就检查不出这类问题。

也就是,静态类型和动态类型说的是什么时候检查的问题,强类型和弱类型说的是就算检查,也检查不出来,或者没法检查的问题。

类型检查、类型推导和类型转换

举个例子:

1
a = b + 10 
  • 如果 b 是一个浮点型,b+10 的结果也是浮点型。如果 b 是字符串型的,有些语言也是允许执行 + 号运算的,实际的结果是字符串的连接。这个分析过程,就是类型推导(Type Inference)。

  • 当右边的值计算完,赋值给 a 的时候,要检查左右两边的类型是否匹配。这个过程,就是类型检查(Type Checking)。

  • 如果 a 的类型是浮点型,而右边传过来的是整型,那么一般就要进行缺省的类型转换(Type Conversion)。

引用的消解

在程序里使用变量、函数、类等符号时,我们需要知道它们指的是谁,要能对应到定义它们的地方。这个过程,可以叫引用消解。

在集成开发环境中,当我们点击一个变量、函数或类,可以跳到定义它的地方。另一方面,当我们重构一个变量名称、方法名称或类名称的时候,所有引用它的地方都会同步修改。这是因为 IDE 分析了符号之间的交叉引用关系。

左值和右值

举个例子,对下面变量a取值:

1
a = 3;

假设 a 变量原来的值是 4,如果还是把它的值取出来,那么成了 3=4,这就变得没有意义了。所以,不能把 a 的值取出来,而应该取出 a 的地址,或者说 a 的引用,然后用赋值操作把 3 这个值写到 a 的内存地址。这时,我们说取出来的是 a 的左值(L-value)。

左值最早是在 C 语言中提出的,通常出现在表达式的左边,如赋值语句的左边。左值取的是变量的地址(或者说变量的引用),获得地址以后,我们就可以把新值写进去了。

与左值相对应的就是右值(R-value),右值就是我们通常所说的值,不是地址。

从类型体系的角度理解继承和多态

面向对象的另外两个重要特征:继承和多态

继承的意思是一个类的子类,自动具备了父类的属性和方法,除非被父类声明为私有的。

多态的意思是同一个类的不同子类,在调用同一个方法时会执行不同的动作。

对继承和多态做语义分析:

首先,从类型处理的角度出发,我们要识别出新的类型。之后,就可以用它们声明变量了。

第二,从作用域的角度来看,一个类的属性(或者说成员变量),是可以规定能否被子类访问的。以 Java 为例,除了声明为 private 的属性以外,其他属性在子类中都是可见的。所以父类的属性的作用域,可以说是以树状的形式覆盖到了各级子类。

第三,要对变量和函数做类型的引用消解。

但是对于强类型语言来说,编译期无法知道变量的真实类型,可能只知道它的父类型,这样就不能做正确的引用的消解,只能到运行期再解决这个问题。

在运行期,我们能知道变量具体指向的是那个对象,对象里是保存了真实类型信息,在调用类的属性和方法时,我们可以根据运行时获得的,确定的类型信息进行动态绑定。

比如,逐级查找某个方法的实现,如果本级和父类都有这个方法,那么本级的就会覆盖掉父类的,这样就实现了多态。

如果当前类里面没有实现这个方法,它可以直接复用某一级的父类中的实现,这实际上就是继承机制在运行期的原理。

继承情况下对象的实例化

在存在继承关系的情况下,创建对象时,不仅要初始化自己这一级的属性变量,还要把各级父类的属性变量也都初始化。

在逐级初始化的过程中,我们要先执行缺省的成员变量初始化,也就是变量声明时所带的初始化部分,然后调用这一级的构造方法。如果不显式指定哪个构造方法,就会执行不带参数的构造方法。

总结

通过上面这些介绍,基本可以看出来:语义分析的本质,就是针对上下文相关的情况做处理。

囫囵吞枣,大概学习了一遍,哎,复杂程度指数级上涨,难受QAQ

CATALOG
  1. 1. 前言
  2. 2. 开始
  3. 3. 作用域 (Scope)
  4. 4. 生存期 (Extent)
  5. 5. 面向对象的语义特征
    1. 5.1. 从类型角度
    2. 5.2. 从作用域角度
    3. 5.3. 从生存期的角度
  6. 6. 闭包
    1. 6.1. 静态作用域(Static Scope)
    2. 6.2. 实现闭包思路
    3. 6.3. 闭包小结
  7. 7. 类型系统
    1. 7.1. 设计类型系统的特征
    2. 7.2. 类型检查、类型推导和类型转换
  8. 8. 引用的消解
  9. 9. 左值和右值
  10. 10. 从类型体系的角度理解继承和多态
    1. 10.1. 对继承和多态做语义分析:
    2. 10.2. 继承情况下对象的实例化
  11. 11. 总结