我们经常会讨论到静态语言与动态语言的对比。
静态语言和动态语言的说法不太严谨,准确地说,是静态类型语言(static typing language)和动态类型语言(dynamic typing language)。
两者的主要区别:
- 静态类型语言,可以在编译期确定symbol的类型,比如C++/C#里,我们显式定义一个symbol的类型;比如一些函数式语言里,类型系统通过类型推导确定symbol的类型。
- 动态类型语言相反,运行时才知道各个symbol的类型。
静态类型语言,在编译期就可以获得充足的类型信息,因此可以在编译期做各种类型检查。因此,通常情况下,静态类型语言更适合大型工程,协同开发的效率很高。
我们写静态类型语言的时候,编译器或者插件会做很多工作,比如分析函数的signature,保证实现内和调用方形式参的绑定、数据在variable间的流动是类型一致的。
而动态类型语言,如果也想用于大型工程开发,会借鉴静态类型语言的一些机制或开发方式。
比如typescript,通过扩展类型系统、让程序员增加注解等方式,让js也具备了部分静态类型语言的能力。
Lua是游戏开发中常见的动态类型语言。
如果我们想把Lua也用于大型工程开发,也可以借鉴typescript的实现,扩展Lua的语言机制,让程序员多写一些类型注解,搞一个typelua出来。而且事实上,也已经有了类似的方案:
效果是这样:
local t : string = "Hello, world"
定义local完自己加个类型注解。
不仅如此,typedlua还加了不少类型推导/检查友好的语言机制,比如可空类型、组合类型等等。
但是,这种方案应用起来其实就是用一门像lua的方言,现有的lua工程无法无缝迁移。
而且,一门新的语言过于重量级,如果背后没有大公司支撑,让人只敢当做学术研究,不敢应用在工程中。
所以,今天小说君想聊一聊另一种更简单粗暴的方案——直接写个抽象解释器,在编译期做静态分析。
我们在工程中对lua静态分析的诉求,可以简单分为三个层次。
1.补全和信息显示。
- 敲一个字母,可以列个list告诉我们local或module内的其他符号。
- 可以查看module内定义的函数的signature。
- 可以定制查看一些预定义的静态注解数据。比如静态语言导出的供lua调用的接口;比如模块内显式定义的symbol的lua类型(函数或table)
2.类型检查。
- 一些错误检查,比如对一个nil做table access时报错;比如对一个非function类型的symbol做function call时报错;比如对非数值类型做数值运算时报错。
- lua中一个函数的返回值是不确定的,通常我们开发规范要限定lua函数的各种情况返回值需要满足同一个约束。比如必须有返回值,必须有一个确定类型的返回值等。这时候,需要有对return statement的一致性检查。
3.智能提示。
我们在IDE中写静态类型代码,所有的提示要什么有什么。
比如:
- 可能递归的函数调用。
- 可能无法退出的循环迭代。
- 无法到达的代码。
等等。
一个完整的抽象解释器,写起来难度不亚于一个重新定义模型的lua虚拟机。
在本篇文章中,小说君仅就一个比较简单也比较常见的case来做简单的实现,希望能起到抛砖引玉的作用。
C#中,静态分析最常见的一个例子是Nullability Analysis。
比如:
这个静态分析功能,能提前查出很多比较低级的潜在bug,也是静态类型语言的优势之一。
我们今天就探讨下如何通过静态分析的方式,能判断出lua中的possible nil exception。
lua中,可能会抛nil exception的地方有两个:
- 对nil做table index。
local t = a.B
- 对nil做function call。
a()
某个对a的引用,a的Nullability有三种:
- 必然为nil。
if a thenelse a()end
或
a = nila()
2.可能为nil。
比如从目前为止的env状态,无法判断a是否存在。
3.必然不为nil。
a()a()
其中,如果能执行到第二行,a必然不为nil。
还有其他带断言的情况:
if a then a()end
只要我们的静态分析,可以检查并提示这几种情况,平时开发中大部分笔误写出的需要运行时甚至线上才能遇到的runtime nil exception就可以提前避免。
接下来,简单看下如何简单粗暴地实现一个抽象解释器,以及如何简单粗暴地支持一下前述的possible nil exception检查功能。
首先,我们需要一个parser。这个parser至少要提供如下功能:
- 可以完整支持要分析的语言的所有词法。
- 可以拿到节点关系完整的语法树。
- 可以遍历语法树。
随便从github选一个。不满足上述条件没有关系,只要挑的实现足够简单,我们很容易就可以补充。
小说君随便选了一个fork了过来,并且对细节做了些补充,欢迎大家取用。
parser的部分过于简单,我们看一下这个Visitor定义,就差不多能明白整个syntaxNode的设计了。
public interface ISyntaxVisitor { void Visit(SyntaxVariable node); void Visit(SyntaxNilLiteral node); void Visit(SyntaxVarargsLiteral node); void Visit(SyntaxBoolLiteral node); void Visit(SyntaxUnaryOp node); void Visit(SyntaxBinaryOp node); void Visit(SyntaxStringLiteral node); void Visit(SyntaxNumberLiteral node); void Visit(SyntaxLuaJITLongLiteral node); void Visit(SyntaxTableAccess node); void Visit(SyntaxFunctionCall node); void Visit(SyntaxTableConstructor node); void Visit(SyntaxTableConstructor.Entry node); void Visit(SyntaxBreak node); void Visit(SyntaxReturn node); void Visit(SyntaxBlock node); void Visit(SyntaxConditionalBlock node); void Visit(SyntaxIf node); void Visit(SyntaxWhile node); void Visit(SyntaxRepeat node); void Visit(SyntaxFunctionDefinition node); void Visit(SyntaxAssignment node); void Visit(SyntaxNumericFor node); void Visit(SyntaxGenericFor node); void Visit(SyntaxLabel node); void Visit(SyntaxGoTo node);}
拿到一个parse后的语法根节点,Visit,就能递归遍历这棵语法树的所有类型节点了。
parser帮我们拿到了语法树节点,接下来我们还需要拿到语义模型。
语法树中的所有语法节点都是描述性质的,只说明了这个位置有这么个语法节点,无法说明更多信息。
接下来还要做简单的语义分析。
首先,我们构建一个语义模型,把语法节点跟语义模型关联起来。
比如说,lua中,我们一个变量的类型可以是number、string、function、table等。一个对变量a的引用,语法树中对应的是一个SyntaxVariable节点。但是这个a目前有可能是什么值、如何被初始化、如何知道连续的两行代码引用的是不是同一个a,这些都属于语义信息,无法直接从语法树中获取到。
静态语言中,构建语义模型,主要的工作是构造一个符号表,然后我们可以通过语法树节点去查找关联的符号。
一些基本的符号:
FunctionSymbol | 具体的函数定义信息 |
GlobalSymbol | 对_G的符号引用 |
IteratorSymbol | 一种特殊的VariableSymbol,for语句中的迭代器变量 |
LocalSymbol | 常见的VariableSymbol,一个local变量 |
ParameterSymbol | FunctionSymbol中的形参符号实体 |
TableElementSymbol | 一次table access,属于一种annotation |
然后是符号的作用域。
lua虽然是个嵌入式的脚本语言,但是在符号绑定上也有一定程度的静态语言特征。比如这个例子:
function f() print(a)enda = "global"local a = "test"f()$lua main.luaglobal
local a = "test"function f() print(a)endf()$lua main.luatest
几点结论:
- 第一段代码第2行的a,绑定到了_G的a。
- 第二段代码第4行的a,绑定到了外层scope的local a。
- function definition内部的scope,可以有限引用外部scope的symbol。
按照这些特征,我们增加一个作用域结构即可。
function f内部,如果需要获取variable a的symbol,会先查当前scope内部的符号表,查不到的话查外层符号表,直到变为一个对_G的引用(GlobalSymbol)。
语义模型的工作就差不多这样了。
最终我们产出的是一个接口:根据语法树中的任意节点,获取节点关联的Symbol。
以上就是做possible nil exception check的准备工作。
接下来进入正题。
如果说,动态类型语言做静态分析的难题在于,编译期我们无法获取足够的信息。那么,只要我们编译期有选择地「执行」一遍代码,就能获取到更多的信息。
有选择地「执行」,就是指抽象解释。
local a = {}
运行完这样一行代码,我们不需要知道a具体维护了一个什么值。
- 我们只需要知道,执行完这行代码,a一定是一个非nil值;
- 深入定义的话,我们知道,执行完这行代码,a是一个没有任何字段定义的table。
抽象执行中抽象的含义,在分支结构中更容易理解。
local b = {}if a then a() b = nilelse a()endb()
如果要正确分析block1~3,我们要借助控制流分析的方法;但是分析block4的时候,我们又无法用控制流分析。
先捋一捋流程:
- 每个block执行完毕都会产出一组predicate。比如block2的产出是a必然不为nil+b必然为nil。
- 每个block都有一组dominators(tips:block的dominator表示,如果要执行到某个block,则一定执行过该block的dominator),block内的分析,需要依赖dominator产出的predicate,条件结构中的cond是一种特殊的predicate,会自动附加到该条件对应的block中。比如block2的预定义predicate就有a~=nil。
- block4之前,出现了一个merge point。由于具体的分支结构是运行时才能确定的,因此分析block4的时候要判定b为可能nil。
从分析可以看出来,首先要做的是,把一段代码拆分成不同的block,获取block间的dominate关系。
逻辑比较简单,一个scope内的statements,非分支结构的连续的语句各自合并,分支结构节点单独拆分成一组block。
接下来,开始按顺序执行block。
lua中的block分为两类:
- 最外层scope的block,就是一个lua文件里最外层的语句。
- 函数定义内部的block。
第一类很容易处理,按顺序执行就行了。
需要注意的是,由于涉及到函数的跳转和分支结构,因此需要维护在immutable结构中,每次跳转或进不同分支,都要拷贝状态,控制收回时再回溯到初始的状态。
第二类处理起来比较复杂,每个函数定义的内部block可能会执行多次。
- 第一次,仅作为函数定义处理。这次处理,不对执行环境做任何假设,只关注local和形参在流程分析中可能产生的nil exception,其他对_G的symbol引用都处于一种待决议状态。
- 函数每次被调用,都会额外处理一次。被调用的处理,需要带上当前的抽象解释上下文。除了第一次的分析内容之外,还要填充待决议的symbol,进行一次完整分析。
执行一个block的逻辑就是简化版的执行一个block组。
注意这样几点:
- block内的statements,可以理解为同scope的连续block,每个block一个statement。
- 每个statement的执行,同样要依赖前面的predicate,产生新的predicate。
local bprint(b.B)print(b.B)
静态类型语言的分析中,第3行会报possible nil exception,而第4行不会。
我们在分析时同样,第3行会产出一个b必然不为nil的predicate,因此第4行不会报possible nil exception。
3.statement内,主要关注table index和function call两种操作。如果对某个symbol做table index或function call时,并没有查到前置的该symbol不为nil的predicate,那么这里就需要报一个possible nil exception。
如前所述,整体的逻辑很直接,实现起来简单粗暴。
当然,篇幅所限,有些议题还没有讨论到。有兴趣的小伙伴可以边实现边和小说君探讨。
比如:
- 执行的过程中如何避免无限递归。
- 多态、元表这些高级结构如何处理。
- 如何继续扩展,做比较完善的类型检查。
等等。
感谢观看!如果您对这篇文章感兴趣,不要犹豫,点赞、在看、分享、关注、留言。您的支持就是我继续更新的动力。