Better C++:如何设计一个更好的C++编程语言


Author: Kimmy

去年拖了很长的一段时间终于填完了 C++ 20 的坑。其中也提到了,C++ 20 并不完善。诸如 module、coroutine 以及 concept,语言上的特性虽然已经加上了,但仍然需要标准库和社区的支持。这个目标是准备放在明年的 C++ 23 里面的,但由于我实在等不及再要几年标准完稿然后供应商实现这个过程,就尝试换了个思路来解决这些问题。

标题既然提到了 Better C++,我们就先分别来看一下 C++ 存活的这几十年里已经出现了哪些(至少宣称)Better C++ 的语言。

Better C++ ?

首先说 Java。但 Java 因为其特殊的运行时需求,跟 C++ 本来就完全不在一个生态位。其最终的结果也就是把 C++ 多出来的那部分应用开发领域的功能给占了。这里的应用包括服务端应用。所以硬说是 Better,也只 Better 在了去掉了一大堆复杂特性导致自己的抽象能力不够,设计模式来凑的情况。不过,偷师来的一堆工具链算是给后来的人们设置了一个标准,这也是至今 C++ 都没能做得很好的地方。

虽然 Java 的运行时本身是个问题,但像 Java 一样重度依赖运行时(至少是 GC)的 Better C++ 也不少。D 和 Golang 就分别是复杂和简单设计的代表。D 语言在 Andrei 当年加入的时候还短暂火了一把(也就大概就火到 Haskell 这个水平);但还是因为其过于复杂,那可真是要啥有啥,一个典型的缝合怪(甚至一定程度上兼容 Java 的语法,dwt 就是源码级转译 swt 的 UI 工具库),劝退了大部分人。

这个复杂程度是怎么衡量的呢,一方面是特性的数量,C++ / D 独有的模板、CTFE、UFCS 等特性又进一步加深了组合各种语言元素的复杂度;另一方面则是来自 Golang 社区的人的标准,即关键字的数量。有一个简单的统计,D 语言基本上是常见编程语言里面关键字最多的那个(Golang 相对较少,至少在我看到这个标准的年代,还没有 C 语言多,这也是当时不少人宣传的一个重点)。

D 语言最难的一点是真的没有什么商业运作在推进。甚至少有什么知名企业声称自己在使用 D 语言。缺少宣传攻势的情况下,跟其他竞争者相比自然处于劣势,所以目前也是在边缘挣扎。

Golang 走了另一个极端。这个极端的结果,用 Bjarne 的话说,就是裸体把程序员勾过来,完后一件一件的穿回去。比如今年出现的那个 Java 1.5 时代的泛型。甚至还不如 1.5,毕竟 Java 1.5 已经把该加的标准库组件都加上了。

好在 Golang 背后有几个好家长,能够给这语言撑得起来。又上了云时代的顺风车,成为了容器平台的基础组件。但再伸更远就有点力不从心了:说能做系统编程,但是砍不掉运行时这个依赖;说能做应用编程,但抽象能力上甚至连 GUI 框架接口设计起来都很麻烦。没有泛型之前甚至要靠生成来解决代码大量重复的问题。

less is more 大概说的就是这种现象吧,特性上少了,语言更简单了,但是要做的工作却猛然增加了。

C# 面临的问题基本跟 Java 一样,可能更大的问题是开源比 Java 要晚,而且早起被微软的名声带的有点偏。东西呢是个好东西,但还是那个问题,生态位不同。早些年微软也有过实验,即整个操作系统都跑在管理运行时之上,这样其实就能拿 C# 干些系统编程的活了。但 Singularity 和 Midori 都是试验性作品,随后就被关停了。留下的 Joe Duffy 老师的几篇文章倒是真不错。

闲话扯得有点多,我们再来看看一个相当符合理想的编程语言应该是什么样子的吧。

Better C++ !

C++ 一个非常严重的问题是 module,多个编译单元的组合还在依赖操作系统提供的动态/静态链接,而头文件这种继承自 C 的机制,虽然一定程度上保证了接口与实现隔离,但确实整体包含和解析的操作让这个过程慢了不少,还不好控制整体的结构。使用的时候可能没那么明显,但当你去编写一堆头文件的时候,怎么保证不出现循环依赖都能抠破脑袋。

module 通常来说会有两种作用,一方面作为一个独立的构建单元,能够让外部程序进行引用和链接,另外一方面,使用 module 做符号的隔离,也会比 C++限行两套特性(另一个就是 namespace)来干两件事情更方便。另外不知道这是不是个传统,TypeScript 也给自己硬塞了一个 namespace 进去。可能也是因为当年 JavaScript 的 module 难产,不得不专门搞得一套符号管理方案吧。

理想状态下的 module 应该是用来切分编译单元和进行符号管理的工具。同时,作为一个编译期可控的对象,完全可以像其他元素一样,给参数化起来。这样的结果就是,我们在特定的场景,完全可以通过 module 的组合来简单实现 mixin 或者更加灵活的操作,而不必动用一些特殊构造的 class 甚至 template。

参数化的 module 还有更多好处。参数化以后我们就能给 module 引入类型,这样就可以通过 moudle type 来限制 module 的结构和实现接口,这样就等于用 module 实现了 concept,又省掉了一个巨坑。module type 本身也可以是参数化的,这样直接一步到胃可以比肩 Haskell。

以这个标准看,现在(C++ 20)的 module 就完全没有任何用。

另一个难以推进的特性就是模式匹配了。这不仅在于语言特性上单纯地加几个关键字的问题,而是类型系统上就特别难以适配这堆奇怪的要求。匹配到的究竟是值还是引用?是移动还是复制?是鸡肋还是派生类?是……反正就说不清你懂吧。

这一点上确实建议 C++ 像隔壁的 Rust 学习,甚至步子大一点重新设计下类型定义的语法。当然了该留的还是得留,class 不能少,不要让程序员陷入手动做动态分发的地步。还有隔壁微软的 C++/CLI 的 ref 挺好用的,也可以抄过来,做点引用语义的类型?或者干脆步子再大一点全部改成引用语义得了。

这样对于模式匹配来说,很容易做到定义和使用的一致性,模式匹配处的模式,就是对象定义处的定义,结构上刚好对应,这样反过来也不至于混淆。凭借本来就强大的编译时引擎,这点类型检查和匹配的工作量应该还是很容易就能完成的。

不能改的几个地方,一个就是前面说的 class 了。这是 C++ 之所以能成为 C++(C with class)的根本,这点上作为一个特色鲜明的特性是一定要保留的。而且本身同时拥有 fp 和 oo 特性,也是某些后现代编程语言的宣传标语。

另外,不可变这一点上是需要尽全力保持的。C++ 的特色就是 const 能够约束到的范围远比那些 final sealed 之类的要远。这一点也可以更进一步,就是做到默认不可变,但是保留 mutable 关键字。这一点就能让 C++ 在全力融入全民 fp 新时代的同时,还能通过特有的 mutable 开洞保留一定的命令式范式,按照 Bjarne 的说法,给程序员该有的选择嘛。

引用也是需要保留的。但可能并不需要现在的这种形式。因为 const 和引用组合造成的复杂关系是劝退人的一大难题,隔壁的 Rust 为了这些概念额外造了一大堆的新东西,也没能绕过这个坑。众所周知,引用只是一个简单的代理对象。所以在前面的各种特性之上,只要简单定义一个新的类型,就能实现如同引用一般的效果了:

type ref<t> = { mutable content: t };

这里看上去可能会比较奇怪,因为并不再是使用模板来定义泛型了。首先,前面提到的强大的 module 已经承载了模板的大部分功能在,而泛型本来就应该是像泛型一样的泛型,并非 C++ 如今用模板打的一个巨型补丁。模板带来的混乱导致调试困难这个问题,困扰了 C++ 程序员几十年了,是时候抛弃这个诡异特性了。

甚至,作为早期混乱的一个来源之一,尖括号形式的模板参数也可以考虑换掉。新的编程语言里也就少数编程语言还在坚守这个奇怪用法,对编译器前端工作人员相当不友好。可以考虑学习下 Scala,D 或者一些函数式编程语言,用更清晰的写法,比如用后缀的方式看起来甚至会更直观:

int option list // vs list<optional<int>>

为了避免歧义,可以在泛型参数上做一些简单的标记。这样一来我们的引用定义就变成了:

type 'a ref = { mutable content: 'a };

其他相应的特性我们因地制宜做一些简单的调整。比如把一些语句可以进一步变成表达式、简化一下函数声明和类型标注的语法、加入一些诸如惰性求值之类的特性。我们基本能得到一个改进了新的特性和保留但更新了大部分已有特性的Better C++。

而且这个 C++不像 C++23 那样我们还得等几年才能用到,而是已经有了完善的实现啦。

创建时间:2022-07-09 最近更新时间:2024-10-27