Skip to content

本文通过讨论如何设计一门编程语言的方式,来普及编程语言理论中的一些基础概念、实现思路和现状。

我来设计编程语言……吗?

现在由我们来设计一门全新的编程语言:C--。

对,就是我们自己设计。

我们先抛下一些编译原理、编译器和解释器的实现等一些复杂的东西,不会去讨论一些特性的具体实现(看看标题:漫谈!)。

我们来自底向上建设这门属于我们自己的编程语言。

最底层我们直接假设好了,我们这门编程语言的最终编译产物是 RISC-VI 格式,有一个 RISC-VI 指令集与之对应。这个汇编语言十分基础,只能对内存和寄存器进行一些简单的操作。

代码在哪里跑?

这个问题其实应该是 RISC-VI 指令集或者架构的问题,和我们要设计的高级语言特性似乎没有关系。但这其实关系到了你的代码处于计算机系统的哪一层,这是编程语言设计者首先就必须要考虑的问题:

计算机系统的本质,就是分层的虚拟机

在这个系统的分层虚拟机模型中,上层会包装和屏蔽下层的操作接口,并增加了自身的特性,以供更上一层使用。

操作系统就是一层对于硬件(裸机)的虚拟,例如你的计算机是 x86 指令集架构,当你将你写的 C 语言小程序编译到二进制后运行时,操作系统是需要首先解析这个二进制文件的格式,如果你是 Linux 操作系统,那这个可执行文件的格式就是 elf 格式。按照 elf 格式解析完成后,操作系统会将文件中的各个段加载到内存中,并跳转到代码段的第一个指令开始执行。当然实际不会这么简单,真正的操作系统还会更精细地管理内存资源,并通过进程等方式隔离运行中的不同任务。

很巧合的是(?),C 语言编译的二进制文件,不仅仅可以在操作系统这一层执行,其实也是可以在裸机上运行的。一个很典型的例子就是,Linux 内核大部分就是由 C 语言写出来的(最近主线中合入了 rust 代码,未来可期)。在系统编程这个层面,C 程序主要使用的是操作系统提供的系统调用。例如,在 x86 的 linux 下,程序需要将文件中的数据读入内存时,通常调用 sys_read 这个系统调用。在收到这个系统调用后,linux 会代你从文件中读取,当然也少不了前置的权限校验等工作。然而,如果你要使用 C 编写一个操作系统,当然就没有系统调用给你使用了,甚至连文件这个概念都是操作系统抽象出来的。这时你不得不通过某种方式直接和硬盘驱动交互,通过修改硬盘控制器中一些寄存器,来达到读取某个位置的一些数据这一简单的目的。

通过这样的描述,可以看出操作系统完美符合上述的虚拟机的定义。甚至可以说,Linux 就是一个用于执行 elf 文件的虚拟机(当然它还干了更多)。如果不考虑操作系统提供给 C 语言的标准库,C 语言是运行在裸机这一层语言(其实是 C 语言的编译产出运行在这一层,不过先这么说着吧)。

我们来看另一个🌰:Python。Python 是一个典型的解释型语言。它的官方解释器是 CPython,这是一个用 C 语言编写的 Python 解释器。套用上面的分层模型,那就是 CPython 是基于操作系统的一个虚拟机:它包装了操作系统提供的接口,供上层的 Python 程序去调用。

当然,极端一点,即使是 C 语言这种编译型语言,也可以勉强看作是解释型语言:CPU 确实是一条一条读取指令、解析执行的,只是这些指令是二进制的。而 Python 作为解释型语言,它的指令是可读的,CPython 的输入是人类可读的字符串。

解释型语言的一个巨大的好处,就是可移植性强。解释器屏蔽了不同操作系统的底层差异,向高级语言这一层提供了相同的 API,这样你写的代码,就可以一次编写,处处执行了。当然,根据苦难守恒定律,你是轻松了,苦的是写解释器的人。不过,即使是一门编译型语言,编译器也不得不根据不同的指令集架构去分别实现,看起来也没苦到哪去~

既然提到了处处执行,就不得不讲一下最著名的一次编译,处处执行:Java!Java 将编译和解释结合到了一起。JVM 就相当于 Python 中的 CPython,是 Java 语言的一个运行时(Runtime)。java 文件首先需要编译成 class 文件,JVM 读入的文件格式,就是 class 文件格式。这个文件也是一个二进制文件,但是其格式在不同指令集架构的操作系统上是相同的。这就是为什么,我在 x86 下编译的 class 文件,放到 RISC-V 上的 JVM 中也可以执行。JVM 在读入 class 文件后,还是会像解释器一样,一条一条加载指令解析执行。所以很难去界定 Java 究竟是一门编译型语言还是解释型语言。

JVM 是一个很成功的虚拟机(计算机系统层面),它不仅支持了 Java 语言在其上运行,还支持了许多其他语言,如 Scala 和 Groovy。当然这基于一个重要的事实:这些语言都可以编译到 class 格式。

我们普遍认为:解释型语言的执行效率较低,而编译型语言的执行效率比较高。然而随着编程语言的发展,许多解释型语言都加入了一些语言特性来提升运行效率。以 Java 为例,在 JVM 解释执行 class 文件的阶段,JVM 会动态分析热点代码,将热点代码直接编译为机器语言。再次执行到热点代码时就不会再次解释,而是直接执行已经编译好的机器语言了。这个技术被称为 JIT(Just-in-time compilation,即使编译)。类似的,Python 也有 numba 这个库用于加速执行,也是使用了 JIT 技术。

当然,如果你写出了一个 C 语言的解释器,你也可以说 C 语言是解释型语言(

类型系统

现在我们确定下来了 C-- 的运行位置,但我们的 C-- 还是非常简陋,甚至根本不存在:

  • 如果是走编译型语言的路子,RISC-VI 是一个指令集架构,一般和高级语言无关
  • 如果走解释型语言的路子,RISC-VI 以及对应的解释器可能是由我们设计和实现的

RISC-VI 是一个只能操作内存和寄存器的汇编语言,内存区域和寄存器在它眼中,只是一些无意义的字节数组,你可以操作任何一个字节(在下层虚拟机允许的范围内)

我们假设现在我们的语言没有类型系统,所有的操作都是直接处理字节数组。如果是在 C 语言中,相当于不定义任何类型,直接使用 void * 指针,去操作所有的字节。能使用的操作只有取地址和解引用,以及对字节赋值和取值,那和直接写汇编语言都没啥区别!我们在堆上创建一个四字节整数并赋值为 1 的方法就像下面一样(当然用的是 C 语法,实际上没有类型系统真的和汇编一样):

c
void *intBytes = malloc(4);    // 堆上分配四字节
*(intBytes+3) = 0x01;          // 假设大端序,偏移为 3 处设置为 1

我们熟知的,int、float 啥啥啥,都去哪了?那就是类型系统的工作了。

类型的本质是对一块内存区域的解释方式

类型系统将无序的堆栈空间划分为有实际意义的块,根据类型的不同赋予它们不同的解释方式。当然对于程序员来说,不同的类型最直观的差异在于定义类型时语法的不同。比如在 C 语言中,int 通常用于表示四字节整形,而 double 则表示符合 IEEE 754 规范的双精度浮点型。类型将影响编译器生成的运行时对这段字节的操作方式。

有了类型系统后,上面的创建四字节整数的方法就变成了这样:

c
int *intBytes = (int *)malloc(4);
*intBytes = 1;

由于我们声明了 intBytes 是一个 int 类型的指针,指向的那片内存区域应当按照整型变量去解析。于是第二行我们可以直接给 intBytes 赋值为 1,而不需要担心这个 1 会被赋值给第 0 个字节而非第 3 个。编译器在得知 intBytes 是一个 int 类型的指针时,对其的一切操作都会按照 int 类型的操作加以定制。生成的最终的机器代码其实还是将第三个字节置为 1,但是这是编译器帮我们实现的,我们只管操作各种类型即可。

有一个有趣的点,就是 C 语言在编译期优化了指针变量的偏移计算,例如上述的例子:intBytes+1 这个地址实际上代表了 intBytes 指向的地址 +4,因为 int 类型占用四个字节。这也是 C 语言借此实现数组的重要依据。

类型系统是一个编译期特性(对于 C 语言来说)

同样我们定义的结构体,本质上也是用于指导编译器操作内存的,例如如下的结构体:

c
typedef exampleStruct struct {
	int a;
	int b;
}

这个结构体中有两个 int 变量,占用内存 8 字节。那么当我们定义一个 exampleStruct 类型的指针 esp,指向一片内存区域时,实际上就是认为这个地址及其后 8 字节的区域按照 exampleStruct 的结构去解析。当我们使用 esp.b 或者 esp->b 去操作这里的 int b 时,实际上就是在按照 int 的方式操作 esp 指向地址之后的第四到第七个字节。

所以可以这么说,结构体中定义的变量,在运行时只用于指示这个变量的地址相对于结构体起始位置的偏移。

至于不使用指针的方式,直接在函数中使用 exampleStruct esp; 声明一个结构体,则属于编译器对于栈上变量分配的特殊处理,甚至可以理解为语法糖。因为你:

  1. 不需要手动初始化这个结构体,声明一下就能用,实际上编译器在编译时就决定了这个结构体在栈帧的位置
  2. 不需要管理这个结构体的生命周期,函数结束自动释放,当然释放的方式就是栈指针上移(栈向下生长),不会覆写这片内存

当然栈上分配的坏处也不用多说,它的第二点好处就是坏处,函数返回后会自动释放,就没法在函数之外去使用了。

相对于 C 来说,Java 的类型系统就十分局限:类型信息被直接写进了对象所在的内存中,强制类型转换也只能在类型树的父子节点之间进行。

传值还是传引用?

一个经常被讨论的、同时也是经常会犯错的点,就是函数的传参,到底是传值还是传引用。

究其本质,可以认为所有的函数传参都是传值,所谓的传引用,本质上也是基于传值做的优化。

C 语言自不必多说,无论是基于寄存器传参还是基于栈传参,都是要将原内容复制一份或者将原内容备份一份,保证在新函数返回后这部分内容不会发生变化。如果传递的是个指针,可以去除其指针的属性来看,本质上只是传递了一串数字(根据指令集架构可能是 32 位或 64 位),同你将这个地址赋值给一个 long 变量后传递的结果是一样的。

传引用这种说法,主要出现在 Java 中。引用本质上是一种对象句柄,程序可以通过句柄访问对象的部分信息。句柄不代表对象的内存地址,但是在实现上一定包含了对象的实际内存地址。当传递一个引用到函数或方法中时,可以看作传递的是一个包含对象实际地址的结构体,和 C 语言的传递指针类似,所以行为类似。

然而 Java 中还包含了 8 种基本数据类型,这 8 种基本数据类型又有其对应的包装类,在实现上显得很不统一,据说是早期为了吸引 C++ 程序员迁移的一种举措,属实失了优雅。

数组是什么?

在拥有了基础的类型系统后,下面就需要考虑一种特殊但是很常见的复合数据类型:数组。但是,数组是什么,以及数组真的存在吗?

C 语言在运行时实际上是没有数组的,数组成了一个编译期语法糖。C 基于指针实现了数组。数组名实际上也是指向数组第零个元素的地址,即当我声明 int a[10] 时,a 在使用时等同于 &a[0]。而数组的下标运算(方括号),也是基于类型指针的偏移实现的。当使用 a[1] 获取数组中的第一个元素时,可以认为等同于 *(a+1),更具体的:

c
int b = a[0];

// 等同于
void *p = (void *)a;
p += 4;
int b = *((int *)p);

由于数组的实现是基于特定类型指针的,且可以和对应类型的指针任意转换,所以实际上对于数组越界,在语言层面是没有任何检查的。例如我在栈上定义了一个长度为 10 的数组,那么我去读取和写入第 11 个元素有时是没有问题的。

有人会说不对不对,数组越界会出现段错误 segment fault。这实际上不是 C 语言在检查,而是在读取和写入超限内存时,可能读取到了不可读的内存、写入了不可写的内存,引起了操作系统的报错。这不属于语言层面的错误。

在 C 语言中,C 语言向参数传递数组有三种方式:传递指针、传递已定义大小的数组和传递未定义大小的数组,分别如下:

c
void func(int *array);
void func(int array[10]);
void func(int array[]);

值的一提的是,在使用这三种方式传参时,第一种和第三种,在 func 中都没有办法通过 len 获取原数组的长度,第二种获取的长度永久为 10,即使实参并不是一个长度为 10 的数组。这足以说明,数组的长度信息,是写在其类型定义中的,当由于传参等方式导致类型的变化,会导致长度信息的丢失。这更贴合了数组本质是由指针来实现的这一说法:实际的存储内存中,除了连续的各个元素外,就没有其他信息了。

与之相对的,Java 是有真正的数组的。Java 中每个数组都是一个对象,数组的相关信息,如基本类型和长度等,都写在对象头中。所以 Java 可以在运行时检查数组是否超限访问,并抛出 IndexOutOfBoundsException。

面向过程还是面向对象?

实际上,这说不上是一个问题,广义上来说,面向对象和面向过程更多的是一种编程思想和范式而非具体的编程语言的差别。C 语言同样可以通过定义结构体来实现面向对象的编程。

那么可以狭义定义一下面向过程和面向对象:只有原生支持、完整实现了面向对象的三大特性的语言(封装、继承和多态),才可以称为面向对象的编程语言。

封装自然不必多说,简单的封装连 C 语言都可以做得到:包装成一个结构体就可以了。然而,封装的一个重要的目的:“对象的内部实现细节被隐藏起来,外部只能通过对象提供的接口来访问和操作对象的数据。”,对于 C 语言的结构体,是没有访问控制的,内部的字段也可以被任意修改,这种封装实质上是没有意义的。至于 Java、CPP 和 C 的一个语法上很大的区别:可以直接通过 . 运算调用对象(结构体)的成员方法,在实现上实际上并没有什么特殊的:在底层实现上,成员方法就是第一个参数为对象指针的函数,编译器自动将对象指针添加到函数参数中并命名为 this 指针,除此以外成员方法和一般函数并无不同。

在实现上,Java、C++ 和 Golang 有异曲同工之妙:它们都通过直接或者间接的方式,通过组合来实现了继承。由于需要使用的是组合,所以在自定义构造方法时,都需要首先调用父类的构造方法。golang 的继承中组合的意味最为明显:直接在结构体中定义一个无名的父类结构,这使得访问父类的变量本质上就是直接访问这个父类对象的变量,而使得更像是一个语法糖了:

go
type Parent struct {
	a int64
}

type Child struct {
	Parent
	b int64
}

c := &Child{Parent{}, 0}
a := c.a
//或者
a := c.Parent.a;

相对于 Java 和 C++,Golang 的继承更像是过家家,并没有规定子类对父类变量的访问控制,而是直接沿用了通过首字母大小写来决定是否包导出的方法,缺乏像 Java 一般的 protected 来具体控制子访问父的场景。

多态这一特性,一个典型的表现就是:父指针指向不同子类对象时,调用其共有的函数,不同的子类会有不同的行为。Java 和 C++ 以两种典型的方式实现了多态。Java 由于包含一个运行时(JVM),使得它在实现多态时非常容易:虽然使用的是父类型的对象句柄去调用的方法,但是由于可以根据对象句柄定位到对象的具体信息,包含各种类型和方法信息,调用具体的实现方法也就变得比较容易,所以说 Java 的多态是一种运行时多态。而 C++ 则是一种编译期多态。C++ 中,每个类包含了一个虚函数表,对象在实例化时会包含指向自己的虚函数表的指针,虚函数即可能会由于继承而被重写的方法的列表。编译器保证如果子类和父类同时实现了一个方法,那么这个方法在父类和子类的虚函数表中位于同一个位置。这样,对于一个可能被继承的方法的调用,都变成了类似于“调用虚函数表中第 N 个函数” 这样的指令。在通过父类指针调用方法时,实际上找到的是子类对象虚函数表中的函数指针,从而实现了调用子类的函数实现,实现了多态。

泛型的实现

泛型,得益于 idea 的智能提示,基本算是最广为使用的高级语言特性了。然而,Java 直到 JDK 5 才实现了泛型编程,而 C++ 则更是在 C++11 标准中才引入模板编程(泛型的一种实现方法),算是个很新的特性。很有趣的是,这两种实现恰好代表了两种截然不同的泛型实现方式。

以 Java 为例,泛型是一个编译期实现,在运行时则是无感的,即所谓的类型擦除。所以 Java 的泛型只用于编译期的类型检查:检查你所传入的对象或者类型是否符合泛型参数中规定的类型。如果你通过某种方式绕过了编译期的泛型检查:例如手动构造了一个 class 文件,或者干脆就是运行时通过反射等方式,那么 JVM 对此是无能为力的。例如,你声明了一个 List<String> 的列表,常规写代码只能 List 内塞入 String 类型的变量。但由于类型擦除的特性,在运行时它本质上只是个 List,或者叫 List<Object> 也可以,那如果在运行时通过某种方式塞进去一个 Integer,JVM 是不会报错的。

C++ 中,泛型是通过代码生成来实现的,所以 C++ 中泛型的实现被称为模板。编译器会检查模板类的每一个实例化的位置都涉及了哪些模板参数,针对每一种模板参数,生成这套模板参数对应的每一个定义在泛型类中的方法。例如,如果我定义了一个 ClassName< typename T > 的模板类,其中包含了一个 Test(T t) 方法,并在下面通过 ClassName<int> testObj 根据这个模板创建了一个对象。那么在编译产出中,其实是真实存在 Test(int t) 这个方法的。和 Java 一样,运行时也是无法感知泛型的存在的,根据模板生成的类和方法,同手动定义的无异。但是这种方式实现泛型,相对于 Java 无疑更加安全。由于使用的不是类型擦除而是代码生成,不会出现像 Java 一样只要绕过编译就完全没有检查的现象。

C++ 的泛型有一个缺点,就是通过模板生成的最终实现类必须是完整的,所以即使不包含模板参数的函数,也会被重复生成。即使它们的代码都相同。这样就可能会造成比较大的空间浪费。C# 在实现泛型时,针对这一点进行了优化,C# 不会在编译期直接生成最终的模板代码,而是统计出这个模板类有多少种不同的实现,.Net 运行时(CLR,类似于 JVM)的 JIT 编译期会为这个模板生成一份共享的机器码,而类型特化的信息存在一个额外的表里面由每个实例化泛型类型自己存储,这样就可以共享大部分代码了。具体可见这篇论文。不过这也怪不得 C++,C# 有一个运行时,除非 C++ 也有一个自己的运行时,或者将运行时编译到结果产物中,否则是很难实现这种动态共享的。

尾声

当你定好了上面的内容,你就差不多确定了你的编程语言的大致样貌,甚至你还没确定它的语法!剩余的就是一些非常通用、按部就班的内容:去实现它!

当然,你可以尽情地给你的语言添加一些高级特性:例如 gc、例如特殊的生命周期管理。拥有一个 VM 会让高级特性的实现轻松很多,但不是必须的:例如 golang 在实现 gc 时,直接将 gc 相关的代码编译进最终的产物中,相当于你的每一个编译产物都带了一个小小的 VM。

当然,大部分人并需要自己实现编程语言,但是了解这些编程语言之间的一些共性和特性仍然是必要的。世界上没有最好的编程语言,只有某个场景下最适合的编程语言。所以那些最佳编程语言之争,就显得非常可笑:每个编程语言的出现,必然是为了解决某个问题而来。如果有这样一门新编程语言,除了语法之外,和另一门已经存在的编程语言在特性上没有任何不同,那这门新语言是难以长久的:如果不能解决新问题,为什么要费心思学新语言呢?

当然,了解这些特性,还是有一些意想不到的好处的。比如程序员间喝酒吹牛、或者一些技术群中又在争论最好的语言时(当然是有理有据的争论),你就可以高谈阔论了。

如有转载或 CV 请标注本站原文地址