当返回的变量超出了函数的范围时,我对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()
答案 0 :(得分: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();
在上面的代码中,可能有三个字符串:result
里面的foo
,返回值(让我们称之为__ret
)和x
,以及两个可能的优化可以应用: NRVO 和通用 copy-elision 。 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和copy elision的文章。您可能有兴趣阅读这些文章。
答案 1 :(得分:2)
简短回答:
除非编译器通过内联或某些特定于编译器的魔法编译length()
时看到复制构造函数和main
方法的实现,否则它将无法优化远离那份副本。
答案很长:
C ++标准通常从不直接规定应该或不应该执行哪些优化。实际上,根据定义,优化是一种不会改变良好程序的行为的东西。
如果编译器能够证明obj.name
的特定调用导致副本的存在是观察者无法提供的,则可以自由地删除该副本。这可能是你的情况,只需要一点内联,所以理论上允许这个复制省略,因为你不打印或以任何方式使用它的效果。
现在,仔细看看,标准的第12.8条确实列出了另外四种情况(与异常处理有关,被调用者的返回值,例如你案件中name
的内部,以及临时绑定到引用)。我在这篇文章中列出它们以便于参考,但它们都不符合您从调用中收到临时值并用于调用const
方法的情况。
因此,这些明确的“例外”不允许仅通过检查main
并注意const
的{{1}}限定符来优化副本。
当满足某些条件时,允许省略实现 复制/移动类对象的构造,即使复制/移动 对象的构造函数和/或析构函数具有副作用。在这样的 在这种情况下,实现处理省略的源和目标 复制/移动操作只是两种不同的方式来指代 同一个对象,该对象的破坏发生在后期 两个物体在没有物体的情况下被摧毁的时间 优化。这种复制/移动操作的省略,称为复制 elision,在下列情况下允许(可能是 合并消除多份副本):
- 在一个回复声明中 函数具有类返回类型,当表达式是名称时 非易失性自动对象(函数或catch子句除外) 参数)具有与函数return相同的cvunquali fi ed类型 类型,通过构造,可以省略复制/移动操作 自动对象直接进入函数的返回值
- 在a throw-expression,当操作数是非易失性的名称时 自动对象(函数或catch子句参数除外) 其范围不超出最内层封闭的末端 try-block(如果有的话),从操作数复制/移动操作 通过构造,可以省略异常对象(15.1) 自动对象直接进入异常对象
- 暂时的 尚未绑定到引用(12.2)的类对象 复制/移动到具有相同cv-unquali fi ed类型的类对象, 通过构造临时可以省略复制/移动操作 对象直接进入省略的复制/移动目标
- 什么时候 异常处理程序的异常声明(第15条)声明了一个 相同类型的对象(除了cv-quali fi cation)作为例外 对象(15.1),可以通过处理来省略复制/移动操作 exception-declaration作为异常对象的别名,如果是 除执行之外,程序的含义将保持不变 构造函数和析构函数,用于声明的对象 例外声明。
答案 2 :(得分:1)
是const成员函数,因此保证不修改字符串的状态
那不是真的。 std::string
可能有一个mutable
数据成员,任何函数都可能会const
关闭this
或其任何成员。
答案 3 :(得分:1)
了解编译器优化的最佳方法是查看它生成的程序集,并确切了解编译器实际执行的操作。很难预测给定编译器在每种情况下可能会或可能不会进行哪种优化,而且大多数人通常要么过于悲观,要么过于乐观。
另一方面,通过检查编译器的输出,您可以准确地看到它的作用,而无需任何猜测。
在Visual Studio中,通过设置项目属性,可以获得与源代码交错的程序集的有用输出 - &gt; C / C ++ - &gt;输出文件 - &gt;汇编器输出 - &gt; “使用源代码汇编”或just supplying /Fas to the command line。您可以告诉GCC使用-S输出汇编,但这不会将汇编行与源行相关联;对于you have to use objdump或the -fverbose-asm commandline option,如果它恰好适用于您的版本。
例如,代码中的一个块(在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()
函数。
答案 4 :(得分:0)
返回值优化是通过消除带有函数本地作用域的临时或对象并使用被删除的对象作为返回对象的别名来消除return语句中的隐式副本。
显然,这仅适用于函数构造return语句中使用的对象的情况。如果返回的对象已存在,则不会创建额外的对象,因此必须将返回的对象复制到返回对象。函数中没有其他对象构造可以消除。
尽管如此,编译器可以进行它认为合适的任何优化,只要符合程序不会观察到任何行为上的差异,因此任何(不可观察的)都是可能的。