C C中的类型安全通用数据结构

2021-04-08 21:05:43

新一代低级编程语言的崛起,如Rust,Go和Zig导致了C及其原始类型系统陷入了一些荒唐。尽管如此,具有足够的创造力,可以在C中实现令人惊讶的复杂结果。一个这样的结果是通用数据结构。这篇帖子评论用于在C中实现通用数据结构的两种技术:不用利地使用原始内存和指针投射,并安全地使用通过宏生成代码。 1

我们将实现的通用数据结构是堆栈。作为热身,我们' LL编写一个常规的非通用堆栈,只能为int值工作。我们的最低级别堆栈将仅支持两个操作,推动和流行,而不是最有用的数据结构,但足以涵盖数据结构实施的根本挑战。

我们的堆栈' s内部状态包括长度,容量和指向堆栈的指针' s堆分配的数据。长度是堆栈中的元素数,而容量是分配的内存可以保持的最大元素数(不是分配的存储器的大小以字节为单位,这是容量* sizeof(int))。

我们' ll从推送操作开始。首先,如果其他元素没有足够的容量,则调整堆栈大小。然后,堆栈' s长度递增,并且将值写入数据阵列的末尾:2

void intstack_push(Intstack * stck,int值){ 如果(!stck){ 返回; } if(stck-> len + 1> stck->容量){ / * todo:处理算术溢出。 * / size_t new_capacity = stck->容量* 2; int * new_data = realloc(stck-> data,new_capacity * sizeof(int)); if(!new_data){ / * todo:处理内存错误。 * / 返回; } stck->容量= new_capacity; stck-> data = new_data; } stck-> len ++; stck->数据[stck-> len-1] =值; }

请注意,如果它无法重新分配内存,则realloc可能会返回null指针,因此它不安全地将返回值分配给stck->数据不首先返回。另请注意,使用Realloc假定STCK->数据先前已被Malloc分配,假设我们&#39的构造函数被支持。

POP操作递减长度字段并返回前一个元素。返回值被包裹在介质数据结构中,因为在stck为null或空的情况下,没有弹出的值返回。 3.

Intresult Intstack_pop(Intstack * stck){ 如果(!stck || stck-> len == 0){ 返回interresult_error(); } stck-> len--; 返回Intresult_of(Stck->数据[stck-> len]); }

typedef struct { BOOL错误; 结果; }内部; Intresult Intresult_Of(int v){ intresult r = {.error = false,.result = v}; 返回r; } Intresult Intresult_Error(){ intresult r = {.Error = true}; 返回r; }

我们' LL还提供了一个方便地创建Intstack对象的构造函数。它初始化长度为0的堆栈和堆分配内存的小初始容量。

Intstack Intstack_new(){ size_t容量= 8; int * data = malloc(容量* sizeof(int)); 如果(!数据){ / * todo:处理内存错误。 * / } Intstack Stck = {.len = 0,.capacity =容量,.data = data}; 返回stck; }

由于构造函数从堆分配内存,因此我们需要相应的析构函数来释放它:

Intstack int_stack = intstack_new(); Intstack_push(& int_stack,1); INTSTACK_PUSH(& INT_STACK,2); intresult r = intstack_pop(& int_stack); 断言(!r.Error); 断言(r.result == 2); INTSTACK_FREE(& INT_STACK);

Intstack仅适用于INT值。如果您想要一个char堆栈,您必须编写另一个堆栈实现。如果您这样做,您会发现CharStack的代码几乎与Intstack的代码相同,因为堆栈,如大多数容器数据结构,并不是以任何方式操纵其元素,它只是存储它们。它需要了解它们的唯一方法是他们每个人都占用了多少内存。因此,而不是为每个元素类型编写不同的堆栈,让' s尝试编写适用于任何元素类型的堆栈。

关键识别是,即使在编译时既不知道元素的大小也不是已知的,堆栈的元素仍然可以存储为非结构化二进制数据,只要我们跟踪每个元素占用多少内存。

在C中,二进制数据可以在字符数组中存储。举例说明,假设我们定义一个点类型:

我们可以使用memcpy将点对象存储在像这样的char数组中,以将字节复制到数组中:

//初始化超过足够容量的数组。 char数据[100]; //初始化点对象。 点P = {.x = 42,.y = 43}; //将点对象复制到数组中。 Memcpy(数据,& p,sizeof(point)); //打印数组的字节。 for(size_t i = 0; i< sizeof(point); i ++){ printf("数据[%ld] =%d \ n" i,data [i]); }

数据[0] = 42 数据[1] = 0 数据[2] = 0 数据[3] = 0 数据[4] = 43 数据[5] = 0 数据[6] = 0 数据[7] = 0

但是,阵列的各个元素必须被视为不透明,因为点的内存布局取决于编译器。

这种技术是我们通用堆栈类型,不一接的基础。 4 unsafaceStack具有Char *数据字段而不是Int *数据,以及若要追踪每个对象占用的字节数的额外objsize字段:

在UnsadeStack_push中调整数组大小的逻辑类似于Intstack_push的逻辑。正如我们在该点示例中所看到的,我们必须使用Memcpy将值复制到堆栈的末尾,而不是直接将其分配给阵列的索引,因为该值可能是任意大的。 UNSAFESTACK_PUSH接受类型void *的值,以便可以传递任何类型的对象。

void unsaceStack_push(未活性* stck,void *值){ 如果(!stck){ 返回; } if(stck-> len + 1> stck->容量){ size_t new_capacity = stck->容量* 2; char * new_data = realloc(stck-> data,new_capacity * stck-> objsize); if(!new_data){ / * todo:处理内存错误。 * / 返回; } stck->容量= new_capacity; stck-> data = new_data; } memcpy(stck-> data +(stck-> len * stck-> objsize),值,stck-> objsize); stck-> len ++; }

同样,UnsafaceStack_pop返回类型void *的指针,因为数据结构中的对象的类型为' t在编译时。指针是将阵列的偏移量计算为stck-> len * stck-> objsize:

void * unsaceStack_pop(未活性* stck){ 如果(!stck || stck-> len == 0){ 返回null; } stck-> len--; 返回stck->数据+(stck-> len * stck-> objsize); }

通过返回空指针可以发信号通知错误,因此我们不需要一个' t需要一个unsaceestack的结果对象。

unsaceestack unsaceStack_New(size_t objsize){ size_t容量= 8; char * data = malloc(容量* objsize); 如果(!数据){} UnsafaceStack Stck = {.len = 0,.capacity =容量,.objsize = objsize,.data = data}; 返回stck; } void unsaceestack_free(未活性* stck){ 如果(stck){ 免费(Stck->数据); } }

unsaceestack' s api有点不同于intstack' s。将值按到堆栈上时,我们提供指向它的指针而不是元素本身,并且在弹出值时,我们将返回值,然后将其转录。

与Java和型断言的投射不同,C中的CASTS完全不安全。如果CAST无效,则编译器不会在编译时抱怨运行时在运行时抛出异常。相反,它将默默地继续进行奇怪和可怕的事情,例如访问未初始化的内存或覆盖属于其他变量的内存。由于C是一种静态类型的语言,我们更愿意避免这些可能的错误。

让'返回基本问题。如果我们想要类型安全堆栈,那么我们必须编写一个不同的,虽然几乎相同,实现每种类型。它'基本上是代码重复的问题。

幸运的是,C有一种处理代码复制:宏的机制。我们可以使用宏为我们的模板生成宏而不是写出完整的堆栈实现。我们将采取我们的Intstack实现,将所有使用的INT替换为类型参数,然后将整个实现包裹在类型中参数化的宏。每当我们想要为新类型使用堆栈时,我们' ll调用宏生成实现的代码。就C编译器而言,它' s'■我们是否为代码中的具体类型写了一个单独的实现,以便编译器可以正确地键入代码。

宏代码有点难以读取,而不是最不重要的,因为每行必须以反斜杠结束以将宏继续下一行继续,但它可识别几乎与Intstack的代码几乎相同。语法typename ## _ new,typename ## _ free等,告诉预处理器胶合typename,宏参数,对字面字符串(如_new或_free),产生floatstack_new或stringstack_free等结果。无论我们在原始Intstack代码中的位置,宏参数类型都被替换。

#define decl_stack(typeName,类型)\ typedef struct {\ size_t len,容量; \ 类型*数据; \ }类型; \ \ typedef struct {\ BOOL错误; \ 类型结果; \ Typename ##结果; \ \ typename typename ## _ new(){\ size_t容量= 8; \ 键入* data = malloc(容量* sizeof(类型)); \ 如果(!数据){} \ typename stck = {.len = 0,.capacity =容量,.data = data}; \ 返回stck; \ \ \ void typename ## _ free(typename * stck){\ 如果(stck){\ 免费(Stck->数据); \ \ \ \ size_t typename ## _长度(TypeName * stck){\ 返回stck? stck-> len:0; \ \ \ void typename ## _ push(typeName * stck,键入值){\ 如果(!stck){\ 返回; \ \ \ 如果(stck-> len + 1> stck->容量){\ size_t new_capacity = stck->容量* 2; \ 类型* new_data = realloc(stck-> data,new_capacity * sizeof(类型)); \ \ 如果(!new_data){\ 返回; \ \ \ stck->容量= new_capacity; \ stck-> data = new_data; \ \ \ stck-> len ++; \ stck->数据[stck-> len-1] =值; \ \ \ typename ##结果typeName ## _ POP(TypeName * stck){\ 如果(!stck || stck-> len == 0){\ typename ##结果错误= {.Error = True}; \ 返回errerval; \ \ \ 类型值= stck->数据[stck-> len-1]; \ stck-> len--; \ typename ##结果r = {.Error = false,.result = value}; \ 返回r; \ }

然后,我们调用decl_stack来声明新的堆栈类型,无论是在标题文件中还是在程序的顶级:

结果API实现了Intack的安全性和便利性和unsaceStack的一般性:

safeintstack safe_int_stack = safeintstack_new(); safeintstack_push(& safe_int_stack,1); safeintstack_push(& safe_int_stack,2); safeintstackresult r = safeintstack_pop(& safe_int_stack); 断言(!r.Error); 断言(r.result == 2); safeintstack_free(& safe_int_stack);

请注意,SafitIntstack仍然只能安全为C' s类型系统,例如,它将允许您使用预期int的字符串文字,只有编译器警告。这是一个无法解决的基本限制。

安全堆栈数据结构在手写代码上没有开销,除了每个新的声明以常量金额增加程序大小(与不安全堆栈不同,它使用与所有数据结构相同的代码)。顺便提及,此代码生成技术基本上是如何在C ++中的幕后实现模板。

可以以类型系统没有本地支持的语言编写类型安全的通用数据结构,这些语言支持它们与C'灵活性。 5但是我们使用的技术 - 未经检查的指针演员和不卫生词汇宏 - 如果不当使用,本身就非常不安全。灵活性,简约和安全性:一种语言可以最多达到三分之一。 Rust和Haskell选择灵活性和安全性。去选择安全性和简单性。 6 C选择灵活性和简单性。每种选择都有其权衡。现代语言更倾向于安全的趋势是C代码中发现的无数错误的直接后果,可以通过更强大的编译时间保证来防止。尽管如此,随着该帖子所示,灵活性和简单性是一种强大的组合。

我假设读者精通C. C上的经典文本是Brian Kernighan和Dennis Ritchie的C编程语言,在作者之后被称为K& r&#39。寻找第二版。虽然K& r非常好,但最佳实践已经过度,自1988年以来已经改变了一些语言功能。Jens Gustedt的现代C是对现代语言的重大介绍。 ↩

在这里和整个I' ve添加了托代,标记更强大的实现需要处理边缘案例的位置。 ↩

传统上,通过返回特殊错误值,或"带外"通过设置全局errno变量。我发现后一种方法犹豫不断,前者是不可能的,因为任何int值是Intstack_pop的可能的合法返回值。 ↩

GLIB,GNOME桌面环境的实用程序库,使用此技术的GARRAY通用数组类型。 ↩ 有关更多的证据,请参阅Daniel Holden' S Cello,C CeloSo在C中的高级编程框架。 或许是争议的。 Go确实允许一些不安全的构造,如界面{},但它在很大程度上使C.↩的鲁莽允许剥夺了鲁莽的允许