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 等人提出了许多相互竞争的提案。这些提案各有优势,并且都包含以下几个共同要素:
提供一种在编译期测试类型是否“可简单重定位(trivially relocatable)”的方法,例如一个 std::is_trivially_relocatable_v
提供一个实际用于重定位对象的函数,例如 std::relocate() 或 std::trivially_relocate(),接受源指针和目标指针作为参数,将源对象重定位到新的位置,并结束原对象的生命周期,然后启动新对象的生命周期;
提供一种方式,用于标记某个类型为“可简单重定位”,例如:通过关键字或属性(attribute);
提供一套规则,用于在编译期推导某个类型是否可以简单重定位。
具体实现的细节可能因提案而异。但如果假设有上述这些工具可用,只需对前面展示的实现稍作调整,同样的 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 重新编译时,无需修改源码,就可能直接获得更快的运行速度。