我什么时候应该按值传递或 return 结构?

When should I pass or return a struct by value?

在 C 中,结构可以按值 passed/returned 或按引用(通过指针)passed/returned。

普遍的共识似乎是,在大多数情况下,前者可以应用于小型结构而不会受到惩罚。参见 Is there any case for which returning a structure directly is good practice? and Are there any downsides to passing structs by value in C, rather than passing a pointer?

并且从速度和清晰度的角度来看,避免取消引用都是有益的。但是什么才算是呢?我想我们都同意这是一个小结构:

struct Point { int x, y; };

我们可以相对不受惩罚地传递价值:

struct Point sum(struct Point a, struct Point b) {
  return struct Point { .x = a.x + b.x, .y = a.y + b.y };
}

Linux 的 task_struct 是一个大结构:

https://github.com/torvalds/linux/blob/b953c0d234bc72e8489d3bf51a276c5c4ec85345/include/linux/sched.h#L1292-1727

我们希望不惜一切代价避免放入堆栈(尤其是那些 8K 内核模式堆栈!)。但是中等的呢?我假设小于寄存器的结构很好。但是这些呢?

typedef struct _mx_node_t mx_node_t;
typedef struct _mx_edge_t mx_edge_t;

struct _mx_edge_t {
  char symbol;
  size_t next;
};

struct _mx_node_t {
  size_t id;
  mx_edge_t edge[2];
  int action;
};

什么是最好的经验法则 来确定一个结构是否足够小以至于可以安全地按值传递它(缺少一些情有可原的情况,例如一些深度递归) ?

最后请不要告诉我需要分析。当我太 lazy/it 不值得进一步调查时,我要求使用启发式方法。

编辑:根据目前的答案,我有两个后续问题:

  1. 如果结构实际上比指向它的指针小怎么办?

  2. 如果浅拷贝是所需的行为(被调用的函数无论如何都会执行浅拷贝)怎么办?

编辑:不知道为什么这被标记为可能重复,因为我实际上 link 我问题中的另一个问题。我要求澄清什么是 small 结构,我很清楚大多数时候结构应该通过引用传递。

我的经验,近 40 年的实时嵌入式,最近 20 年使用 C;是最好的方法是传递一个指针。

无论哪种情况,都需要加载结构的地址,然后需要计算感兴趣字段的偏移量...

传递整个结构时,如果不是引用传递, 然后

  1. 没有入栈
  2. 它被复制,通常是通过隐藏调用 memcpy()
  3. 它被复制到现在'reserved'的一段内存中 并且对程序的任何其他部分不可用。

当按值返回结构时存在类似的注意事项。

然而,"small" 结构, 可以完全保存在一个工作寄存器中到两个 在这些寄存器中传递 特别是如果使用某些级别的优化 在编译语句中。

考虑的细节'small' 取决于编译器和 底层硬件架构。

如何将结构传入或传出函数取决于目标平台的应用程序二进制接口 (ABI) 和过程调用标准(PCS,有时包含在 ABI 中)(CPU/OS,对于有些平台可能会有多个版本)。

如果 PCS 实际上允许在寄存器中传递结构,这不仅取决于它的大小,还取决于它在参数列表中的位置和前面参数的类型.例如,ARM-PCS (AAPCS) 将参数打包到前 4 个寄存器中,直到它们已满,然后将更多数据传递到堆栈上,即使这意味着参数被拆分(如果有兴趣,全部简化:文档可从 ARM 免费下载).

对于返回的结构体,如果不通过寄存器传递,大多数PCS会由调用者在栈上分配space,并将指向结构体的指针传递给被调用者(隐式变体)。这与调用者中的局部变量相同,并为被调用者显式传递指针。但是,对于隐式变体,结果必须复制到另一个结构,因为无法获得对隐式分配结构的引用。

一些 PCS 可能对参数结构做同样的事情,其他的只是使用与标量相同的机制。无论如何,您可以推迟此类优化,直到您真正知道需要它们为止。另请阅读目标平台的 PCS。请记住,您的代码在不同平台上的性能可能更差。

注意:现代 PCS 不使用通过全局临时变量传递结构,因为它不是线程安全的。然而,对于一些小型微控制器架构,这可能有所不同。大多数情况下,如果他们只有少量堆栈(S08)或受限功能(PIC)。但是在大多数情况下,结构也不会在寄存器中传递,强烈建议使用指针传递。

如果只是为了原件的不变性:传一个const mystruct *ptr。除非你放弃 const 至少在写入结构时会发出警告。指针本身也可以是常量:const mystruct * const ptr.

所以:没有经验法则;这取决于太多因素。

真正的最佳经验法则是,在通过引用或按值将结构作为参数传递给函数时,避免按值传递。 风险几乎总是大于收益。

为了完整起见,我会指出当 passing/returning 按值构造结构时会发生一些事情:

  1. 结构的所有成员都复制到堆栈上
  2. 如果return按值访问结构,所有成员都会从函数的堆栈内存复制到新的内存位置。
  3. 操作容易出错 - 如果结构的成员是指针,一个常见的错误是假设你可以安全地按值传递参数,因为你是在指针上操作 - 这会导致很难发现错误。
  4. 如果你的函数修改输入参数的值并且你的输入是结构变量,按值传递,你必须记住总是 return 按值传递结构变量(我已经看到这个几次)。这意味着复制结构成员的时间加倍。

现在就结构的大小而言,足够小意味着什么 - 所以它 'worth' 按值传递它,这取决于一些事情:

  1. 调用约定:编译器在调用该函数时自动将什么保存在堆栈中(通常是一些寄存器的内容)。如果您的结构成员可以利用此机制复制到堆栈上,则不会受到任何惩罚。
  2. 结构成员的数据类型:如果你的机器的寄存器是16位的,而你的结构成员的数据类型是64位的,显然一个寄存器放不下,所以一个寄存器必须执行多个操作复制.
  3. 您的机器实际拥有的寄存器数量:假设您的结构只有一个成员,一个 char(8 位)。当按值或按引用(理论上)传递参数时,这应该会导致相同的开销。但还有另一种潜在的危险。如果你的架构有独立的数据和地址寄存器,按值传递的参数将占用一个数据寄存器,而按引用传递的参数将占用一个地址寄存器。按值传递参数会给通常比地址寄存器使用更多的数据寄存器施加压力。这可能会导致堆栈溢出。

底线 - 很难说什么时候可以按值传递结构。不这样做更安全:)

由于问题的参数传递部分已经回答,我将重点关注 returning 部分。

IMO 最好的做法是完全不 return 结构或指向结构的指针,而是将指向 'result struct' 的指针传递给函数。

void sum(struct Point* result, struct Point* a, struct Point* b);

这样做有以下优点:

  • result 结构可以存在于堆栈或堆上,由调用者自行决定。
  • 没有所有权问题,因为很明显调用者负责分配和释放结果结构。
  • 结构甚至可以比需要的更长,或者嵌入到更大的结构中。

以抽象的方式传递给函数的一组数据值是一个值结构,尽管没有声明。 您可以将函数声明为结构,在某些情况下需要类型定义。当你这样做时,一切都在堆栈上。这就是问题所在。通过将您的数据值放在堆栈上,如果在您使用或将数据复制到其他地方之前使用参数调用函数或子程序,则它很容易被覆盖。最好使用指针和类。

在小型嵌入式架构(8/16 位)上——总是 通过指针传递,因为非平凡的结构不适合这么小的寄存器,而那些机器通常也缺乏寄存器。

在类似 PC 的体系结构(32 位和 64 位处理器)上 -- 按值传递结构是可以的,前提是 sizeof(mystruct_t) <= 2*sizeof(mystruct_t*) 并且函数没有很多(通常超过 3 个机器字的价值)其他论据。在这些情况下,典型的优化编译器将 pass/return 寄存器或寄存器对中的结构。然而,在 x86-32 上,由于 x86-32 编译器必须处理异常的寄存器压力,应该对这个建议持保留态度——由于减少了寄存器溢出和填充,传递指针可能仍然更快。

在PC-likes中按值返回一个结构体,遵循相同的规则,除了当一个结构体通过指针返回时,要填写的结构体应该是也通过指针传入——否则,被调用者和调用者必须就如何管理该结构的内存达成一致。

注意:以某种方式这样做的原因相互重叠。

When to pass/return by value:

  1. 对象是基本类型,如intdouble、指针。
  2. 必须制作对象的二进制副本 - 并且对象不大。
  3. 速度很重要,按值传递更快。
  4. 对象在概念上是一个小数字

    struct quaternion {
      long double i,j,k;
    }
    struct pixel {
      uint16_t r,g,b;
    }
    struct money {
      intmax_t;
      int exponent;
    }
    

When to use a pointer to the object

  1. 不确定是值还是指向值的指针更好 - 所以这是默认选择。
  2. 对象很大。
  3. 速度很重要,传递指向对象的指针更快。
  4. 堆栈的使用很关键。 (严格来说,这在某些情况下可能会受到价值的青睐)
  5. 需要修改传递的对象。
  6. 对象需要内存管理。

    struct mystring {
      char *s;
      size_t length;
      size_t size;
    }
    

注意:回想一下,在 C 中,没有什么是真正通过引用传递的。即使传递指针也是按值传递,因为指针的值被复制和传递。

我更喜欢按值传递数字,无论是 int 还是 pixel,因为它在概念上更容易理解代码。通过地址传递数字在概念上有点困难。对于较大的数字对象,通过地址传递可能更快

已传递地址的对象可以使用 restrict 通知函数对象不重叠。

在典型的 PC 上,即使对于相当大的结构(许多字节),性能也应该不是问题。因此,其他标准也很重要,尤其是语义:您确实想要制作一份副本吗?或者在同一个物体上,例如操作链表时?准则应该是用最合适的语言结构来表达所需的语义,以使代码可读和可维护。

也就是说,如果有任何性能影响,它可能不像人们想象的那么清楚。

  • Memcpy 速度很快,内存局部性(这对堆栈有好处)可能比数据大小更重要:复制可能全部发生在缓存中,如果你通过 return 堆栈上的按值结构。此外,return 值优化应该避免冗余复制要被 returned 的局部变量(天真的编译器在 20 或 30 年前就这样做了)。

  • 传递指针会为内存位置引入别名,这样就无法再有效地缓存这些位置。现代语言通常更注重价值,因为所有数据都与副作用隔离,从而提高了编译器的优化能力。

底线是肯定的,除非您 运行 遇到问题时可以随意按值传递,如果它更方便或合适的话。它甚至可能更快。