通过 Zig,学习 C++ 元编程
尽管 Zig 社区宣称 Zig 语言是一个更好的 C (better C),但是我个人在学习 Zig 语言时经常会“触类旁通”C++。在这里列举一些例子来说明我的一些体会,可能会有一些不正确的地方,欢迎批评指正。
“元能力” vs “元类型”
在我看来,C++的增强方式是希望赋予语言一种“元能力”,能够让人重新发明新的类型,使得使用 C++的程序员使用自定义的类型,进行一种类似于“领域内语言”(DSL)编程。一个通常的说法就是 C++中任何类型定义都像是在模仿基本类型int
。比如我们有一种类型 T,那么我们则需要定义 T 在以下几种使用场景的行为:
|
|
通过定义各种行为,程序员可以用 C++去模拟基础类型int
,自定义的创造新类型。但是 Zig 却采取了另一条路,这里我觉得 Zig 的取舍挺有意思,即它剥夺了程序员定义新类型的能力,只遵循 C 的思路,即struct
就是struct
,他和int
就是不一样的,没有必要通过各种运算符重载来制造一种“幻觉”,模拟int
。相反,Zig 吸收现代语言中最有用的“元类型”,比如slice
,tuple
,tagged union
等作为语言内置的基本类型,从这一点上对 C 进行增强。虽然这样降低了语言的表现力,但是却简化了设计,降低了“心智负担”。
比如 Zig 里的tuple
,C++里也有std::tuple
。当然,std::tuple
是通过一系列的模板元编程的方式实现的,但是这个在 Zig 里是内置的,因此写代码时出现语法错误,Zig 可以直接告诉你是tuple
用的不对,但是 C++则会打印很多错误日志。再比如optional
,C++里也有std::optinonal<T>
,Zig 里只用?T
。C++里有std::variant
,而 Zig 里有tagged union
。当然我们可以说,C++因为具备了这种元能力,当语法不足够“甜”时,我们可以发明新的轮子,但是代价就是系统愈发的复杂。而 Zig 则持续保持简单。
不过话说回来,很多底层系统的开发需求往往和这种类型系统的构建相悖,比如如果你的类型就是一个int
的封装,那么即使发生拷贝你也无所谓性能开销。但是如果是一个struct
,那么通常情况下,你会比较 care 拷贝,而可能考虑“移动”之类的手段。这个时候各种 C++的提供的幻觉,就成了程序员开发的绊脚石,经常你需要分析一段 C++表达式里到底有没有发生拷贝,他是左值还是右值,其实你在写 C 语言的时候也很少去考虑了这些,你在 Zig 里同样也不需要。
类型系统
C 语言最大弊病就是没有提供标准库,C++的标准库你要是能看懂,得具备相当的 C++的语法知识,但是 Zig 的标准库几乎不需要文档就能看懂。这其实是因为,在 C++里,类型不是一等成员(first class member),因此实现一些模版元编程算法特别不直观。但是在 Zig 里,type
就是一等成员,比如你可以写:
|
|
即,把一个type
当成一个变量使用。但是 C++里如何来实现这一行代码呢?其实是如下:
|
|
那么我们如果要对某个类型做个计算,比如组合一个新类型,Zig 里其实非常直观
|
|
即输入一个类型,输出一个新类型,那么 C++里对应的东西是啥呢?
|
|
相比之下, Zig 直观太多。那么很自然的,计算一个类型,Zig 里就是调用函数,而 C++则是模板类实例化,然后访问类成员。
|
|
相当于对于 InputType 调用一个 Some“函数”,然后输出一个 OutputType。
命令式 VS 声明式
比如实现一个函数,输入一个 bool 值,根据 bool 值,如果为真,那么输出 type A,如果为假那么输出 type B。
|
|
从这里 C++代码可以感觉出,其实你是拿着尺子,对照着基础模式,然后通过模版偏特化来实现不同分支的逻辑。
|
|
这段代码表面上看是声明了一个类型 OutputType,而这个类型的生成依赖于一些条件。而这些条件就是模板元编程,用来从 A 和 B 中选择类型大小更大的类型,如果想要表达更复杂的逻辑,则需要掌握更多模板的奇技淫巧。
如果用 Zig 来做,则要简单的多:
|
|
这段代码和普通的 CRUD 逻辑没什么区别,特殊的地方在于操作的对象是『类型』。
我们再来看递归的列子。比如有一个类型的 list,我们需要返回其中第 N 个 type。同样,由于在 C++中,类型不是一等成员,因此我们不可能有一个vector<type>
的东东。那怎么办呢?方法就是直接把type list
放在模板的参数列表里:typename ...T
。
于是,我们写出“函数原型”:
|
|
然后我们递归的基础情况
|
|
然后写递归式,
|
|
这个地方其实稍微有点难理解,其实就是拿着...T
来模式匹配Head, ...Tail
。
第一个偏特化,如果用命令式,类似于,
|
|
第二个偏特化,类似于
|
|
这里利用的其实是继承,让模板推导一路继承下去,如果 Index 不等于 0,那么Fn<Index, ...>
类其实是空类,即,我们无法继承到using Output = ...
的这个Output
。但是 index 总会等于 0,那么到了等于 0 的那天,递归就终止了,因为,我们不需要继续 Index - 1 下去了,编译器会选择特化好的Fn<0, T,Tail...>
这个特化,而不会选择继续递归。
但是 Zig 实现这个也很直观,由于slice
和type
都是内置的,我们可以直接:
|
|
即这个也是完全命令式的。当然 C++20 之后也出现了if constexpr
和concept
来进一步简化模版元编程,C++的元编程也在向命令式的方向进化。
结束语
尽管 Zig 目前“还不成熟”,但是学习 Zig,如果采用一种对照的思路,偶尔也会“触类旁通”C++,达到举一反三的效果。