大家好,今天是 Go+ 公开课的第 7 讲,话题是「Go+ ClassFile 机制详解」

此前的公开课中很多次都涉及到了 ClassFile,虽然每次的介绍相对比较仓促、零碎,但也为大家铺垫了足够多的基础。今天想和大家完整地谈一下 ClassFile,以及它背后的机制。

在正式的分享前,先和大家插播一个比较重要且有趣的东西:Go+ 编程的在线体验地址(https://play.goplus.org/)正在尝试新的 Smart Format 模式,这是一个将 Go+ 代码风格做到尽量一致化的尝试。

举个例子:Go+ 可以兼容 Go 的代码,会写 Go 代码的朋友可以在体验框中输入一段 Go 的代码,如图所示:

点击「Format」按钮,便会自动转化为 Go+ 对应的写法,如下图所示:

上述的案例是非常标准的 Go 版本的 Hello world 写法,转化成 Go+ 写法后,代码量从 7 行降低到了 1 行。具体的 Format 规则可以详见 gop/x/format 包的 README 文件。这非常适合大家在不了解 Go+ 基础时进行初步的学习与了解。其中包含的语法转换能力后续会不断完善,增加新的代码规范。

一. 为什么需要 ClassFile

下面正式开始本次公开课的分享。首先我们来谈谈 ClassFile。

1. 正在发生中的趋势

每次谈到 ClassFile 我都会提一个比较重要且正在真实发生的趋势 —— 软件吞噬一切行业。而与之对应的矛盾是,现阶段 IT 技术人才的供给严重不足,这也是导致如低代码、无代码等热门概念兴起的原因。

在我看来,无代码的本质就是领域专用软件(DSS),最终以领域软件和配置的方式来实现某一领域的需求适应性;而所谓的低代码,在我看来本质上是领域专用语言(DSL)。

2. DSS & DSL 的问题是什么?

在目前的趋势下,需要低代码或者无代码的行业非常多。随着大量 DSS 和 DSL 的诞生,会相应地出现非常多领域专属的碎片化信息,形成非常多的信息孤岛。

这是一个很重要的问题。在谈我如何考虑、看待信息孤岛这个问题前,先和大家聊一下 Go 语言。

如 Go 语言发明人 Rob Pike 所说,Go 是一门面向连接的语言,此前他也表示过如果只能将 Go 的一个特性带到其他语言中,他不会选择 goroutine 而是选择 interface,因为 interface 不是一个纯技术问题,只有在成为社区共识时才有用,而 goroutine 其他语言是有机会自己实现的。

3. 什么是面向连接

面向连接在本质上,是关于代码如何组合的学问,需要研究一个大型的软件工程如何构建。

连接是一种非常重要的基础能力,解放了很多领域的生产力。比如 Internet 连接了人、服务和物;微信连接了人和组织;Go 语言则是连接了代码和组件。Go+ 所说的面向连接,则是结合软件吞噬一切行业的趋势,连接工种和职业,关注的焦点是低代码及跨工种的协同。

Go+ 希望能够让所有的职业,包括老师、医生、设计、策划等在内的、非常多样化的职业工种互相连接,而不是让越来越多的 DSS 或者 DSL 形成相互隔离的孤岛。虽然 Go+ 基于 Go、兼容 Go 的语法,但从本质上来讲,两者要解决的问题是截然不同的。

 4. 为什么跨工种协同依赖编程语言?

为什么跨工种的协同需要依赖编程语言来完成?DSS 和 DSL 其实已经实现了协同中非常重要的低门槛特性,但却牺牲了开放性与可连接性。

编程语言实际上是工程技术的传承。微软 CEO 纳德拉有一个观点:“今天人们的 IT 支出只占 5%,未来 5 年会翻一倍”,在我的理解中,他所表达的逻辑便是软件会吞噬一切行业。

之所以行业发展会由软件所驱动,本质上是因为所有行业的进化最终都体现在工程技术能力的进化,而编程语言相比自然语言在意图表达上更精准、无歧义,是传承工程技术最佳的选择。这也会使得未来将有越来越多看起来跟编程无关的技术工程,最终都将演进成基于软件的进化。

在这样的逻辑下,我们会面临一个问题:虽然统称为编程语言,但人类社会繁多的学科与专业背后,都有着自身的领域知识与专业术语。

大家可以发现,在今天的自然科学中,大家面对不同的领域知识,引入了专业术语,而不是创造新的自然语言。这给了我一个启发——我们真的需要新的语言么?不见得。

在多数情况下,我们需要的是领域知识的封装,而不是一种新的语法或者语言。

现阶段的DSL 是一种过渡性质的产物,是极其不划算的。我对于 PaaS 领域的创业者有个建议,开放性的重要性远远大于便捷性,开放性是产品生命力的基础。大部分 DSL 是违背这一原则的,为了便捷性放弃开放性甚至不开放,从而变成一个个孤岛。

因此,Go+ 的思路是封装领域知识,而不是创造一门新语言。具体的做法便是 ClassFile 所考虑的问题。ClassFile 之于 Go+ ,如同 interface 之于 Go,是 Go+ 最重要的能力,没有之一。

5. ClassFile 是什么东西?

首先,一个 ClassFile 代表了实现某类工程技术的工程项目。我们上述提到每一个领域都会有自身工程项目的设计,一个 ClassFile 其实就代表了一个领域的某一类工程。它通常由以下内容组成:

  • 该类工程的入口文件(单个)

  • 一个或多个的工作文件

强调一点,ClassFile 是一种开放的连接机制,可以认为是定义了一个新的 DSL,该 DSL 拥有自己的源代码扩展名(扩展名体现它想解决的领域问题),有自己定义的领域知识,但它不能有自己的语法,而是必须使用 Go+ 语法进行表达。

正因如此,它可以和其他基于 Go+ 的 ClassFile 或者其他组件互通,彼此调用。这便是 Go+ 开放性大于便捷性的原因,核心便是 ClassFile 是一种开放的连接机制。

那么为什么命名为 ClassFile 呢?实际上,无论是工程的入口文件或者工作文件,其实都是在定义一个「类」,这便是命名为 ClassFile 的原因,因为它整个文件便是一个类。

我们假设某个名为 XXX.foo 的 ClassFile,内容如下:

var ( ... // 成员变量定义(注意只有第一个 var 块是成员变量定义))
var ( ... // 普通全局变量定义,一般不应该存在)
func Method(...) { // 成员函数定义 ...}... // 全局代码

可以看到这个文件中开始有若干个变量的定义,然后定义了部分函数。在常规 Go+ 的定义中,我们会把它理解为定义了一系列的全局变量和全局的函数。但在 ClassFile 文件中,以上代码中第一个 var 语法块定义的变量是成员变量,并不是全局变量。而全局函数则其实是成员函数。所以这个看起来是在写一个很普通的面向过程的 Go+ 代码,实际上则是在定义一个类。

工程入口文件和工作文件两类不同的 ClassFile,转化后的代码会有所不同。

以下是工程入口文件转换后的代码:

 type XXX struct { ProjectBase // 工程基类,具体名字不同 ClassFile 不同 ... // 成员变量定义(注意只有第一个 var 块是成员变量定义)}
var ( ... // 普通全局变量定义,一般不应该存在 )
func (this *XXX) Method(...) { // 成员函数定义 ... }
func (this *XXX) MainEntry() { ... // 全局代码 }

如上述代码所示,首先它定了一个名字为「XXX」的类,具体的类名便是工程入口文件的文件名(XXX.foo 中的 XXX)。这个类会有嵌入一个工程基类 ProjectBase。具体工程基类叫什么名字由该 ClassFile 的设计者定义。另外,在 ClassFile 文件中可能会有全局代码,在工程入口文件中,转换后会把全局代码放在一个特殊的 MainEntry 函数中。所以我们可以看到,ClassFile 会将普通的面向过程的 Go+ 代码转变为一个类,也就是面向对象的代码。

工作文件转换后和工程入口文件大体一致,但有部分细节不同:

type XXX struct { WorkBase // 工作基类,具体名字不同 ClassFile 不同 *Project // 该工程的实例,就是前一页你自己写的那个 *Project ... // 成员变量定义(注意只有第一个 var 块是成员变量定义)}
var ( ... // 普通全局变量定义,一般不应该存在 )
func (this *XXX) Method(...) { // 成员函数定义 ... }
func (this *XXX) Main() { // 注意和工程入口文件生成的入口方法名不同 ... // 全局代码 }

 如上述代码所示,类的定义有所不同。刚才转换后的类会有一个基类 ProjectBae,在这段代码中则是工作基类 WorkBase。具体工作基类的名字叫什么,同样由该 ClassFile 的设计者定义。另外工作类比工程类多了一个 Project 指针,它指向前面用户定义的工程类。

另外一个很重要的细节是,全局代码生成的方法名是不一样的。对于工程入口文件来说是 MainEntry,对于工作文件来说是 Main。

到这里大家对 ClassFile 的转换规则基本就清楚了。但可能大家会奇怪一个点,我们找不到 main 的入口,它在哪里?

  • 对于非 main package,不需要自动生成 main 入口;

  • 对于 main package,入口由该 ClassFile 的设计者定义(后面介绍实现机制将进一步展开)。

也就是说,对于一个 ClassFile 的工程而言,其实没有一个显式能看到的入口。

对于结构有所理解后,大家可能还是会疑惑整体的流程是如何串联的,这个我们马上介绍实现机制会进行解剖。

目前 ClassFile 大家接触较多的有两个:

1. spx

它的 Github 地址:

https://github.com/goplus/spx

spx 是 Go+ 的 2D 游戏引擎,可以非常方便地进行游戏类项目的开发。这类 ClassFile 定义了两个文件:

  • XXX.gmx (游戏开发的入口文件,单个)

  • XXX.spx (游戏角色的精灵文件,多个)

2. gplot

它的 Github 地址:

https://github.com/go-wyvern/gplot

gplot 是仿照 Matlab 作图的工程项目,后缀有两个:

  • XXX.plot (作图的入口文件,单个)

  • XXX.axis (子作图文件,多个)

目前 ClassFile 实现机制相关的内容谈得还不多,大家可以先从 ClassFile 规格的角度宏观进行理解。

总结一下 ClassFile 思想的精髓。

Go+ 鼓励基于 ClassFile 做领域知识的封装,但不鼓励产生新的 DSL 或者新的方言,所以 ClassFile 的语法还是 Go+ 的语法,不同的 ClassFile 或者普通的 Go+ 代码都可以互相连接。

从低代码的角度看,ClassFile 消除了 OOP 编程带来的理解复杂性。根据我自身对面向儿童教育的理解来看,面向对象对于他们有较高的复杂度,ClassFile 倾向于通过变量和全局函数,用面向过程或者命令式的方式来实现一个类或者说面向对象,从而降低门槛。

从分工角度看,ClassFile 是由设计者实现了工程上的所有连接代码,具体从事该专业领域的人只需要借助 ClassFile 设计者提供的框架来完成具体实现,拿趁手的工具进行干活即可。所以 ClassFile 本质上是实现了不同工种如何分工的协议。

这也是为什么我们说 Go+ 实现跨工种、低代码协同背后,最重要的支撑便是 ClassFile,为什么 ClassFile 是 Go+ 最重要的语法特性,没有之一。

 二. ClassFile 是怎么工作的?

Go+ 工程的运行方式通常很简单,无论是普通的 Go+ 项目或者 ClassFile 形式的项目,通过 gop run . 便能够将一个项目跑起来。但是 gop run . 又是怎么跑 ClassFile 的?

我们首先需要思考 Go+ 是怎么识别 XXX.spx, XXX.plot 这些非 .gop 后缀的文件;其次是这些文件中的代码虽然是 Go+ 的语法,但其代码看起来并不完整,Go+ 又是如何将他们串联起来的?

1. 如何识别文件扩展名?

创建一个基于 ClassFile 的工程和创建普通 Go+ 的项目非常一致。首先 gop mod init 一个模块,然后再 gop mod download 依赖包。在 ClassFile 的情况下载时,Go+ 会判断某一个包是不是 ClassFile,如果是,便在 gop.mod 工程中添加 register 指令进行注册。运行项目时,会检查 gop.mod 中是否有 register ClassFile 指令,有则读取 ClassFile 工程的 gop.mod 文件,找到其 classfile 指令得到文件扩展名,也就是注册的后缀。

大家可以看到,读取 classfile 指令后,我们便知道该工程创建的文件名大概率会是 .plot 或者 .axis 后缀,通过这样的机制实现了源代码的自定义扩展名的识别。

2. ClassFile 本身只是一种代码生成机制

在介绍 ClassFile 的时候,大家可以看到它本质上来讲只是一种代码的生成机制,并不会很智能地帮你干非常多的事情。最核心的,是把面向过程的代码变成面向对象的代码,也是为什么叫 ClassFile 的原因。

它唯一能做的智能是比较基础的,也就是对符号的解析。实际上 ClassFile 会定义一个隐藏的指针,但大家很少会接触到,原因便是 Go+ 中符号背后的逻辑含义。比如看到一个符号 F,可能的理解有三个:

  • this.F // this 代表当前类

  • foo.F // 这里 foo 代表一类 ClassFile 机制,比如 spx

  • F // 全局函数或者全局的其他含义

这三个都是符号可能的解释,也是一个判断的顺序。先判断是否代表某个类的符号(成员变量或成员函数),再判断是否 ClassFile 机制所定义,以此类推。

3. main 入口是怎么生成的?

此前我们就介绍过代码生成的整体结构,接下来重点介绍下 main 入口。我们提到过 Go+ 其实没有 main 函数,实际上可以理解为 ClassFile 认为工程入口应该有 main 方法,所以 ClassFile 会自动帮整个工程生成 main 入口。

生成的逻辑比较简单:

type XXX struct { ProjectBase // 工程基类,具体名字不同 ClassFile 不同 ...}
func (this *XXX) Main() // 你可能说,没看到 Main 啊? // 是的,自动生成的代码里面只有 MainEntry,没有 Mainfunc main() { new(XXX).Main() }

首先 new 一个工程入口类。这也是工程入口文件只能有一个的原因,因为 new 的类是唯一的。new 完类后会调用 main 的方法,生成 main 函数。

所以 main 函数通常只有一行代码:

new(XXX).Main()

有人可能会奇怪,工程入口生成的代码是 MainEntry(),并没有自动生成 Main() 函数,那么为什么会调用 Main()?为什么没看到 Main?

因为用户写的代码是在 MainEntry 里面,但 Main 则是由 ClassFile 设计者在 ProjectBase 工程基类中提供。这也就是前面我们所说,ClassFile 设计者实现工程的连接代码,具体干活的人只需要干具体的活即可。

也就是说 Main 函数并不是由使用者的工程提供,而是由 ProjectBase 工程基类的实现者实现的。那么 Main 到底在哪里呢?这里会涉及到一个 Go+ 隐藏的语法特性——模板 receiver 指针:

type Base { ... }
// 此处语法只是设想中的,还没有实现//func [T < Base] (recv *T) Method(...) { // T 必须嵌入 Base ... }

 你可以把它理解为是虚函数。当然实现上它并不是用虚函数实现的,而是一种类模板的机制实现。但实际上这种语法还没有被支持,它只是我们设想的一种模板语法。

我们设想定义一个模板的 receiver,假设有一个 T 类从 Base 类继承。当然 Go+ 中没有继承这个概念,意思是代表 T 必须是嵌入 Base 或匿名组合 Base,组合完后可以认为 T 相当于是 Base 的派生类。派生类中会定义某一个函数,这个函数不是由派生类自己实现,而是由基类辅助实现,比较像虚函数的概念,但没有虚函数的虚表等功能,用的是模板。

从这个示例来看,本质上相当于 ProjectBase 工程基类中有一个 Main 方法,大致的代码会是如下形式:

 func [T < ProjectBase] (recv *T) Main() { ... }

 也就是 ProjectBase 工程基类定义了一个模板的方法叫 Main,Main 中包含一个派生类 T,我们可以在这个类里面访问 T 中的各种方法。

假如有这个语法,那 ClassFile 设计者就可以帮派生类实现 Main 函数,也就拥有了“连接” 的能力。它会把整个工程中包括基类和所有的“工作者”、游戏中各种精灵的能力串联起来,使整个程序运行起来。

在这个方法实现前,我们当前用的是一个替换的机制,这个机制要求我们定义一个以 Gopt 前缀开头的函数,代表这是一个模板 receiver 的语法:

func Gopt_Base_F(recv interface{}, ...) { ... // 看到这个函数,Go+ 会给 Base 类增加 F 方法 // 相当于 func [T < Base] (recv *T) F(...),但 *T 用了 interface{} }

基于「Gopt_基类名字_方法」这个命名规则,我们定义它的语义相当于是定义了一个从 Base 派生的 T 的 F 函数。因为 Go+ 目前还不支持模板,实际上我们使用的是 interface{} 来代表 receiver,而不是模板类型 T。也就是 *T 实际上是使用 interface{} 来表达。

将这个语法应用于此前的例子中,相当于我们需要定义一个 Gopt_ProjectBase_main,以及函数 recv。在这样的函数中去输入连接的代码。

func Gopt_ProjectBase_Main(recv interface{}) { ... }

刚才提到 main 函数通常只有一行如下的代码:

new(XXX).Main()

但实际上 main 函数变成了一个全局的方法调用,写出来的形式是:

foo.Gopt_ProjectBase_Main(new(XXX))

 最后的 main 入口则如下述代码所示:

type XXX struct { ProjectBase // 工程基类,具体名字不同 ClassFile 不同 ...}
func main() { foo.Gopt_ProjectBase_Main(new(XXX)) // 比如对 spx,它是 spx.Gopt_Game_Main }

目前大家用得最多的一个 ClassFile 就是 spx,它的函数名是 spx.Gopt_Game_Main,因为工程基类是spx.Game。所以,大家可以看到 spx 生成的main函数都是这样的调用:spx.Gopt_Game_Main(new(XXX))。

以上就是我们对于 ClassFile 实现机制的全局流程介绍。

我们来做个简单回顾。

首先是 ClassFile 如何找到文件扩展名,其实是用了一个在我们自己实现的 ClassFile 工程中引入 register 的语法,注册一个 ClassFile。注册 ClassFile 时会把 ClassFile 对应的包下载下来,而 ClassFile 工程的 gop.mod 中定义了 classfile 的语法,里面会定义这个 ClassFile 用了什么样的文件后缀,这样 Go+ 就可以识别相应的源代码文件后缀。识别了后缀以后,再结合 main 函数的生成过程,我们就可以把整个流程串联起来。

 三. 练习题

 1. 基础练习

  • 基于某种已知的 Go+ ClassFile 工程做一个例子:

    - https://github.com/goplus/spx

    - https://github.com/go-wyvern/gplot

    - 通过例子理解 ClassFile 背后的运行机制

2. 进阶练习

  • 选择一个你感兴趣的领域,用 Go+ ClassFile 去抽象一门 DSL

  • 以下是一些可能的方向(但不局限于此):

    - 服务端 API 测试

    - 手机端或 PC 端的测试(可以只支持某个领域,如游戏)

    - 服务端的开发框架

    - ...

 后记

在讲练习题时,我们再次强调用 ClassFile 去抽象 DSL,最大的作用是首先保证了低代码和低门槛,而不是去引入所谓的面向对象甚至一些工程化的概念。尽量的抽象,让大家只需要接触变量、函数,以及比函数更基础的命令就可以编写代码。

 如果试着去写 Go+ spx 可以发现,一个 Go+ spx 的游戏几乎不用自己去定义函数,通过调用 spx 中的各种命令就可以做出一些游戏,包括完成度挺高的飞机大战等,最初也没有用到函数定义。所以这种创作的门槛是很低的。

 第二点,为什么我们说 ClassFile 比较像 DSL 呢?因为你可以定义很多内建的指令。对于任何一门语言来说,内建指令的能力是有限的,它们通常也都很基础。但当我们定义一个 DSL,通常会有非常多领域性的内建函数(或者叫命令)。这些指令对于领域而言其实比较像领域知识的封装,它比较像「专业术语」。

 所以站在 ClassFile 的视角来看,会发现它和自然科学中写文章的逻辑蛮像的。我们让 ClassFile 的设计者定义了很多专业术语,具体做工程时不需要关心专业术语背后封装的知识是什么样的,直接引用即可。所以 ClassFile 实际上是在试图简化领域的表达。

 就像我在分享开始的时候说的那样,为一个领域创造一门编程语言是非常不划算的,这是我多年工程做下来后一个非常强烈的认知。原因正如此前所说:开放性的重要度远远大于便捷性。

 很有意思的一点,大部分 DSL 的生命周期都非常有限,很难流行起来。在我 Go+ 的一些早期演讲中也有提到过,我发现了语言进化一个重要现象——脚本语言是集中式爆发的:我们今天所流行的脚本语言,基本都是在 1990 年至 1995 年这 5 年内发明的,在这之后再也没有出现新的流行的脚本语言。

 我认为本质上 DSL 和脚本语言最初的构想都是偏领域性的,那为什么后来再也没有出现流行的脚本语言?背后的原因是:越到今天,开放性的重要度大于便捷性体现得越突出。

 不过,我们对于 DSL 是有诉求的。DSL 背后的逻辑是符合潮流的低代码,所以 DSL 还是会大量出现。但大量出现的 DSL 不应该放弃开放性,所以我认为通用语言需要去支持某种意义上的 DSL,这也就是 Go+ 对这个问题的回答

——ClassFile。

总结一下:Go+ 的 ClassFile,其实是在通用语言中引入一种 DSL 的表达方式,但它又维持了语法的一致性。这就是我今天要介绍的内容,谢谢大家。