如何在不同开发语言中使用绑定变量_动态语言如何做静态分析

我们经常会讨论到静态语言与动态语言的对比。

静态语言和动态语言的说法不太严谨,准确地说,是静态类型语言(static typing language)和动态类型语言(dynamic typing language)。

两者的主要区别:

  • 静态类型语言,可以在编译期确定symbol的类型,比如C++/C#里,我们显式定义一个symbol的类型;比如一些函数式语言里,类型系统通过类型推导确定symbol的类型。

  • 动态类型语言相反,运行时才知道各个symbol的类型。

静态类型语言,在编译期就可以获得充足的类型信息,因此可以在编译期做各种类型检查。因此,通常情况下,静态类型语言更适合大型工程,协同开发的效率很高。

我们写静态类型语言的时候,编译器或者插件会做很多工作,比如分析函数的signature,保证实现内和调用方形式参的绑定、数据在variable间的流动是类型一致的。

而动态类型语言,如果也想用于大型工程开发,会借鉴静态类型语言的一些机制或开发方式。

比如typescript,通过扩展类型系统、让程序员增加注解等方式,让js也具备了部分静态类型语言的能力。

Lua是游戏开发中常见的动态类型语言。

如果我们想把Lua也用于大型工程开发,也可以借鉴typescript的实现,扩展Lua的语言机制,让程序员多写一些类型注解,搞一个typelua出来。而且事实上,也已经有了类似的方案:

https://github.com/andremm/typedlua

效果是这样:

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。

比如:

65949bed8bf6412e428805cd5fab4a3a.png

这个静态分析功能,能提前查出很多比较低级的潜在bug,也是静态类型语言的优势之一。

我们今天就探讨下如何通过静态分析的方式,能判断出lua中的possible nil exception。

lua中,可能会抛nil exception的地方有两个:

  • 对nil做table index。

local t = a.B
  • 对nil做function call。

a()

某个对a的引用,a的Nullability有三种:

  1. 必然为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了过来,并且对细节做了些补充,欢迎大家取用。

https://github.com/fingerpasswang/Relua

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变量
ParameterSymbolFunctionSymbol中的形参符号实体
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

几点结论:

  1. 第一段代码第2行的a,绑定到了_G的a。

  2. 第二段代码第4行的a,绑定到了外层scope的local a。

  3. 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()

18ff177d04fd02f5e21d40f662b78d6b.png

如果要正确分析block1~3,我们要借助控制流分析的方法;但是分析block4的时候,我们又无法用控制流分析。

先捋一捋流程:

  1. 每个block执行完毕都会产出一组predicate。比如block2的产出是a必然不为nil+b必然为nil。

  2. 每个block都有一组dominators(tips:block的dominator表示,如果要执行到某个block,则一定执行过该block的dominator),block内的分析,需要依赖dominator产出的predicate,条件结构中的cond是一种特殊的predicate,会自动附加到该条件对应的block中。比如block2的预定义predicate就有a~=nil。

  3. block4之前,出现了一个merge point。由于具体的分支结构是运行时才能确定的,因此分析block4的时候要判定b为可能nil。

从分析可以看出来,首先要做的是,把一段代码拆分成不同的block,获取block间的dominate关系。

逻辑比较简单,一个scope内的statements,非分支结构的连续的语句各自合并,分支结构节点单独拆分成一组block。

接下来,开始按顺序执行block。

lua中的block分为两类:

  1. 最外层scope的block,就是一个lua文件里最外层的语句。

  2. 函数定义内部的block。

第一类很容易处理,按顺序执行就行了。

需要注意的是,由于涉及到函数的跳转和分支结构,因此需要维护在immutable结构中,每次跳转或进不同分支,都要拷贝状态,控制收回时再回溯到初始的状态。

第二类处理起来比较复杂,每个函数定义的内部block可能会执行多次。

  • 第一次,仅作为函数定义处理。这次处理,不对执行环境做任何假设,只关注local和形参在流程分析中可能产生的nil exception,其他对_G的symbol引用都处于一种待决议状态。

  • 函数每次被调用,都会额外处理一次。被调用的处理,需要带上当前的抽象解释上下文。除了第一次的分析内容之外,还要填充待决议的symbol,进行一次完整分析。

执行一个block的逻辑就是简化版的执行一个block组。

注意这样几点:

  1. block内的statements,可以理解为同scope的连续block,每个block一个statement。

  2. 每个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。


如前所述,整体的逻辑很直接,实现起来简单粗暴。

当然,篇幅所限,有些议题还没有讨论到。有兴趣的小伙伴可以边实现边和小说君探讨。

比如:

  • 执行的过程中如何避免无限递归。

  • 多态、元表这些高级结构如何处理。

  • 如何继续扩展,做比较完善的类型检查。

等等。

感谢观看!如果您对这篇文章感兴趣,不要犹豫,点赞、在看、分享、关注、留言。您的支持就是我继续更新的动力。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值