通过 Zig,学习 C++ 元编程

尽管 Zig 社区宣称 Zig 语言是一个更好的 C (better C),但是我个人在学习 Zig 语言时经常会“触类旁通”C++。在这里列举一些例子来说明我的一些体会,可能会有一些不正确的地方,欢迎批评指正。

“元能力” vs “元类型”

在我看来,C++的增强方式是希望赋予语言一种“元能力”,能够让人重新发明新的类型,使得使用 C++的程序员使用自定义的类型,进行一种类似于“领域内语言”(DSL)编程。一个通常的说法就是 C++中任何类型定义都像是在模仿基本类型int。比如我们有一种类型 T,那么我们则需要定义 T 在以下几种使用场景的行为:

1
2
3
4
5
T x; //构造
T x = y; //隐式拷贝构造
T x{y}; //显示拷贝构造
x = y; //x的类型是T,复制运算符重载,当然也有可能是移动运算符重载。
//以及一大堆其他行为,比如析构等等。

通过定义各种行为,程序员可以用 C++去模拟基础类型int,自定义的创造新类型。但是 Zig 却采取了另一条路,这里我觉得 Zig 的取舍挺有意思,即它剥夺了程序员定义新类型的能力,只遵循 C 的思路,即struct就是struct,他和int就是不一样的,没有必要通过各种运算符重载来制造一种“幻觉”,模拟int。相反,Zig 吸收现代语言中最有用的“元类型”,比如slicetupletagged 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就是一等成员,比如你可以写:

1
const x: type = u32;

即,把一个type当成一个变量使用。但是 C++里如何来实现这一行代码呢?其实是如下:

1
using x = uint32_t;

那么我们如果要对某个类型做个计算,比如组合一个新类型,Zig 里其实非常直观

1
fn Some(comptime InputType: type) type

即输入一个类型,输出一个新类型,那么 C++里对应的东西是啥呢?

1
2
3
4
template <typename InputType>
struct Some {
  using OutputType = ...
}

相比之下, Zig 直观太多。那么很自然的,计算一个类型,Zig 里就是调用函数,而 C++则是模板类实例化,然后访问类成员。

1
Some<InputType>::OutputType

相当于对于 InputType 调用一个 Some“函数”,然后输出一个 OutputType。

命令式 VS 声明式

比如实现一个函数,输入一个 bool 值,根据 bool 值,如果为真,那么输出 type A,如果为假那么输出 type B。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
//基本模式
template <bool, typename A, typename B>
struct Fn {
	using OutputType = A;
};

//特例化的模式
template<typename A, typename B>
struct Fn<false, A, B> {
	using OutputType = B;
};

从这里 C++代码可以感觉出,其实你是拿着尺子,对照着基础模式,然后通过模版偏特化来实现不同分支的逻辑。

1
Fn<sizeof(A) > sizeof(B), A, B>::OutputType

这段代码表面上看是声明了一个类型 OutputType,而这个类型的生成依赖于一些条件。而这些条件就是模板元编程,用来从 A 和 B 中选择类型大小更大的类型,如果想要表达更复杂的逻辑,则需要掌握更多模板的奇技淫巧。

如果用 Zig 来做,则要简单的多:

1
2
3
4
5
6
fn Fn(comptime A:type, comptime B: type) type {
	if (@sizeOf(A) > @sizeOf(B)) {
		return A;
	}
	return B;
}

这段代码和普通的 CRUD 逻辑没什么区别,特殊的地方在于操作的对象是『类型』。

我们再来看递归的列子。比如有一个类型的 list,我们需要返回其中第 N 个 type。同样,由于在 C++中,类型不是一等成员,因此我们不可能有一个vector<type>的东东。那怎么办呢?方法就是直接把type list放在模板的参数列表里:typename ...T

于是,我们写出“函数原型”:

1
2
template <int Index, typename ...T>
struct Fn;

然后我们递归的基础情况

1
2
3
4
template <typename Head, typename ...Tail>
struct Fn<0, Head, Tail...> {
	using Output = Head;
};

然后写递归式,

1
2
3
4
template<int Index, typename Head, typename ...Tail>
struct Fn<Index, Head, Tail...> : public Fn<Index - 1, Tail...>
{
};

这个地方其实稍微有点难理解,其实就是拿着...T来模式匹配Head, ...Tail

第一个偏特化,如果用命令式,类似于,

1
2
if (Index == 0)
    return Head;

第二个偏特化,类似于

1
2
3
else {
	return Fn(Index-1, Tail...);
}

这里利用的其实是继承,让模板推导一路继承下去,如果 Index 不等于 0,那么Fn<Index, ...>类其实是空类,即,我们无法继承到using Output = ...的这个Output。但是 index 总会等于 0,那么到了等于 0 的那天,递归就终止了,因为,我们不需要继续 Index - 1 下去了,编译器会选择特化好的Fn<0, T,Tail...>这个特化,而不会选择继续递归。

但是 Zig 实现这个也很直观,由于slicetype都是内置的,我们可以直接:

1
2
3
4
5
6
7
8
fn chooseN(N: u32, comptime type_list: []const type) type {
    return type_list[N];
}

pub fn main() void {
    const type_list = &[_]type{ u8, u16, u32, u64 };
    std.debug.print("{}\n", .{chooseN(2, type_list)});
}

即这个也是完全命令式的。当然 C++20 之后也出现了if constexprconcept来进一步简化模版元编程,C++的元编程也在向命令式的方向进化。

结束语

尽管 Zig 目前“还不成熟”,但是学习 Zig,如果采用一种对照的思路,偶尔也会“触类旁通”C++,达到举一反三的效果。