语义分析的结果会被送去字节码生成器,所以该结果必须接近字节码。而字节码格式是以类为单位的,所以语义分析的结果也应当是“类”。

这里的类不光是class,还包括了interface。在字节码和标准库的反射中,并不对两者做区分,只是将interface作为一个“修饰符”而已。同样的还有annotation(由于Latte-lang不支持定义注解,注解需要用java定义然后在Latte中使用。所以结果中不包括注解)。在语言层面,类和接口区别非常大,所以在这里还是将两者做了区分。

由于语义分析没有统一的算法,各个语言的特性也不同,所以下文将直接以Latte-lang实现的语义分析为模板进行描述,不会具有普遍性。但是对于类java语言完全可以照搬这一套实现方式。若是Lisp系之类与java相差非常大的,看一看也无妨。因为这一套结构实际上是对字节码的一种“封装”,毕竟JVM语言总是需要转为字节码的。

由于我定下的语义分析输出是接近字节码的表示,那么语义分析就必须输出一个个类型而不是单个的语句。而且任何值都会包含类型,所以总是需要先解析类型的。

#步骤 我将语义分析分为4步

注:为了本系列文章前后连贯且不受更新影响,所有链接都指向曾经版本的一个tag。代码结构与最新版是一致的,不过有些具体实现会变更(已知方法重写和方法寻找逻辑有问题并已在后续版本做了修复)

  1. recording 记录所有等待解析的类型。记录类型名称和类型的种类(类、接口),记录下每个文件对应的import
  2. signatures 记录父类,接口,对类型填入构造函数、方法、字段,以及以上所有位置的注解。这个步骤会完成继承树的构建,方法、构造函数签名的完成(方法名、参数类型),但是实际的指令或值只会很有限的解析出来(例如默认参数)。
  3. validate 验证有效性。检查以下:
  1. 循环继承
  2. 方法重写
  3. abstract方法 非abstract类是否重载了所有的abstract方法。
  4. 注解 一些注解的检查,例如@Override/@FunctionalInterface/@FunctionalAbstractClass
  5. data class data class必要方法的生成。
  1. parse 最重要的步骤:对方法、注解填入各种指令和值

前三步的代码量相对还是比较少的。

#第一步 第一步,对于类型需要建立一个 Map<String,STypeDef> types 用于记录即可。这个Map不光需要记录“待解析”的类型,还需要记录已经编译完成的类型。对于import需要建立一个 Map<String,List<Import>> fileNameToImport ,表示文件名到import的映射。 首先找出当前的package命名空间,然后这个文件中所有的类都在这个命名空间下,遇到类/接口时也可以方便的构造完整类名。 然后找出所有的Import并记录在List中。当寻找结束时就可以存入fileNameToImport映射中了。 最后一次遍历,找出所有的类型定义,并获得完整类名,记录在types映射中。

#第二步

第二步就开始“恶心”了。个人建议构造一个STypeDef getTypeWithName(String name,LineCol lineCol)函数(点击这里看源码)。接收名称和行号信息,并返回找到的类型。 这个函数需要保证类的内容是“懒加载”的,否则会出现无限循环。例如:

class X
    method():X

也就是说,类为X,其中有一个方法的返回类型也是X。若在构造这个类型时就试图将方法解析出来,则会不断尝试获取X类型,从而陷入无限循环。 正确做法是将那些不存在循环的内容先解析出来,然后记录这个类型,最后再解析那些可能造成循环的内容。

要获取已编译的类信息,可以解析字节码,也可以直接使用反射库。用反射好处在于动态加载的字节码也可以获取到,而解析字节码可以保证取得一切信息,包括非标准的Attribute以及运行时不可见注解。

这一步最关键的一点是统一。已编译的类型和未编译的类型的来源是不一致的,但它们必须变为统一且一致的结构。这实际上也算是设计语言时需要考虑到的一步。

例如,为了与java完美互通,Latte-lang没有实现null值检查。因为若是要加上,那么所有java类库实际上都是nullable的,编码时会加上无数不必要的null check。

下一篇单独描述一下第三步的做法。第三步里包含了一个值得思考和提升的算法(方法重写检查算法、是否重写了所有abstract方法的检查)。

最后,希望看官能够关注我的编译器哦~Latte