C++ 移动语义入门

移动语义是 C++ 11 引入的特性。一个对象不再需要某种资源(如堆内存、套接字、文件等)时,可以把持有的资源交给其他对象,而不是复制一份再交给其他对象,省下重复分配和复制的工作。右值引用和移动语义就能够表达这种意图。

表达式的值类别 ​

首先要区别 类型 和 值类别 两个概念。例如我们声明一个变量 int x = 1;,显然 x 的数据类型是 int。从表达式的角度来看 x,类型告诉我们这个表达式“是什么”(是数字?字符串对象?指针?),值类别告诉我们这个表达式如何标识一个对象,以及该对象的资源是否可以被复用。值类别是表达式的属性,而不是对象的属性。

现代 C++ 有三种值类别:lvalue(左值),xvalue(将亡值),prvalue(纯右值)。其中 lvalue 和 xvalue 合称为 glvalue(广义左值),xvalue 和 prvalue 合称 rvalue(右值)。“左值”和“右值”这两个术语属于历史遗留问题,不代表它们一定能/不能放到赋值号的左边。“将亡值”不保证对象马上被销毁,只是意味着资源可以被转移。常见的表达式值类型如下:

表达式值类别理解方式
s,其中 s 是已命名的 std::stringlvalue(左值)标识一个对象
std::move(s)xvalue(将亡值)标识原对象,但允许资源被移走
std::string{"hi"}prvalue(纯右值)用于计算值、初始化结果对象
42prvalue一个值
rr,其中声明为 std::string&& rrlvalue名字表达式仍标识一个对象,即使是右值引用,它也是左值
  • 一个 glvalue(“广义”左值)是一个表达式,其求值确定了一个对象或函数的身份;

  • 一个 prvalue(“纯”右值)是一个表达式,其求值

    • 计算内置运算符操作数的值(这种 prvalue 没有结果对象),或者

    • 初始化一个对象(这种 prvalue 被认为有一个结果对象)。

      结果对象可以是一个变量,一个由 new-expression 创建的对象,一个由 临时实质化 创建的临时对象,或者它们的成员。请注意,非 void 被丢弃的 表达式具有结果对象(实质化的临时对象)。此外,每个类和数组 prvalue 都有一个结果对象,除非它是 decltype 的操作数;

  • 一个 xvalue(“将过期”的值)是一个 glvalue,它表示一个其资源可以被重用的对象;

  • 一个 lvalue 是一个不是 xvalue 的 glvalue;

更详细的定义可在 cppreference 找到。

引用参数 ​

实践中的函数有三种常见的引用参数:

cpp
void read(const std::string&); // 只读借用;可绑定左值,也可绑定右值
void edit(std::string&);       // 可修改的左值引用;不绑定临时字符串
void take(std::string&&);      // 右值引用;可绑定临时对象或 std::move(s)

以 T&& 类型作为形参,可以区分实参对象是否允许被移走它持有的资源。但是将一个右值引用绑定到拥有资源的对象并不会移走它的资源,也不会创建另一个拥有资源的对象,例如:

cpp
std::string s = "hello";
std::string&& rr = std::move(s); // rr 是 s 的别名,s 的内容还没变
std::string a = rr;             // rr 表达式是左值:拷贝
std::string b = std::move(rr);  // 选择适用的右值构造重载

右值引用是一个左值,在函数体内需要注意:

cpp
void take(std::string&& parameter) {
    std::string a = parameter;            // 拷贝
    std::string b = std::move(parameter); // 移动
}

又如,该程序最后输出为 1,因为虽然 main 函数中传入了右值,但是在函数体内 other 是具名的右值引用,因此 other 作为表达式是一个左值。在 A::foo 函数中,自然就会匹配到 val(A&) ,也就是左值引用版本。想要保持表达式是右值,需要用到完美转发。

cpp
class A {
public:
	void foo(A&& other) {
		val(other);
	}

	void val(A&) {
		std::cout << "1" << std::endl;
	}
	void val(A&&) {
		std::cout << "2" << std::endl;
	}
};

int main() {
	A a, b;
	a.foo(std::move(b));
}

“拷贝”是比“移动”更保守的做法,因为具名的参数可能会被多次使用,如果资源被拿走了,后面却又用到了源对象,很可能出现运行时的错误。确实需要“移动”时,需要用 std::move() 明确表示。

在普通引用形参的绑定和重载决议中,编译器选择重载函数不关心传入的实参 变量 被声明为左值还是右值引用,关心的是 表达式 的类型和值类别。

移动构造与移动赋值 ​

假设某人准备搬家,一般的做法是把他的家具全部从旧房子搬到新房子去,而不是把所有家具重新买一份放到新房子,然后扔掉那些旧家具。在对象持有资源时,如果要转移它持有的资源,用拷贝构造函数会造成不必要的性能损失。C++ 11 引入了右值引用和移动语义解决这个问题。

如果某个对象拥有一块大小为 N 的堆内存,对它进行拷贝,则需要再新对象内重新分配内存,并复制其中的元素,复杂度为 O(N)O(N);如果进行的是移动,可以让新对象直接接管堆内存的指针,原来的对象进入可以被安全销毁的状态,复杂度为 O(1)O(1)。

text
拷贝前:a ──→ [1, 2, 3]
拷贝后:a ──→ [1, 2, 3]     b ──→ [1, 2, 3](另一块内存)
------------------------------------------------------
移动前:a ──→ [1, 2, 3]
移动后:a ──→ nullptr       b ──→ [1, 2, 3](原来那块内存)

下面展示了一个拥有移动构造、移动赋值函数的类:

cpp
// Invariant: size_ == 0 iff data_ == nullptr.
// This class explicitly promises an empty source after moving to another object.
class Buffer {
public:
    Buffer() noexcept = default;
    // 无参构造标记为不抛出异常,使用编译器提供的默认构造函数

    explicit Buffer(std::size_t size)
        : size_(size), data_(size ? std::make_unique<int[]>(size) : nullptr) {}
    // explicit 表示该构造函数不能用于隐式类型转换
    // 初始化列表首先初始化 size_,若size不为0,则申请长度为size的堆内存数组,否则为空指针

    ~Buffer() = default;
    // 析构函数使用默认析构函数

    Buffer(const Buffer& other) : Buffer(other.size_) {
        if (size_ != 0) {
            std::copy_n(other.data_.get(), size_, data_.get());
        }
    }
    // 拷贝构造函数,使用左值引用形参

    Buffer& operator=(const Buffer& other) {
        if (this != &other) {
            Buffer copy(other); // Allocation can throw; *this is still unchanged.
            swap(copy);
        }
        return *this;
    }
    // 拷贝赋值函数,使用左值引用形参,且返回对自身的引用

    Buffer(Buffer&& other) noexcept
        : size_(std::exchange(other.size_, 0)), data_(std::move(other.data_)) {}
    // 移动构造函数,使用右值引用形参,标记为不抛出异常

    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            data_ = std::move(other.data_); // Releases our previous allocation.
            size_ = std::exchange(other.size_, 0);
        }
        return *this;
    }
    // 移动赋值函数,使用右值引用形参,标记为不抛出异常

    void swap(Buffer& other) noexcept {
        using std::swap;
        swap(size_, other.size_);
        swap(data_, other.data_);
    }

    // [[nodiscard]] 表示返回值不可忽略,否则会报出警告
    [[nodiscard]] std::size_t size() const noexcept { return size_; }
    [[nodiscard]] bool empty() const noexcept { return size_ == 0; }
    [[nodiscard]] const int* data() const noexcept { return data_.get(); }

    int& at(std::size_t index) {
        if (index >= size_) {
            throw std::out_of_range("Buffer::at");
        }
        return data_[index];
    }

    const int& at(std::size_t index) const {
        if (index >= size_) {
            throw std::out_of_range("Buffer::at");
        }
        return data_[index];
    }

private:
    std::size_t size_ = 0;
    std::unique_ptr<int[]> data_;
};

inline void swap(Buffer& left, Buffer& right) noexcept { left.swap(right); }

被移动后的对象可以继续使用,但不应依赖它原有的内容,也不能假设该对象为空(称为“值未指定”)。移动语义不保证源对象被移动后置空,除非类本身提供了更强的保证。

特殊成员函数 ​

  • 析构函数:~Object()
    • 调用每个成员和基类的析构函数
    • 负责在对象生命周期结束后释放其持有的资源
  • 拷贝构造函数:Object(const Object& other)
    • 调用每个成员和基类的拷贝构造函数
    • 通过另一个同类对象初始化当前对象
    • 规定了对象如何被拷贝(例如浅拷贝、深拷贝)
    • 默认为浅拷贝
  • 拷贝赋值函数:Object& operator=(Object& other)
    • 调用每个成员和基类的拷贝赋值函数
    • 需要检查自赋值的情况,即检查赋值者与被赋值者是否是同一个对象
    • 默认为浅拷贝
    • 一般先检查自赋值,调用拷贝构造创建临时对象,然后从临时对象赋值
  • 移动构造函数:Object(Object&& other)
    • 调用每个成员和基类的移动构造函数
    • 通过移动而非拷贝来初始化一个对象,通常涉及资源的转移,使得原对象变为无效状态
  • 移动赋值函数:Object& operator=(Object&& other)
    • 调用每个成员和基类的移动赋值函数
    • 通过移动的方式进行赋值,转移其他对象持有的资源,使原对象变为无效状态

三五零法则 ​

三五零法则 是一种最佳实践的规范(尤其是配合 RAII),不符合也不是语法错误。

  • 三法则:如果一个类需要显式定义【析构函数、拷贝构造函数、拷贝赋值函数】其中一项时,那么它应当显式定义这全部的三项。
    • 注:在代码中定义函数体、=delete、=default 都是“用户定义的特殊函数”。
  • 五法则:是三法则的扩展。如果一个类需要显式定义【析构函数、拷贝构造函数、拷贝赋值函数、移动构造函数、移动赋值函数】其中一项时,那么建议显式定义这全部的五项。
    • 不实现移动语义也不是错误的,但是复制操作效率相对更低。
    • 用户定义的析构函数、拷贝构造函数、拷贝复制函数会阻止编译器生成默认的移动构造、移动赋值函数,所以如果我们需要移动语义,必须定义以上全部五个特殊成员函数。
    • 如果定义了移动赋值/移动构造函数,相应的默认拷贝构造/拷贝赋值函数会被标记为删除状态。
  • 零法则:如果没有显式定义任何特殊成员函数,则编译器会隐式定义所有特殊成员函数。
    • 如果一个类需要自己管理动态资源,则需要遵循五法则;如果不需要,则遵循零法则。
    • 建议使用智能指针等工具自动管理资源,避免重复释放、内存泄漏等问题。

具有自定义析构函数、复制/移动构造函数或复制/移动赋值运算符的类应专门处理所有权(这遵循单一职责原则)。其他类不应具有自定义析构函数、复制/移动构造函数或复制/移动赋值运算符。

使用 =default 只是请求编译器生成默认函数,而不保证能生成成功。例如:

cpp
struct B {
	B() = default;
	B(B&& b) = delete;
	B& operator=(B&& b) = delete;
};


struct A {
	B b;
	A() = default;
	A(A&& a) = default;
	A& operator=(A&& a) = default;
};


int main(void) {
	A a1, a2;
	// A a3(std::move(a1));
    // error: use of deleted function 'A::A(A&&)'
    // note: 'A::A(A&&)' is implicitly deleted because the default definition would be ill-formed:
    // error: use of deleted function 'B::B(B&&)'
}

移动构造需要初始化 B 类成员对象 b,但它的移动构造函数被删除了,因此编译器无法生成需要的移动构造函数(不能绕过成员定义的限制),将其标记为已删除。但如果我们为 A B 添加了拷贝构造函数,则可以退化到拷贝完成初始化。

另外,如果这个移动构造函数是标记为默认函数后被删除,该函数会被重载决议忽略,如果有可用的拷贝构造函数,可退化到拷贝。如果是用户定义的 =delete,该函数仍然会被重载决议考虑,选中该函数会导致编译错误(即使有可用的拷贝构造也不会使用)。

标准库模板 std::is_move_constructible_v<T> 可用于检查是否能用 T&& 构造 T,而不是判断是否有一个可用的移动构造函数。这意味着,如果我们只定义了拷贝构造函数(编译器这时不会生成默认的移动构造函数),虽然传入右值引用时的行为是拷贝,但这个模板仍然得到 true。

以下代码可以验证部分 Buffer 类的特殊成员函数的行为:

cpp
void check(bool condition, const char* message) {
    if (!condition) {
        throw std::runtime_error(std::string("Check failed: ") + message);
    }
}

int main() {
    using tutorial::Buffer;
    static_assert(std::is_nothrow_move_constructible_v<Buffer>);
    // 确定有不抛出异常的移动构造函数
    static_assert(std::is_nothrow_move_assignable_v<Buffer>);
    // 确定有不抛出异常的移动赋值函数

    Buffer original(3);
    original.at(0) = 42;
    Buffer copied = original;
    check(copied.data() != original.data(), "copy owns a separate allocation");
    // 验证拷贝构造函数,因为初始化 copied 的是左值
    copied.at(0) = 7;
    check(original.at(0) == 42, "copy is independent");
    // 验证拷贝构造函数

    const int* allocation = original.data();
    Buffer moved = std::move(original);
    check(moved.data() == allocation && moved.at(0) == 42, "move transfers allocation");
    check(original.empty() && original.data() == nullptr, "our source becomes empty");
    // 验证移动构造函数是否真正转移了原对象的资源

    Buffer target(10);
    target = std::move(moved);
    check(target.data() == allocation && moved.empty(), "move assignment transfers allocation");
    target = std::move(target);
    check(target.data() == allocation && target.at(0) == 42, "our self-move is a no-op");
    // 验证移动赋值函数是否能转移原对象资源,以及对于自赋值情况的特殊处理(什么都不做)

    original = target; // 拷贝赋值
    original.at(0) = 99;
    check(target.at(0) == 42, "copy assignment is deep");
    // 验证深拷贝
    original = original;
    check(original.at(0) == 99, "self-copy preserves value");
    // 自拷贝的情况,仍保持原来的值

    Buffer empty;
    Buffer empty_copy(empty);
    target = std::move(empty);
    check(target.empty() && empty_copy.empty(), "empty buffers are supported");
    // 空容器
    bool rejected = false;
    try { target.at(0); } catch (const std::out_of_range&) { rejected = true; }
    check(rejected, "at() checks bounds even after a move");
    // 验证这次实现的 Buffer 是保证移动后清空原对象的
    std::cout << "copy: independent allocation\nmove: same allocation, empty source\n";
    std::cout << "assignment, self-move, empty and bounds checks passed\n";
}

std::move ​

std::move() 是 <utility> 下定义的函数,作用是产生一个可被绑定到右值引用的表达式。这个函数并不像字面意义那样“移动”了什么东西,也不会产生汇编指令:

cpp
 /**
   *  @brief  Convert a value to an rvalue.
   *  @param  __t  A thing of arbitrary type.
   *  @return The parameter cast to an rvalue-reference to allow moving it.
  */
  template<typename _Tp>
    constexpr typename std::remove_reference<_Tp>::type&&
    move(_Tp&& __t) noexcept
    { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

可以看到 std::move 只是把参数做了类型转换——转换为允许移动的右值,不涉及对传入实参的任何修改。std::move 也不会移除实参的 const 属性,例如:

cpp
int main(void) {
	const string s1 = "hello";
	string s2 = std::move(s1);
	cout << (void*)s1.c_str() << endl; // 0x64feac
	cout << (void*)s2.c_str() << endl; // 0x64fe94
	return 0;
}

虽然 std::move 确实返回了 s1 的右值引用,但它带着const,这就意味着 std::string& 形参不能绑定这个右值引用。这样就只能让拷贝构造的 const std::string& 绑定它,最终退化到了拷贝构造。

移动意味着需要修改对象持有的资源的所有权状态,因此通常来说移动接口接受的都是非 const 的右值引用。如果一个类型只有移动构造/赋值,且禁止对应的拷贝构造/赋值(例如 unique_ptr ),从一个带 const 限定的对象移动资源通常会导致编译错误。

关于 noexcept ​

标准库常用 std::move_if_noexcept 保证移动语义的异常安全,其实它和 std::move 很像,只不过会根据传入类的特殊函数的情况确定返回哪种引用,从而影响后续模块使用移动还是拷贝。

cpp
  /**
   *  @brief  Conditionally convert a value to an rvalue.
   *  @param  __x  A thing of arbitrary type.
   *  @return The parameter, possibly cast to an rvalue-reference.
   *
   *  Same as std::move unless the type's move constructor could throw and the
   *  type is copyable, in which case an lvalue-reference is returned instead.
   */
  template<typename _Tp>
    constexpr typename
    conditional<__move_if_noexcept_cond<_Tp>::value, const _Tp&, _Tp&&>::type
    move_if_noexcept(_Tp& __x) noexcept
    { return std::move(__x); }

从注释和代码中可以知道,在常见标准库实现中:

元素类型条件扩容时常见的搬迁选择
移动构造保证不抛异常移动
移动可能抛异常,且能拷贝拷贝
移动可能抛异常,且不能拷贝仍需移动;若抛异常,某些保证受限

C++ 不强制移动构造函数和移动赋值函数必须是 noexcept 的,但如果确定这些函数不会抛出异常,最好加上,这会影响编译器的重载决议。在移动元素的过程中,执行移动的行为可能是比较容易的,但如果中途突然抛出异常,那就很难恢复原来的状态了。如果通过拷贝搬迁,旧元素仍保留原值;新内存里的构造失败后,可清理已经创建的新元素,保留旧容器。这有助于满足相应操作的强异常保证。

  • 假如有 vector<Buffer> vb;,当动态数组 vb 需要扩容时,标准库为防止在移动过程中破坏原有元素,需要强异常保证:重新分配内存并搬移旧元素时,必须保证即使抛出异常,原来的 vector 必须保持不变。如果移动构造函数可能抛出异常,编译器将会选择使用拷贝构造函数(通常使用 std::move_if_noexcept 等实现):
    • 如果移动构造函数是 noexcept 的,则使用高效的移动;
    • 如果移动构造函数不是 noexcept 的且允许拷贝,则退化为低效但安全的拷贝构造;
    • 如果移动构造函数不是 noexcept,但又不允许拷贝,则只能用移动,可能影响异常安全。
  • 其他标准库函数(例如 vector::erase 等)也会用类似的规则判断是否使用移动。
  • 移动构造通常只是转移指针、句柄、文件描述符等资源,不分配内存;移动赋值通常释放当前资源再接管新资源,而释放/析构本身不应抛。因此它们通常可以做到不抛异常。

noexcept 是对调用者的承诺,并不是从语法层面上保证这个函数一定就不会抛出异常。如果标记了 noexcept 的函数抛出了异常,将会直接调用 std::terminate 终止进程。

如果类含有较为复杂的成员,可以根据实际情况标记 noexcept,或者不写特殊成员函数,让编译器推导。例如:

cpp
struct A {
	A() = default;
	A(A&& a) {};
};

struct B {
	B() = default;
	B(B&& b) noexcept {};
};

class C {
	A a;
	B b;
};

int main(void) {
    cout << std::is_nothrow_move_constructible_v<C>; // 0
}

C 的两个成员中有一个不支持 noexcept 的移动构造函数,因此编译器推导 C 的默认移动构造函数也不是 noexcept 的。

拷贝被删除、移动不是 noexcept 的类型能否放入 vector?

通常可以,这时仍然可以调用移动语义,但是不再提供异常安全保证。

完美转发 ​

假设我们需要一个包装函数:

cpp
void val(A&) {
    std::cout << "lvalue\n";
}

void val(A&&) {
    std::cout << "rvalue\n";
}

template<typename T>
void wrapper(T&& arg) {
  // TODO: 如何传递 arg 到函数 val?
}

我们希望传递给 wrapper 的参数能够按照“原样”传递给 val,不改变它的左/右值属性。假设有 A 类对象 a,也就是:

  • wrapper(a) 调用 val(A&)
  • wrapper(A{}) 调用 val(A&&)
  • wrapper(std::move(a)) 调用 val(A&&)

假如我们直接传递 arg,也就是 val(arg),在 val 的函数体中,arg 作为表达式是左值。不论我们传递给 wrapper 的是左值还是右值,都会选中 val(A&) 重载,传递的右值没有被保留下来。

如果我们对传入的实参都加上 std::move,xvalue 和 prvalue 确实能作为右值被传递到 val,但是 lvalue 也被当作右值传递了。如果 val 会转移 arg 的资源,那么传入的左值会被破坏掉。

模板函数中的 T&& 和其他地方的不同。普通的函数,例如 void foo(A&& a); 代表接收 A 类右值参数,这里的 A&& 就只是右值引用。但如果是模板函数:

cpp
template<typename T>
void foo(T&& arg);

这里的 T&& 具有特殊的类型推导条件,称为转发引用。当传入左值时,T 会被推导为它的引用,相当于 T = A&,那么带回到 T&& arg 中,arg 的类型就是 A& &&;如果传入右值,模板推导 T = A,代入 T&& 得到 arg 的类型是 T&&,恰好是右值引用。至于前面的左值情形,C++ 有引用折叠规则:

组合结果
T& &T&
T& &&T&
T&& &T&
T&& &&T&&

可以粗略的记为,只要有一个左值引用,最后折叠得到的就是左值引用。常见的关于转发引用的判断有:

声明是否是这里讨论的转发引用?
template<class T> void f(T&&)是,调用时推导不带 cv 限定的 T
void f(std::string&&)否,是固定类型右值引用
template<class T> void f(const T&&)否,带 const 的形式不满足条件
template<class T> struct C { void f(T&&); };否,T 来自类模板,而不是本次函数调用推导
auto&& r = expression;通常按转发引用规则推导(初始化列表除外)

最终,我们得到的包装函数类似于下面的形式:

cpp
void sink(const std::string&); // 借用
void sink(std::string&&);      // 可消费右值

template<class T>
void relay(T&& value) {
    sink(std::forward<T>(value));
}

注意到这里的完美转发函数 forward 带着模板参数 <T>。使用转发引用需要加上这个模板参数。传入函数体的实参是左值,丢失了值类别信息;但是上面的转发引用能保留调用者传入参数的类型信息,std::forward<T> 就能利用这个信息,结合引用折叠规则,决定返回左值还是右值。例如:

cpp
void sink(const std::string&); // 接收左值
void sink(std::string&&);      // 接收右值

template<class T>
void relay(T&& arg) {
    sink(std::forward<T>(arg));
}

std::string s = "hello";
relay(s);
relay(std::move(s));

两次调用中,外层模板的推导结果不同:

调用推导出的 T函数体里的 argforward<T>(arg)
relay(s)std::string&左值左值
relay(std::move(s))std::string左值右值

传入左值时:

Diagram

Rendering diagram…

Diagram preview
100%
Wheel or pinch to zoom · Drag to pan · Double-click to fit · Esc to closePinch to zoom · Drag to pan · Tap Fit to reset

传入右值时:

Diagram

Rendering diagram…

Diagram preview
100%
Wheel or pinch to zoom · Drag to pan · Double-click to fit · Esc to closePinch to zoom · Drag to pan · Tap Fit to reset

因此可得到下面的结论:

cpp
sink(value);                  // 总把参数作为左值传下去
sink(std::move(value));       // 无条件转为右值,可能消费调用者传来的左值
sink(std::forward<T>(value)); // 按 T 恢复调用者的类别

完美转发可以理解为static_cast<T&&>(value),结合引用折叠规则得到:

调用者实参推导的 Tforward 结果
非 const 左值U&U&,左值
const 左值const U&const U&,左值
非 const 右值UU&&,右值

std::move 和 std::forward 的用法,不同之处在于:在明确消费对象,转移资源时用 std::move;在转发引用参数需要原样交给下一层时用 std::forward<T>。不要把 forward 当作更高级的 move,也不要对同一参数反复转发给可能消费它的操作。

返回值优化 ​

假设我们有一个巨大的对象,且需要在工厂函数中返回该对象:

cpp
struct DaWeiDai {
    int data[1000];
}

DaWeiDai factory() {
    DaWeiDai liangzi;
    return liangzi;
}

不考虑任何优化,调用者如果需要一个 DaWeiDai,需要经过三步。首先在 factory() 函数中构造该类对象,然后拷贝一份返回给调用者,最后再将局部作用域中的对象析构。

C++ 提供了 RVO(Return Value Optimization,返回值优化)和 NRVO(Named RVO,具名返回值优化),让返回的对象可以直接在调用出构造,减少复制带来的开销。在 C++ 17之前,RVO 和 NRVO 都是“允许优化”,而在 C++ 17 之后,RVO 变成了必须优化的标准。当用一个同类型的纯右值初始化对象时,不再创建临时对象,对象会被直接构造在目标位置上,即使构造函数有副作用(例如打印输出)。

cpp
Item make_prvalue() {
    return Item{1}; // C++17:同类型 prvalue 直接构造结果对象
}

Item make_named() {
    Item local{2};
    return local;   // 可选 NRVO;不做 NRVO 时,这里可隐式移动
}

Item make_forced_move() {
    Item local{3};
    return std::move(local); // 阻止 local 的 NRVO,通常增加一次移动
}

既不能拷贝、又不能移动的类型可以按照这样的方式返回:

cpp
struct Immovable {
    Immovable() = default;
    Immovable(const Immovable&) = delete;
    Immovable(Immovable&&) = delete;
};

Immovable make() { return Immovable{}; }
auto value = make(); // C++17 合法:直接构造

但不能在 make 函数中先创建再返回临时对象。

杂项 ​

接口参数形式 ​

参数形式典型用途调用方需要知道什么
const T&只读借用不转移所有权;不要保存超过实参生命周期的引用
T&修改已有对象对方会修改对象,调用需要左值
T获得自己的对象,可能保存或消费左值通常拷贝,右值通常移动或直接构造
固定类型 T&&表达接收可消费对象的接口调用方提供右值;函数体仍须实际执行转移
推导出的 T&&包装、转发接口用 forward 保留调用者的类别

标准类型先行,尽量用零法则 ​

cpp
struct Message {
    std::string text;
    std::vector<int> samples;
};

标准成员已经能管理资源(RAII),编译器生成的成员操作通常就够了。不要为了“使用移动语义”给每个类手写特殊成员函数。

unique_ptr 是独占所有权工具;shared_ptr 的拷贝则共享所有权并更新引用计数,移动转移一个已有的持有者。选哪个应由所有权模型决定,不是根据有没有 std::move 决定。

push_back 与 emplace_back ​

  • push_back:接受一个已构造的对象(左值或右值),在容器尾部添加时会先创建临时对象,然后通过拷贝或移动构造函数将其放入容器中,如果是拷贝操作,临时对象随后会被销毁。
  • emplace_back:直接在容器尾部构造对象,可以使用传入的参数作为构造函数参数,无需先创建临时对象,从而避免了额外的拷贝或移动操作。
  • push_back(x) 对非 const 左值通常拷贝,push_back(std::move(x)) 可以移动。emplace_back 把参数转发给元素构造函数,不会自动把所有左值都变成右值。
  • push_back 只能接受一个已构造对象作为参数。emplace_back 可以接受多个参数,直接转发给对象的构造函数,适合在容器尾部直接构造复杂对象。
cpp
class Person {
	int _age;

public:
	 Person(int age) : _age(age) {
		cout << "Construct a person." << _age << endl;
	}

	Person(const Person &p) : _age(p._age) {
		cout << "Copy-Construct" << _age << endl;
	}

	Person(const Person &&p) noexcept: _age(p._age) {
		cout << "Move-Construct" << _age << endl;
	}
};

int main(void) {
	vector<Person> p;

	auto p1 = Person(826);
	p.push_back(p1);
	p.push_back(826);
	p.emplace_back(114);
	p.emplace_back(p1);
}

表面上看起来,push_back 和 emplace_back 都能接收构造函数的参数。但是如果我们给构造函数加上 explicit:

[错误] no matching function for call to 'std::vector<Person>::push_back(int)'

给 Person 类增加一个数据成员也可以发现 push_back 这里其实是隐式转换。扩展一下:

cpp
class Person {
	int _age;
	int _x;

public:
	explicit Person(int age, int x) : _age(age), _x(x) {
		cout << "Construct a person." << _age << endl;
	}

	Person(const Person &p) : _age(p._age), _x(p._x) {
		cout << "Copy-Construct" << _age << endl;
	}

	Person(const Person &&p) noexcept: _age(p._age), _x(p._x) {
		cout << "Move-Construct" << _age << endl;
	}
};

int main(void) {
	vector<Person> p;

	auto p1 = Person(826, 132);
	p.push_back(p1);
	cout << p.size() << endl;
	cout << p.capacity() << endl;
//	 p.push_back(826);
	p.emplace_back(114, 514);
	cout << p.size() << endl;
	cout << p.capacity() << endl;
	p.emplace_back(p1);
	cout << p.size() << endl;
	cout << p.capacity() << endl;
}

输出为:

Construct a person.826
Copy-Construct826
1
1
Construct a person.114
Move-Construct826
2
2
Copy-Construct826
Move-Construct826
Move-Construct114
3
4

Construct a person.826 是由 p1 的构造产生的,之后将该对象 push_back ,产生了拷贝构造;值为 114 的对象,是直接构造在 vector 末端的,emplace_back 将它的参数传递给了 Person 的构造函数。很明显这时候触发了扩容,因此 826 对象被移动到了新的数组中,产生了日志 Move-Construct826。最后 emplace_back(p1) 触发的是拷贝构造(因为 p1 是左值,如果要移动需要 std::move),再次触发了扩容,因此前两个对象又被移动,产生了相应的移动日志。

我真的需要 move 吗? ​

  1. 源对象以后还需要原来的内容吗? 需要就不要随意消耗它。
  2. 源对象是 const 吗? 普通移动通常不能消费 const 对象。
  3. 接收方真的支持并执行移动吗? 转换不等于资源转移。
  4. 有没有更直接的构造方式? 返回局部结果时,优先 return local;。
C++ 模板入门
静态库与共享库
由 Valaxy v1.1.0 驱动|主题-Yunv1.1.0