Skip to content

Latest commit

 

History

History
218 lines (138 loc) · 13.1 KB

copy-elision.md

File metadata and controls

218 lines (138 loc) · 13.1 KB

保证的复制消除

阅读前建议先了解:移动语义入门、值类别。本篇介绍的新增能力属于 C++17;后续版本差异会另行标注。

学习目标与“允许省略”的旧模型

C++14 已允许编译器省略某些复制或移动构造,即使被省略的构造函数带有可观察副作用。但这仍主要是一项许可:初学者容易把 return T{...} 想成“先创建临时对象,再由优化器碰巧移走”。具名局部变量返回与临时纯右值返回也经常被笼统地叫作 RVO,掩盖了两者保证级别的差异。

C++17 重塑纯右值语义:在规定的同类型初始化场景中,纯右值直接初始化最终结果对象,中间对象并不存在。这通常被称为“保证的复制消除”,但准确理解应从延迟实质化和结果对象入手,而不只是优化开关。

读完后,你应能解释不可复制、不可移动类型为何可以按值返回,区分保证场景与可选 NRVO,并判断基类子对象、转换和析构可访问性等边界。

最小语法

T make() {
    return T(arguments); // 同类型纯右值:C++17 保证直接构造结果对象
}

T make_named() {
    T local(arguments);
    return local;        // 具名对象:NRVO 仍是允许而非强制
}

按值返回不等于必然复制

以下片段只对比写法;完整、可运行的程序见后文。

// C++17 同类型纯右值可直接构造最终结果对象
return Value(42);
// 具名局部返回:NRVO 仍是允许而非强制的优化
Value result(42);
return result;

这两种函数体分别讨论。第一种在相应 C++17 场景不需要中间对象的复制或移动;第二种不能依赖 NRVO 一定发生。适合按值返回计算结果,通常无需为避开“想象中的复制”返回悬空引用或写 return std::move(result)。构造、析构及类型可访问性仍须满足各自规则。

第一个完整示例

Token 同时删除复制和移动构造。若返回过程必须经过中间对象,这段程序就无法通过编译;C++17 的纯右值模型使结果直接出现在 token 的存储位置。

#include <iostream>

class Token {
public:
    explicit Token(int value) : value_(value) {}
    Token(const Token&) = delete;
    Token(Token&&) = delete;
    int value() const { return value_; }

private:
    int value_;
};

Token make_token() {
    return Token(42);
}

int main() {
    Token token = make_token();
    std::cout << "token=" << token.value() << '\n';
}

程序输出 token=42,且不会调用复制或移动构造。保证适用于直接返回同类型纯右值等规定场景;返回具名局部变量依赖 NRVO,仍不是语言强制保证。即使没有中间对象,析构函数在相关上下文中仍必须可访问且未删除。

C++17 纯右值模型

C++17 不只是“要求编译器做一次优化”,而是重新规定许多纯右值直到需要对象存储时才物化。return Token(42) 的纯右值直接初始化调用方结果对象,中间临时对象在抽象机中就不存在,因此类型可以删除复制和移动构造。

这与可选优化的“先创建临时再省略移动”不同。即使关闭常见优化,规定场景也必须合法。析构函数仍需在返回点可访问且未删除,因为语言要验证最终对象生命周期。

纯右值现在更适合被理解为“如何初始化最终结果”的描述,而不是已经占据某处存储的匿名对象。只有在语境需要对象身份时,才通过临时量实质化转换创建结果对象,例如绑定引用、访问成员或执行某些转换。

这种模型区分非常重要:编译器不是先假想调用被删除的移动构造,再优化掉它;在保证场景的程序语义里根本没有那次移动。因此 -fno-elide-constructors 之类选项不能让合法的 C++17 保证场景变成必须移动。

保证场景与 NRVO

同类型纯右值初始化目标对象、返回同类型纯右值等属于保证场景。具名返回值优化 NRVO 处理的是 return local;,局部变量是左值,标准仍允许但不强制省略。若 NRVO 未发生,编译器尝试移动,因此只移动类型通常仍可工作,但完全不可移动类型不能依赖 NRVO。

返回函数参数也不同于返回局部临时;参数已经是独立对象,不属于保证的直接构造目标。

常见保证场景包括用同类型纯右值初始化对象,以及函数 return 表达式是与返回类型匹配的纯右值。这里“同类型”会忽略顶层 cv 限定,但不能把基类子对象、委托构造等可能发生存储重叠的场景一概当作保证目标。

#include <iostream>

class Selection {
public:
    explicit Selection(int value) noexcept : value_(value) {}
    Selection(const Selection&) = delete;
    Selection(Selection&&) = delete;

    int value() const noexcept { return value_; }

private:
    int value_;
};

Selection choose(bool first) {
    if (first) {
        return Selection(1);
    }
    return Selection(2);
}

int main() {
    Selection selected = choose(false);
    std::cout << "selected=" << selected.value() << '\n';
}

控制流有多个返回点并不妨碍保证规则;每个被执行的返回表达式都是 Selection 纯右值,直接初始化调用方结果对象。相反,若两个分支先分别创建具名局部变量再 return local;,就回到了可选 NRVO,完全不可移动的类型将不再可移植。

与转换、基类和子对象的边界

若返回表达式类型需要先转换成返回类型,是否存在中间对象取决于具体初始化规则,不能仅凭“按值返回”判断。工厂函数最好直接构造声明的返回类型,而不是依赖一串用户定义转换后再期待所有步骤都被消除。

初始化潜在重叠子对象时,语言不能总是假设源纯右值就是最终完整对象。例如构造基类子对象时,目标存储可能属于某个更大派生对象,相关规则与初始化完整对象不同。编写不可移动类型时,应对具体表达式做编译测试,不把“C++17 都会消除复制”当作总规则。

抛出异常对象时的复制消除、捕获处理器参数以及协程参数等也各有独立条款,不能由返回值保证规则直接类推。

生命周期和副作用

不应把复制/移动构造函数的可观察副作用作为业务逻辑。允许或保证的省略会改变这些函数是否执行。对象析构时间按相关规则选择较晚时点,但具体场景仍应以生命周期规则为准。

引用绑定会触发纯右值的临时量实质化,并可能延长临时对象生命周期。此时仍只有一个结果对象,只是它获得了可被引用的身份和存储。成员访问通常也需要先物化对象,再定位成员。

对于传统可选复制消除,如果实现把源和目标视为同一对象,析构发生在“若不省略时两个对象本应析构的较晚时点”。资源管理必须依靠正常析构语义,不能依赖为了日志或计数而出现固定次数的复制、移动或析构调用。

性能含义

按值返回大型对象可以直接构造到调用方存储,常与清晰所有权设计一致。它减少中间分配和搬迁,但对象内部是否分配仍由类型实现决定。为了“帮助优化”而手写 std::move(local) 可能阻止 NRVO。

return std::move(local); 把表达式变为 xvalue,通常使其不再满足 NRVO 的具名局部变量形式,于是必须执行移动。编译器可能警告这种悲观移动。正确写法通常是 return local;:允许实现做 NRVO,未做时语言规则再尝试移动。

保证复制消除只移除对象间复制/移动,不会自动消除构造函数内部的堆分配、系统调用或数据初始化。性能分析仍应观察最终类型的构造成本,而不是把所有按值返回都视为零成本。

示例解析与实践

示例的 Token 完全不可复制移动,却能从 make_token 返回,直接证明这里是语言保证。工程中优先按值返回拥有结果,避免返回局部引用;对 NRVO 场景保留简单 return local;,并让类型在合理时仍具备正确移动语义。

临时量实质化转换

C++17 prvalue 在需要 glvalue 身份时经 temporary materialization conversion 初始化一个临时结果对象,并产生指向它的 xvalue。绑定引用、成员访问、某些转换等语境会触发。

这让“prvalue 本身就是一个早已存在临时对象”的旧直觉失效。先决定目标存储,再用 prvalue 初始化它,能解释为何同类型返回没有中间 move。

引用绑定可延长某些直接临时寿命,但经函数参数/返回引用、子对象和转换链的规则各有边界。复制消除不等于所有临时引用都安全。

析构函数仍需可访问

即使抽象机不构造可被移动的中间对象,返回/初始化语境仍要求最终类型析构函数在相应点可访问且未删除。完全不可移动类型可以返回,完全不可析构类型不能借保证消除绕过生命周期检查。

私有析构配合友元工厂时,调用方最终销毁对象也必须有合法访问/所有权封装。保证构造位置不改变访问控制。

不适用的目标子对象

保证同类型 prvalue 直接初始化通常针对完整对象或明确非潜在重叠子对象。基类子对象、使用 delegating constructor 的某些目标等可能是潜在重叠子对象,不能一概省去移动。

这是因为完整派生对象布局可能让子对象与其他对象表示存在特殊关系,语言不能总把源临时存储等同最终子对象存储。编写不可移动基类构造包装时必须以实际表达式编译验证。

数组元素/成员初始化是否保证也取决于目标类型与初始化规则,不应从返回值例子泛化“任何 prvalue 都无临时”。标准术语比 RVO 口号更可靠。

NRVO 的精确候选形态

NRVO 典型要求 return 操作数是自动存储期、非 volatile 的具名局部对象,且类型与返回类型满足对应条件。函数参数和 handler 参数不属于同一候选。

多个 return 若返回不同局部变量,编译器可能无法为它们选择同一返回槽,NRVO 几率降低且不保证。工厂可直接返回各分支 prvalue,或让类型正确可移动。

给局部加 volatile 会阻止普通移动/NRVO候选路径并改变访问语义,不是调试复制次数的可取方法。使用编译器选项观察可选消除时,也要知道保证场景不能被关闭。

隐式移动

return local 在未执行 NRVO 时,会按返回语句规则把某些局部视为可移动候选,先尝试右值重载,再按规则回退。无需手写 move;手写反而阻止 NRVO。

函数参数返回虽非保证/NRVO对象,也可有隐式移动规则。参数本来已是独立对象,最终仍可能执行移动构造,完全不可移动类型不能这样返回。

副作用与调试

复制/移动构造中的日志、计数和注册不能作为业务必需副作用,因为允许/保证消除会改变调用次数。测试应断言最终对象资源,不断言固定 move 日志序列。

-fno-elide-constructors 可帮助观察某些可选路径,但 C++17 保证 prvalue 场景仍不调用被删除移动。不同优化级别/编译器的 NRVO选择也可不同,均可能标准合规。

性能分析应看对象内部构造、分配和返回槽生成;“日志没有 move”不代表零成本,“看到 move”也可能只是未优化构建的可选路径。

构造语境速查

场景 C++17 保证/边界
T x = T(args) prvalue 直接初始化结果对象
return T(args) 返回 T 直接在调用结果存储构造
按值参数由同类型 prvalue 初始化 直接构造参数对象
NRVO return local 仍是允许优化,不是普遍强制
return std::move(local) 阻止 NRVO 候选形状,通常不推荐
基类子对象初始化 潜在重叠子对象限制保证消除
委托构造目标 同样需考虑潜在重叠边界
删除复制/移动 保证消除场景可仍合法,但析构需可访问
临时物化 仅在需要 glvalue/对象身份等语境发生
副作用 不应依赖可选 NRVO 是否发生

运行本篇示例

源码保存在本文的完整 cpp 围栏中。以下命令从仓库根目录执行,提取并验证本篇全部示例:

python3 tools/verify_examples.py --compiler clang++ --path docs/cpp17/copy-elision.md

权威资料

提案用于理解设计动机和最初采用的方案;规范性行为应以对应标准版本和后续缺陷修正后的工作草案为准。