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() 时试图解引用 x1impl ,发生崩溃。

因此,整改思路就是让私有类 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),让 ImplX 都操作它,相当于通过中介来通信。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 对象。而旧 serverdisp_/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,旧 serverpimpl 被搬空,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 个:

  1. implstd::unique_ptr,移动的是 unique_ptr 对象本身(指针 + 所有权),不是 impl 堆对象 —— 堆对象的地址不变。
  2. 即使不用 unique_ptr,指针赋值也只是拷贝地址值,不会创建新的 impl 对象。

所以直接捕获 impl* 是安全的。


4. 其他相关改动

修改 说明
server::operator=(server&&) 中先 stop() 再接管 避免直接释放仍在运行的旧 pimpl
disp_/broker_shared_ptr 改为 unique_ptr 表达独占所有权语义,调用方和 session 借用裸指针
posted @ 2026-05-12 14:10  3的4次方  阅读(10)  评论(0)    收藏  举报