前言
「自己实现一个 STL 容器」是理解 C++ 对象模型和内存管理的最好练习。很多人以为std::vector就是一个会自动扩容的数组,照着写一个new T[n]、delete[]的包装类就完事。真正动手后才会发现:容器必须区分「已分配的内存」和「已构造的对象」,必须在扩容时只搬移有效元素,必须在拷贝构造抛异常时把半成品清理干净,还必须保证移动后的源对象处于可析构状态。
标准库把这套逻辑拆成三层:分配器(allocator)负责申请和释放原始内存,std::allocator_traits负责构造和销毁对象,容器负责维护大小、容量和迭代器。理解这三层分工,比记住vector有多少个成员函数更重要。
本文实现一个简化版的MiniVector<T>,支持默认构造、拷贝、移动、push_back、emplace_back、reserve、resize、at、迭代器遍历。它不追求与std::vector完全兼容,而是把关键机制暴露出来。代码基准 C++17。
一、容器为什么不能直接用 new[]
假设要写一个动态数组,最直觉的做法是:
// 适用于 C++17(错误示范)
template <class T>
class BadArray {
public:
explicit BadArray(std::size_t n) : data_(new T[n]), size_(n) {}
~BadArray() { delete[] data_; }
private:
T* data_;
std::size_t size_;
};这段代码有三个根本问题:
new T[n]会立即构造 n 个T对象。std::vector的容量可以大于元素个数,多出来的位置必须是「未构造的原始内存」,不能提前构造。对没有默认构造函数的类型,new T[n]直接编译失败。- 无法支持「先分配容量、再逐个构造」的扩容策略。
push_back需要在未构造的槽位上用拷贝或移动构造,而不是赋值。 new[]与delete[]配对,但容器的容量和元素个数是分开的,delete[]只能释放整个数组,无法表达「释放 capacity 个槽位」这件事。
因此 STL 容器使用分配器的四个动作:
| 动作 | 作用 | 对应函数 |
|---|
| 分配原始内存 | 得到未构造的存储 | allocate(n) |
| 构造对象 | 在指定地址开始对象生命周期 | construct(p, args...) |
| 销毁对象 | 结束生命周期,调用析构函数 | destroy(p) |
| 释放内存 | 归还存储 | deallocate(p, n) |
std::allocator_traits<Allocator>为这些操作提供统一接口:即使分配器没有直接定义construct,traits 也会给出默认实现(通常是 placement new)。这就是容器能适配自定义分配器的原因。
二、大小、容量与迭代器失效
容器要维护三个量:
data_:指向分配到的存储首地址;size_:已经构造的元素个数;cap_:存储能容纳的元素个数。
关系始终是size_ <= cap_。[data_, data_ + size_)是有效元素,[data_ + size_, data_ + cap_)是未构造的原始内存,不能解引用。
reserve(n)把容量提升到至少 n,不改变元素个数;resize(n)改变元素个数,必要时扩容并构造新元素。push_back在size_ == cap_时扩容,常见策略是容量翻倍。标准不规定扩容因子,std::vector的具体增长策略因实现而异,本文用翻倍只是示意。
扩容会重新分配存储并把元素搬到新地址,因此所有指向旧存储的指针、引用、迭代器都会失效。这是vector最重要的失效规则,也是reserve存在的意义:提前预留足够容量,避免循环中反复扩容。
三、完整实现
下面的实现把分配、构造、销毁分开,并用 copy-and-swap 提供拷贝赋值的强异常保证。
// 适用于 C++17
#include <cstddef>
#include <memory>
#include <stdexcept>
#include <type_traits>
#include <utility>
template <class T, class Allocator = std::allocator<T>>
class MiniVector {
public:
using value_type = T;
using allocator_type = Allocator;
using size_type = std::size_t;
using difference_type = std::ptrdiff_t;
using reference = value_type&;
using const_reference = const value_type&;
using pointer = typename std::allocator_traits<Allocator>::pointer;
using const_pointer = typename std::allocator_traits<Allocator>::const_pointer;
using iterator = pointer;
using const_iterator = const_pointer;
MiniVector() = default;
explicit MiniVector(size_type n) { resize(n); }
MiniVector(const MiniVector& other) : alloc_(other.alloc_) {
if (other.size_ == 0) {
return;
}
data_ = alloc_traits::allocate(alloc_, other.size_);
cap_ = other.size_;
size_type constructed = 0;
try {
for (; constructed < other.size_; ++constructed) {
alloc_traits::construct(alloc_, data_ + constructed,
other.data_[constructed]);
}
size_ = other.size_;
} catch (...) {
for (size_type i = 0; i < constructed; ++i) {
alloc_traits::destroy(alloc_, data_ + i);
}
alloc_traits::deallocate(alloc_, data_, cap_);
data_ = pointer{};
cap_ = 0;
throw;
}
}
MiniVector(MiniVector&& other) noexcept(
std::is_nothrow_move_constructible<Allocator>::value)
: alloc_(std::move(other.alloc_)),
data_(other.data_),
size_(other.size_),
cap_(other.cap_) {
other.data_ = pointer{};
other.size_ = 0;
other.cap_ = 0;
}
MiniVector& operator=(const MiniVector& other) {
if (this != &other) {
MiniVector tmp(other); // 可能抛异常,*this 不受影响
swap(tmp);
}
return *this;
}
MiniVector& operator=(MiniVector&& other) noexcept(
std::is_nothrow_move_assignable<Allocator>::value) {
if (this != &other) {
release();
alloc_ = std::move(other.alloc_);
data_ = other.data_;
size_ = other.size_;
cap_ = other.cap_;
other.data_ = pointer{};
other.size_ = 0;
other.cap_ = 0;
}
return *this;
}
~MiniVector() { release(); }
void swap(MiniVector& other) noexcept {
using std::swap;
swap(data_, other.data_);
swap(size_, other.size_);
swap(cap_, other.cap_);
}
size_type size() const noexcept { return size_; }
size_type capacity() const noexcept { return cap_; }
bool empty() const noexcept { return size_ == 0; }
reference operator[](size_type i) noexcept { return data_[i]; }
const_reference operator[](size_type i) const noexcept { return data_[i]; }
reference at(size_type i) {
if (i >= size_) {
throw std::out_of_range("MiniVector::at");
}
return data_[i];
}
reference front() { return data_[0]; }
reference back() { return data_[size_ - 1]; }
iterator begin() noexcept { return data_; }
iterator end() noexcept { return data_ + size_; }
const_iterator begin() const noexcept { return data_; }
const_iterator end() const noexcept { return data_ + size_; }
const_iterator cbegin() const noexcept { return data_; }
const_iterator cend() const noexcept { return data_ + size_; }
T* data() noexcept { return data_; }
const T* data() const noexcept { return data_; }
void reserve(size_type newCap) {
if (newCap <= cap_) {
return;
}
pointer newData = alloc_traits::allocate(alloc_, newCap);
size_type constructed = 0;
try {
for (; constructed < size_; ++constructed) {
alloc_traits::construct(
alloc_, newData + constructed,
std::move_if_noexcept(data_[constructed]));
}
} catch (...) {
for (size_type i = 0; i < constructed; ++i) {
alloc_traits::destroy(alloc_, newData + i);
}
alloc_traits::deallocate(alloc_, newData, newCap);
throw;
}
for (size_type i = 0; i < size_; ++i) {
alloc_traits::destroy(alloc_, data_ + i);
}
if (data_ != pointer{}) {
alloc_traits::deallocate(alloc_, data_, cap_);
}
data_ = newData;
cap_ = newCap;
}
void resize(size_type n) {
if (n < size_) {
for (size_type i = n; i < size_; ++i) {
alloc_traits::destroy(alloc_, data_ + i);
}
size_ = n;
} else if (n > size_) {
reserve(n);
for (; size_ < n; ++size_) {
alloc_traits::construct(alloc_, data_ + size_);
}
}
}
void clear() noexcept {
for (size_type i = 0; i < size_; ++i) {
alloc_traits::destroy(alloc_, data_ + i);
}
size_ = 0;
}
void push_back(const T& value) {
if (size_ == cap_) {
grow(size_ + 1);
}
alloc_traits::construct(alloc_, data_ + size_, value);
++size_;
}
void push_back(T&& value) {
if (size_ == cap_) {
grow(size_ + 1);
}
alloc_traits::construct(alloc_, data_ + size_, std::move(value));
++size_;
}
template <class... Args>
reference emplace_back(Args&&... args) {
if (size_ == cap_) {
grow(size_ + 1);
}
alloc_traits::construct(alloc_, data_ + size_,
std::forward<Args>(args)...);
++size_;
return data_[size_ - 1];
}
void pop_back() {
--size_;
alloc_traits::destroy(alloc_, data_ + size_);
}
private:
using alloc_traits = std::allocator_traits<Allocator>;
void grow(size_type needed) {
size_type newCap = (cap_ == 0) ? 1 : cap_ * 2;
if (newCap < needed) {
newCap = needed;
}
reserve(newCap);
}
void release() noexcept {
clear();
if (data_ != pointer{}) {
alloc_traits::deallocate(alloc_, data_, cap_);
data_ = pointer{};
cap_ = 0;
}
}
Allocator alloc_{};
pointer data_{};
size_type size_ = 0;
size_type cap_ = 0;
};使用示例:
// 适用于 C++17
#include <iostream>
int main() {
MiniVector<int> values;
values.reserve(4);
for (int i = 0; i < 5; ++i) {
values.push_back(i * i);
}
values.emplace_back(42);
for (int value : values) {
std::cout << value << ' ';
}
std::cout << "\ncapacity=" << values.capacity()
<< " size=" << values.size() << '\n';
}按标准语义,reserve(4)后前四个push_back不会扩容,第五个触发扩容;具体输出取决于扩容策略。
四、关键实现细节与差距
为什么reserve里用std::move_if_noexcept。扩容要把旧元素搬到新存储。如果T的移动构造可能抛异常,而T又可拷贝,move_if_noexcept会选择拷贝:拷贝失败时旧元素原封不动,容器满足强异常保证。如果移动是noexcept,则选择移动,避免深拷贝。若类型只能移动且移动可能抛异常,它仍会移动,此时只能提供基本保证。
为什么扩容失败时旧数据还在。reserve先分配新存储,逐个构造新元素;任何一步抛异常,就销毁已构造的新元素、释放新存储并重抛。旧存储和旧元素直到新存储全部构造成功后才销毁。这种「先做新、成功再换」的顺序,是强异常保证的标准手法。
为什么析构和release要分开。release既销毁元素又释放存储,供析构函数和移动赋值复用。clear只销毁元素、不释放存储,容器仍可继续使用,容量不变。
移动后源对象要重置。移动构造和移动赋值都把源对象的data_置空、size_和cap_置 0。否则源对象析构时会释放已经被目标接管的存储,造成重复释放。被移动对象随后处于「有效但未指定」状态,这里我们把它变成空容器,是一种更强的约定。
pop_back不检查空容器。标准容器的pop_back在空容器上是未定义行为,这里保持一致。调用方必须先检查empty()。
operator[]不检查边界。与std::vector一致,越界是 UB;需要检查时用at(),它抛std::out_of_range。
分配器状态被简化。本例假设所有实例使用相等的分配器(默认std::allocator满足)。真正的 STL 实现还要处理propagate_on_container_copy_assignment等 traits,本文不展开。
与 std::vector 的差距
这个MiniVector只覆盖了核心机制,距离标准容器还很远:
| 维度 | MiniVector | std::vector |
|---|
| 中间插入/删除 | 不支持 | insert/erase |
| 分配器传播 | 简化处理 | 完整 traits 支持 |
| 异常保证 | 部分强保证 | 明确规定的各级保证 |
| 迭代器类型 | 裸指针 | 实现定义的随机访问迭代器 |
| 辅助接口 | 少量 | assign、shrink_to_fit、比较运算符等 |
| 调试检查 | 无 | 部分实现有_GLIBCXX_DEBUG等模式 |
即便如此,实现一遍仍能解释很多平时当成「黑箱」的行为:为什么reserve能避免迭代器失效、为什么push_back的实参引用容器内部元素时不能在扩容后再用、为什么自定义类型需要可移动或可拷贝、为什么vector的析构是线性的。
常见坑点
- ❌ 用
new T[n]预分配容量,要求T必须可默认构造。
✅ 用allocator::allocate拿原始内存,只在真正插入时construct。
- ❌
allocate之后用delete[]释放。
✅ 必须用同一个分配器的deallocate(ptr, n),且 n 与分配时一致。
- ❌ 只
deallocate不destroy,非平凡析构函数不执行。
✅ 先对每个已构造元素调用destroy,再释放存储。
- ❌ 扩容后继续使用指向旧存储的指针、引用或迭代器。
✅ 扩容会使它们全部失效;提前reserve,或在扩容后重新获取。
- ❌ 拷贝构造中途抛异常,直接往外扔,已构造的部分泄漏。
✅try/catch中销毁已构造元素并释放存储,再throw;重抛。
- ❌ 移动构造只搬指针,不把源对象的指针置空。
✅ 源对象重置为空,避免两个对象析构同一块存储。
- ❌ 用
memcpy搬移非平凡可拷贝类型。
✅ 用移动或拷贝构造;memcpy对含指针、虚表或资源句柄的类型是 UB。
- ❌ 认为
reserve会改变size(),或认为resize一定重新分配。
✅reserve只改容量;resize改元素个数,容量足够时不分配。
总结
| 层次 | 职责 | 关键接口 |
|---|
| 分配器 | 申请/释放原始内存 | allocate、deallocate |
| traits | 构造/销毁对象 | construct、destroy |
| 容器 | 维护 size/cap、迭代器、异常安全 | reserve、push_back、resize |
实现 STL 容器的核心不是背接口,而是守住三条不变量:size_ <= cap_;只有[0, size_)内是活对象;任何一次扩容都要先保证新存储构造成功,再销毁旧存储。把这三条落实到每一行代码,拷贝、移动、异常、析构的行为就都能推导出来。
真正写生产代码时仍应使用std::vector:它经过充分测试,异常保证、分配器传播和边界情况都有明确规范。自己实现的价值在于获得「可推演」的理解——当你知道push_back背后发生了什么,再看迭代器失效和性能问题,就不会只停留在记忆结论的层面。