避免在C++循环中循环中复杂对象时最小化范围效率低下的技术

Techniques to avoid minimal scope inefficiency with complex objects in loops in C++?

本文关键字:循环 效率 范围 技术 最小化 对象 C++ 复杂      更新时间:2023-10-16

问题优先

C++是否有一个优雅的解决方案来防止出于效率原因而不得不声明仅在循环外循环中使用的复杂对象变量?

详细说明

一位同事在我们的代码策略中提出了一个有趣的观点,它指出(释义):始终对变量使用最小范围,并在第一次初始化时声明变量

编码指南示例:

// [A] DO THIS
void f() {
  ...
  for (int i=0; i!=n; ++i) {
    const double x = calculate_x(i);
    set_squares(i, x*x);
  }
  ...
}
// [B] DON'T do this:
void f() {
  int i;
  int n;
  double x;
  ...
  for (i=0; i!=n; ++i) {
    x = calculate_x(i);
    set_squares(i, x*x);
  }
  ...
}
这一切都很好,而且这

当然没有错,直到你从原始类型转移到对象。(对于某种接口

例:

// [C]
void fs() {
  ...
  for (int i=0; i!=n; ++i) {
    string s;
    get_text(i, s); // void get_text(int, string&);
    to_lower(s);
    set_lower_text(i, s);
  }
  ...
}

在这里,字符串 s 将被破坏,它是每个循环周期的内存释放,然后每个周期get_text函数都必须为 s 缓冲区重新分配内存。

  // [D]
  string s;
  for (int i=0; i!=n; ++i) {
    get_text(i, s); // void get_text(int, string&);
    to_lower(s);
    set_lower_text(i, s);
  }

现在,S缓冲区中分配的内存将在循环运行之间保留,我们很可能会节省分配。

免责声明请注意:由于这是循环,我们谈论的是内存分配,因此我认为通常考虑此问题并不为时过早。当然,在某些情况下和循环中开销无关紧要;但是n有唠叨的倾向,即比开发人员最初预期的更大,而代码有在性能确实很重要的上下文中运行的唠叨倾向。

无论如何,现在"通用"循环构造的更有效方法是违反代码局部性并声明复杂对象不合适,"以防万一"。这让我相当不安。

请注意,我考虑这样写:

// [E]
void fs() {
  ...
  {
    string s;
    for (int i=0; i!=n; ++i) {
      get_text(i, s); // void get_text(int, string&);
      to_lower(s);
      set_lower_text(i, s);
    }
  }
  ...
}

没有解决方案,因为可读性受到的影响更大!

进一步思考get_text函数的接口无论如何都是非惯用的,因为无论如何,参数都是昨天的,并且"好"接口将按值返回:

  // [F]
  for (int i=0; i!=n; ++i) {
    string s = get_text(i); // string get_text(int);
    to_lower(s);
    set_lower_text(i, s);
  }

在这里,我们不为内存分配支付双倍的费用,因为极有可能通过 RVO 从返回值构造s,因此对于 [F],我们支付的分配开销与 [C] 相同。但是, [C] 情况不同,我们无法优化此接口变体。

所以底线似乎是使用最小范围(可能)损害性能并使用干净的接口,我至少认为按值返回比 out-ref-param 的东西会阻止优化机会要干净得多——至少在一般情况下。

问题

不在于有时为了提高效率而不得不放弃干净的代码,问题在于一旦开发人员开始发现这种特殊情况,整个编码指南(参见 [A]、[B])就会失去权威。

现在的问题是:见第一段

写起来显然更有效:[示例 D 的开始...]

我对此表示怀疑。您需要为从循环外开始的默认构造付费。在循环中,get_text调用可能会重新分配缓冲区(取决于get_textstring的定义方式)。请注意,对于某些运行,您实际上可能会看到改进(例如,在字符串逐渐变短的情况下),对于某些运行(字符串长度在每次迭代时增加约 2 倍)性能受到巨大影响。

如果不变量构成瓶颈(探查器会告诉您),将它们从循环中提升出来是完全有意义的。否则,请选择惯用的代码。

我要么:

  • 对这些重量级人物的规则破例。 如"D",请注意,您可以根据需要限制范围。
  • 允许帮助程序函数(字符串也可以是参数)
  • 如果你真的不喜欢这些,你可以使用一个多元素对象在for循环的作用域中声明一个局部,该对象包含你的计数器/迭代器和临时的。 std::pair<int,std::string>将是一种选择,尽管专用容器可以减少语法噪音。

(在许多情况下,out 参数会比 RVO 样式更快)

取决于get_text的实现。

如果可以实现它,以便它大部分时间重用字符串对象中分配的空间,那么一定要在循环外部声明该对象,以避免在每次循环迭代时分配新的动态内存。

动态分配成本高昂(最好的单线程分配器

需要大约 40 条指令才能进行单次分配,多线程会增加开销,并非所有分配器都是"最好的"),并且可能会对内存进行分段。

(顺便说一句,std::string通常实现所谓的"小字符串优化",避免对小字符串进行动态分配。因此,如果您知道大多数字符串都足够小,并且std::string的实现不会更改,那么理论上即使在每次迭代中构造一个新对象时,您也可以避免动态分配。然而,这将是非常脆弱的,所以我建议不要这样做。


在一般情况下,这完全取决于使用它们的对象和函数的实现方式。如果你关心性能,你必须根据具体情况处理这些类型的"抽象泄漏"。因此,明智地选择您的战斗:首先测量和优化瓶颈。

如果你有一个字符串类的写入时复制实现,那么to_lower无论如何都会分配内存,所以不清楚你是否可以通过简单地在循环外声明 s 来获得性能。

在我看来,有两种可能性:1.)你有一个类,它的构造函数做了一些不平凡的事情,不需要在每次迭代中重做。那么在逻辑上就很简单了,将声明放在循环之外。2.) 你有一个类,它的构造函数不做任何有用的事情,然后将声明放在循环中。

如果 1. 为 true,那么您可能应该将对象拆分为一个辅助对象,例如,分配空间并执行非平凡初始化,以及一个蝇量级对象。如下所示:

StringReservedMemory m (500); /* base object for something complex, allocating 500 bytes of space */
for (...) {
   MyOptimizedStringImplementation s (m);
   ...
}