[英]Do C++ compilers optimize return-by-value member variables
当返回的变量超出函数范围时,我对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访问器时,应返回对成员的引用 ,而不是copy 。
我对临时的C ++返回值优化有很好的了解,现代的C ++编译器是否可以进行此优化? 有什么理由不应该这样做吗?
我觉得您对返回值的优化不是很了解,否则您将不会提出第二个问题。 让我们以此为例。 当用户代码具有:
std::string foo() {
std::string result;
result = "Hi";
return result;
}
std::string x = foo();
在上面的代码中,可能有三个字符串: foo
result
,返回值(将其__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;
}
现在回到您的原始问题,因为成员变量在调用成员函数之前就存在,因此无法应用此优化。 编译器无法将返回值放在member属性所在的位置,因为该变量已经存在于已知位置,而不是__ret
(由调用者提供)的地址。
简短答案:
除非编译器在通过内联或某些编译器特有的魔术编译main
时考虑到复制构造函数和length()
方法的实现,否则它将无法优化该副本。
长答案:
C ++标准通常从不直接规定应该或不应该执行哪些优化。 实际上,根据定义,优化几乎不会改变格式良好的程序的行为。
如果编译器能够证明对obj.name
的特定调用obj.name
导致副本的存在,而该副本的存在是观察者无法证明的,则可以取消该副本。 只需一点内联,这也可能是您的情况,因此从理论上讲,此复制省略在这里是允许的, 因为您不会以任何方式打印或使用其效果。
现在,仔细观察一下,该标准的第12.8条确实列出了四种其他情况(与异常处理,被调用方的返回值(例如,您的情况中name
的内部)以及将临时对象绑定到引用有关)。 我在这篇文章中列出了它们,以方便参考,但是它们都不适合您的情况,在这种情况下,临时调用是从调用中接收的,并用于调用const
方法。
因此,这些明确的“例外”不允许仅通过检查main
并注意length()
的const
限定符来优化复制。
当满足某些条件时,即使该对象的复制/移动构造函数和/或析构函数具有副作用,也允许实现忽略类对象的复制/移动构造。 在这种情况下,实现将忽略的复制/移动操作的源和目标视为引用同一对象的两种不同方式,并且该对象的销毁发生在两个对象本来应该以较晚的时间发生。没有优化就销毁。 在以下情况下允许复制/移动操作的这种省略,称为复制删除(可以合并以消除多个副本):
—在具有类返回类型的函数的return语句中,当表达式是具有与函数返回类型相同的cvunqualitype类型的非易失性自动对象(函数或catch子句参数除外)的名称时,通过将自动对象直接构造到函数的返回值中,可以省略复制/移动操作
—在throw-expression中,当操作数是非易失性自动对象(函数或catch子句参数除外)的名称时,其范围不会超出最里面的try-block的末尾(如果存在)一种),可以通过将自动对象直接构造到异常对象中来省略从操作数到异常对象(15.1)的复制/移动操作
—当尚未绑定到引用(12.2)的临时类对象将被复制/移动到具有相同cv-unqualitype类型的类对象时,可以通过将临时对象直接构造到对象中而省略复制/移动操作。省略复制/移动的目标
—当异常处理程序的异常声明(第15条)声明与异常对象(15.1)相同类型的对象(cv限定除外)时,可以通过处理异常声明来省略复制/移动操作如果程序的含义没有改变,则将其作为异常对象的别名,但对于由异常声明所声明的对象执行构造函数和析构函数除外。
是const成员函数,因此保证不修改字符串的状态
这不是真的。 std::string
可以具有mutable
数据成员,以及任何功能可投的const
关闭this
或它的任何部件。
了解编译器进行了哪些优化的最佳方法是查看它生成的程序集,并确切地了解编译器实际执行的操作。 很难预测给定编译器在每种情况下可能会或可能不会进行哪种优化,并且大多数人通常要么过于悲观,要么过于乐观。
另一方面,仅检查编译器的输出,就可以准确地看到它的功能,而无需进行任何猜测。
在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()
函数。
返回值优化是关于消除return语句中的隐式副本,方法是消除具有函数局部作用域的临时对象或对象,并将被消除的对象用作返回对象的别名。
显然,这仅在函数正在构造return语句中使用的对象时适用。 如果要返回的对象已经存在,则不会创建额外的对象,因此必须将要返回的对象复制到返回对象。 功能中没有其他可以消除的对象构造。
尽管有上述所有条件,但编译器可以进行其认为合适的任何优化,只要符合标准的程序无法观察到行为上的差异,从而一切(不可观察的)都是可能的。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.