最近在我的C肌肉上工作,并浏览了我与之合作过的许多库,这无疑让我对什么是好的实践有了一个很好的了解。我还没有看到一个函数返回一个结构:
1
| something_t make_something() { ... } |
从我所吸收的内容来看,这是一种"正确"的方式:
1 2
| something_t *make_something() { ... }
void destroy_something(something_t *object) { ... } |
代码片段2中的体系结构远比代码片段1流行。所以现在我问,为什么我会直接返回一个结构,就像在代码片段1中那样?当我在这两个选项之间进行选择时,我应该考虑哪些差异?
此外,这个选项是如何比较的?
1
| void make_something(something_t *object) |
- 我看到的重要区别是复制与否以及堆与堆栈。
- 不要同时标记C和C++。两种语言的同一个问题的答案是非常不同的。挑一个。
- 我做了一些编辑,试图避免将此问题标记为基于意见。"最佳实践"可能是一条非常模糊的线,一个选项比另一个更好的想法可能是主观的。如果问题的编辑版本与你想知道的内容相差太远,请告诉我。
- @Dietrichepp谢谢!
- 如果结构太大,无法像正常返回值那样返回(例如,在寄存器中),那么绝大多数ABI都要求编译器将第一个窗体转换为第二个窗体,从而有效地传递make_something函数将填充的隐藏指针。因此,从对象代码的角度来看,这两个表单基本上是相同的,唯一的区别是您希望您的API在客户机上看起来是什么样的。出于这个原因,我绝大多数时间都会选择表格1,因为它非常简单。让编译器完成传递指针的脏工作。
- @科迪格雷:除了……ABI兼容性可以通过使用不透明类型(lundin的答案)来实现,这需要通过指针传递。当它重要的时候,它真的很重要。
- @iharob:复制是没有问题的;堆栈与堆是参数的一部分。
- @codygray这个问题现在被标记为C,而c没有abi,也没有对象代码透视图。
- 还有第三种模式,int make_something(something_t *object, int sizeof) { ... },调用者预先分配结构,函数用数据填充它。
- 还有编写透明代码的问题。任何做过大量C编程的人都应该期望函数返回指向某个正常行为的指针,而返回结构是我在stackexchange问题之外从未见过的。
当something_t很小(读:复制它和复制指针一样便宜)并且您希望在默认情况下对其进行堆栈分配时:
1 2 3 4 5 6
| something_t make_something (void);
something_t stack_thing = make_something ();
something_t *heap_thing = malloc(sizeof *heap_thing );
*heap_thing = make_something (); |
当something_t较大或希望对其进行堆分配时:
1 2 3
| something_t *make_something(void);
something_t *heap_thing = make_something(); |
不管something_t的大小,如果您不关心它的分配位置:
1 2 3 4 5 6 7
| void make_something (something_t *);
something_t stack_thing ;
make_something (&stack_thing );
something_t *heap_thing = malloc(sizeof *heap_thing );
make_something (heap_thing ); |
- 这条线索有很多好的答案。这个词用最少的词解释得最多。
- 除了正确考虑对象的大小外,后一个示例对于易于使用和简单性是一个很好的建议。也许您可以返回一个带有执行错误代码的int。
- 几乎太少的话。
- 您应该看看Lundin的答案:您省略了C库使用不透明类型的一个非常重要的原因,这可能是解释OP注意到的样式2流行的一个原因。
- 如果您选择了选项2,还需要提供free_something。
- @Matthieum.:我省略了关于不透明类型的讨论,因为它更为复杂,而样式2可以实现它,但是您仍然可以提供用于样式1或3的不透明类型。例如,SDL有一些结构,其中一些字段被记录为"供内部使用"——如果您知道自己在做什么,就可以使用它们,但是API提供了足够的功能,您在正常情况下不需要使用它们。
- @Orangedog:可能是的,特别是如果free_something需要做任何清理。但是您也可以说"make_something返回一个指针,它可以像一些标准库函数那样传递给free"。
- 样式3在相反的场景中也很有用,在这种情况下,实际上您确实关心对象的分配位置,可能是因为它的标识很重要。
- 样式3对我来说似乎是最C化的,但我认为大多数C库都会让make_something返回一个错误代码。
这几乎总是关于ABI的稳定性。库版本之间的二进制稳定性。在不存在的情况下,有时需要动态调整结构的大小。很少涉及到非常大的struct或性能。
在堆上分配struct并返回它的速度几乎与按值返回一样快,这是非常罕见的。struct必须是巨大的。
实际上,速度不是技术2背后的原因,而是指针返回,而不是值返回。
技术2用于ABI稳定性。如果您有一个struct,而您的下一个版本的库又添加了20个字段,那么您以前版本的库的使用者如果是手工构建的指针,那么它们是二进制兼容的。他们所知道的struct结束后的额外数据是他们不必知道的。
如果您在堆栈上返回它,调用方将为它分配内存,并且它们必须同意您对它的大小的看法。如果您的库自上次重建以来进行了更新,那么您将丢弃堆栈。
技术2还允许您隐藏返回指针之前和之后的额外数据(将数据附加到结构结尾的版本是的变体)。您可以用一个可变大小的数组结束结构,或者用一些额外的数据预先结束指针,或者两者兼而有之。
如果希望在稳定的ABI中堆栈分配struct,那么几乎所有与struct对话的函数都需要传递版本信息。
所以
1
| something_t make_something(unsigned library_version) { ... } |
其中,库使用library_version来确定预期返回的something_t的版本,并改变它操作的堆栈的大小。使用标准C是不可能的,但是
1
| void make_something(something_t* here) { ... } |
是。在这种情况下,something_t可能有一个version字段作为其第一个元素(或大小字段),您需要在调用make_something之前填充它。
其他采用something_t的库代码会查询version字段,以确定它们使用的something_t的版本。
根据经验,您不应该按值传递struct对象。实际上,只要它们小于或等于CPU在一条指令中可以处理的最大大小,就可以这样做。但在风格上,即使是在那时,人们通常也会避开它。如果从不按值传递结构,则可以稍后向结构添加成员,而这不会影响性能。
我认为void make_something(something_t *object)是使用C中结构的最常见方法。您将分配留给调用者。它效率高,但不漂亮。
然而,面向对象的C程序使用something_t *make_something(),因为它们是用不透明类型的概念构建的,这迫使您使用指针。返回的指针是否指向动态内存或其他内容取决于实现。不透明类型的OO通常是设计更复杂的C程序最优雅和最好的方法之一,但遗憾的是,很少有C程序员知道/关心它。
- 一个答案涉及不透明类型,这是ABI稳定性的基石。谢谢您,先生。
- -1表示"小于或等于CPU在一条指令中可以处理的最大大小"。malloc接受的不仅仅是一条指令。在通过引用传递结构(因为这取决于它的使用方式)而不是堆分配结构之前,没有确切的方法来确定它必须有多大,但是在实践中,对于大多数用例来说,它比sizeof(void*)大得多。例如,大多数游戏将按值传递4x4矩阵。
- @当你说"面向对象"时,你指的是C建立之后流行的编程范式,还是其他什么?我不是说在C语言中使用OOP是不可能的,我只是想确保我在考虑正确的概念。
- @Gordongustafson面向对象是一种公认的正确设计程序的好方法。语言的选择无关紧要。OO由三个部分组成:实现和数据的私有封装(有些重要),模块化编程,其中每个类都是自治的,只涉及其自己的指定用途(非常重要),以及具有/不具有多态性的继承(有时可能有用)。即使是由优秀程序员编写的古代C程序也使用了一种面向对象的方法,尽管这些类当时被称为"ADT"。
- 在C中实现纯私有封装的唯一方法是通过不透明类型(使用static数据可以实现半arsed版本,但这不允许类的多个实例,也不允许线程安全)。不透明类型也可以用来实现继承和多态性。
第一种方法的一些优点:
- 少写代码。
- 更习惯于返回多个值的用例。
- 在没有动态分配的系统上工作。
- 对于小物体或小物体来说可能更快。
- 无内存泄漏,因为忘记了free。
一些缺点:
- 如果对象很大(例如,兆字节),可能会导致堆栈溢出,或者如果编译器不能很好地优化它,可能会很慢。
- 可能会让那些在70年代学过C的人大吃一惊,当时这是不可能的,而且还没有跟上时代。
- 不适用于包含指向自身一部分的指针的对象。
- "可能会让70年代学过C的人大吃一惊,当时这是不可能的,而且还没有跟上时代。"这不是件好事吗?:)
- "由于忘记释放而没有内存泄漏"-内存泄漏更容易实现,只需在生成和销毁之间添加一些返回/中断/转到语句。
我有点惊讶。
不同之处在于,示例1在堆栈上创建一个结构,示例2在堆上创建它。在C或C++代码中,C是有效的,在堆上创建大多数对象是习惯和方便的。在C++中,它不是,主要是在堆栈上运行。原因是,如果您在堆栈上创建一个对象,析构函数会自动调用,如果您在堆栈上创建它,则必须显式调用它。因此,确保没有内存泄漏和处理异常会更容易,因为堆栈上的所有内容都在进行。在C中,无论如何都必须明确地调用析构函数,并且没有特殊析构函数的概念(当然,您有析构函数,但它们只是具有destroy_MyObject()等名称的普通函数)。
现在C++中的异常是用于低级容器对象,例如向量、树、散列映射等等。它们确实保留堆成员,并且它们有析构函数。现在,大多数重内存对象都由一些直接的数据成员组成,这些成员给出大小、ID、标记等,然后是STL结构中的其余信息,可能是像素数据的矢量,也可能是英文单词/值对的映射。所以大多数数据实际上是堆,甚至在C++中。
而现代C++是这样设计的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class big
{
std::vector<double> observations; // thousands of observations
int station_x; // a bit of data associated with them
int station_y;
std::string station_name;
}
big retrieveobservations(int a, int b, int c)
{
big answer;
// lots of code to fill in the structure here
return answer;
}
void high_level()
{
big myobservations = retriveobservations(1, 2, 3);
} |
将编译成相当有效的代码。大型观察成员不会生成不必要的临时副本。
- 要说C++使用堆小于C是愚蠢的。首先,raii并不一定意味着本地类不使用堆,它只是意味着它不容易泄漏内存。如果它不使用堆,那么为什么需要析构函数?"3法则。几乎每个C++标准库容器都使用堆,包括STD::String。C和C++之间的一个主要区别是,在C中,只需要在需要时使用堆,而在C++中,你经常会在不知道的情况下使用它。这实际上是C++对嵌入式系统开发的主要原因之一。
与其他一些语言(如Python)不同,C没有元组的概念。例如,以下内容在python中是合法的:
1 2 3 4 5
| def foo():
return 1,2
x,y = foo()
print x, y |
函数foo返回两个作为元组的值,分配给x和y。
因为C没有元组的概念,所以从一个函数返回多个值是不方便的。解决此问题的一种方法是定义一个结构来保存值,然后返回该结构,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| typedef struct { int x , y ; } stPoint ;
stPoint foo ( void )
{
stPoint point = { 1, 2 };
return point ;
}
int main ( void )
{
stPoint point = foo ();
printf("%d %d
", point. x, point. y );
} |
这只是一个示例,您可能会看到一个函数返回一个结构。
- 好的,这很好,但是在返回类型的所有差异之间,总是取决于偏好吗?
- 这不能回答问题。返回多个值也可以通过返回指向具有多个成员的结构的指针来实现。这个问题被认为是理所当然的,为什么返回一个结构,并将其移过这个结构,以询问通过值或指针返回结构之间的选择是由哪些因素决定的。这根本不是为什么我们会首先返回一个结构。
- @用户3386109在堆栈上返回结构的"不便"不是真实的。如一个更好的答案所解释的,它取决于结构的大小。在没有指针参数的情况下,小的可以很容易地"复制"到堆栈中或从堆栈中取出,而大的则除了需要更多的堆栈空间外,还需要更多的"工作"来将它们弹出。