0

当返回的变量超出函数的范围时,我对 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()
4

5 回答 5

6

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

在上面的代码中,可能存在三个字符串:resultinside foo、返回值(让我们调用它__ret)和x,以及可以应用的两种可能的优化:NRVO和通用复制省略。NRVO是编译器在处理函数时执行的优化foo,它包含在 mergint 中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复制省略的文章。您可能有兴趣阅读这些文章。

于 2012-06-15T21:10:33.803 回答
2

简短的回答:

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

长答案:

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

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

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

因此,这些明确的“例外”不允许仅通过检查main和注意const.length()

当满足某些条件时,允许实现省略类对象的复制/移动构造,即使对象的复制/移动构造函数和/或析构函数具有副作用。在这种情况下,实现将省略的复制/移动操作的源和目标简单地视为引用同一对象的两种不同方式,并且该对象的销毁发生在两个对象本应被删除的较晚时间。没有优化就被破坏了。这种复制/移动操作的省略,称为复制省略,在以下情况下是允许的(可以结合起来消除多个副本):

— 在具有类返回类型的函数的 return 语句中,当表达式是具有与函数返回类型相同的 cvunqualified 类型的非易失性自动对象(函数或 catch 子句参数除外)的名称时,可以通过将自动对象直接构造到函数的返回值中来省略复制/移动操作

— 在 throw 表达式中,当操作数是非易失性自动对象(函数或 catch 子句参数除外)的名称时,其范围不超出最里面的封闭 try 块的末尾(如果有一)、从操作数到异常对象(15.1)的复制/移动操作可以通过将自动对象直接构造到异常对象中来省略

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

— 当异常处理程序的异常声明(第 15 条)声明与异常对象(15.1)具有相同类型的对象(cv 限定除外)时,可以通过处理异常声明来省略复制/移动操作作为异常对象的别名,如果程序的含义将保持不变,除了为异常声明声明的对象执行构造函数和析构函数。

于 2012-06-15T21:16:00.900 回答
1

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

这不是真的。std::string可能有一个mutable数据成员,并且任何函数都可以强制转换它或它的const任何this成员。

于 2012-06-15T20:39:33.533 回答
1

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

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

在 Visual Studio 中,您可以通过设置项目属性 -> C/C++ -> 输出文件 -> 汇编程序输出 -> “使用源代码进行汇编”,或仅提供 /Fas 到命令行您可以使用-S告诉 GCC 输出程序集,但这不会将程序集行与源代码行关联起来;为此,您必须使用 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 符号名称)如您所见,在这种情况下,它NamedObject::name()在 .clear() 之前和在 .length() 之前再次调用该函数。

于 2012-06-15T20:59:25.800 回答
0

返回值优化是通过消除函数本地范围内的临时对象或对象并将被消除的对象用作返回对象的别名来消除 return 语句中的隐式副本。

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

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

于 2012-06-15T20:51:33.943 回答