使用新标签兼容规则在C语言中实现参数化类型
C23引入了一个关于结构体、联合体和枚举兼容性的新规则,该规则已从今年4月发布的GCC 15开始出现在编译器中,Clang也将在今年晚些时候支持。在不同翻译单元中定义的相同结构体一直是兼容的——这对它们的工作方式至关重要。在此规则更改之前,翻译单元内的每个此类定义都是一个不同的、不兼容的类型。新规则规定,实际上它们是兼容的!这解锁了使用宏进行类型参数化的可能性。
翻译单元如何拥有结构体的多个定义?作用域。在C23之前,以下代码无法编译,因为复合字面量类型和返回类型是不同的类型:
1
2
3
4
5
6
7
|
struct Example { int x, y, z; };
struct Example example(void)
{
struct Example { int x, y, z; };
return (struct Example){1, 2, 3};
}
|
否则,在example内部定义struct Example是可以的,尽管有些奇怪。起初这可能看起来不是什么大事,但让我们重新审视我的动态数组技术:
1
2
3
4
5
|
typedef struct {
T *data;
ptrdiff_t len;
ptrdiff_t cap;
} SliceT;
|
我为每个可能想要放入切片中的T写出其中一个。使用新规则,我们可以稍微改变它,注意标签(struct后面的名称)的引入:
1
2
3
4
5
6
|
#define Slice(T) \
struct Slice##T { \
T *data; \
ptrdiff_t len; \
ptrdiff_t cap; \
}
|
这使得"提前写出"的事情变得更简单,但有了新规则,我们可以跳过"提前"部分,按需创建切片类型。由于匹配的标签和字段,每个具有相同T的声明与其他声明兼容。因此,例如,使用这个宏,我们可以声明使用针对不同元素类型参数化的切片的函数。
1
2
3
4
5
6
|
Slice(int) range(int, Arena *);
float mean(Slice(float));
Slice(Str) split(Str, char delim, Arena *);
Str join(Slice(Str), char delim, Arena *);
|
或者在我们的模型解析器中使用它:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
typedef struct {
float x, y, z;
} Vec3;
typedef struct {
int32_t v[3];
int32_t n[3];
} Face;
typedef struct {
Slice(Vec3) verts;
Slice(Vec3) norms;
Slice(Face) faces;
} Model;
typedef Slice(Vec3) Polygon;
|
我担心这些宏可能会混淆我的工具,特别是Universal Ctags,因为它对我来说很重要。所有工具处理原型都比预期要好,但ctags看不到具有切片类型的字段。总的来说,它们就像C++模板的一种非常有限的形式。虽然只有类型被参数化,而不是操作这些类型的函数。除了不必要的宏滥用之外,这种新技术在泛型函数方面没有任何作用。另一方面,我的泛型切片函数补充了新技术,特别是在C23新的typeof的帮助下,缓解了_Alignof的限制:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
typedef struct { char *beg, *end; } Arena;
void *alloc(Arena *, ptrdiff_t count, int size, int align);
#define push(a, s) \
((s)->len == (s)->cap \
? (s)->data = push_( \
(a), \
(s)->data, \
&(s)->cap, \
sizeof(*(s)->data), \
_Alignof(typeof(*(s)->data)) \
), \
(s)->data + (s)->len++ \
: (s)->data + (s)->len++)
void *push_(Arena *a, void *data, ptrdiff_t *pcap, int size, int align)
{
ptrdiff_t cap = *pcap;
if (a->beg != (char *)data + cap*size) {
void *copy = alloc(a, cap, size, align);
memcpy(copy, data, cap*size);
data = copy;
}
ptrdiff_t extend = cap ? cap : 4;
alloc(a, extend, size, align);
*pcap = cap + extend;
return data;
}
|
这利用了采用新标签规则的实现也具有即将到来的C2y空指针规则的事实(注意:还需要一个协作的libc)。把它们放在一起,现在我可以写这样的东西:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
Slice(int64_t) generate_primes(int64_t limit, Arena *a)
{
Slice(int64_t) primes = {};
if (limit > 2) {
*push(a, &primes) = 2;
}
for (int64_t n = 3; n < limit; n += 2) {
bool valid = true;
for (ptrdiff_t i = 0; valid && i<primes.len; i++) {
valid = n % primes.data[i];
}
if (valid) {
*push(a, &primes) = n;
}
}
return primes;
}
|
但很快就会遇到限制。定义一个Map(K, V)而没有泛型函数来操作它是没有意义的。这也不起作用:
1
2
3
4
|
typedef struct {
Slice(Str) names;
Slice(Slice(float)) edges;
} Graph;
|
由于宏中的Slice##T,需要为每个元素类型建立唯一的标签。宏的参数必须是一个标识符,所以你必须逐步构建它(或定义另一个宏),这有点违背了目的,这完全是为了方便。
1
2
3
4
5
6
|
typedef Slice(float) Edges;
typedef struct {
Slice(Str) names;
Slice(Edges) edges;
} Graph;
|
好处很小,可能不值得付出代价,但至少值得研究。如果你想看这个技术的实际效果,或者测试你本地C实现的能力,我写了一个小演示:demo.c