| tags: [ Compiler PEG ] categories: [ Development ]
一门纯粹的编程语言:PureLang
「一个人能力有大小,但只要有这点精神,就是一个高尚的人,一个纯粹的人,一个有道德的人,一个脱离了低级趣味的人,一个有益于人民的人。」 —— 《纪念白求恩》
引子
如同剑客需要一把趁手的绝世好剑,很多程序员也在毕生企盼一门如意的完美编程语言,很可惜,这种语言至今没有出现:
Seven languages in seven weeks 和 Seven more languages in seven weeks 挑了 14 种小众语言让大家开拓眼界;
Programming Languages Influence Network 展示了影响最大的几个语言:Lisp, Smalltalk, C/Java/Pascal/C++, Haskell;
An opinionated history of programming languages 讲述了几个主要语言的影响力和未来命运,原文被网友吐槽得太厉害而删掉了😀;
Computer Language History 囊括了几乎所有编程语言,可以很清晰的看到语言演进的流派;
Growing a language Guy Steele 关于语言设计的真知灼见非常值得拜读;
图灵奖得主 Edsger W. Dijkstra:谦卑的程序员(1972 年)(上)(下)谈到形式证明应该跟程序一体,而不是先写程序再单独写证明,这个思路已经在 Idris、Lean 等语言中得到体现;
The lost treasure of language design, 这位暴躁老哥的编程语言大吐槽很好玩;
Why static languages suffer from complexity,很认同作者的观点:能统一编译期和运行期语法的静态强类型语言最佳。C/C++/Rust/Haskell 都是反面例子,宏、模版语法与常规语法不一致,就是所谓的二象性(biformity);Lisp能统一但是不是静态类型语言;Java 的编译期构造语法极弱; 支持 dependent type的 Idris 统一语法了但是额外特性太多而不实用,Zig 虽然没有 dependent type 也支持统一语法了,但语言本身过于底层。
Out of the Tar Pit,论文分析了程序的复杂性来源,提出 Functional Relational Programming 编程范式是最佳范式;
目标
语言设计是个大话题,众说纷纭,这里总结一下我觉得是「好语言」的设计目标:
从语法风格上,类 Pascal / C 风格的语言接近人类自然语言和数学语言,比 Lisp、Smalltalk、APL、Forth 这类特立独行的语言要更受欢迎;
从表达能力上,简洁又表达能力强的语言是大家喜爱的,很不幸的是 Pascal / C 家族的语言往往很复杂而且在变得更复杂,例外的是 Go 语言很克制,但 Golang 的表达能力很弱,可谓开历史倒车,而简洁有力的 Lisp、Smalltalk、APL、Forth 在语法风格上一概不讨喜,微软的试验性语言 Koka 强调 Minimal but General,实在是一股清流;
在类型系统上,2025 年了,静态强类型加类型推导俨然是主流了,连 C++ 这种老古董也加了一点类型推导能力;
在编程范式上,一切皆表达式的函数式编程已经是高精尖的代名词了;
在语言扩展性上,宏语法和常规语法应该是同构的,类型和变量应该是同构的;
在语言实现上,应该能融合语言实现和常规程序(也是一种同构),使得语言的使用者也能一定程度的改进语言,不必事事等语言设计者,也就是 Guy Steel 的 Growing a language 的核心观点;
在正确性验证上,实践基于「柯里-霍华德同构」的形式化证明应是语言设计和实现的终极追求;
PureLang
上面扯了一些有的没的,比较抽象,下面以一种假想的语言来具象化,我称这门语言为 PureLang,一门纯粹的编程语言,其语法跟 Smalltalk 类似可以写在一张明信片大小的纸上。
语法
所有语言的语法离不开三个概念:变量、函数、类型,分别对应了数据、代码、数据结构。操作符、控制结构都可以认为是特殊的函数。
PureLang 的语法可谓「披着 C 皮但有着 Lisp 心」,本质就是 Lisp 的 S-exp ,但为了适应主流的 C 代码风格喜好做了一点伪装,很特别的是这个语言没有关键字,以下是一些示例,所有的语句要么是字面量或符号,要么是函数调用。
# "#" 是注释开始,";" 是 statement 结束,最后一个 statement 可以省略分号
# 等价 define_and_assign(a: Int,`+`(3, 4))
a: Int := 3 + 4;
# b 的类型自动推导出来,等价 define_and_assign(b, `+`(3, 4))
b := 3 + 4;
# 等价 assign(a, `+`(5, 6)),这里用反引号避免歧义,也增加了可读性
a = `+`(5, 6);
# if 是个特殊函数
a = if(a > 10, 100, -100);
# block 也是个表达式,block 在函数参数里是 call by name 语义
a = if(a > 10, { a + a }, { a * a});
# while 是个特殊函数,这里丢弃了它的返回值,注意 while 末尾要加分号,
# 注意 block 在函数参数里是 call by name 语义
while({ a < 20 }, {
println(fmt("a = {a}");
++a
});
# multi-method 语义,注意末尾要加分号
f := (i: Int = 10, j: Int = 20) Int { i + j };
# multi-method 语义,省略了函数返回类型,可以自动推导出来
f := (i: Double = 1.0, j: Double = 2.0) { i + j };
# 两个参数都是默认值,因为返回值是 Int 类型,所以调用了整数加法
a = f();
# j 用了默认值
a = f(10);
# 命名参数
a = f(j = 30, i = 20);
# 两个参数都显式指定
a = f(100, 200);
# 因为参数是 Double 类型,所以调用了浮点数加法,d 的类型自动推导出来
d := f(5.0, 6.0);
# 赋值会清除之前的 multi-method 定义
f = `+`;
上面的语法可以总结成三条:
定义和赋值变量,lambda 可以作为值赋值给变量;
调用 lambda,也就是调用函数,操作符只是函数的语法糖;
定义 lambda
本质上第一条语法就是第二条,也就是说跟 Lisp 一样,一切非原子值都是如下形式的 S-exp:
Callable(Expr | Identifier | <Identify: Type> | <Identifier[: Type] = Expr>, ...)
第三条稍微特殊点,其实也就是个特殊函数lambda
的语法糖:
(Identifier [[: Type] = Expr], ...) [Type] BLOCK
以上就是 PureLang 的全部语法了,加上数字、字符串等字面量的无需多言的语法,用一张明信片写都缀缀有余,PureLang 对应其它语言的语法是用 S-exp 的语义和自定义操作符实现的,但有的语法肯定实现不了,那么解决办法是:不支持!其实回顾汇编语言就可以看到数据加函数就足够了,类型都不是必需的,形形色色的语法和类型一是提供表达的便利,二是增强对语义的约束从而提高正确性,良好的语法设计要在语法的表达便利、正确约束和繁复程度之间权衡,既不能太基本而陷入机器的细节,又不能太高级而陷入语法的细节。
类型
上面讲了变量和函数的语法,提到 PureLang 没有关键字,那 Int
和 Double
怎么来的呢?其实类型跟变量没太大区别,类型主要是编译期用的「数据」,而变量主要是运行期用的「数据」,说「主要」是因为有跨界:C++ 的 RTTI、Java 和 Go 的反射就是类型跑到运行期,C++ 的 constexpr 就是变量跑到编译期。PureLang 中的类型跟变量的语法是一样的,也就是说二者是同构的,下面是一些列子:
# 与 NodeJS 类似,模块的值是一个结构体
std := import("std");
# "." 是个操作符,获取结构体成员
Int := std.types.Integer;
Double := std.types.Double;
String := std.types.String;
Void := std.types.Void;
# ProductType() 函数定义了一个结构体
User := std.types.ProductType(name: String = "Jack", age: Int = 20);
# 一个全局函数
greet := (user: User) Void { println(user.name + fmt(" with age {user.age}")) };
# 类似 Scala、Python 的类,结构体名字本身是个 constructor 函数
you := User(name = "You", age = 30);
# 调用函数
greet(you);
# 方法和函数是统一的,obj.method(arg) 等价 method(obj, arg)
you.greet();
# 可能的范型设计
sum := (a: std.types.Array) Int { ... };
a := std.types.Array(type = Int, size = 32);
a(0) = 0; # 类似 Scala,数组本身是个 Callable,表示索引
a(1) = 1;
println(sum(a))
上面的类型以及构造类型的函数都在编译期求值,本质是在操作符号表和抽象语法树 ,编译器的行为是「标记为无副作用的函数以及仅调用这些函数的函数都被求值,直到不能再求值」,这有点像编译器的常量表达式消除,类似 C++ constexpr、Lisp macro 和 Zig comptime,达到编译期的语法和运行期的语法一致(很不幸 Zig 搞了个 comptime 关键字从而不是完全同构),并通过暴露符号表和抽象语法树、编译阶段的 API,进一步达到编译器的代码和用户应用的代码浑然一体:语言的用户也能参与语言的实现,比如实现更好的类型检查,这就是上面目标里的第 5、6 两点,而有了这两点,用户也能参与第 7 个目标的实现,至此,7 个目标都涵盖了,虽然假想的 API 还不成熟,但可以看到并不需要很复杂的语法。
操作符
PureLang 支持类似 Scala 的自定义操作符,以弥补一定的语法糖,达到主流语言的一些便利语法,代码示例如下:
# `->`(k, v) 构造一个 Pair 实例
m := std.Map(1 -> 2, 3 -> 4);
# 精确小数运算
a := Decimal("1.3");
b := Decimal("2.7");
c := a * b;
惰性求值
Haskell 是最著名的惰性求值函数式编程语言之一,在 Haskell 中,所有的计算都是惰性执行的,这意味着除非一个表达式的值被实际需要,否则不会进行计算,这使得 Haskell 在处理无限列表和其他复杂数据结构时特别强大,但是实践证明这个设计导致理解代码性能问题很头大。Scala 语言则用关键字 lazy
引入了显式的惰性求值,在 PureLang 中默认是立即求值,只有当函数参数中的 block 才是惰性求值,这是为了方便实现带有条件执行语义的函数,如下所示:
# 两个 println() 在进入 if() 之前就被执行了
if(2 > 1, println("larger"), println("smaller"));
# 两个 println() 在 if() 内部条件执行
if(2 > 1, { println("larger") }, { println("smaller") });
# 等价 `||`(2 > 1, println("smaller")),因此会输出 "smaller"
2 > 1 || println("smaller");
# 等价 `||`(2 > 1, { println("smaller") }),因此不会输出 "smaller"
2 > 1 || { println("smaller") };
f := (i: Int) { println(i); println(i) };
# a 会在 f() 里累加两次
f({ ++a });
这个特性等价于 Scala 的 call by name,不同的是在调用函数时表达,而不是在函数定义时,这个设计很像 Mathematica 的 Unevaluated,这样设计有两个原因:
避免引入类似 Common Lisp 中
if
等 special form 的参数求值特殊规则,PureLang 的 block 作为 call by name 语义是通用的;阅读代码时可以清晰看到 call by name 语义,而无需像 Common Lisp 那样要看文档,也无需像 Scala 那样要看函数签名,Rust 语言的
mut
在调用函数时也要写上,原因也是为了代码可读性。
但这种设计有个缺陷,由于函数在声明时没想到会被按 call by name 方式传参,导致参数在函数内部被无意中重复求值而引起重复的副作用,所以这个特性还是要小心使用。其实所有语言都支持一种显式的惰性计算,就是把计算封入闭包里,但这会导致语法有点难看:
# 用闭包实现延迟计算,但这个很丑,而且 while_with_closure() 内部
# 要用 closure() 而非 expr 来使用参数,也很丑
while_with_closure( (){ a > 0}, (){ --a; println(a) });
编译
词法分析、语法分析、语义分析(包含构造符号表、抽象语法树、类型检查)、编译期执行、目标代码生成,这一套下来细节很多,但由于同构的设计,也许在模仿《Lisp 之根源》实现一个解释器核后,所有逻辑都可以用 PureLang 自身实现了(依赖解释器宿主的一些能力,比如 I/O、内存分配),进一步可以实现自举的伟大复兴!
关于目标代码生成,PureLang 应该以 Go、C/C++、JavaScript、Java 为目标,以利用这些语言的编译器优化和库生态。
内存管理
PureLang 的最大愿望是减轻程序员的心智负担,提供类似 Lisp SLIME、Smalltalk IDE 的「可塑开发(moldable development)」乐趣,因为目标语言多为带 GC 的语言,因此 PureLang 会使用 GC 技术以免除程序员手工管理内存的痛苦。
LSP
由于 PureLang 在编译期可以执行代码,因此是不方便用简单的 Ctags 之类工具跳转符号的,而恰好 PureLang 的编译器本身内置了解释器,正好可以用来实现 LSP server。
结语
PureLang 是个假想的语言,可能永远都不会实现出来,用 PureLang 「貧瘠」的语法可能也不太方便直观的表达主流编程语言的一些高级特性,如 Generic、Trait、Pattern Match、Annotation,但是,一个程序员对完美编程语言没有梦想,那跟咸鱼有什么分别呢?在这个巨头横行把持主流语言发展、不断语法复杂化的世界,用最简洁的语法糖表达最高级的语言设计,本身就是一个充满乐趣的挑战。