编译器是否C++优化按值返回的成员变量

Do C++ compilers optimize return-by-value member variables

本文关键字:返回 成员 变量 是否 C++ 优化 编译器      更新时间:2023-10-16

当返回的变量超出函数范围时,我对C++返回值优化有很好的掌握,但是返回成员变量呢?请考虑以下代码:

#include <iostream>
#include <string>
class NamedObject {
 public:
  NamedObject(const char* name) : _name(name) {}
  std::string name() const {return _name;}
 private:
  std::string _name;
};
int main(int argc, char** argv) {
  NamedObject obj("name");
  std::cout << "name length before clear: " << obj.name().length() << std::endl;
  obj.name().clear();
  std::cout << "name length after clear: " << obj.name().length() << std::endl;
  return 0;
}

哪些输出:

name length before clear: 4
name length after clear: 4

显然,obj.name().clear()作用于临时副本,但是调用obj.name.length()呢? std::string::length() const成员函数,因此保证不会修改字符串的状态。因此,应该允许编译器不复制成员变量,而直接将其用于调用 const 成员函数,这似乎是合理的。现代C++编译器是否进行了此优化?有什么理由不应该或不能制作吗?

编辑:

澄清一下,我不是在问标准返回值优化在这里是否有效;我明白为什么在我最初问这个问题的时候没有。通常定义的 RVO 在这里不起作用,因为返回的值不会超出函数的范围。

我要问的是:如果编译器在调用时可以确定调用不会有副作用,是否允许跳过副本?即,它是否可以表现得好像

obj.name().length()

obj._name.length()

name()函数按值返回,这意味着所有操作都在临时变量上执行。

因此,应该允许编译器不复制成员变量,而直接将其用于调用 const 成员函数,这似乎是合理的。

这种假设在许多方面都是不正确的。当函数声明为 const 时,您是在告诉编译器不会修改对象的状态,以便编译器可以帮助您验证这一点。返回类型是编译器可以为您执行的检查的一部分。例如,如果将返回类型更改为:

std::string& name() const { return _name; }

编译器会抱怨:你答应name()不会修改状态,但你提供了一个参考,其他人可以通过它来做。此外,该函数的语义是它提供调用方可以修改的副本。如果副本被省略(不可能省略它,但为了论证),那么调用代码可以修改看似本地副本的内容,并实际修改对象的状态。

通常,在提供 const 的访问器时,应返回对成员的引用而不是副本

我对临时C++返回值优化有很好的把握,[...]现代C++编译器是否进行了此优化?有什么理由不应该或不能制作吗?

我有一种感觉,你并没有很好地掌握返回值优化是什么,否则你不会提出第二个问题。让我们在一个例子上解决这个问题。当用户代码具有以下条件时:

std::string foo() {
   std::string result;
   result = "Hi";
   return result;
}
std::string x = foo();

在上面的代码中,可能有三种字符串:result foo 内部、返回值(我们称之为 __ret )和 x ,以及两种可以应用的优化:NRVO 和泛型复制省略NRVO是编译器在处理函数foo时执行的优化,它包含合并result__ret,将它们放在相同的位置并创建一个对象。优化的第二部分必须在调用方完成,它再次合并两个对象的位置 x__ret .

在实际实现时,我将从第二个开始。调用方(在大多数调用约定中)负责为返回的对象分配内存。如果没有优化(并且在一种伪代码中),这就是调用者上发生的事情:

[uninitialized] std::string __ret;
foo( [hidden arg] &__ret );          // Initializes __ret
std::string x = __ret;

现在,因为编译器知道临时__ret只会在初始化x将代码转换为:

[uninitialized] std::string x;
foo( [hidden arg] &x );             // Initializes x

并且省略了调用方的副本。foo 内部的副本以类似的方式省略。转换后的(以符合调用约定)函数为:

void foo( [hidden uninitialized] std::string* __ret ) {
   std::string result;
   result = "Hi";
   new (__ret) std::string( result );   // placement new: construct in place
   return;
}

现在,在这种情况下,优化完全相同。由于result只生存为了能够初始化返回的对象,因此它可以重用相同的空间,而不是创建新对象:

void foo( [hidden uninitialized] std::string* __ret ) {
   new (__ret) std::string();
   (*__ret) = "Hi";
   return;
}

现在回到原始问题,由于成员变量在调用成员函数之前就已存在,因此无法应用此优化。编译器不能将返回值放在与成员属性相同的位置,因为该变量已在已知位置处于活动状态,该位置不是__ret的地址(由调用方提供)。

我过去写过关于NRVO和复制elision的文章。您可能有兴趣阅读这些文章。

简短回答:

除非编译器在编译main时通过内联或某些编译器特定的魔法看到复制构造函数和length()方法的实现,否则它将无法优化该副本。

长答案:

C++标准通常从不直接规定应该或不应该执行哪些优化。 事实上,根据定义,优化几乎不会改变格式良好的程序的行为。

如果编译器能够证明对obj.name的特定调用会产生一个副本,而观察者无法证明其存在,则可以自由地省略该副本。 只要稍微内联一下,也可能是你的情况,所以这个复制省略理论上是允许的,因为你不会以任何方式打印出来或使用它的效果。

现在,仔细观察,标准的第 12.8 条确实列出了四种其他情况(与异常处理、被调用方的返回值(例如您的案例中的name内部以及将临时绑定到引用有关)。 为了便于参考,我在这篇文章中列出了它们,但它们都与您的情况不匹配,其中临时是从调用中接收并用于调用const方法的。

因此,这些明确的"例外"不允许仅仅通过检查main并注意到length()const限定符来优化副本。

当满足某些条件时,允许省略实现 类对象的复制/移动构造,即使复制/移动 对象的构造函数和/或析构函数具有副作用。在这样的 情况下,实现处理省略的源和目标 复制/移动操作只是两种不同的引用方式 同一对象,并且该对象的销毁发生在较晚的 如果没有 优化。 这种复制/移动操作的省略,称为复制 在以下情况下允许使用(可能是 组合以消除多个副本):

— 在返回语句中 具有类返回类型的函数,当表达式是 非易失性自动对象(函数或 catch 子句除外) 参数),与函数返回具有相同的 CVUNQUALIFIED 类型 类型,可以通过构造 自动对象直接进入函数的返回值

— 在 抛出表达式,当操作数是非易失性的名称时 自动对象(函数或 catch 子句参数除外) 其范围不超过最内层封闭的末端 try-block(如果有),从操作数复制/移动操作 对于异常对象 (15.1) 可以通过构造 自动对象直接进入异常对象

— 当临时 尚未绑定到引用 (12.2) 的类对象将是 复制/移动到具有相同 CV 非限定类型的类对象, 可以通过构造临时 对象直接进入省略的复制/移动的目标

— 当 异常处理程序的异常声明(条款 15)声明了一个 与例外类型相同(CV 资格除外)的对象 对象 (15.1),可以通过处理 异常声明作为异常对象的别名,如果 程序的含义将保持不变,除了执行 由 异常声明。

是 const 成员函数,因此保证不会修改字符串的状态

那不是真的。 std::string可能具有mutable数据成员,任何函数都可以将constthis或其任何成员中剥离出来。

返回值优化是通过消除函数本地作用域的临时或对象并使用要消除的对象作为返回对象的别名来消除 return 语句中的隐式副本。

显然,这仅适用于函数构造 return 语句中使用的对象的情况。如果返回的对象已存在,则不会创建额外的对象,因此必须将返回的对象复制到返回对象。函数中没有其他可以消除的对象构造。

尽管有上述所有情况,编译器可以进行任何它认为合适的优化,只要符合程序可以观察到行为差异,因此任何事情(不可观察)都是可能的。

了解编译器进行哪些优化的最佳方法是查看它生成的程序集,并确切地了解编译器的实际作用。很难预测给定的编译器在每种情况下可能会或可能不会进行什么样的优化,大多数人通常要么过于悲观,要么过于乐观。

另一方面,只需检查编译器的输出,您就可以准确了解它的作用,而无需任何猜测。

在 Visual Studio 中,可以通过设置项目属性 -> C/C++ -> 输出文件 -> 汇编程序输出 ->"带有源代码的程序集

",或者只是向命令行提供/Fas,获得与源代码交错的程序集的有用输出。 你可以告诉 GCC 使用 -S 输出汇编,但这不会将汇编线与源线相关联;为此,您必须使用 objdump 或 -fverbose-asm 命令行选项,如果它碰巧在您的版本中有效。

例如,代码中的一个块(在 MSVC 中完全发布时编译)是:

; 23   :    obj.name().clear();
    lea ecx, DWORD PTR _obj$[esp+92]
    push    ecx
    lea esi, DWORD PTR $T23719[esp+96]
    call    ?name@NamedObject@@QBE?AV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@XZ ; NamedObject::name
    mov DWORD PTR [eax+16], ebx
    cmp DWORD PTR [eax+20], edi
    jb  SHORT $LN70@main
    mov eax, DWORD PTR [eax]
$LN70@main:
    mov BYTE PTR [eax], bl
    mov ebx, DWORD PTR __imp_??3@YAXPAX@Z
    cmp DWORD PTR $T23719[esp+112], edi
    jb  SHORT $LN84@main
    mov edx, DWORD PTR $T23719[esp+92]
    push    edx
    call    ebx
    add esp, 4
$LN84@main:
; 24   :    std::cout << "name length after clear: " << obj.name().length() << std::endl;
    lea eax, DWORD PTR _obj$[esp+92]
    push    eax
    lea esi, DWORD PTR $T23720[esp+96]
    call    ?name@NamedObject@@QBE?AV?$basic_string@DU?$char_traits@D@std@@V?$allocator@D@2@@std@@XZ ; NamedObject::name
    mov BYTE PTR __$EHRec$[esp+100], 2
    mov ecx, DWORD PTR __imp_?endl@std@@YAAAV?$basic_ostream@DU?$char_traits@D@std@@@1@AAV21@@Z
    mov eax, DWORD PTR [eax+16]
    mov edx, DWORD PTR __imp_?cout@std@@3V?$basic_ostream@DU?$char_traits@D@std@@@1@A
    push    ecx
    push    eax
    push    OFFSET ??_C@_0BK@PFKLDML@name?5length?5after?5clear?3?5?$AA@
    push    edx
    call    ??$?6U?$char_traits@D@std@@@std@@YAAAV?$basic_ostream@DU?$char_traits@D@std@@@0@AAV10@PBD@Z ; std::operator<<<std::char_traits<char> >
    add esp, 8
    mov ecx, eax
    call    DWORD PTR __imp_??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@I@Z
    mov ecx, eax
    call    DWORD PTR __imp_??6?$basic_ostream@DU?$char_traits@D@std@@@std@@QAEAAV01@P6AAAV01@AAV01@@Z@Z
    cmp DWORD PTR $T23720[esp+112], edi
    jb  SHORT $LN108@main
    mov eax, DWORD PTR $T23720[esp+92]
    push    eax
    call    ebx
    add esp, 4

(您可以使用 undname.exe 取消修饰 MSVC 符号名称)如您所见,在这种情况下,它在 .clear() 之前和 .length() 之前再次调用 NamedObject::name() 函数。