C++移动语义的一个易错点
C++移动语义的一个易错点
我们为类实现移动构造和移动赋值时,需要确保它的所有成员都被正确移动。听起来很容易,但是有2个场景比较容易忽略。
另外,如果移动语义实现的不正确,则在诸如 STL 容器扩容的场景,一旦选择到移动构造/移动赋值,则很容易出 Bug 。
注意:本文示例代码,没有考虑自赋值问题。
场景1:Pimpl 结构,私有类中持有公有类的指针
代码如下,由于使用了 std::unique_ptr ,所以 X 不支持拷贝,因此没有实现拷贝语义。
#include <iostream>
#include <memory>
struct X {
X(int val)
: impl(std::make_unique<Impl>(this, val))
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
}
~X() { std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl; }
X(X&& rhs) noexcept
: impl(std::move(rhs.impl))
{
// Bug: impl->parent_ 现在还指着 &rhs,不是 this
std::cout << __PRETTY_FUNCTION__ << ":" << this
<< " (rhs=" << &rhs << ")" << std::endl;
}
X& operator=(X&& rhs) noexcept
{
impl = std::move(rhs.impl);
return *this;
}
int value() const { return impl->value; }
void f() { impl->f(); }
private:
struct Impl {
Impl(X* parent, int val)
: parent_(parent)
, value(val)
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << ", parent:" << parent_ << std::endl;
}
Impl(Impl&& rhs) noexcept
: parent_(std::exchange(rhs.parent_, nullptr))
, value(std::exchange(rhs.value, -1))
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << ", parent:" << parent_ << std::endl;
}
Impl& operator=(Impl&& rhs) noexcept
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << ", parent:" << parent_ << std::endl;
parent_ = std::exchange(rhs.parent_, nullptr);
value = std::exchange(rhs.value, -1);
return *this;
}
void f()
{
std::cout << "impl:" << this << ", parent_=" << parent_
<< ", parent_->value()=";
if (parent_) {
std::cout << parent_->value() << std::endl;
} else {
std::cout << "CRASH" << std::endl;
}
}
X* parent_;
int value = 42;
};
std::unique_ptr<Impl> impl;
};
int main()
{
X x1(1);
std::cout << "x1.value() = " << x1.value() << std::endl;
x1.f(); // ok, parent_=&x1, value()=1
std::cout << "\n=== move ===\n"
<< std::endl;
X x2 = std::move(x1);
std::cout << "x2.value() = " << x2.value() << std::endl;
x2.f(); // BUG: parent_ 仍为 &x1, value()会解引用parent_.impl空指针
}
/* 输出:
X::Impl::Impl(X *, int):000002ABBE1AA4A0, parent:000000D2E1AFF9E0
X::X(int):000000D2E1AFF9E0
x1.value() = 1
impl:000002ABBE1AA4A0, parent_=000000D2E1AFF9E0, parent_->value()=1
=== move ===
X::X(X &&):000000D2E1AFF9D8 (rhs=000000D2E1AFF9E0)
x2.value() = 1
impl:000002ABBE1AA4A0, parent_=000000D2E1AFF9E0, parent_->value()=
*/
上例中,在 X 类中使用了 Pimpl 设计模式,X 类没有额外的数据成员。X::Impl 类中持有 X 类的裸指针(持有裸指针是为了在私有类访问公有类的状态,本例中没有展现。而用裸指针则是表达一个弱引用的意思,X::Impl 类的对象一定是依附于一个 X 类对象,且理论上两者应该是一一对应的)。
但是坏就坏在这个“一一对应”没有正确实现:当移动 x1 时,x1 内部的 X::Impl 类对象没有发生实际移动(实际移动的是 std::unique_ptr 对象),导致在移动构造出来的 x2 对象中的 X::Impl 对象中持有的仍然是移后源对象 x1 的指针,由于 x1 本身移动了,其 impl 成员指针变为 nullptr ,导致调用 x2.f() 时试图解引用 x1 的 impl ,发生崩溃。
因此,整改思路就是让私有类 X::Impl 中持有的公有类 X 的指针能够随移动而同步更新,真正实现公有类和私有类一一对应的语义。
整改方案1:移动后重新绑定公有类指针
#include <iostream>
#include <memory>
struct X {
X(int val)
: impl(std::make_unique<Impl>(this, val))
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
}
~X() { std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl; }
X(X&& rhs) noexcept
: impl(std::move(rhs.impl))
{
impl->parent_ = this; // <-- 唯一改动:重新绑定
std::cout << __PRETTY_FUNCTION__ << ":" << this
<< " (rhs=" << &rhs << ")" << std::endl;
}
X& operator=(X&& rhs) noexcept
{
impl = std::move(rhs.impl);
impl->parent_ = this; // <-- 唯一改动:重新绑定
return *this;
}
int value() const { return impl->value; }
void f() { impl->f(); }
private:
struct Impl {
Impl(X* parent, int val)
: parent_(parent)
, value(val)
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << ", parent:" << parent_ << std::endl;
}
Impl(Impl&& rhs) noexcept
: parent_(std::exchange(rhs.parent_, nullptr))
, value(std::exchange(rhs.value, -1))
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << ", parent:" << parent_ << std::endl;
}
Impl& operator=(Impl&& rhs) noexcept
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << ", parent:" << parent_ << std::endl;
parent_ = std::exchange(rhs.parent_, nullptr);
value = std::exchange(rhs.value, -1);
return *this;
}
void f()
{
std::cout << "impl:" << this << ", parent_=" << parent_
<< ", parent_->value()=";
if (parent_) {
std::cout << parent_->value() << std::endl;
} else {
std::cout << "CRASH" << std::endl;
}
}
X* parent_;
int value = 42;
};
std::unique_ptr<Impl> impl;
};
int main()
{
X x1(1);
std::cout << "x1.value() = " << x1.value() << std::endl;
x1.f(); // ok, parent_=&x1, value()=1
std::cout << "\n=== move ===\n"
<< std::endl;
X x2 = std::move(x1);
std::cout << "x2.value() = " << x2.value() << std::endl;
x2.f(); // 现在正常了:parent_ 已重新绑定到 &x2
}
/* 输出:
X::Impl::Impl(X *, int):00000275EDF4A660, parent:000000C3D8F2FB80
X::X(int):000000C3D8F2FB80
x1.value() = 1
impl:00000275EDF4A660, parent_=000000C3D8F2FB80, parent_->value()=1
=== move ===
X::X(X &&):000000C3D8F2FB78 (rhs=000000C3D8F2FB80)
x2.value() = 1
impl:00000275EDF4A660, parent_=000000C3D8F2FB78, parent_->value()=1
X::~X():000000C3D8F2FB78
X::~X():000000C3D8F2FB80
*/
唯一变动就是在公有类 X 的移动构造和移动赋值中,添加更新 impl 私有类指针的动作。为什么是在公有类中更新私有类指针?因为私有类的移动语义实现本身就是正确的(公有类的移动语义实现也是正确的,只是公有类还需要额外负责管理私有类的公有类指针)。
整改方案2:直接注入私有类需要的公有类资源,去掉公有类指针
这个方案有很明显的弊端:它相当于直接值捕获了公有类中的成员,导致后续没法同步公有类中做出的变更。但如果在设计上可以接受这种解耦,则 move 后天然正确,无需任何额外处理。
#include <iostream>
#include <memory>
struct X {
X(int val)
: impl(std::make_unique<Impl>(val))
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
}
~X() { std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl; }
X(X&& rhs) noexcept
: impl(std::move(rhs.impl))
{
std::cout << __PRETTY_FUNCTION__ << ":" << this
<< " (rhs=" << &rhs << ")" << std::endl;
}
X& operator=(X&& rhs) noexcept = default;
int value() const { return impl->value; }
void f() { impl->f(); }
private:
struct Impl {
Impl(int val)
: value(val)
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
}
Impl(Impl&& rhs) noexcept = default;
Impl& operator=(Impl&& rhs) noexcept = default;
void f()
{
// 这里不再依赖 X* parent_
// 只操作自己的 value
std::cout << "impl:" << this << ", value=" << value << std::endl;
}
int value = 42;
};
std::unique_ptr<Impl> impl;
};
这个例子里 Impl 不再持有 X* parent_,所以 move unique_ptr 天然正确。代价就是 Impl 也无法访问 X 的其他成员了,如果后续 X 新增状态,Impl 感知不到。
整改方案3:引入额外的稳定的中介层
引入一个堆上分配的间接层(State),让 Impl 和 X 都操作它,相当于通过中介来通信。State 地址在堆上固定不变,move 自然不受影响。
#include <iostream>
#include <memory>
struct X {
X(int val)
: state_(std::make_shared<State>(State { val }))
, impl(std::make_unique<Impl>(state_))
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << ", state_:" << &state_ << ", state_.get():" << state_.get() << std::endl;
}
~X() { std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl; }
// move 构造/赋值默认即可,state_ 是 shared_ptr,只复制不改变 State 地址
X(X&& rhs) noexcept = default;
X& operator=(X&& rhs) noexcept = default;
int value() const { return state_->value; }
void f() { impl->f(); }
private:
struct State {
int value = 42;
// 需要让 Impl 访问的其他状态也放这里
};
struct Impl {
Impl(std::shared_ptr<State> state)
: state_(std::move(state))
{
}
void f()
{
// state_ 在 move 前后始终指向同一个堆对象
std::cout << "impl state_:" << &state_ << ", state_.get():" << state_.get() << ", value=" << state_->value << std::endl;
}
std::shared_ptr<State> state_;
};
friend Impl;
std::shared_ptr<State> state_;
std::unique_ptr<Impl> impl;
};
int main()
{
X x1(1);
x1.f();
X x2 = std::move(x1);
x2.f(); // 正确:state_ 地址不变,value 仍然是 1
}
/* 输出:
X::X(int):0000003116FCFE30, state_:0000003116FCFE30, state_.get():0000020F0A84A3F0
impl state_:0000020F0A84A3C0, state_.get():0000020F0A84A3F0, value=1
impl state_:0000020F0A84A3C0, state_.get():0000020F0A84A3F0, value=1
X::~X():0000003116FCFE18
X::~X():0000003116FCFE30
*/
优点:move 后完全不需要额外处理,天然正确。
缺点:引入了堆分配开销;State 变成共享资源,需要注意线程安全;设计上不再是纯粹的 Pimpl,Impl 的依赖从 X 变成了 State 。
场景2:类中成员是捕获了 this 指针的lambda表达式
#include <functional>
#include <iostream>
struct X {
X(int val)
: value(val)
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
f = [this]() {
std::cout << this << ":" << value << std::endl;
};
}
~X() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
X(const X& rhs)
: value(rhs.value)
, f(rhs.f)
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
}
X& operator=(const X& rhs)
{
std::cout << __PRETTY_FUNCTION__ << std::endl;
value = rhs.value;
f = rhs.f;
return *this;
}
X(X&& rhs) noexcept
: value(std::exchange(rhs.value, -1))
, f(std::exchange(rhs.f, nullptr))
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
}
X& operator=(X&& rhs) noexcept
{
std::cout << __PRETTY_FUNCTION__ << std::endl;
value = std::exchange(rhs.value, -1);
f = std::exchange(rhs.f, nullptr);
return *this;
}
int value = 42;
std::function<void()> f;
};
int main()
{
X x1(1);
std::cout << x1.value << std::endl;
x1.f();
std::puts("----");
auto x2 = std::move(x1);
std::cout << x2.value << std::endl;
x2.f();
}
/* 输出:
X::X(int):0000004A224FF9A0
1
0000004A224FF9A0:1
----
X::X(X &&):0000004A224FF958
1
0000004A224FF9A0:-1
X::~X()
X::~X()
*/
显然,这里出现了错误:x2 是通过 x1 移动构造出来的(可以看到它们的 this 指针不同),但是直接访问 x2.value 的值是正确的 1 ,而通过 x2.f() 间接访问 x2.value 的值却是非法的 -1 。
这说明,当 X 类发生移动构造出 x2 时,其成员变量 f 中捕获的 this 指针仍然属于移后源对象 x1 ,导致 x2.f() 会访问 x1.value ,移后源对象的状态已经是非定义的,最好不要再使用(没崩只是因为 x1 还在栈上没析构,一旦 x1 生命周期结束,这里就是UB了)。
因此,整改思路就是不要捕获 this 指针,以免对象拷贝和对象移动时,产生不受管理的生命周期依赖。
整改方案1:避免捕获 this,转而捕获值拷贝
代价就是会引入拷贝开销,且不再能与 this 的变化同步。
X(int val)
: value(val)
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
int v = value;
f = [v]() {// 这样f中就不含有this指针了
std::cout << v << std::endl;
};
}
整改方案2:重建lambda
代价就是,原来lambda中可能持有的一些状态,需要你自己拿出来然后重新在 rebind() 函数中给新的lambda设置上。
struct X {
X(int val)
: value(val)
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
rebind();
}
~X() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
X(const X& rhs)
: value(rhs.value)
, f(rhs.f)
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
rebind();
}
X& operator=(const X& rhs)
{
std::cout << __PRETTY_FUNCTION__ << std::endl;
value = rhs.value;
rebind();
return *this;
}
X(X&& rhs) noexcept
: value(std::exchange(rhs.value, -1))
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
rebind();
rhs.f = nullptr;
}
X& operator=(X&& rhs) noexcept
{
std::cout << __PRETTY_FUNCTION__ << std::endl;
value = std::exchange(rhs.value, -1);
rebind();
rhs.f = nullptr;
return *this;
}
int value = 42;
std::function<void()> f;
private:
void rebind()
{
f = [this]() {
std::cout << this << ": " << value << std::endl;
};
}
};
整改方案3:使用智能指针
使用 shared_ptr
最无脑的做法,如果 lambda:
- 会异步执行
- 生命周期独立于对象
- 可能晚于对象析构
则用 shared_ptr 是最佳实践。
但也有缺点:
- 使得
X类的对象必须在堆上分配。 X.f无法在构造函数中初始化(因为shared_from_this()无法在构造函数中正确调用),只能构造函数执行完后初始化- 可能引入循环引用
#include <functional>
#include <iostream>
#include <memory>
struct X : std::enable_shared_from_this<X> {
X(int val)
: value(val)
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
}
~X() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
X(const X& rhs)
: value(rhs.value)
, f(rhs.f)
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
}
X& operator=(const X& rhs)
{
std::cout << __PRETTY_FUNCTION__ << std::endl;
value = rhs.value;
f = rhs.f;
return *this;
}
X(X&& rhs) noexcept
: value(std::exchange(rhs.value, -1))
, f(std::exchange(rhs.f, nullptr))
{
std::cout << __PRETTY_FUNCTION__ << ":" << this << std::endl;
}
X& operator=(X&& rhs) noexcept
{
std::cout << __PRETTY_FUNCTION__ << std::endl;
value = std::exchange(rhs.value, -1);
f = std::exchange(rhs.f, nullptr);
return *this;
}
void init()
{
std::weak_ptr<X> weak = shared_from_this(); // 引用关系 x -> f -> self -> x,因此self必须持有弱引用而非强引用,否则就是循环引用了,X将无法被析构。
f = [weak]() {
if (auto self = weak.lock()) {
std::cout << self.get() << ":" << self->value << std::endl;
}
};
}
int value = 42;
std::function<void()> f;
};
int main()
{
auto x1 = std::make_shared<X>(1);
x1->init();
std::cout << x1->value << std::endl;
x1->f();
std::puts("----");
auto x2 = std::move(x1);
std::cout << x2->value << std::endl;
x2->f();
}
/* 输出:输出中没有出现X的移动,因为移动的实际上是shared_ptr类对象
X::X(int):00000250D87EFB10
1
00000250D87EFB10:1
----
1
00000250D87EFB10:1
X::~X()
*/
使用 unique_ptr
如果 X 不需要拷贝语义,不需要共享生命周期,则可以使用 unique_ptr ,然后通过私有类的方式来访问。参见上述场景1。
附:my-rpclib 项目中的移动语义问题及修正
1. server::impl::parent_ 在移动后未回绑
对应博客:场景1(Pimpl + 回指指针)
问题:
server 采用 Pimpl 惯用法,impl 中持有 server* parent_ 指向它的拥有者:
struct server::impl {
server* parent_; // 回指 server
// ...
void start_accept() {
acceptor_.async_accept(socket_, [this](...) {
// 创建 session 时需要用到 parent_->disp_ / parent_->broker_
auto s = std::make_shared<server_session>(parent_, ...);
});
}
};
server 原有的移动构造只搬运了 pimpl,没有更新 impl::parent_:
// 原来的实现
server::server(server&& other) noexcept
: disp_(std::move(other.disp_))
, broker_(std::move(other.broker_))
, pimpl(std::move(other.pimpl))
{
// 缺:pimpl->parent_ = this;
}
move 后 pimpl->parent_ 仍指向旧 server 对象。而旧 server 的 disp_/broker_ 已被搬空,后续 accept 新连接时访问 parent_->disp_/parent_->broker_ 会解引用空指针。
修正:
新增 rebind_impl_ownership() 在 move 后回绑 impl::parent_:
void server::rebind_impl_ownership()
{
if (!pimpl) {
return;
}
pimpl->parent_ = this;
std::unique_lock<std::mutex> lock(pimpl->sessions_mutex_);
for (auto& session : pimpl->sessions_) {
session->parent_ = this;
}
}
在 move 构造和 move 赋值中调用:
server::server(server&& other) noexcept
: disp_(std::move(other.disp_))
, broker_(std::move(other.broker_))
, pimpl(std::move(other.pimpl))
{
rebind_impl_ownership(); // <--
}
2. server_session::parent_ 在移动后未更新
对应博客:场景1 的变体
问题:
server_session 持有 server* parent_ 回指它的拥有者。在 server 被 move 之前已存在的 session,其 parent_ 仍然指向旧 server:
class server_session : public async_writer {
friend class rpc::server;
// ...
server* parent_;
// ...
};
这些 session 关闭时会调用 parent_->close_session(self),如果 parent_ 指向旧对象,而旧对象已被掏空,也会出问题。
修正:
和问题 1 一起修复,在 rebind_impl_ownership() 中遍历 pimpl->sessions_ 统一回绑:
for (auto& session : pimpl->sessions_) {
session->parent_ = this;
}
3. async_run 的 lambda 捕获了 server::this
对应博客:场景2(lambda 捕获 this)
问题:
// 原来的实现
void server::async_run(std::size_t io_threads, std::size_t worker_threads)
{
pimpl->loop_workers_.create_threads(io_threads, [this]() { // 捕获了 server::this
pimpl->io_.run();
});
}
IO 线程在 server 对象上长期运行。一旦 server 被 move,旧 server 的 pimpl 被搬空,IO 线程再通过旧 this->pimpl 访问 io_ 就成了空指针解引用。
修正:
改为捕获稳定的 impl*,不经过 this 间接访问:
void server::async_run(std::size_t io_threads, std::size_t worker_threads)
{
auto* impl = pimpl.get();
pimpl->loop_workers_.create_threads(io_threads, [impl]() { // 直接捕获 impl*
impl->io_.run();
});
}
impl* 在 move 前后始终有效,原因有 2 个:
impl是std::unique_ptr,移动的是unique_ptr对象本身(指针 + 所有权),不是impl堆对象 —— 堆对象的地址不变。- 即使不用
unique_ptr,指针赋值也只是拷贝地址值,不会创建新的impl对象。
所以直接捕获 impl* 是安全的。
4. 其他相关改动
| 修改 | 说明 |
|---|---|
server::operator=(server&&) 中先 stop() 再接管 |
避免直接释放仍在运行的旧 pimpl |
disp_/broker_ 从 shared_ptr 改为 unique_ptr |
表达独占所有权语义,调用方和 session 借用裸指针 |

浙公网安备 33010602011771号