04.从0实现一个JVM语言系列之语义分析器-Semantic-03月05号更新

从0实现JVM语言之语义分析-Semantic

源码github, 如果这个系列文章对您有帮助, 希望获得您的一个star

本节相关语义分析package地址

系列导读 00.一个JVM语言的诞生

由于时间原因, 最后一节中间代码生成(暂时的阶段性的最后一节, 后面忙完会继续更新), 只能白天发布, 请大家见谅!

致亲爱的读者:

    个人的文字组织和写文章的功底属实一般, 写的也比较赶时间, 所以系列文章的文字可能比较粗糙,
难免有词不达意或者写的很迷惑抽象的地方 

    如果您看了有疑问或者觉得我写的实在乱七八糟, 这个很抱歉, 确实是我的问题, 您如果有不懂的地方
的地方或者发现我的错误(文字错误, 逻辑错误或者知识点错误都有可能), 可以直接留言, 我看到都会回复您!

系列食用方法建议

    由于时间原因, 目前测试并不完善, 所以推荐如下方式根据您的目的进行阅读

    如果您是学习用, 建议您先将整个项目clone到本地, 然后把感兴趣的章节删除, 自己重写对照着重写
    书写完每一步测试一下能否正常运行(在指定的路径去读取源码测试能否编译成功并在命令行执行

    java Application(类名)

尝试能否输出期望结果, 我没有研究Junit对编译器输出class文件进行测试, 所以目前可能需要您手动测试)

    按照以上步骤, 等您将所有模块重写一遍, 大概也对这个系列的脉络有深刻理解了! 如果您重头开始重写, 
往往可能由于出现某些低级错误导致长时间debug才找得到错误, 所以对于初学者, 推荐采用自己补写替换模块的
方式

    对于希望贡献代码的朋友或者对Cva感兴趣的朋友, 欢迎贡献您的源码与见解, 或者对于该系列一些错误/
bug愿意提出指正的朋友, 您可以留言或者在github发issue, 我看到后一定及时处理!

本节提纲

  1. 引言

  2. 语义分析实现

    2.1. 符号表

    2.2. 语义分析思路

  3. 分析过程

    3.1. 类型检查

    3.1.1. 表达式检查

    3.1.2.. 语句检查

    3.2. 类型匹配

    3.3. 其他检查

    3.4. 错误纠偏

  4. 本节设计模式浅析

引言

语义分析器工作基于语法分析器输出的抽象语法树, 通过对该语法树的分析做进一步的检查,
回答我们, 也回答代码生成器最后一个问题:源程序是否符合语义规则. 如果确实存在问题,
那么这段代码即使翻译成机器码(在这里是我们后面要生成的JVM汇编指令), 也不可能执行成功,
必然收到JVM的拒绝执行(读者可以自己尝试瞎写命令然后用jasmin汇编成字节码, 看报错反应, 哈哈),
或者造成一些意想不到的问题, 因此语义分析有问题势必造成后面的错误, 所以语义分析旨在搜集
代码出现的逻辑错误(多数是类型检查的类型问题), 并指出, 以让接下来的步骤能正常进行

在这个阶段要给出尽可能准确的报错信息, 供用户参考并修改源代码.
语义分析中最重要的工作便是类型检查, 此外还会有一些其他的检查, 例如变量在使用前是否声明等.

语义分析实现

符号表

语义分析的正常进行少不了符号表的参与. 所谓符号, 程序中的变量、方法、字段、类都是符号.
符号表存储了程序中的符号的相关信息, 这些信息包括类型、作用域、访问控制信息等等, 而且符号表必须非常高效,
因为程序中符号的规模会非常大. 我们的符号表都是采用HashMap, JVM针对HashMap的优化可谓是比较极致,
我们的JDK8后引入了红黑树, 对于Map, HotSpot会倾向于将其编译成本地代码, 在一些情况下,
其运行效率甚至超过一般的手写分支判断

我们的哈希表以符号名字为键, 以符号的相关信息为值, 建立映射.
并按照树形结构, 组织各个作用域的符号及信息, 建立全局符号表.

全局符号表的大致结构如下:

// TODO: 树形图

+ ClassMap
  + ClassBinding
    + BaseClass
    + FieldMap
      + Field
      + ...
    + MethodMap
      + Method
      + ...
  + ...
+ MethodVariableMap

+ ClassMap

  全局符号表的入口, 直接维护了类名和类的相关信息的

+ ClassBinding

  存储了类的相关信息, 包括父类, 字段表, 方法表. 其中字段表和方法表是以名为键的映射. 

+ Field

  存储了字段的声明类型

+ Method

  存储了方法的相关信息, 包括声明的返回类型, 参数的个数及各自类型. 

+ MethodVariableMap

  参数和本地变量表, 是名字和类型的映射. 当分析某个方法的时候, 对于该方法的参数和本地变量的访问是相当频繁的,
 因此将他们单独存储到某个位置, 分析完毕即销毁, 优化空间的占用. 

语义分析思路

本质上说, 所谓"分析"仅仅是语法树的遍历而已, 只是附带上了附加条件, 要求某子树符合某个要求. 此外我们应当注意到,
类型的声明、字段的声明和方法的声明, 并没有任何值得语义分析的地方, 真正值得我们去分析和检查的是语句和表达式,
查看它们是否合法. 因此, 分析过程可简要分成两步.

  1. 信息收集和索引

    对当前语法树的前几层进行遍历, 扫描并存储类信息, 各自的字段和方法相关信息, 构建全局随时可用的"全局符号表",
    该过程仅在语义分析实际进行之前进行一次.

  2. 语义分析和检查

    收集要分析的方法的参数和变量信息, 然后顺序遍历方法的每一条语句和表达式. 如果找到错误, 那么就输出一条信息,
    提示用户在某位置发现何种类型的错误, 并尽可能进行恢复, 继续检查下文, 在一趟分析中给出尽可能多的错误信息.

对于每个方法, 都执行一遍步骤2, 直到所有的方法都分析过. 如果未发现任何错误, 那么进入下一阶段, 如果发现问题,
那么在给出所有信息后, 退出编译过程.

分析过程

类型检查

类型检查是语义分析的重点所在, 如果此处的检查没有通过, 那么这个程序必定存在问题, 必定不能运行.
下面通过几个例子来展示类型检查的工作细节.

表达式检查

对于表达式而言, 类型检查主要分为以下几类

  • 操作符

    对于单目运算符逻辑非 !主要检测其操作数是否是前端boolean
    (只有前端用户才会看到boolean, 在编译器后端boolean会被处理为int),
    对于双目运算符如+ - * /类型检查的步骤是:

    先确认两侧/单侧的表达式类型, 然后确认两侧表达式类型是否匹配,
    最后确认当前的操作符能否对该类型进行操作全部没有问题的话, 才认为该表达式通过了检查,
    并确认该表达式的值类型(在代码中表达式的结果值直接取双目运算符的左边)
    (在这里, 我们前面的toEnum()方法就派上了用场)

    错误语法例子如:

    • 10 + true

      显然 + 两侧类型不匹配, 类型检查的给出的信息是,

      Cva日后将遵循JVM规范, 基本运算可以是范围小于 int 的整形, 做运算时都强转为 int
      如 byte char short 类型转为操作数都视为 int, 编译器不报错

      Error: Line 1 Add Expr ression: the type of left is @int, but the type of right is @boolean

    • true < false

      两侧类型是一致的, 但很显然, 这个比较没有任何意义, 因此这个表达式也是个错误

      Error: Line 1 only numeric can be compared.

    • !200

      只有布尔值才能取逻辑非, 因此这个表达式显然也是非法的

      Error: Line 1 the Expr r cannot calculate to a boolean.

  • 方法调用

    Cva目前仅支持实例方法调用, 暂不支持静态方法、方法重载等.

    对于方法调用表达式, 类型检查主要关注形参及实参.
    首先确认形参和实参数量是相等的, 然后确认参数的类型是一一对应的.
    通过检查后, 表达式的值类型被设定成为该方法的返回值类型.

    假定本类中有有方法 int compute(int a, int b), 对它的两种错误调用如下

    • this.compute(10)

      显然, 这并不能通过第一步: 对于参数个数的检查

      Error: Line 1 the count of arguments is not match.

    • this.compute(10, false)

      显然, 第二个参数的类型不是匹配的

      Error: Line 32 the parameter 2 needs a int, but got a boolean

    • println(Expr expr)

      应当注意到, 在本程序中我们认为write(控制台写操作, println, echo, printf)是一个语句,
      (内置方法关键字)而不是函数调用表达式. 这样做的目的是为了简化该编译器开发,
      但其本质依旧是函数调用, 因此对于它的类型检查等同函数调用. 写操作应检查参数是否为string 或者基本类型,
      因此Expr expr最终应当能求得一个string 或者 整形(目前, 以后完善boolean和浮点数情形

  • 返回表达式

    这里是确认方法声明处的返回类型和实际的返回类型是匹配的. 首先检查 return 关键字后紧跟的表达式是否有意义,
    然后再确认这个有意义的表达式是否符合返回类型. 考虑这样的一个源程序:

    boolean doSomething()
    {
        // Some VarDecls
        // Some Statements
        return 666;
    }
    

    很明显, 实际返回类型和声明的返回类型不一致, 我们应当给出相应的错误提示
    此外, 在Cva中, void返回类型的方法, 我们允许不显式return, 也可以在方法结束时 使用return; 语句

    Error: Line 3 the return Expr ression's type is not match the method "DoSomething" declared.

  • 标识符 / 字面量 / this / new cvaIdentifierExpr r()

    这里是讨论剩余的几类特殊的表达式, 变量/字段引用, 字面量, this关键字, 实例化对象表达式.

    • 标识符

      查找标识符大致分为两步, 首先在参数列表/本地变量表查找该标识符, 若查找失败, 再去类/基类字段声明列表中尝试查找.
      若最终查找失败, 则报一个错.

      Error: Line 1 you should declare "x" before use it.

    • 字面量 / this

      这个种类主要包括常量(数字整形字面量, true ,false), this关键字. 应当特别注意this关键字,
      它指代当前类的实例, 它的类型自然是该关键字所处的类的类型.

    • new cvaIdentifierExpr r()

      应当注意到本程序不支持构造函数, 因此每个类只有一个形式上的无参构造器. 除主类外,
      对于其他普通类的声明顺序不做要求. 如果尝试实例化一个不存在的类, 也会报告错误.

      `Error: Line 1 cannot find the declaration of class "XXX".`
      

语句检查

有了前面表达式级的类型检查作为基础, 做语句的类型检查就很方便快捷了.

  • write

    上文(writeExpr )已给出解释

  • if / while / for

    对于这种类型的语句, 只需要检查是否符合对应的规则即可. 例如条件判断处必须是个布尔类型的表达式

  • {StatementList}

    这种类型的表达式, 按顺序轮流进行检查即可

  • cvaIdentifierExpr r = Expr r;

    赋值语句, 只要等号两侧的类型是互相匹配的, 那就允许赋值.

类型匹配

应当注意到, 我们之前提到的一直是"类型匹配", 而不是"类型相等". 所以我们其实允许合法的类型隐式转换.

其他检查

在本程序里, 我们还做了另外两个对于标识符的检查:变量/字段标识符(CvaIdentifierExpr expr),
类名标识符(也是identifier). 由于这两类标识符存在于不同的符号表里面, 因此本程序可以声明类似
SomeThing SomeThing; 这样类型和名称一样的变量/字段,这种声明是合法的, 在使用时具体的意义取决于这个符号所在位置的语义.

错误纠偏

前面已经提到, 在这个阶段, 我们要在一遍扫描中给出尽可能多的信息, 因此我们需要实现错误恢复功能. 出现的具体错误和恢复思想如下

  • 使用了未声明的变量

    一旦发现某处使用了未使用的变量, 那么会立即给出信息提示此处发现一个未声明变量, 但是分析还是要继续, 于是原地定义该变量是
    一个 unkonwn 类型, 使用该类型, 进行接下来的分析.

  • 操作符型表达式

    例如 true + 10, !200这种类型的错误, 我们优先考虑操作符的语义, 例如在我们的程序中, 加减乘必定得出一个整数,
    比较运算必定得出布尔类型的值. 我们假定该操作符被正确使用并得出结果, 然后进行下面的分析.

  • 方法调用

    方法调用出现了问题, 例如参数不全、参数类型不匹配, 我们给出应当提示用户的信息之后, 假定方法正常调用, 按照该有的返回值进行下面的分析.

本节设计模式浅析

本节的设计模式主要是几个哈希表使用了单例模式, 使用双检锁实现, 也可以使用枚举实现, 会非常简洁

posted @ 2021-03-02 16:49  throw_new_NullPointe  阅读(195)  评论(0编辑  收藏  举报