【问题标题】:When should I pass or return a struct by value?我什么时候应该按值传递或返回结构?
【发布时间】:2015-09-07 22:51:19
【问题描述】:

在 C 中,结构可以通过值传递/返回,也可以通过引用(通过指针)传递/返回。

普遍的共识似乎是,在大多数情况下,前者可以应用于小型结构而不会受到惩罚。见Is there any case for which returning a structure directly is good practice?和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;
};

确定一个结构是否足够小以安全地按值传递它的最佳经验法则是什么?

最后,请不要告诉我我需要配置文件。当我太懒/不值得进​​一步调查时,我要求使用启发式方法。

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

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

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

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

【问题讨论】:

  • 为什么要把整栋房子寄给只需要你地址的人。传递指针总是更可取的。
  • @VinayShukla 传递指针总是至少和传递值一样快。但是如果你做了很多指针解引用,那可能会抵消传递参数的任何微不足道的优势。
  • @Daniel 我同意你的观点,但总是更好地通过指针传递结构而不是通过值传递它。还有许多其他优点,例如按值传递执行浅拷贝,并且修改的值不会被反映。
  • @VinayShukla 为什么这是一个优势?通常,浅拷贝正是需要的行为。
  • 这是一个非常好的问题!每个人总是说“小结构也可以”,但没有定义“有多小”。

标签: c shallow-copy


【解决方案1】:

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

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

另一方面,在类似 PC 上按值返回结构遵循相同的规则,除了当结构通过指针返回时,要填充的结构应该传入 也可以通过指针 - 否则,被调用者和调用者不得不就如何管理该结构的内存达成一致。

【讨论】:

  • 我可能遗漏了一些明显的东西,但为什么是“sizeof(mystruct_t)
  • @Claudiu -- 这基本上是一种说法“它占用的内存不超过两个机器字”
【解决方案2】:

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

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

传递整个struct时,如果不是引用传递, 那么

  1. 它没有放在堆栈上
  2. 它被复制,通常是通过隐藏调用 memcpy()
  3. 它被复制到现在“保留”的内存部分 并且不能用于程序的任何其他部分。

结构体按值返回时也有类似的考虑。

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

被认为是“小”的细节 取决于编译器和 底层硬件架构。

【讨论】:

  • 另一个原因,恕我直言,它避免了混淆。如果你总是传递/返回指针,你永远不必怀疑代码中的任何特定引用是指针还是副本。
  • 第 1 点对于当前的 32+ 位 CPU/平台来说是完全错误的,对于现代 8/16 位 MCU(例如 MSP430)来说是错误的。出于类似的原因,与 3 相同。否则,默认情况下该函数将不是线程安全的,从而使此类函数无法与线程一起使用。
  • 再次阅读您的答案!你写的对象没有放在栈上。抛开根本不使用堆栈的实现(C 不强制要求堆栈),现代架构确实将它放在堆栈上。他们还应该去哪里?使用(隐藏的)全局变量是无稽之谈,而且不是线程安全的。你写的是 80 年代和 90 年代一部分的实践,但绝对不是 OP 所指的现代实现和架构。
  • 这个答案没有多大意义。完全荒谬的是您的“它被复制到现在'保留'并且程序的任何其他部分不可用的内存部分”声明。好吧,我真希望我的结构所在的内存在其生命周期内不可用。然后,正如奥拉夫指出的那样,它当然是在堆栈上传递的,否则如何(除非它适合寄存器)?是的,除了(某种)mempcpy()之外,还有什么办法?不知道你所说的“隐藏”是什么意思,除了你不必明确写下来,谢天谢地。
  • @Olaf 不仅隐藏变量不是线程安全的;更一般地说,这些函数不是可重入的,也不能是递归的,换句话说,它不是标准的 C。
【解决方案3】:

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

IMO 最好的做法是根本不返回结构或指向结构的指针,而是将指向“结果结构”的指针传递给函数。

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

这有以下优点:

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

【讨论】:

    【解决方案4】:

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

    如果 PCS 确实允许在寄存器中传递结构,这不仅取决于它的大小,还取决于它在参数列表中的位置以及前面参数的类型。例如,ARM-PCS (AAPCS) 将参数打包到前 4 个寄存器中,直到它们已满,并将进一步的数据传递到堆栈中,即使这意味着参数被拆分(所有简化,如果感兴趣:文档可从 ARM 免费下载)。

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

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

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

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

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

    【讨论】:

    • 如果没有更深入地了解每个 ABI/调用约定,我认为对于大多数处理器/ABI,经验法则是“如果结构大小小于或等于数据CPU的总线然后按值传递就可以了”。 8 位 PIC 1 个字节,32 位 ARM 4 个字节,64 位 Intel PC 8 个字节等等。但如果考虑可移植性,最好的经验法则可能是始终通过引用传递。
    • @Lundin:我主要接受可变性作为按值传递的原因。对于返回值,我实际上看不到什么原因,因为您无法获取结果的地址来存储结构以供以后使用(并且您也不能直接从调用中访问字段:f().field1 不起作用。请注意,AAPCS 确实实际上将最多 128(4*32 位)位的容器类型打包到寄存器中,而不仅仅是 1。所以,事情要复杂得多。
    • @Lundin:我认为 API 允许结构的大小是适当小的 2 的幂,但通常不是 2 的幂的结构是很常见的不接受这种特殊待遇。因此,在许多平台上,传递 32 位结构甚至 64 位结构可能比传递 24 位结构更快。
    • @Lundin:如果您转换它,我会接受您的评论作为答案。您是唯一提供可操作的经验法则的人。
    • @KaitingChen:有些答案没有“经验法则”(但可能存在“愚蠢法则”——并不意味着 Lundin 的答案就是一个!)。问问自己,为什么您实际上在搜索中发现了这么多不同且部分相反的答案!
    【解决方案5】:

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

    为了完整起见,我会指出,当按值传递/返回结构时,会发生一些事情:

    1. 结构的所有成员都复制到堆栈中
    2. 如果按值返回结构,则再次将所有成员从函数的堆栈内存复制到新的内存位置。
    3. 该操作容易出错 - 如果结构的成员是指针,一个常见的错误是假设您可以安全地按值传递参数,因为您正在对指针进行操作 - 这可能会导致很难发现错误。
    4. 如果您的函数修改了输入参数的值并且您的输入是结构变量,按值传递,您必须记住始终按值返回结构变量(我已经看过很多次了)。这意味着复制结构成员的时间加倍。

    现在要了解足够小的结构大小意味着什么 - 因此它“值得”按值传递它,这将取决于几件事:

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

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

    【讨论】:

      【解决方案6】:

      注意:这样做的原因是一种或另一种重叠。

      何时按值传递/返回:

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

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

      何时使用指向对象的指针

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

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

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

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

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

      【讨论】:

        【解决方案7】:

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

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

        • Memcpy 速度很快,并且内存局部性(这对堆栈有利)可能比数据大小更重要:如果您在堆栈上按值传递和返回结构,则复制可能都发生在缓存中.此外,返回值优化应避免对要返回的局部变量进行冗余复制(天真的编译器在 20 或 30 年前就这样做了)。

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

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

        【讨论】:

          【解决方案8】:

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

          【讨论】:

          • 您的第一个想法(函数的一组参数在逻辑上是一个结构)是有效的,但忽略了传递机制可能不同(单个值可以在寄存器中传递)这一点。不过,您的覆盖问题是没有根据的。如果一个流氓指针覆盖了你的堆栈,无论如何你都会被搞砸;如果您的意思是在需要引用时使用复制,则确实应该简单地使用指针;但只有那时。
          猜你喜欢
          • 1970-01-01
          • 2017-11-10
          • 1970-01-01
          • 1970-01-01
          • 2020-07-16
          • 1970-01-01
          • 2019-07-08
          • 2012-01-08
          • 1970-01-01
          相关资源
          最近更新 更多