C23新标签兼容规则实现参数化类型

本文详细介绍了C23新引入的结构体标签兼容规则,通过宏定义实现类似C++模板的参数化类型定义,展示了动态数组切片和泛型函数的具体实现方法,并讨论了该技术的优势与局限性。

使用新标签兼容规则在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

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计