15.3. 简单的重定位

C++ 在编程界以一种能够在最大程度上利用,计算机或感兴趣的硬件平台的语言而闻名。毕竟,C++ 的一些核心理念可以被概括为“不会为没有使用的东西付出代价”,以及“不应该存在更低级语言的一席之地(除了偶尔使用一些汇编代码)”。后者解释了为何前一节中提到的 std::start_lifetime_... 函数如此重要。

这可能就是为什么,当人们发现我们在执行速度方面还有提升空间时,这会成为 C++ 开发者社区,特别是 C++ 标准委员会成员所关注的话题。我们都非常重视这门语言的核心理念。

一个可以做得更好的例子是,当遇到某些类型时,将源对象移动到目标对象,然后销毁原始对象的操作,实际上可以替换为调用 std::memcpy():直接复制一段字节数据通常比依次执行移动和析构操作更快(如果这并没有更快,那你的 std::memcpy() 实现可能需要改进),尽管移动赋值和析构函数的组合已经很快了。

事实上,有很多类型都可以考虑进行这样的优化,包括 std::string、std::any、std::optional<T>(取决于 T 是什么类型)、上一节中的 Point3D 类,以及未定义第 1 章中提到的6个特殊成员函数的类(包括基本类型),等等。

为了理解这种优化的影响,请考虑以下 resize() 自由函数,它模仿了某些管理连续内存的容器(比如本书中多次出现的 Vector<T> 类型)的 resize() 成员函数。这个函数将 arr 从旧容量 old_cap 调整到新容量 new_cap,并在末尾填充默认构造的对象:

//
// 这不是一个好的函数接口,但我们希望
// 保持示例相对简单
//
template <class T>
  void resize
    (T *&arr, std::size_t old_cap, std::size_t new_cap) {
    //
    // 我们可以处理默认构造函数抛出异常的情况,
    // 但这会使我们的代码变得稍微复杂一些。
    // 这些增加的复杂性虽然有价值,但对于
    // 我们在这里讨论的主题来说并非重点。
    //
    static_assert(
      std::is_nothrow_default_constructible_v<T>
    );

    //
    // 有时,根本不需要做任何事情
    //
    if(new_cap <= old_cap) return;

    //
    // 分配一块原始内存(尚未创建对象)
    //
    auto p = static_cast<T*>(
      std::malloc(new_cap * sizeof(T))
    );
    if(!p) throw std::bad_alloc{};
    // ...

此时,我们已经准备好复制(或移动)对象了:

    // ...
    //
    // 如果移动赋值不会抛出异常,则采用激进策略(使用移动)
    //
    if constexpr(std::is_nothrow_move_assignable_v<T>) {
      std::uninitialized_move(arr, arr + old_cap, p);
      std::destroy(arr, arr + old_cap);
    } else {
      //
      // 由于移动赋值可能会抛出异常,我们采取保守策略,改用复制
      //
      try {
        std::uninitialized_copy(arr, arr + old_cap, p);
        std::destroy(arr, arr + old_cap);
      } catch (...) {
        std::free(p);
        throw;
      }
    }

    //
    // 用默认构造的对象填充剩余的空间
    // (请记住:我们已通过 static_assert 确保 T::T() 不会抛出异常)
    //
    std::uninitialized_default_construct(
      p + old_cap, p + new_cap
    );

    //
    // 用新的内存块(现在包含对象)替换旧的内存块(现在包含对象)
    //
    std::free(arr);
    arr = p;
  }

观察该函数,尽管使用 std::uninitialized_move() 接着 std::destroy() 的组合已经是一条高效的路径,但我们还可以做得更快 —— 可以将线性数量的移动赋值操作和析构函数调用完全替换为对 std::memcpy() 的调用。

我们如何实现这一点呢?目前,Arthur O’Dwyer、Mingxin Wang、Alisdair Meredith 和 Mungo Gill 等人提出了许多相互竞争的提案。这些提案各有优势,并且都包含以下几个共同要素:

具体实现的细节可能因提案而异。但如果假设有上述这些工具可用,只需对前面展示的实现稍作调整,同样的 resize() 函数就可以受益于这种简单重定位优化:

template <class T>
void resize
(T * &arr, std::size_t old_cap, std::size_t new_cap) {
  static_assert(
    std::is_nothrow_default_contructible_v<T>
  );
  if(new_cap <= old_cap) return arr;
  auto p = static_cast<T*>(
    std::malloc(new_cap * sizeof(T))
  );

  if(!p) throw std::bad_alloc{};
  //
  // 这是理想情况
  //

  if constexpr (std::is_trivially_relocatable_v<T>) {
    // 对于可简单重定位的类型,使用 std::relocate
    // 这相当于 memcpy(),同时认为源区间 [arr, arr + old_cap) 内对象的生命周期结束,
    // 目标区间 [p, p + old_cap) 内对象的生命周期开始。
    //
    // 注意:这假设了 std::is_trivially_relocatable_v<T> 包含着
    // std::is_trivially_destructible_v<T>(即析构函数也是简单的)。
    std::relocate(arr, arr + old_cap, p);
    //
    // 如果移动赋值不会抛出异常,则采用激进策略
    //
  } else if constexpr(
    std::is_nothrow_move_assignable_v<T>
  ){
    std::uninitialized_move(arr, arr + old_cap, p);
    std::destroy(arr, arr + old_cap);
  } else {
    // ... 参考之前的代码示例
  }
}

这一看似简单的优化已可以带来显著的性能提升,有报告称在一些常见情况下性能提升可达 30%。但这些结果仍属于实验性的成果,我们期待看到更多的基准测试数据。如果各项提案如我们所预期的那样达成一致,并最终纳入 C++ 标准,这种优化将成为标准的一部分。

此类潜在的性能提升正是 C++ 语言所追求的目标之一,因此可以合理地预期,简单可重定位(trivial relocatability)特性将在可预见的未来成为现实。问题是“如何”实现:编译器应如何检测类型的简单重定位属性?当默认的简单重定位推导规则不满足需求时,开发者又该如何在自己的类型中标记该属性?

截至 2025 年 2 月,C++ 标准委员会已投票通过将简单重定位纳入即将成为 C++26 的标准中。所以可以期待:一些使用先前 C++ 标准编译的程序,在使用 C++26 重新编译时,无需修改源码,就可能直接获得更快的运行速度。