
吴信谊:Go+ ClassFile 原理与实战
一. ClassFile 原理
老许在此前的公开课分享过,Go+ 是一门面向连接的语言。
大家通过屏幕上的图片可以发现,Go+ 连接了非常多的 DSL。DSL 是领域专用语言,英文全称为 Domain Specific Language。领域专用语言是用来解决特定领域中特定任务的计算机语言。相比于通用语言,DSL 的开发相对容易,但开发的步骤是一致的,同样包含词法分析、抽象语法树构建、编译等等。
假设借助 Go+ 的 ClassFile 引擎构建 DSL 语言,开发者则不需要关心语言开发本身的过程,只需去定义领域的专有知识,这样便可大大降低开发的门槛,帮助开发者轻松搞定一门 DSL 语言。
Go+ 除了能够定义一门 DSL 语言,也可以定义、运行多门 DSL 语言。因此,Go+ 天然支持多门 DSL 语言的交叉。
有很多的科学家与学者其实研究的是交叉学科,同时面向多个领域。若能借助 Go+ 这种支持交叉编程的语言或工具,可能会出现意想不到的化学现象。
那么,Go+ 具体如何定义 DSL?我们先举一个例子。
上图中时通过 Go+ 的 ClassFlie 绘制出的一条 SinX 曲线。观察该图可以发现几个问题:
为什么扩展名是 .plot 不是 .gop,gop 是如何做到可以运行 .plot 的代码?
linspace 和 plot 并不是 Go + 的内置函数,是如何做到函数调用的?
它是通过 Go+ 的 ClassFile 引擎做的,那它是如何跟 ClassFile 引擎建立联系的?
linspace 返回的是一个向量,Go+ 是否支持向量计算?
带着这四个问题,我们先接着往下看。
1. ClassFile 在 Go+ 编译过程中扮演的角色
上图描述的主要是 ClassFile 在 Go+ 编译过程中扮演的角色。图中信息大致可以分为三部分:
源码。也就是 DSL 语言的源码;
ClassFile。ClassFile 专门用来定义 DSL 语言所需要的接口;
编译器相关模块组件。包含抽象语法树、编译器、模块管理、ClassFile 引擎等。
DSL 语言代码在实际运行过程中,首先模块管理会读取源码中的 gop.mod 文件,这个文件会对源码进行一个描述,如果是 DSL 语言便会注册一个 ClassFile。注册的 ClassFile 被模块管理识别后,模块管理会下载 ClassFile 的 package,下载后读取 package 中的 gop.mod 文件(该文件中包含该 ClassFile 具体实现的描述)。
同时,模块管理还会将 ClassFile 引擎注册,注册后 ClassFile 引擎便会包含该 ClassFile 的基本信息。
在编译的时候,抽象语法树会读取这个 ClassFile 引擎,获取该引擎支持的扩展名。这样抽象语法树便可对源码进行解析,得到抽象语法树。编译器同时也会读取 ClassFile 引擎,并针对 ClassFile 的工作模式进行特殊的处理。
我们刚才提到,DSL 语言跟 ClassFile 间的关联基本通过 gop.mod 实现,那么 ClassFile 具体的设计规格是如何的?
Go+ 的官网目前暂时还没有具体的 ClassFile 设计规格文档,但老许此前提过一个 issue:
issue 地址:https://github.com/goplus/gop/issues/915
这个 issue 中包含两个部分:
ClassFile 的 gop.mod 定义
DSL 语言的 gop.mod 定义
首先我们来看 ClassFile 的定义。
import "github.com/goplus/gop/cl"
cl.RegisterClassFileType(ExtGmx, ExtSpx, "pkgPathOfClassFile")
该部分主要相比于 go.mod 增加了几个语句,也就是 gop.mod 1.0。也就是说 ClassFile 是基于 gop.mod 1.0 进行的开发。
module moduleOfClassFile
go 1.17gop 1.0
classfile ExtGmx, ExtSpx, "pkgPathOfClassFile"
require ( ...)
第二个语句是 ClassFile,这部分语句定义的是 ClassFile 需要支持的扩展名。也就是如果哪个 DSL 语言注册了 ClassFile,编译器要自动为中间代码 import ClassFile 需要依赖的包。
module moduleUserProj
go 1.17gop 1.0
register moduleOfClassFile // add by `gop mod download pkgPathOfClassFile`
require ( pkgPathOfClassFile vX.X.XX)
这部分是 DSL 语言部分的 gop.mod。该部分会注册相应的 ClassFile 目录。
Go+ 模块管理部分会读取 DSL 语言上的 gop 文件,通过解析获取注册的 ClassFile 的引擎目录,通过 gop.mod 下载相应的 ClassFile。下载完成后再解析 ClassFile 中的 gop.mod,通过解析得到 ClassFile 的解释文件,从而通过解释文件注册到编译器中。
2. ClassFile 相关概念定义
Classfile 基本概念:Classfile 有两个基本概念,一个叫主体,另一个叫个体,对主体进行操作的代码写到主体文件中,对个体进行操作的代码,写到个体文件中,主体文件和个体文件,采用不同的扩展名,需要将扩展名定义到 gop.mod 的 classfile 语句中。
主体对象和个体对象:每个 Classfile 必须定义一个主体对象,个体对象可定义和可不定义,编译器在编译的时候,会创建一个以主体文件名为名称的主体结构体,内部会包含主体对象,同时,会创建一个以个体文件名为名称的个体结构体,内部包含个体对象,同时主体对象还会包含所有创建的个体结构体,个体结构体中也会包含主体结构体。
代码入口定义:编译器会给主体结构体生成 MainEntry() 方法,主体文件中的语句,会被放到该 MainEntry 方法中,同时给个体结构体生成 Main 方法,个体文件中的语句,会被放到这个 Main 方法中。最后生成 main 函数,并加入 {classfile}.Gopt_{主体对象名称}_Main() 的函数调用。
ClassFile 函数名称:当 DSL 语言调用了某一个 xxx 函数时,编译器回去 classfile 引擎中查找是否包含了该函数,如果未包含,则去查找主体对象下是否包含了该函数,如果再未找到,则去查找是否有 Gopt_{主体对象名称}_xxx 为名称的函数。ClassFile 需要根据自己的需要的入参去选择合适的定义方式。
二. ClassFile 实战
1. 模块初始化
上文中我们提到 ClassFile 和 DSL 模块的定义。通过 gop mod init 进行模块的初始化,生成 gop.mod。对 gop.mod 修改后,增加上图中最后一段语句,也就是 ClassFile 中需要说明的几个关键字,比如需要支持的扩展名「.p」;个体文件因为在这个例子中没有使用到,因此我们用“空”来代替。
最后的这段语句,便是当 DSL 语言使用到 ClassFile 引擎时,编译器需要自动为中间代码 import 的 package。
上图是 DSL 部分,需要去注册一个 ClassFile。因为示例为本地测试,所以采用的是 replace 方法,让 ClassFile 重新指向一个本地的 ClassFile。
这个行为和 go.mod 的 replace 行为是一致的。
2. ClassFile 定义
上图是常规的“Hello world”示例,通过 say “Hello world ”的方式,打印到标准输出当中。
上图是编译器生成的中间代码。除了 say(“hello world”)部分,基本全是编译器自动添加的。
这部分内容的结构看似繁琐,但实际尝试去自己写 ClassFile 后,就觉得这么设计是非常科学的。
上图便是为 say(“hello world”)代码所设计的 ClassFile。其中定义的 Gopt_Speak_Main 便是该 ClassFile 的入口,会在该函数中调用MainEntry 方法,来执行我们的 DSL 代码。最后在 MainEntry 中调用 say 函数,通过 fmt.Println 实现标准输出。
上图中浅蓝色部分是我们定义的几个常量。其中 GopPackage = true 代表该 ClassFile 是 Go+ 的一个包;gop_game = "Speak" 的意思是该 ClassFile 的主体名称是 “Speak”。
3. 类 Matlab 画图组件
linspace 是 Matlab 的一个语句,我们这里实际上是仿照 Matlab 实现 ClassFile 内部的函数。然后通过 Go+ 的 for 循环进行 y 的 x 平方计算,最终通过 plot 函数输出函数曲线。
上图是 Go+ 编译过程的中间代码。大家可以发现,所有的代码都被定义到 MainEntry 中。
前文中“Hello world”示例是通过 ClassFile.say 调用 ClassFile 中定义的函数,该示例中则出现了三种情况的调用:
ClassFile.Linspace
this.plot
math.Pi
同样是通过函数调用,但中间代码中为什么会出现不同的形式?这需要看一下 ClassFile 的定义。
首先,ClassFile 定义了主体对象 Figure,入口函数跟随变更。main 对象中的 index 不能直接调用 MainEntry 的原因,便是因为需要对主体对象先进行初始化。在初始化时,需要对 index 和 Figure 进行初始化,然后才能无异常的调用 plot。
示例中我们通过 Figure 对象构建了一个坐标,因此我们需要先对坐标以及 UI 引擎进行定义。
初始化函数会对坐标函数以及 UI 引擎进行初始化,对坐标进行绘制并将绘制后的图片输出到 UI 引擎中。
4. 操作符重载
操作符重载是在实践过程中发现很重要的部分。在此前的例子中,我们便将 Matlab 中没有内置的赋值方式进行了改写。
因为 Go+ 暂时不支持向量的运算,所以需要对诸如乘法操作符等进行操作符重载。具体实现的过程如下:
编译 binary 表达式,如 x * x
获取 x 类型
判断 x 是否具有 Gop_Mul 的方法
如果有,则将 x * x 改为 x.Gop_Mul(x)
生成的中间代码如下图所示:
上图代码中大家可以发现,此前的 this.Plot 变成了 this.Plot__1,这是 Go+ 中支持的函数重载。
下图中我们对向量的加减乘除都进行了操作符重载的定义。
5. 函数重载
前面生成的中间代码中我们发现,plot 生成的中间代码 由 this.Plot 变成了 this.Plot__1,因为 plot 由原来的入参 []float64 变更为 Vector,因此为了让我们的 plot 支持多种入参方式,我们把各种入参可能性都做了定义,编译器会自动选择最为合适的函数来调用。
函数重载具体的实现过程如下:
对于函数名称以 __x 结尾的函数(x可以为数字或字母),我们称为重载函数;
编译器会查找重载函数中符合条件的函数,并且使用。
6. 类 Matlab 画图组件中「子坐标」功能
熟悉 Matlab 的朋友知道,Matlab 是通过 subplot 函数来绘制子坐标。第一个参数是需要绘制的坐标组的行数,第二个参数为绘制的坐标组的列数,第三个参数为当前需要绘制的坐标处于的位置,因此第一个坐标绘制的是 sin(x),第二个坐标绘制的是 cos(x)。
下图是编译器生成的中间代码:
因为调用了 subplot 函数,所以需要在 Figure 主体对象中定义 subplot 方法。区别于 Figure 中只有一个坐标的方法,这个示例中需要把 Figure 进行重新定义。
该示例中 Figure 对象需要支持的是二维数组的坐标体,pos 定义的是当前 Figure 正在绘制的是第几幅坐标;w、h 代表每个坐标的长度和所占的像素多少;rows 和 cols 用来定义坐标的行和列数。下图是 Subplot 的实现:
将 Figure 对象重新定义后,对旧函数也需要进行重新改写。此前是坐标组写法,因此需要对坐标组中的坐标进行绘制。
然后将绘制结果保存到 convas 对象中,通过 convas 对象输出 Image。最后是使用 ClassFile 个体对象的特性来绘制子坐标。
因为 ClassFile 不仅包含主体对象,可能还包含个体对象。在 Matlab 引擎中,主体对象就是 Figure,个体对象就是 Figure 中包含的坐标。我们可以将这几个例子拆解成两部分:
同时,我们需要对 gop.mod 进行一个新的定义:
主体对象中文件的写法可修改成如下;
var 中包含的是需要绘制的两个坐标,坐标文件的名称通过个体对象文件的文件名获取;run(1,2)的意思是我们需要绘制一个「一行两列」的坐标组。
下图为 ClassFile 生成的中间代码:
大家可以发现主体结构定义生成的结构体中,即包含主体文件对象,还包含了刚刚定义好的两个坐标的对象。
其中,每个坐标对象还内置了 ClassFile 定义的坐标对象,并内置了主体的 index 结构体。与此前示例不同的是,编译器为每个坐标生成了一个 Main 方法,所有的个体文件中的代码也都被包含到相应的坐标函数中。
要想运行即包含主体对象又包含个体对象的函数,只需要让 Gopt_Figure_Run 函数执行坐标一与坐标二的 Main 函数即可。因此,Run 的具体实现可改写成如下形式:
首先,通过反射的方式拿到 index 的结构体,通过结构体的每一个 Field 判断是否包含 Main 函数。
如果包含,则调用。通过 subplot 函数定义坐标具体绘制的位置,最终把生成的坐标赋值给 Align。这样,我们就实现了通过 ClassFile 的个体对象的特性,来绘制的坐标组。