C API 设计:谁应该分配内存?

57

在 C API 中,适当/首选的内存分配方式是什么?

一开始我可以看到两种选项:

1)让调用者处理所有(外部)内存管理:

myStruct *s = malloc(sizeof(s));
myStruct_init(s);

myStruct_foo(s);

myStruct_destroy(s);
free(s);

_init_destroy函数是必需的,因为内部可能会分配更多的内存,而这些内存必须在某处处理。

这种方法的缺点是代码会变得更长,但在某些情况下可以省略malloc(例如,可以传递栈分配的结构体):

int bar() {
    myStruct s;
    myStruct_init(&s);

    myStruct_foo(&s);

    myStruct_destroy(&s);
}

另外,调用者需要知道结构体的大小。

2) 在_init中隐藏malloc,并在_destroy中隐藏free

优点:代码更短,因为这些函数总是会被调用。结构体完全不透明。

缺点:无法传递以其他方式分配的结构体。

myStruct *s = myStruct_init();

myStruct_foo(s);

myStruct_destroy(foo);

目前我倾向于第一种情况;但是,我对C API设计不了解。


3
顺便说一下,我觉得这会是一个很好的面试问题,可以比较并对比这两种设计。 - frankc
3
这是Armin Ronacher写的一篇关于如何使结构体不透明但仍允许自定义分配的文章:http://lucumr.pocoo.org/2013/8/18/beautiful-native-libraries/。 - Sam Hartsfield
11个回答

20

#2 的另一个缺点是调用者无法控制如何分配内存。可以通过提供 API 让客户端注册自己的分配/释放函数(例如 SDL 做的那样)来解决这个问题,但即使这样可能也不够细粒度。

#1 的缺点是当输出缓冲区大小不固定时它的表现不好(例如字符串)。最好情况下,你需要提供另一个函数来获取缓冲区长度,以便调用者可以进行分配。在最坏情况下,这根本不可能以高效的方式实现(即在单独路径上计算长度比计算并复制要昂贵得多)。

#2 的优点���允许你将数据类型严格公开为不透明指针(即声明结构体但不定义它,并且一致使用指针)。然后,在库的未来版本中,你可以随意更改结构体的定义,而客户端仍保持二进制兼容性。使用 #1,则必须通过要求客户端以某种方式在结构体内指定版本(例如 Win32 API 中的所有那些 cbSize 字段),然后手动编写能够处理旧版本和新版本的结构体的代码来保持二进制兼容性,才能实现此目标。

通常来说,如果你的结构体是透明数据,并且不会随着库的未来次要修订而发生更改,则可以选择 #1。如果它是一个或多或少复杂的数据对象,并且你想要完全封装以使其对于未来开发具有抗干扰性,则选择 #2。


1
+1 对于抽象和不透明指针的观点非常赞同 - 这是一个巨大的优势,因为它完全将你的实现与调用代码解耦。 - Paul R
关于何时使用每种方法的实际明智建议,这是一个不错的答案。 - mtraceur

20

每次都使用第二种方法。

为什么?因为使用第一种方法需要向调用者泄露实现细节。调用者至少需要知道结构体的大小。如果更改对象的内部实现,则必须重新编译使用它的任何代码。


3
这意味着#2可以作为一个二进制兼容的接口来实现,对API进行次要版本添加、增强等不会在.so或.dll文件中发布时破坏客户端代码。 - kert
3
调用者需要知道对象的大小(也许还有对齐方式?),但这并不意味着它必须在静态时知道:你可以使用 myStruct_size(void)myStruct_alignment(void)。参见此问题 - djsp
@JeremyP 这种设计使得使用静态内存或重复使用同一块内存变得不可能 - 而内存分配是静态隐藏实现的问题之一。我同意,然而,使用起来并不愉快。也许,一个中间解决方案是在API的一部分中实现*_alloc(...)方法。这样,“懒惰”的用户可以选择动态分配,而包装器(例如C++)可以进行自己的内存管理。 - djsp
1
@Kalrish 是的,但这有什么关系呢?如果您坚持要能够从堆栈中分配内存,那么您就无法进行适当的封装。对象应始终作为引用实现,并且每个理智的面向对象语言都是以这种方式实现的。C++不是一个理智的面向对象语言,幸运的是这个问题不是一个C++问题,所以我们可以忽略它。 - JeremyP
与第三条评论相关的问题,@JeremyP六年后。假设库执行普通数组分配,它必须返回或提供数组大小给用户,对吗(用于循环等)? - Erdem Tuna
显示剩余5条评论

12
为什么不两者都提供,以兼顾两者的优点呢?
使用“_init”和“_terminate”函数来实现方法#1(或您认为适合的任何名称)。
对于动态分配,请使用额外的“_create”和“_destroy”函数。由于已经存在“_init”和“_terminate”,因此这实际上可以归结为:
myStruct *myStruct_create ()
{
    myStruct *s = malloc(sizeof(*s));
    if (s) 
    {
        myStruct_init(s);
    }
    return (s);
}

void myStruct_destroy (myStruct *s)
{
    myStruct_terminate(s);
    free(s);
}

如果你想让它不透明,那么将_init和_terminate设置为static并且不要在API中公开它们,只提供_create和_destroy。如果需要其他的分配,例如使用给定的回调函数,则为此提供另一组函数,例如_createcalled、_destroycalled。

重要的是要跟踪分配情况,但你必须无论如何都要这样做。你必须始终使用所使用的分配器的对应项进行释放。


3
有没有一个众所周知的C库采用了这种方法? - cubuspl42
1
@cubuspl2 有没有哪个著名的C库或作者记录了他们为什么没有采取这种方法? - mtraceur

10

我最喜欢的一个设计良好的 C API 的例子是GTK+,它使用了你所描述的方法 #2。

虽然你的方法 #1 的另一个优点不仅是可以在堆栈上分配对象,还可以多次重复使用同一实例。如果这不是常见的用例,那么 #2 的简单性可能是一个优点。

当然,这只是我的观点:)


现在,这是一个有趣的评论。我听过很多人说恰恰相反,GTK+是一个糟糕的API。不幸的是,我只用过一点点,我通常都在使用C++和Gtkmm。我的经验记得引用计数指针和_new和_free函数,这似乎更符合第三个选项。我很想知道你对你的观点的原因。 - Thanatos
2
GLib/Gtk的一般设计哲学似乎是“我们原则上不使用C++,所以我们将手工编写所有相同的东西”。这种方法在某种意义上具有一些优点,因为它仍然是一个纯C API,这使得它更容易与各种仅限于C的FFI一起使用...但从纯C/C++的角度来看,它似乎相当不实用。 - Pavel Minaev

4
两种方法在功能上是等价的。但是,在我的看法中,方法2更容易使用。选择方法2而不是方法1的原因有几个:
  1. 更加直观。为什么我必须在使用myStruct_Destroy销毁对象后还要调用free呢?

  2. 隐藏了myStruct的细节,用户不必担心它的大小等问题。

  3. 在方法2中,myStruct_init不必担心对象的初始状态。

  4. 你不必担心用户忘记调用free导致内存泄漏。

然而,如果您的API实现作为单独的共享库发布,那么方法2是必须的。为了使您的模块与不同编译器版本之间malloc/newfree/delete的实现不发生冲突,您应该自己处理内存分配和释放。请注意,这对C++比C更为真实。


1
两者不相等,因为后者需要动态分配,而前者则不需要。 - Tom
好的...是的。应该说功能等价的。已更新。 - 341008

4
我对第一种方法的问题并不在于它对调用者来说更长,而是因为现在API被限制在能够扩展其使用的内存量上,因为它不知道接收到的内存是如何分配的。调用者并不总是预先知道它将需要多少内存(想象一下如果你试图实现一个向量)。
你没有提到的另一个选择,在大多数情况下会过度,就是传递一个函数指针,API使用它作为分配器。这不允许你使用堆栈,但允许你做一些像使用内存池替换malloc之类的事情,同时保持API控制何时要分配内存。
至于哪种方法是正确的API设计,C标准库两种方式都有。 strdup() 和 stdio 使用第二种方法,而sprintf和strcat使用第一种方法。个人而言,我更喜欢第二种方法(或第三种方法),除非1)我知道我永远不需要重新分配内存并且2)我期望我的对象的生命周期很短,因此使用堆栈非常方便。
编辑:实际上还有一种选择,以前有著名的先例,那就是像strtok()一样使用静态变量。不好,只是出于完整性而提到。

2
两种方式都可以,我倾向于使用第一种方式,因为我所做的大部分 C 语言工作都是针对嵌入式系统的,所有的内存都是堆栈上的小变量或静态分配的。这样就不会出现内存耗尽的情况,要么一开始就有足够的内存,要么从一开始就完蛋了。当你只有2K的RAM时,这是个好消息 :) 所以我的所有库都像#1一样,假设内存已经分配好了。
但这只是C开发的边缘案例。
话虽如此,我可能仍然会选择#1。也许使用init和finalize/dispose(而不是destroy)作为名称。

2

这可能会引起一些思考:

情况1模仿C ++的内存分配方案,具有更多或更少相同的好处:

  • 在堆栈上轻松分配临时变量(或在静态数组或类似的东西中编写自己的结构分配器以替换malloc)。
  • 如果初始化出现问题,则轻松释放内存

情况2隐藏了更多关于使用结构的信息,也可以用于不透明结构,通常当用户看到的结构与库内部使用的结构不完全相同时(例如,在结构末尾可能会隐藏一些更多的字段)。

情况#1和情况#2之间的混合API也很常见:有一个用于传递指向某个已初始化结构的指针的字段,如果它为空,则进行分配(并始终返回指针)。使用这种API时,即使执行了分配,释放通常也是调用者的责任。

在大多数情况下,我可能会选择情况#1。


1
我会选择(1),并进行一个简单的扩展,即使您的_init函数始终返回对象的指针。然后,您的指针初始化可能只需读取:
myStruct *s = myStruct_init(malloc(sizeof(myStruct)));

正如您所看到的,右侧仅仅引用了类型而不再引用变量。然后一个简单的宏至少部分地给出了(2)。

#define NEW(T) (T ## _init(malloc(sizeof(T))))

而你的指针初始化读取如下

myStruct *s = NEW(myStruct);

你如何处理malloc失败? - Secure
@Secure:很好的观点。我认为应该使_init函数能够处理传入NULL指针并在返回时将其直接传递。像往常一样,检查指针是否为空留给指针的用户来完成。 - Jens Gustedt
在这方面的另一种设计理念是,大多数函数应该期望有效指针(deallocator 显然除外)并断言它们不为空。这将使您的方法有效地使用 assert 来进行程序逻辑,这是绝对不可取的。当然,这取决于您的整个程序设计,但个人而言,我更喜欢明确处理错误。也就是说,在使用指针进行任何其他操作之前,需要单独使用 malloc 并测试其有效性。 - Secure
@Secure:我倾向于扩展惯例,检查由宏“NEW”返回的指针。这只是这种惯例的轻微扩展,因为您已经必须检查几个函数,不仅包括“malloc”,还包括“realloc”和“calloc”(以及我可能忘记的其他函数)。 - Jens Gustedt

1

两种方法都可以接受 - 正如您所指出的那样,它们之间存在权衡。

两种方法都有大量的现实世界示例 - 正如Dean Harding所说,GTK+使用第二种方法; OpenSSL是使用第一种方法的一个例子。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接