C++

C++: is_move_constructible

Posted by w@hidva.com on February 15, 2023

我个人有个习惯, 喜欢在摸鱼的时候翻翻自己之前写过的大块代码. 当时在写这种大块代码时是处于一种很激情的状态, 恨不得立刻将脑袋里的想法泼洒到屏幕上实现出来, 所以就需要一种激情冷却下来之后, 一种事后的角度来审视这坨代码, 看看有咩有当时上头时无意留下的一些蠢活. 这不这次摸鱼就发现了如下代码:

struct RawTask {
  core::Header* ptr; // always valid!
public:
  RawTask(core::Header* p) noexcept: ptr(p) {
    assert(p != nullptr);
  }

  RawTask(RawTask&& o) noexcept:
    ptr(o.ptr) {
    o.ptr = nullptr;
    assert(this->ptr != nullptr);
  }

  // 展开之后:
  // RawTask(const RawTask&) = default.
  // RawTask& operator=(const RawTask&) = default;
  HOLO_ALLOW_COPY(RawTask);
};

struct Task {
  raw::RawTask raw;
public:
  ~Task() noexcept {
    auto& self = *this;
    if (self.raw.ptr == nullptr) {
      return;
    }
    if (self.header().state.ref_dec()) {
      std::move(self.raw).dealloc();
    }
  }
}

由于 RawTask::RawTask(RawTask&&) 的存在, 导致 RawTask 不再是 is_trivially_copyable_v, 这导致了我们可能会丧失一些优化机会, 比如 optional<RawTask> 的移动构造现在也不是 trivially 的了; 而我们代码中可是大量使用 OptPtr<RawTask> 来着; (OptPtr<T> 语义上等同于 optional<T>, 只不过不会额外使用一个 bool 变量来表示值不存在, 而是零值来表示值不存在, 类似于 rust 中的 Option.)

当时实现 RawTask::RawTask(RawTask&&) 的背景应该是代码中有一些类, 比如上面中的 Task, 其中有个 raw 成员, 并且要求 Task(Task&& o) 移动构造之后, o.raw.ptr = nullptr. 如 ~Task 实现所示, 这里 core::Header 指向着一坨自带 ref count 的引用计数, 如果 o.raw.ptr 不置为 nullptr, 会导致 double ref_dec 操作, 这会进一步导致 double-free 等问题. 通过在 RawTask 实现这个逻辑避免了这些类再实现自己的 move. 所以我们要移除 RawTask 移动构造, 就必须为这些类实现自己的 move, 比如上面的 Task 对应的移动构造便是:

Task(Task&& o) noexcept:
  raw(o.raw.ptr) {
  o.raw.ptr = nullptr;
  assert(this.raw.ptr != nullptr);
}

为了避免遗漏, 我的做法是将 RawTask(RawTask&&) 标记为 delete, 我本来以为这样所有包含 RawTask 的类, 隐式利用到 RawTask 移动构造的类, 类似上面 Task 的那种类, 都会触发编译报错, 然后我一一修复这些编译报错之后再取消标记 RawTask(RawTask&&) 就万事大吉了. 没想到这一番操作下来之后还是碰到了问题:

struct EcImpl {
  RawTask raw;
};
class EcSchedulable: public EcImpl {
public:
  EcSchedulable(EcSchedulable&& other) noexcept:
    EcImpl(std::move(other)) {
    DCHECK(other.raw.ptr == nullptr);  // dcheck 失败
    DCHECK(this->raw.ptr != nullptr);
  }
};

幸亏我这里加了个冗余 dcheck, 自流浪地球饱和式救援学到的饱和式 dcheck 救了我一命== 这可和我一开始的预期不太对啊, 本来预期 RawTask 移动构造标记为 delete 之后, EcImpl 应该也随之不支持移动构造, 即 EcImpl(std::move(other)) 应该触发编译报错来着. 简单总结下来, 就是我被下面的 demo 程序困惑了:

#include <utility>
#include <type_traits>

struct S {
  void* x;
public:
  S() {}
  S(S&&) = delete;
  S(const S&) = default;
};

struct Z {
  S s;
public:
  // Z(Z&&) = delete;
};

// static_assert(std::is_trivially_move_constructible_v<S>);  // false
static_assert(std::is_trivially_move_constructible<Z>::value, "");

int main() {}

我真的不明白啊, 按照 Move constructors 中规则:

Implicitly-declared move constructor If no user-defined move constructors are provided for a class type (struct, class, or union), and all of the following is true:

  • there are no user-declared copy constructors;
  • there are no user-declared copy assignment operators;
  • there are no user-declared move assignment operators;
  • there is no user-declared destructor. then the compiler will declare a move constructor as a non-explicit inline public member of its class with the signature T::T(T&&).

The implicitly-declared or defaulted move constructor for class T is defined as deleted if any of the following is true:

  • T has non-static data members that cannot be moved (have deleted, inaccessible, or ambiguous move constructors);

这里 Z 命中了 ‘Implicitly-declared move constructor’, 编译器会加个 Z(Z&&) 声明, 但同时 Z 也命中了 ‘is defined as deleted’ 规则, 即最终编译器会加个 Z(Z&&) = delete. 但实际上 Z 不光可以 move, 而且还是 trivially move, 这完全不符合下面的描述啊, 毕竟 std::is_trivially_move_constructible_v<S> 可是 false 来着:

Trivial move constructor The move constructor for class T is trivial if all of the following is true:

  • the move constructor selected for every non-static class type (or array of class type) member of T is trivial.

最后我把 S::x 的类型替换如下:

struct X {
  X() noexcept {}
  X(const X&) noexcept {
    puts("f");
  }
  X(X&&) noexcept {
    puts("u");
  }
  X& operator=(X&&) noexcept {
    puts("c"); return *this;
  }
  X& operator=(const X&) noexcept {
    puts("k"); return *this;
  }
};

在运行时输出了 “f” 时, 瞬间勾起了我很久的记忆, 那是 2016-12-29 时, 我还刚刚入门 C++ primer, 其中提到过一句话:

在回到上面 C++ 标准中关于 Move constructors 的一些规则解释中:

T has non-static data members that cannot be moved

实际上这里 S 虽然 S(S&&) = delete, 但 S(const S&) = default, 即 S 是可以 moved! 虽然并不是通过 S 的移动构造来 move, 而是通过 S 的拷贝构造.

the move constructor selected for every non-static class type (or array of class type) member of T is trivial

对于 S 来说, 这里 “move constructor selected” 是 S(const S&) 不是 S(S&&)!

后记

我大抵是 Rust 用的多了, 完全忘记了 C++ 并没有过真正的 move 语义, 就如同 std::is_trivially_move_constructible 实现一样, C++ 类可以 move 只是意味着存在一个构造函数, 其接受 T&& 类型参数.