资讯动态

C++中construct_at和uninitialized_copy方法的使用

发布时间:2026/10/14 22:49:16 来源:尧图企业网站定制
前言C 把内存和对象分成两个概念申请内存不等于创建对象。operator new或malloc拿到的只是一段字节里面没有对象必须在这段字节上调用构造函数C 才认为那里有一个对象存在。标准库为这个在原始内存上创建对象的动作提供了一组工具统称为未初始化内存算法uninitialized memory algorithms算法引入版本作用std::uninitialized_copyC98在原始内存上拷贝构造一段元素std::uninitialized_fillC98在原始内存上填充构造std::uninitialized_moveC17移动构造版本扩容必备std::uninitialized_default_constructC17默认构造版本std::construct_atC20在指定地址上构造单个对象std::destroy_atC17析构单个对象以上每个算法都有_n后缀的指定个数版本。construct_at是其中最重要的一个它是placement new 的 constexpr 替代品也是自己实现容器vector、optional、any时绕不开的基础设施。本文从为什么需要它讲起逐层展开。一、为什么需要 construct_at1.1 placement new 的三个问题传统的写法是 placement new#include newvoid* mem ::operator new(sizeof(T));T* p new (mem) T(42); // 在 mem 上构造p-~T(); // 手工析构::operator delete(mem);问题有三问题一不能 constexpr。new表达式永远不能在常量求值constant evaluation中使用因此std::vector、std::optional这类模板在 C20 之前无法完全 constexpr 化。C20 的constexpr std::vector之所以能实现靠的就是construct_at。问题二语义不清晰容易漏掉前置条件。placement new 的mem必须满足T的对齐要求写错对齐在 x86 上可能看起来正常换到 ARM 或开了-fsanitizealignment立刻炸。问题三与allocator_traits的构造路径不统一。容器通过std::allocator_traitsA::construct(a, p, args...)构造对象默认实现就是内部的 placement new。用户想定制构造行为在 C20 之前无路可走。1.2 construct_at 的签名namespace std {templateclass T, class... Argsconstexpr T* construct_at(T* location, Args... args);}语义在location指向的原始存储上用args...构造一个T返回location。是constexpr的可用于常量求值要求location满足T的对齐调用它不改变任何指针只是让那里有一个对象这件事成立内部会voidify转成void*后调用 placement 形式因此也可被类重载。二、construct_at 与 destroy_at 配对使用有构造就有析构对应的工具是 C17 的std::destroy_at。它的额外价值在于对类型更宽容如果T是标量int、指针它会生成一个伪析构调用pseudo-destructor call不产生任何代码而p-~T()更难写对。这也让模板代码不需要为标量特化。construct_at可以直接用在栈上的原始缓冲区里这是它最朴素的用法alignas(S) unsigned char buf[sizeof(S)]; // 原始存储对齐必须写对S* p std::construct_at(reinterpret_castS*(buf), 42); // 在 buf 上构造std::cout p-v \n;std::destroy_at(p); // 显式析构不能省注意alignas(S)不能省略——char数组的对齐只有 1而S可能需要 8 或 16 的对齐对齐不足是未定义行为。三、uninitialized_copy 家族容器扩容时需要把一段元素搬到新内存。但新内存是原始的不能直接赋值——必须拷贝构造。namespace std {templateclass InputIt, class ForwardItForwardIt uninitialized_copy(InputIt first, InputIt last, ForwardIt d_first);templateclass InputIt, class Size, class ForwardItForwardIt uninitialized_copy_n(InputIt first, Size count, ForwardIt d_first);}行为是对[first, last)中的每个元素在d_first开始的原始内存上做拷贝构造返回构造完成后的尾后指针。3.1 关键差异赋值 vs 构造T* raw static_castT*(::operator new(n * sizeof(T)));std::copy(src, src n, raw); // ❌ 对原始内存做赋值 - UBstd::uninitialized_copy(src, src n, raw); // ✅ 在新位置拷贝构造std::copy内部执行*d *s而*d处没有对象对一个不存在的对象调用operator是未定义行为。对std::string、std::vector这类有指针成员的类这意味着对垃圾指针做赋值几乎必崩。uninitialized_copy则是对每个位置调用construct_at(d, *s)从零建立对象。3.2 异常安全它是怎么回滚的uninitialized_copy有一个非常重要但容易被忽略的保证如果拷贝构造过程中某个元素抛异常它会把已经构造成功的那部分析构掉然后重新抛出异常。这就是强异常安全的基础。// libstdc 的实现思路简化templateclass InputIt, class ForwardItForwardIt uninitialized_copy(InputIt first, InputIt last, ForwardIt d_first) {ForwardIt cur d_first;try {for (; first ! last; first, cur)std::construct_at(std::addressof(*cur), *first);return cur;} catch (...) {for (ForwardIt p d_first; p ! cur; p)std::destroy_at(std::addressof(*p)); // 回滚已构造的部分throw;}}反过来看如果你用for循环手写construct_at就必须自己写这段try/catch回滚逻辑。忘了写就是内存泄漏 对象处于半构造状态这是自己实现容器时最常见的坑之一。四、代码实战实战 1手写一个最简 vector 的扩容// myvec.cpp — g -stdc20 myvec.cpp -o myvec#include iostream#include memory#include string#include utilitytemplateclass Tclass MyVector {public:MyVector() default;MyVector(const MyVector) delete;MyVector operator(const MyVector) delete;~MyVector() { clearAndFree(); }void push_back(const T value) {ensureCapacity(size_ 1);std::construct_at(data_ size_, value); // 拷贝构造size_;}void push_back(T value) {ensureCapacity(size_ 1);std::construct_at(data_ size_, std::move(value)); // 移动构造size_;}T operator[](std::size_t i) { return data_[i]; }const T operator[](std::size_t i) const { return data_[i]; }std::size_t size() const { return size_; }std::size_t capacity() const { return cap_; }private:void ensureCapacity(std::size_t need) {if (need cap_) return;std::size_t newCap cap_ ? cap_ * 2 : 1;// 1. 申请原始内存未构造T* newData std::allocatorT{}.allocate(newCap);// 2. 把已有元素移动构造到新内存 —— 关键用 uninitialized_move// 它会在异常时回滚已经搬走的元素T* end newData;if (data_) {end std::uninitialized_move(data_, data_ size_, newData);}// 3. 析构旧元素释放旧内存if (data_) {std::destroy(data_, data_ size_);std::allocatorT{}.deallocate(data_, cap_);}data_ newData;cap_ newCap;(void)end;}void clearAndFree() {if (!data_) return;std::destroy(data_, data_ size_); // 析构全部元素std::allocatorT{}.deallocate(data_, cap_); // 释放原始内存data_ nullptr;size_ cap_ 0;}T* data_ nullptr;std::size_t size_ 0;std::size_t cap_ 0;};int main() {MyVectorstd::string v;v.push_back(hello);v.push_back(world);v.push_back(std::string(100, x)); // 触发扩容for (std::size_t i 0; i v.size(); i)std::cout i : v[i] \n;std::cout size v.size() cap v.capacity() \n;return 0;}这段代码正确性的三个关键点全部来自本文主题扩容时用uninitialized_move而不是std::move新内存是原始的必须构造而非赋值析构时用std::destroy而不是::operator delete先逐个析构再释放内存顺序反了就是 UBconstruct_at返回的就是location所以data_ size_的写法可以链式使用。实战 2uninitialized_copy 与 std::copy 的行为对比// compare.cpp — g -stdc20 compare.cpp -o compare#include algorithm#include iostream#include memory#include stringint main() {std::string src[3] {alpha, beta, gamma};// ---- 正确uninitialized_copy 在原始内存上构造 ----std::string* buf std::allocatorstd::string{}.allocate(3);std::uninitialized_copy(std::begin(src), std::end(src), buf);for (int i 0; i 3; i) std::cout buf[i] ;std::cout \n;std::destroy(buf, buf 3);std::allocatorstd::string{}.deallocate(buf, 3);// ---- 危险std::copy 到原始内存 ----// std::string* bad std::allocatorstd::string{}.allocate(3);// std::copy(std::begin(src), std::end(src), bad); // UB对未构造的 string 赋值// bad[0] 的析构从未发生内部指针被覆盖程序崩溃或内存泄漏return 0;}如果打开那两行注释用-fsanitizeaddress,undefined编译会立刻看到堆内存错误的报告。这是很多人在写自定义容器时踩过的真实坑。五、uninitialized_* 家族速查算法语义典型用途construct_at(p, args...)在p上构造单个对象容器emplace/push_backdestroy_at(p)析构p处的对象与construct_at配对destroy(first, last)批量析构容器clear()uninitialized_copy(f, l, d)拷贝构造到原始内存vector拷贝构造uninitialized_copy_n(f, n, d)指定个数版本只知道长度时uninitialized_move(f, l, d)移动构造到原始内存vector扩容C17uninitialized_fill(f, l, v)填充构造vector(n, value)uninitialized_fill_n(f, n, v)指定个数填充同上uninitialized_default_construct(f, l)默认构造vector(n)的resizeuninitialized_value_construct(f, l)值初始化T()需要清零语义时注意uninitialized_default_construct与uninitialized_value_construct的区别前者对int不初始化值不确定后者初始化为 0。这对应new int[10]与new int[10]()的差别。常见坑点坑点 1对原始内存使用 std::copyT* raw alloc.allocate(n);std::copy(src, src n, raw); // ❌ 对未构造对象赋值 - UB✅ 正确写法std::uninitialized_copy(src, src n, raw); // ✅坑点 2对齐不足char buf[sizeof(T)];T* p std::construct_at(reinterpret_castT*(buf), args); // ❌ 可能不对齐char数组的对齐只有 1而T可能需要 8 或 16。✅ 正确写法是使用alignas或标准库的std::aligned_storageC23 弃用改用alignas数组alignas(T) unsigned char buf[sizeof(T)]; // ✅T* p std::construct_at(reinterpret_castT*(buf), args);坑点 3忘记 destroy 就 deallocatealloc.deallocate(p, n); // ❌ 直接释放元素里的 string/vector 全部泄漏✅ 正确顺序永远是先destroy全部元素再deallocate原始内存。std::destroy(p, p n);alloc.deallocate(p, n); // ✅坑点 4析构后继续使用use after destroystd::destroy_at(p);p-foo(); // ❌ 生命周期已结束UB指针变量本身仍然指向那块内存编译器不会报错但对象已经不存在了。-fsanitizeaddress也未必能抓到因为内存还没被释放这类 bug 最难查。坑点 5手写循环但没有异常回滚for (int i 0; i n; i)std::construct_at(dst i, src[i]); // ❌ 第 k 个抛异常前 k-1 个泄漏✅ 正确写法是直接调用std::uninitialized_copy或者自己写try/catch回滚如实战 3。坑点 6C20 之前没有 construct_atconstruct_at是 C20 才有的。在 C17 及更早版本里对应的写法是::new (static_castvoid*(p)) T(args...); // C17 及之前的 placement new如果代码需要兼容旧标准可以用一个兼容封装templateclass T, class... Argsconstexpr T* construct_at_compat(T* p, Args... args) {#if __cpp_lib_constexpr_dynamic_alloc 201907Lreturn std::construct_at(p, args...);#elsereturn ::new (static_castvoid*(p)) T(std::forwardArgs(args)...);#endif}坑点 7uninitialized_move之后源对象仍处于有效但未指定状态std::uninitialized_move(src, src n, dst);// 此时 src 里的元素仍存在需要手动 destroy但值已被搬走std::destroy(src, src n); // ✅ 必须仍然析构否则资源泄漏很多人以为移动之后就没了实际上源对象的生命周期没有结束必须照常析构。对std::string来说移动后通常为空但其本身的析构仍然是必须的。总结工具定位一句话记忆construct_at单对象构造placement new 的 constexpr 现代写法destroy_at单对象析构与construct_at严格配对uninitialized_copy批量拷贝构造原始内存上必须构造而不是赋值uninitialized_move批量移动构造扩容的正确姿势性能更好uninitialized_fill批量填充构造vector(n, v)的实现基础掌握这一组算法的意义不在于会调 API而在于理解 C 对象模型的这条红线内存的生命周期 ≠ 对象的生命周期。申请、构造、析构、释放是四个独立步骤顺序错了、漏了一步就是未定义行为。这也是从会用容器到能写容器之间最关键的一道门槛。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑