在C中通过值传递结构而不是传递指针是否有不利之处?


157

在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();

出于某种原因,我认为大多数人会本能地使inspect_data采用指向struct blob的指针,但我不明白为什么。它仍然有一个指针和一个整数,更清楚的是它们在一起。在get_data的情况下,不可能以我之前描述的方式弄乱,因为长度没有输入值,并且必须有返回的长度。


对于它的价值,void examine data(const struct blob)是不正确的。
克里斯·卢茨

谢谢,将其更改为包含变量名。
dkagedal 2011年

1
“从上面没有办法告诉我们,不允许get_data函数查看len指向的内容。也没有任何方法可以使编译器检查该指针实际上返回了一个值。” -这对我完全没有意义(可能是由于最后两行出现在函数外部,因此您的示例是无效的代码);请您详细说明一下?
亚当·斯皮尔斯

2
该函数下面的两行用来说明如何调用该函数。函数签名没有暗示实现只应写入指针这一事实。而且编译器无法知道应该验证将值写入指针,因此返回值机制只能在文档中进行描述。
dkagedal

1
人们在C语言中不经常这样做的主要原因是历史原因。在C89之前,您无法按值传递或返回结构,因此所有早于C89并在逻辑上应该做到的系统接口(例如gettimeofday)都应使用指针,而人们以它为例。
zwol

Answers:


202

对于较小的结构(例如,点,矩形),按值传递是完全可以接受的。但是,除了速度之外,还有另一个原因要引起您注意,必须按值传递/返回大型结构:堆栈空间。

很多C编程都是针对嵌入式系统的,这些系统的内存非常宝贵,堆栈大小可能以KB甚至字节为单位进行度量...如果按值传递或返回结构,则这些结构的副本将被放置在堆栈,可能导致此站点以...命名的情况

如果我看到一个应用程序似乎过多地使用了堆栈,那么按值传递的结构是我首先要寻找的东西之一。


2
“如果您按值传递或返回结构,则这些结构的副本将被放置在堆栈中。”我会称其为braindead的任何工具链都可以这样做。是的,可悲的是有这么多人会这样做,但是C标准并不需要什么。精明的编译器将优化所有内容。
恢复莫妮卡

1
@KubaOber这就是为什么这样做不经常执行的原因:stackoverflow.com/questions/552134/…–
Roddy

1
是否存在将小结构与大结构分隔开的确定线?
乔西·汤普森

63

不这样做的一个未提及的原因是,这可能会导致二进制兼容性很重要的问题。

根据所使用的编译器,可以根据堆栈选项或实现通过堆栈或寄存器传递结构。

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

-fpcc-struct-return

-freg-struct-return

如果两个编译器不同意,事情可能会崩溃。不用说,未说明这样做的主要原因是堆栈消耗和性能原因。


4
这就是我一直在寻找的答案。
dkagedal

2
的确如此,但这些选择与按值传递无关。它们与返回的结构有关,这完全是另一回事。通过参考归还物品通常是双脚射击的必经之路。int &bar() { int f; int &j(f); return j;};
罗迪

19

真正回答这个问题,需要深入了解组装领域:

(以下示例在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的竞争环境都是这样)。我不是组装向导,因此欢迎任何评论!


6
计算指令的数量并不是很有趣,除非您可以显示出巨大的差异,或者计算更有趣的方面,例如难以预测的跳转数量等等。实际的性能属性比指令的数量要微妙得多。 。
dkagedal,2010年

6
@dkagedal:是的。回想起来,我认为我自己的答案写得很差。尽管我不是很在意指令的数量(不知道是什么给您带来了:P印象),但实际上要指出的是,对于小类型,按值传递结构优于按引用传递。无论如何,首选通过值传递,因为它更简单(无需const费时费力,无需担心有人更改数据或一直都在担心),而且我发现按值传递没有太多的性能损失(如果没有获得收益)与许多人的看法相反。
kizzx2

15

一个简单的解决方案是将错误代码作为返回值返回,而将其他所有内容作为函数中的参数返回
。当然,该参数可以是结构,但看不到通过值传递此值的任何特殊优势,只是发送了一个指针。
通过值传递结构是危险的,您需要非常小心传递的内容是什么,请记住在C中没有复制构造函数,如果结构参数之一是指针,则指针值将被复制,这可能非常令人困惑且难以理解。保持。

只是为了完整地回答问题(对Roddy的信任),堆栈使用是不按值传递结构的另一个原因,相信我调试堆栈溢出是真正的PITA。

重播评论:

通过指针传递struct意味着某些实体对该对象拥有所有权,并且完全了解何时应该发布什么。按值传递struct会创建一个对struct内部数据的隐藏引用(指向另一个结构等的指针),这在此情况下很难维护(可能,但为什么呢?)。


6
但是传递指针并不只是“危险的”,仅仅是因为您将其放入了结构中,所以我不买它。
dkagedal

复制包含指针的结构的重点。这一点可能不是很明显。对于不知道他指的是那些人,请进行深层副本与浅层副本的搜索。
zooropa

1
C函数约定之一是在输入参数之前首先列出输出参数,例如int func(char * out,char * in);
zooropa

您是说,例如getaddrinfo()如何将输出参数放在最后?:-)有上千种约定,您可以选择任意一种。
dkagedal

10

到目前为止,这里人们忘记提及的一件事(或者我忽略了它)是结构通常具有填充!

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

每个字符为1个字节,每个短为2个字节。结构有多大?不,不是6个字节。至少没有在任何更常用的系统上。在大多数系统上,它是8。问题是,对齐方式不是恒定的,它取决于系统,因此相同的结构在不同的系统上将具有不同的对齐方式和不同的大小。

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


2
是的,但是存在填充并不依赖于通过值或引用传递结构。
伊利亚

2
@dkagedal:您不理解“不同系统上的不同大小”的哪一部分?只是因为您的系统上就是这种方式,所以您假定它必须与其他任何系统都相同-这就是为什么您不应该按值传递的原因。更改了样本,因此它也无法在您的系统上运行。
麦基

2
我认为Mecki关于struct padding的评论尤其适用于堆栈大小可能成为问题的嵌入式系统。
zooropa

1
我猜想参数的另一面是,如果您的结构是一个简单的结构(包含几个原始类型),则按值传递将使编译器可以使用寄存器来处理它-而如果使用指针,则最终结果会内存,速度较慢。如果这些技巧中的任何一个很重要,那将变得非常底层,并且很大程度上取决于您的目标体系结构。
kizzx2

1
除非您的结构很小,或者您的CPU有许多寄存器(而Intel CPU没有),否则数据将最终存储在堆栈中,这也是内存,并且与其他任何内存一样快/慢。另一方面,指针总是很小,只是一个指针,而指针经常使用时,指针本身通常总是以寄存器结尾。
梅基

9

我认为您的问题已经很好地总结了一切。

通过值传递结构的另一个优点是内存所有权是明确的。毫不奇怪该结构是否来自堆,以及谁负责释放它。


9

我会说按值传递(不是太大)结构(无论是作为参数还是作为返回值)都是一种完全合法的技术。当然,必须注意该结构是POD类型,或者复制语义已明确指定。

更新:抱歉,我的C ++思维上限很高。我记得有一次在C中从函数返回结构是不合法的,但是从那时起这可能已经改变了。只要您希望使用的所有编译器都支持这种做法,我仍然会说它是有效的。


请注意,我的问题是关于C,而不是C ++。
dkagedal

从函数返回结构只是没有用,是有效的:)
Ilya

1
我喜欢llya的建议,将return用作错误代码和参数,以从函数中返回数据。
zooropa

8

这里没有人提及:

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
}

a的成员const structconst,但是如果该成员是一个指针(如char *),则它变为char *const而不是const char *我们真正想要的。当然,我们可以假设const意图文档是,并且任何违反此意图的人都在编写错误的代码(它们确实是),但是对于某些人(尤其是那些花了四个小时来追查原因的人)来说,这还不够好。崩溃)。

替代方法可能是制作一个struct const_blob { const char *c; size_t l }并使用它,但这很杂乱-它遇到了我与typedefing指针相同的命名方案问题。因此,大多数人坚持只具有两个参数(或者在这种情况下更可能使用字符串库)。


是的,这是完全合法的,而且有时候您也想做。但是我同意这是结构解决方案的局限性,您不能使它们指向的指针指向const。
dkagedal 2011年

struct const_blob解决方案的一个棘手问题是,即使const_blob成员的区别blob仅在于“间接常数”,出于严格的别名规则的目的,struct blob*a的类型也struct const_blob*将被认为是不同的。因此,如果代码将a强制转换blob*为a const_blob*,则随后任何使用一种类型的对底层结构的写入都将使其他类型的现有指针无声地失效,从而任何使用都将调用Undefined Behavior(这通常是无害的,但可能是致命的) 。
超级猫

5

http://www.drpaulcarter.com/pcasm/上的PC汇编教程的第150页对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

调用创建前的堆栈:

        +---------------------------+
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规定(非导出或内联函数除外)。顺便说一句,内联函数可能是返回结构最有用的地方之一。
2011年

感谢您的更正。有关呼叫约定的完整详细信息,请参见en.wikipedia.org/wiki/Calling_convention
姚靖国

@dkagedal:重要的不仅是x86恰好以这种方式执行操作,而是存在一种“通用”方法(即该方法),该方法将允许任何平台的编译器支持不符合以下条件的任何结构类型的返回:如此之大,以至于无法击倒筹码。尽管用于许多平台的编译器将使用其他更有效的方式来处理某些结构类型的返回值,但无需将语言限制结构返回类型为平台可以最佳处理的语言。
超级猫

0

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.