在C语言中,通过值传递结构体与通过指针传递结构体相比,有什么缺点吗?

191
在C语言中,通过值传递结构体会有什么缺点吗?如果结构体很大,那么显然会涉及到复制大量数据的性能问题,但对于较小的结构体,这与将多个值传递给函数基本相同。当其作为返回值时,甚至更有趣。C语言只有单一的返回值,但通常需要多个返回值,因此一个简单的解决方案是将它们放入结构体中并返回。是否有任何支持或反对这种方式的理由呢?由于这里谈论的内容可能不是每个人都很清楚,所以我将举一个简单的例子。如果你在使用C语言编程,迟早会开始编写看起来像这样的函数:
void examine_data(const char *ptr, size_t len)
{
    ...
}

char *p = ...;
size_t l = ...;
examine_data(p, l);

这不是问题。唯一的问题在于,您必须与同事一致同意参数的顺序,以便在所有函数中使用相同的约定。

但是当你想要返回相同类型的信息时会发生什么呢?通常会得到类似以下内容:

char *get_data(size_t *len);
{
    ...
    *len = ...datalen...;
    return ...data...;
}
size_t len;
char *p = get_data(&len);

这种做法看起来没问题,但实际上存在很多问题。返回值应该是一个返回值,但是在这个实现中却不是。从以上内容中我们无法得知函数get_data不能查看len指向的内容。而且编译器也没有检查返回值是否通过指针正确地返回。因此,下个月当有人不理解代码就进行修改(可能是因为他没有阅读文档?)时,在没有引起注意的情况下就会出现故障或者随机崩溃。

因此,我提出的解决方案是使用简单的结构体。

struct blob { char *ptr; size_t len; }

这些示例可以重写为:

void examine_data(const struct blob data)
{
    ... use data.tr and data.len ...
}

struct blob = { .ptr = ..., .len = ... };
examine_data(blob);

struct blob get_data(void);
{
    ...
    return (struct blob){ .ptr = ...data..., .len = ...len... };
}
struct blob data = get_data();

由于某种原因,我认为大多数人会本能地使examine_data函数接受一个指向结构体blob的指针,但我不知道为什么会这样。它仍然接受一个指针和一个整数,只是更清晰地表明它们在一起。而在get_data的情况下,无法像之前描述的那样搞砸,因为没有长度的输入值,必须返回一个长度。


就此而言,“void examine data(const struct blob)”是不正确的。 - Chris Lutz
谢谢,我已经修改了它以包含变量名。 - dkagedal
2
从上面的代码中无法判断函数get_data是否被禁止查看len指向的内容。而且编译器也没有检查该指针是否确实返回了一个值,这使得这段代码毫无意义(可能是因为最后两行出现在函数外部导致代码无效)。请问您能否详细说明一下? - Adam Spiers
4
函数下面的两行是为了说明如何调用该函数。函数签名并没有暗示实现只会写入指针的事实。编译器也无法知道它应该验证一个值是否被写入指针,因此返回值机制只能在文档中描述。 - dkagedal
3
C语言中人们不经常这样做的主要原因是历史原因。在C89之前,你无法通过值传递或返回结构体,所以所有早于C89的系统接口在逻辑上应该这样做(比如 gettimeofday),但实际上使用指针代替,人们就把这个当成了例子。 - zwol
传递结构体时,以C结构体格式传递的值是否会被复制到内存中?我需要通过值传递类似于字符串类的东西,它有一个光标和缓冲区指针。我不能有任何额外的指令。假设在函数中有3个char,除非我们使用硬件辅助堆栈,否则它们将直接放入寄存器中而不保存到RAM中,在这种情况下,寄存器将保存到堆栈中而不是以C结构体格式。带有3个char的结构体是否与在堆栈上创建的3个char*相同?我认为在没有指示的情况下,结构体不会被写入RAM。 - user2356685
11个回答

243

对于小的结构体(例如点、矩形),按值传递是完全可接受的。但是,除了速度之外,还有一种原因说明为什么您应该小心通过值传递/返回大的结构体:堆栈空间。

许多 C 编程是针对嵌入式系统的,其中内存非常紧缺,并且堆栈大小可能以 KB 甚至字节来衡量…… 如果您通过值传递或返回结构体,则这些结构体的副本将被放置在堆栈上,可能导致此网站就是以此命名的情况发生...

如果我看到一个似乎使用堆栈过多的应用程序,则首先要查找传递的结构体是否是按值传递的。


5
如果你通过值传递或返回结构体,那么这些结构体的副本将被放置在堆栈上。我认为任何这样做的工具链都很愚蠢。是的,很遗憾,许多工具链会这样做,但这不是C标准所要求的。一个明智的编译器将优化掉所有这些操作。 - Kuba hasn't forgotten Monica
7
这就是为什么这种做法不经常被使用的原因:https://dev59.com/3HRB5IYBdhLWcg3wr44U - Roddy
6
有没有一个明确的界限将小结构体和大结构体区分开来? - Josie Thompson
1
如果在函数内部通过引用访问结构体,与直接访问它(如果按值传递)相比,会对性能产生什么影响?我的意思是,通过按值传递相对较小的结构体应该有性能优势。 - Illya S
不错啊,现在我知道什么是“堆栈溢出”了。 - programmerRaj
显示剩余2条评论

72

没有提到的一个不做这件事的原因是,它可能会导致二进制兼容性问题。

根据编译器使用情况,结构体可以通过堆栈或寄存器传递,具体取决于编译器选项/实现方式。

参见:http://gcc.gnu.org/onlinedocs/gcc/Code-Gen-Options.html

-fpcc-struct-return

-freg-struct-return

如果两个编译器意见不一致,事情就会爆炸。不用说,不这样做的主要原因是堆栈消耗和性能问题。


5
这正是我所期望的答案。 - dkagedal
7
没错,但是这些选项与按值传递无关,它们与返回结构体有关,这完全是另一回事。通过引用返回东西通常是自讨苦吃的。例如int &bar() { int f; int &j(f); return j;}; - Roddy

25

要真正回答这个问题,需要深入研究汇编语言:

(以下示例在x86_64上使用gcc。欢迎任何人添加其他架构,如MSVC,ARM等)

让我们来看一个示例程序:

// foo.c

typedef struct
{
    double x, y;
} point;

void give_two_doubles(double * x, double * y)
{
    *x = 1.0;
    *y = 2.0;
}

point give_point()
{
    point a = {1.0, 2.0};
    return a;
}

int main()
{
    return 0;
}

使用完整的优化进行编译

gcc -Wall -O3 foo.c -o foo

看看汇编代码:

objdump -d foo | vim -

这是我们得到的:

0000000000400480 <give_two_doubles>:
    400480: 48 ba 00 00 00 00 00    mov    $0x3ff0000000000000,%rdx
    400487: 00 f0 3f 
    40048a: 48 b8 00 00 00 00 00    mov    $0x4000000000000000,%rax
    400491: 00 00 40 
    400494: 48 89 17                mov    %rdx,(%rdi)
    400497: 48 89 06                mov    %rax,(%rsi)
    40049a: c3                      retq   
    40049b: 0f 1f 44 00 00          nopl   0x0(%rax,%rax,1)

00000000004004a0 <give_point>:
    4004a0: 66 0f 28 05 28 01 00    movapd 0x128(%rip),%xmm0
    4004a7: 00 
    4004a8: 66 0f 29 44 24 e8       movapd %xmm0,-0x18(%rsp)
    4004ae: f2 0f 10 05 12 01 00    movsd  0x112(%rip),%xmm0
    4004b5: 00 
    4004b6: f2 0f 10 4c 24 f0       movsd  -0x10(%rsp),%xmm1
    4004bc: c3                      retq   
    4004bd: 0f 1f 00                nopl   (%rax)

除去 nopl 补齐,give_two_doubles() 函数占据27字节,而 give_point() 占据29字节。然而,give_point()give_two_doubles() 使用更少的指令。

有趣的是,我们可以看到编译器已经能够将 mov 优化为更快的 SSE2 变体 movapdmovsd。此外,give_two_doubles() 实际上需要在内存中读写数据,这会使速度变慢。

显然,在嵌入式环境中,这些内容可能不适用(C 语言主要用于嵌入式领域)。我不是汇编专家,欢迎任何意见!


9
仅仅计算指令数量并不是很有趣,除非你能展示出巨大的差异,或者计算出更加有趣的方面,比如难以预测的跳转次数等。实际性能特性远比指令数量更加微妙。 - dkagedal
8
@dkagedal: 说得对。回想起来,我认为自己的回答写得非常糟糕。虽然我并没有太关注指令数量(不知道是什么给了你这样的印象:P),但实际要点是将结构体按值传递比按引用传递更可取,尤其是对于小类型。总之,按值传递更受欢迎,因为它更简单(没有生命周期难题,不需要担心别人一直修改你的数据或const),而且我发现按值复制没有太多性能惩罚(如果不是增益),这与许多人可能相信的相反。 - kizzx2

18

这里之前大家忘了提到的一件事(或者我没看到)是结构体通常有填充!

struct {
  short a;
  char b;
  short c;
  char d;
}

每个字符占用1个字节,每个short占用2个字节。这个结构体有多大呢?不是6个字节,至少在任何常用系统上都不是。在大多数系统上,它将为8个字节。问题在于,对齐不是恒定的,而是依赖于系统,因此相同的结构体在不同的系统上具有不同的对齐和大小。

不仅如此,填充还将进一步消耗您的堆栈,并增加了无法预测填充的不确定性,除非您知道您的系统如何进行填充,然后查看您应用程序中的每个结构体并计算其大小。传递指针需要可预测的空间 - 没有不确定性。指针的大小对于系统来说是已知的,它始终是相等的,无论结构体的外观如何,并且指针大小总是选择为它们对齐并且不需要填充。


2
是的,但填充存在并不依赖于按值传递结构还是按引用传递。 - Ilya
2
@dkagedal:你不理解“在不同系统上大小不同”的哪一部分?仅仅因为在你的系统上是这样,你就认为其他任何系统都是一样的 - 这正是你不应该传递值的原因。我改变了示例,以便它在你的系统上也失败了。 - Mecki
2
我认为Mecki关于结构体填充的评论是相关的,特别是对于堆栈大小可能成为问题的嵌入式系统。 - zooropa
1
我猜争论的另一面是,如果你的结构体是一个简单的结构体(包含几个原始类型),按值传递将使编译器能够使用寄存器来处理它--而如果你使用指针,事情最终会进入内存,这会更慢。这变得非常低级,并且很大程度上取决于您的目标架构,如果这些细节有任何影响。 - kizzx2
1
除非你的结构体很小或你的CPU有很多寄存器(Intel CPU没有),否则数据最终会在堆栈上,这也是内存而且与其他内存一样快/慢。另一方面,指针始终很小,只是一个指针,当经常使用时,指针本身通常会最终在寄存器中。 - Mecki
显示剩余9条评论

15
一个简单的解决方案是将错误代码作为返回值,并将其余内容作为函数参数,此参数当然可以是结构体,但不建议以传值方式传递,只需传递指针即可。
以值传递结构体很危险,需要非常小心,记住C语言没有复制构造函数,如果结构体参数中有一个指针,指针的值会被复制,这可能非常令人困惑且难以维护。Roddy的完整答案提到了另一个原因:堆栈使用问题,这也是不传递结构体的另一个原因,相信我,调试堆栈溢出真的很麻烦。
回复评论:
结构体通过指针传递意味着某个实体对此对象拥有所有权,并完全知道何时可以及何时释放。以值传递结构体会导致对结构体内部数据的隐藏引用(例如指向其他结构体的指针等),这很难维护(虽然可能有解决方法,但为什么要这样做?)。

7
把指针放到结构体里并不会让传递指针变得更加“危险”,所以我不认同这种说法。 - dkagedal
1
复制包含指针的结构体确实是个好点子。这一点可能不是很明显。对于那些不知道他在说什么的人,请搜索深拷贝与浅拷贝。 - zooropa
1
C语言函数约定之一是在输入参数前先列出输出参数,例如:int func(char* out, char *in); - zooropa
1
你的意思是像 getaddrinfo() 这样,把输出参数放在最后面吗? :-) 有很多种约定,你可以选择任何一种。 - dkagedal

11

这里有一件之前没有人提到的事情:

void examine_data(const char *c, size_t l)
{
    c[0] = 'l'; // compiler error
}

void examine_data(const struct blob blob)
{
    blob.ptr[0] = 'l'; // perfectly legal, quite likely to blow up at runtime
}

const struct的成员是const类型的,但如果该成员是指针(例如char *),则它会变为char *const而不是真正需要的const char *。当然,我们可以假设const是意图说明文档,并且任何违反此规则的人都在编写糟糕的代码(实际上确实如此),但对于某些人来说这不够好(特别是那些花费四个小时追踪崩溃原因的人)。

另一个选择可能是创建一个struct const_blob { const char *c; size_t l }并使用它,但这显得有些混乱——它涉及到我用typedef指针时遇到的同样的命名方案问题。因此,大多数人只使用两个参数(或更可能的情况是使用一个字符串库)。


是的,这是完全合法的,有时也是您想要做的事情。但我同意,结构体解决方案的限制在于您无法使它们指向的指针指向const。 - dkagedal
struct const_blob 解决方案的一个棘手问题是,即使 const_blob 的成员与 blob 只在“间接常量性”方面不同,类型 struct blob*struct const_blob* 将被视为严格别名规则目的上的不同。因此,如果代码将 blob* 强制转换为 const_blob*,任何后续对使用一种类型写入底层结构的操作都会静默地使另一种类型的任何现有指针无效,从而导致任何使用都会调用未定义行为(这通常可能是无害的,但也可能是致命的)。 - supercat

9

我认为传递(不太大的)结构体作为参数和返回值是一种完全合法的技术。当然,必须注意结构体要么是POD类型,要么复制语义已经明确定义。

更新:抱歉,我一开始考虑的是C ++。我记得以前在C中无法从函数返回结构体,但这可能已经改变了。只要您期望使用的所有编译器都支持该做法,我仍然认为它是有效的。


1
请注意,我的问题是关于C语言,而不是C++。 - dkagedal
从函数返回结构体是有效的,只是没有用 :) - Ilya
1
我喜欢Illya的建议,使用返回作为错误代码和参数来从函数中返回数据。 - zooropa

9

我认为你的问题已经很好地概括了事情的情况。

将结构体按值传递的另一个优点是内存所有权显式。不会疑惑这个结构体是否来自堆,并且谁有责任释放它。


6

PC Assembly Tutorial的第150页(http://www.drpaulcarter.com/pcasm/)清晰地说明了C语言如何返回一个结构体:

C语言也允许将结构体作为函数的返回值,但是很明显结构体无法通过EAX寄存器返回。不同的编译器处理这种情况的方式各不相同。常见的解决方案是将该函数转换为一个接受结构体指针作为参数的函数,并使用该指针将返回值放入定义在调用函数外部的结构体中。

我使用以下C代码来验证上述内容:

struct person {
    int no;
    int age;
};

struct person create() {
    struct person jingguo = { .no = 1, .age = 2};
    return jingguo;
}

int main(int argc, const char *argv[]) {
    struct person result;
    result = create();
    return 0;
}

使用"gcc -S"来为这段C代码生成汇编代码:
    .file   "foo.c"
    .text
.globl create
    .type   create, @function
create:
    pushl   %ebp
    movl    %esp, %ebp
    subl    $16, %esp
    movl    8(%ebp), %ecx
    movl    $1, -8(%ebp)
    movl    $2, -4(%ebp)
    movl    -8(%ebp), %eax
    movl    -4(%ebp), %edx
    movl    %eax, (%ecx)
    movl    %edx, 4(%ecx)
    movl    %ecx, %eax
    leave
    ret $4
    .size   create, .-create
.globl main
    .type   main, @function
main:
    pushl   %ebp
    movl    %esp, %ebp
    subl    $20, %esp
    leal    -8(%ebp), %eax
    movl    %eax, (%esp)
    call    create
    subl    $4, %esp
    movl    $0, %eax
    leave
    ret
    .size   main, .-main
    .ident  "GCC: (Ubuntu 4.4.3-4ubuntu5) 4.4.3"
    .section    .note.GNU-stack,"",@progbits

调用 create 前的堆栈:

        +---------------------------+
ebp     | saved ebp                 |
        +---------------------------+
ebp-4   | age part of struct person | 
        +---------------------------+
ebp-8   | no part of struct person  |
        +---------------------------+        
ebp-12  |                           |
        +---------------------------+
ebp-16  |                           |
        +---------------------------+
ebp-20  | ebp-8 (address)           |
        +---------------------------+

调用 create 后的堆栈:
        +---------------------------+
        | ebp-8 (address)           |
        +---------------------------+
        | return address            |
        +---------------------------+
ebp,esp | saved ebp                 |
        +---------------------------+

2
这里有两个问题。最明显的一个是,这根本没有描述“C如何允许函数返回结构体”。这只描述了在32位x86硬件上如何实现,而这恰好是在寄存器数量等方面最受限制的架构之一。第二个问题是,C编译器为返回值生成代码的方式是由ABI(除非是未导出或内联函数)决定的。顺便说一下,在线性函数可能是返回结构体最有用的地方之一。 - dkagedal
感谢您的更正。关于调用约定的完整详细信息,请参考http://en.wikipedia.org/wiki/Calling_convention。 - Jingguo Yao
@dkagedal:重要的不仅仅是x86恰好以这种方式执行操作,而是存在一种“通用”的方法(即此方法),可以使任何平台的编译器支持返回任何结构类型,只要它不太大以至于会炸掉堆栈。虽然许多平台的编译器将使用其他更有效的方法来处理某些结构类型的返回值,但语言无需将结构返回类型限制为平台可以最优处理的类型。 - supercat

0

我想指出传递结构体的值的一个优点是,优化编译器可以更好地优化您的代码。


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