学习15 分钟阅读编辑本文 ↗

Standard C++ 基础回炉|第 1 课(1-3):函数返回对象、移动语义与 RVO/NRVO

通过对象生命周期跟踪实验,理解函数返回对象时的移动构造、拷贝消除,以及 C++17 下 RVO 与 NRVO 的区别。

Standard C++ 基础回炉|第 1 课(1-3):函数返回对象、移动语义与 RVO/NRVO

1. 学习目标

通过一个 Tracker 类观察对象在函数返回过程中的生命周期,理解:

  • 普通构造、拷贝构造、移动构造
  • 拷贝赋值与移动赋值
  • std::move 的基本作用
  • 函数返回局部对象时发生了什么
  • RVO 与 NRVO 的区别
  • C++17 对 prvalue 返回值的规则
  • -fno-elide-constructors 为什么只能关闭部分拷贝消除
  • moved-from 对象是什么状态
  • 为什么通常不要写 return std::move(local);

2. 实验代码

#include <iostream>
#include <string>
#include <utility>

class Tracker {
public:
    explicit Tracker(std::string name)
        : name_(std::move(name)) {
        log("construct");
    }

    Tracker(const Tracker& other)
        : name_(other.name_ + ".copy") {
        log("copy construct");
    }

    Tracker(Tracker&& other) noexcept
        : name_(std::move(other.name_)) {
        log("move construct");
    }

    Tracker& operator=(const Tracker& other) {
        name_ = other.name_ + ".copy-assigned";
        log("copy assign");
        return *this;
    }

    Tracker& operator=(Tracker&& other) noexcept {
        name_ = std::move(other.name_);
        log("move assign");
        return *this;
    }

    ~Tracker() {
        log("destroy");
    }

private:
    void log(const char* action) const {
        std::cout << action << ": "
                  << (name_.empty() ? "<moved-from>" : name_)
                  << '\n';
    }

    std::string name_;
};

Tracker make_named_object() {
    Tracker value{"named-result"};
    return value;
}

Tracker make_temporary_object() {
    return Tracker{"temporary-result"};
}

int main() {
    std::cout << "=== named object ===\n";
    Tracker first = make_named_object();

    std::cout << "\n=== temporary object ===\n";
    Tracker second = make_temporary_object();

    std::cout << "\n=== end main ===\n";
}

3. <utility> 的作用

代码中:

#include <utility>

主要是为了使用:

std::move()

<utility> 中常见的工具包括:

  • std::move
  • std::forward
  • std::swap
  • std::pair
  • std::make_pair

当前阶段重点掌握 std::move 和后续的 std::forward。

需要特别注意:

std::move 本身并不会真正移动资源,它主要进行类型转换,使对象能够匹配移动构造、移动赋值等接受右值引用的操作。


4. Tracker 类在观察什么

Tracker 的目的不是实现业务功能,而是通过日志观察对象生命周期。

它定义了:

操作 函数


普通构造 Tracker(std::string) 拷贝构造 Tracker(const Tracker&) 移动构造 Tracker(Tracker&&) 拷贝赋值 operator=(const Tracker&) 移动赋值 operator=(Tracker&&) 析构 ~Tracker()

例如:

Tracker a{"AAA"};
Tracker b = a;
Tracker c = std::move(a);

分别可能触发:

construct: AAA
copy construct: AAA.copy
move construct: AAA

5. 构造和赋值一定要区分

5.1 拷贝构造

Tracker a{"AAA"};
Tracker b = a;

此时 b 原本不存在。

这里是在创建一个新对象,所以调用:

Tracker(const Tracker&);

5.2 拷贝赋值

Tracker a{"AAA"};
Tracker b{"BBB"};

b = a;

此时 b 已经存在。

这里只是把新的内容赋给已有对象,因此调用:

Tracker& operator=(const Tracker&);

5.3 移动构造

Tracker a{"AAA"};
Tracker b = std::move(a);

b 原本不存在,所以调用移动构造:

Tracker(Tracker&&);

5.4 移动赋值

Tracker a{"AAA"};
Tracker b{"BBB"};

b = std::move(a);

b 已经存在,所以调用移动赋值:

Tracker& operator=(Tracker&&);

总结:

代码 操作


Tracker b = a; 拷贝构造 Tracker b = std::move(a); 移动构造 b = a; 拷贝赋值 b = std::move(a); 移动赋值

核心判断:

新对象正在产生,就是构造;对象已经存在,就是赋值。


6. 第一个实验:返回有名字的局部对象

函数:

Tracker make_named_object() {
    Tracker value{"named-result"};
    return value;
}

调用:

Tracker first = make_named_object();

value 是一个有名字的局部对象。

直觉上可能认为过程是:

构造 value
    ↓
return value
    ↓
移动到函数返回结果
    ↓
初始化 first

但默认编译时实际输出:

=== named object ===
construct: named-result

没有看到:

move construct

原因是编译器进行了 NRVO。


7. NRVO:Named Return Value Optimization

NRVO 全称:

Named Return Value Optimization

即:

有名返回值优化。

针对:

Tracker value{"named-result"};
return value;

这种返回有名字局部对象的场景。

编译器可以避免先创建一个独立的 value,然后再把它移动到最终对象。

概念上原本是:

函数内部 value
      ↓
    move
      ↓
main 中 first

进行 NRVO 后,可以理解成:

直接在 first 最终占用的存储位置构造对象

效果近似:

Tracker first{"named-result"};

所以:

  • 没有额外拷贝
  • 没有额外移动
  • 没有中间对象的析构

只看到一次:

construct: named-result

8. 使用 -fno-elide-constructors 关闭可选拷贝消除

实验中另一个程序使用类似:

g++ main.cpp -std=c++17 -fno-elide-constructors -o no_elide.exe

运行得到:

=== named object ===
construct: named-result
move construct: named-result
destroy: <moved-from>

这三行非常重要。


8.1 第一步:构造 value

执行:

Tracker value{"named-result"};

输出:

construct: named-result

此时:

value.name_ = "named-result"

8.2 第二步:return value

执行:

return value;

由于 NRVO 被关闭,需要把 value 的内容交给函数返回结果。

这里可以使用隐式移动,因此调用:

Tracker(Tracker&& other) noexcept;

输出:

move construct: named-result

资源关系可以概念化为:

value.name_
"named-result"
      ↓
     move
      ↓
返回对象.name_
"named-result"

8.3 第三步:value 析构

移动完成后,函数即将结束。

局部变量:

value

仍然是一个存在的 C++ 对象,因此必须正常析构。

在当前 std::string 实现和本次运行中,它的 name_ 移动后为空,因此日志输出:

destroy: <moved-from>

完整过程:

value 创建
    ↓
construct: named-result

return value
    ↓
移动构造返回对象
    ↓
move construct: named-result

value 被移动过
    ↓
函数结束
    ↓
value 析构
    ↓
destroy: <moved-from>

9. moved-from 对象是什么

执行:

Tracker b = std::move(a);

之后,a 并没有被销毁。

它仍然是一个合法对象。

对于标准库类型,移动后的对象通常处于:

valid but unspecified state

即:

对象仍然有效,可以被析构、重新赋值等,但通常不能依赖它当前保存的具体值。

本次实验中:

std::string

移动后恰好为空,因此:

name_.empty()

返回 true,日志打印:

<moved-from>

但不能把它背成:

std::move 之后字符串一定为空。

更准确的理解是:

移动后的对象仍然合法,但其具体内容通常不应继续依赖。


10. 第二个实验:直接返回临时对象

函数:

Tracker make_temporary_object() {
    return Tracker{"temporary-result"};
}

调用:

Tracker second = make_temporary_object();

这里与:

Tracker value{"named-result"};
return value;

有一个关键区别。

value 是:

有名字的局部对象。

而:

Tracker{"temporary-result"}

是一个 prvalue(纯右值)。


11. C++17 的直接构造规则

默认程序输出:

=== temporary object ===
construct: temporary-result

关闭可选拷贝消除后:

=== temporary object ===
construct: temporary-result

仍然完全一样。

没有:

move construct

这是因为从 C++17 开始,这种场景下不会要求:

创建临时 Tracker
      ↓
move
      ↓
second

而是直接在最终目标对象的存储位置构造。

可以概念化理解成:

Tracker second{"temporary-result"};

所以只需要一次普通构造。


12. 为什么 -fno-elide-constructors 关不掉它

这是本实验最关键的区别之一。

对于:

Tracker value{"named-result"};
return value;

NRVO 是可选优化。

因此:

-fno-elide-constructors

可以让我们观察到没有 NRVO 时的移动过程。

但是:

return Tracker{"temporary-result"};

在 C++17 的对应场景中,直接构造最终对象是语言规则的一部分。

因此即使指定:

-fno-elide-constructors

仍然不会人为制造一个无意义的临时对象再移动一次。


13. 两种返回方式对比

有名字的局部对象

Tracker make_named_object() {
    Tracker value{"named-result"};
    return value;
}

默认情况下:

construct

通常进行 NRVO。

关闭 NRVO 后:

construct
move construct
destroy moved-from

直接返回 prvalue

Tracker make_temporary_object() {
    return Tracker{"temporary-result"};
}

C++17 下:

construct

即使使用:

-fno-elide-constructors

仍然是:

construct

14. 本次实际输出分析

default.exe

=== named object ===
construct: named-result

=== temporary object ===
construct: temporary-result

=== end main ===
destroy: temporary-result
destroy: named-result

named object

发生 NRVO:

value
  ↓
直接构造到 first 的最终位置

所以没有移动构造。

temporary object

C++17 下直接在最终对象位置构造。

所以同样没有移动构造。


15. no_elide.exe 输出分析

=== named object ===
construct: named-result
move construct: named-result
destroy: <moved-from>

=== temporary object ===
construct: temporary-result

=== end main ===
destroy: temporary-result
destroy: named-result

named object

NRVO 被关闭:

构造 value
    ↓
移动到返回结果
    ↓
value 析构

因此:

construct
move construct
destroy moved-from

temporary object

C++17 的直接构造规则仍然生效:

construct

没有额外移动。


16. 为什么最后先析构 second

main():

Tracker first = make_named_object();
Tracker second = make_temporary_object();

构造顺序:

first
  ↓
second

同一作用域内的局部对象通常按照构造顺序的逆序析构。

因此:

second
  ↓
first

最终输出:

destroy: temporary-result
destroy: named-result

17. RVO 与 NRVO 的理解

广义上,RVO(Return Value Optimization)表示返回值相关的拷贝消除。

常见讨论可以区分为:

NRVO

Tracker func() {
    Tracker obj;
    return obj;
}

obj 是有名字的局部变量。

这是:

Named Return Value Optimization

NRVO 不是所有情况下都强制执行。


直接返回 prvalue

Tracker func() {
    return Tracker{};
}

C++17 起,对这种典型场景直接构造最终结果,不需要先构造一个独立临时对象再移动。

学习时常会把这种现象称作 RVO,但需要知道:

C++17 对 prvalue 的语义已经发生变化,它不仅仅是"编译器觉得可以所以优化一下"。


18. 不要随便 return std::move(obj)

看到:

return obj;

很容易产生一个错误想法:

既然移动比拷贝快,那写成 std::move 岂不是更快?

例如:

Tracker func() {
    Tracker obj{"test"};
    return std::move(obj);
}

通常不推荐这么写。

原因是:

return obj;

可能满足 NRVO 条件。

而:

return std::move(obj);

表达式不再是直接返回那个局部变量本身,通常会破坏 NRVO 的适用条件,从而真的产生一次移动构造。

所以通常应该写:

return obj;

让编译器进行 NRVO;即使没有 NRVO,符合条件时也可以进行隐式移动。


19. 面试回答

Q1:什么是 RVO/NRVO?

可以回答:

RVO 是函数返回对象时的拷贝消除机制。NRVO 是针对有名字局部变量的返回值优化,例如创建局部对象 obj 后 return obj。编译器可以直接在最终返回对象的存储位置构造 obj,避免额外的拷贝或移动。C++17 对直接返回 prvalue 的典型场景提供了语言层面的保证,而 NRVO 本身仍不是普遍强制的。


Q2:为什么定义了移动构造函数却没有执行?

可以回答:

因为编译器可能进行了返回值优化。如果对象可以直接在最终目标位置构造,就根本不存在"从中间对象移动到目标对象"的过程,因此移动构造函数不会被调用。


Q3:std::move 会真正移动对象吗?

可以回答:

不会。std::move 本质上主要是类型转换,把表达式转换为可供移动操作匹配的右值形式。真正的资源转移发生在移动构造函数或移动赋值函数内部。


Q4:对象被 move 后还能使用吗?

可以回答:

对象仍然存在,也必须正常析构。对于标准库对象,移动后通常保证处于有效但未指定的状态,可以重新赋值或析构,但不应该依赖它当前保存的具体值。


Q5:函数返回局部对象时应该写 std::move 吗?

可以回答:

通常不应该。直接 return obj 可以让编译器进行 NRVO;即使没有 NRVO,符合条件时也可以隐式移动。显式写 return std::move(obj) 反而可能阻止 NRVO。


20. 核心结论

本知识点最终需要记住:

  1. 构造和赋值不是一回事。
  2. 移动构造用于创建新对象,移动赋值用于已有对象。
  3. std::move 本身不移动资源。
  4. 被 move 的对象仍然存在,最终仍要析构。
  5. 移动后的标准库对象通常处于 valid but unspecified state。
  6. 返回有名字的局部对象可能触发 NRVO。
  7. 关闭 NRVO 后,return value 通常可以观察到隐式移动。
  8. C++17 直接返回 prvalue 时,可以直接构造最终对象。
  9. 因此 -fno-elide-constructors 也无法让这类 C++17 强制规则消失。
  10. 不要为了"优化"随便写 return std::move(local)。
  11. 能直接构造最终对象时,比先构造再移动更加彻底。

21. 推荐继续实验

在现有代码基础上修改:

Tracker make_named_object() {
    Tracker value{"named-result"};
    return std::move(value);
}

分别运行:

g++ main.cpp -std=c++17 -o default.exe

和:

g++ main.cpp -std=c++17 -fno-elide-constructors -o no_elide.exe

观察:

move construct

是否出现。

然后继续对比:

return value;

与:

return std::move(value);

这样可以直接验证:

显式 std::move 并不总是优化,有时反而会阻止 NRVO。