30. 综合案例:实现一个支持移动语义和完美转发的自定义容器
终于到了最后一章。说实话,前面讲了那么多移动语义和完美转发的理论,如果不亲手写一个容器,总觉得少了点什么。我个人习惯是,学完一个新特性,一定要找个实际场景把它用起来,不然很快就忘了。
这一章,我们就来实现一个轻量级的自定义容器——SimpleVector。它要支持移动语义,还要能完美转发元素。你想想看,这其实就是把前面29章的知识点串起来,做一个实战演练。
容器的设计目标
我不会搞一个特别复杂的容器,那样反而会分散注意力。我们的SimpleVector只需要做到以下几点:
- 支持动态内存管理
- 支持拷贝构造和拷贝赋值
- 支持移动构造和移动赋值
- 支持完美转发式的
emplace_back - 支持迭代器
说白了,就是标准库std::vector的一个极简版本。但麻雀虽小,五脏俱全。
框架设计:先画个图
在写代码之前,我习惯先画一张结构图,理清楚各个模块之间的关系。下面这张图展示了SimpleVector的核心架构:
第一步:基础骨架
我们先搭一个基础框架出来。嗯,这里要注意,内存管理一定要谨慎,我在项目中见过太多因为忘记释放内存导致的崩溃了。
template <typename T>
class SimpleVector {
public:
// 默认构造
SimpleVector() : data_(nullptr), size_(0), capacity_(0) {}
// 析构函数
~SimpleVector() {
clear();
::operator delete(data_);
}
// 获取大小和容量
size_t size() const { return size_; }
size_t capacity() const { return capacity_; }
bool empty() const { return size_ == 0; }
private:
T* data_;
size_t size_;
size_t capacity_;
void clear() {
for (size_t i = 0; i < size_; ++i) {
data_[i].~T();
}
size_ = 0;
}
};
::operator delete而不是delete[],因为我们是手动管理内存,没有用new[]来分配。保持一致很重要。
第二步:拷贝构造与拷贝赋值
拷贝语义是容器的基本功。我记得刚入行时,写了一个容器忘了实现深拷贝,结果两个对象共享同一块内存,析构时double free,查了一整天才找到原因。
// 拷贝构造
SimpleVector(const SimpleVector& other)
: data_(nullptr), size_(0), capacity_(0) {
if (other.size_ > 0) {
data_ = static_cast<T*>(::operator new(other.capacity_ * sizeof(T)));
capacity_ = other.capacity_;
for (size_t i = 0; i < other.size_; ++i) {
new (&data_[i]) T(other.data_[i]);
++size_;
}
}
}
// 拷贝赋值(使用copy-and-swap惯用法)
SimpleVector& operator=(const SimpleVector& other) {
if (this != &other) {
SimpleVector temp(other);
swap(temp);
}
return *this;
}
void swap(SimpleVector& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
std::swap(capacity_, other.capacity_);
}
第三步:移动构造与移动赋值
移动语义才是我们的重头戏。你想想看,如果一个临时对象要拷贝给另一个容器,那多浪费啊。移动语义就是把这个临时对象的资源「偷」过来。
// 移动构造
SimpleVector(SimpleVector&& other) noexcept
: data_(other.data_), size_(other.size_), capacity_(other.capacity_) {
// 把源对象置为空
other.data_ = nullptr;
other.size_ = 0;
other.capacity_ = 0;
}
// 移动赋值
SimpleVector& operator=(SimpleVector&& other) noexcept {
if (this != &other) {
// 先释放当前资源
clear();
::operator delete(data_);
// 偷取对方的资源
data_ = other.data_;
size_ = other.size_;
capacity_ = other.capacity_;
// 把源对象置为空
other.data_ = nullptr;
other.size_ = 0;
other.capacity_ = 0;
}
return *this;
}
noexcept。为什么?因为标准库容器在重新分配内存时,如果移动构造不是noexcept的,它会退而使用拷贝构造,那就失去了移动语义的优势。我曾经踩过这个坑,性能直接掉了一个数量级。
第四步:完美转发——emplace_back
终于到了完美转发。这是emplace_back的核心,它允许我们在容器内部直接构造对象,避免了临时对象的创建和拷贝。
template <typename... Args>
void emplace_back(Args&&... args) {
if (size_ == capacity_) {
// 需要扩容
reserve(capacity_ == 0 ? 1 : capacity_ * 2);
}
// 完美转发参数,在尾部直接构造
new (&data_[size_]) T(std::forward<Args>(args)...);
++size_;
}
void reserve(size_t new_capacity) {
if (new_capacity <= capacity_) return;
T* new_data = static_cast<T*>(::operator new(new_capacity * sizeof(T)));
// 移动已有元素到新内存
for (size_t i = 0; i < size_; ++i) {
new (&new_data[i]) T(std::move(data_[i]));
data_[i].~T();
}
::operator delete(data_);
data_ = new_data;
capacity_ = new_capacity;
}
// 为了方便,也提供一个push_back
void push_back(const T& value) {
emplace_back(value);
}
void push_back(T&& value) {
emplace_back(std::move(value));
}
这里的关键是std::forward<Args>(args)...。它保留了参数原本的值类别——左值还是右值。如果传入的是左值,就调用拷贝构造;如果是右值,就调用移动构造。这就是完美转发的精髓。
第五步:迭代器支持
没有迭代器的容器是不完整的。我们简单实现一下:
using iterator = T*;
using const_iterator = const T*;
iterator begin() { return data_; }
iterator end() { return data_ + size_; }
const_iterator begin() const { return data_; }
const_iterator end() const { return data_ + size_; }
T& operator[](size_t index) { return data_[index]; }
const T& operator[](size_t index) const { return data_[index]; }
完整示例:看看效果
我们来写一个测试用例,看看这个容器到底好不好用:
#include <iostream>
#include <string>
struct Person {
std::string name;
int age;
Person(const std::string& n, int a) : name(n), age(a) {
std::cout << "构造: " << name << "\n";
}
Person(const Person& other) : name(other.name), age(other.age) {
std::cout << "拷贝: " << name << "\n";
}
Person(Person&& other) noexcept
: name(std::move(other.name)), age(other.age) {
std::cout << "移动: " << name << "\n";
}
};
int main() {
SimpleVector<Person> people;
// 使用emplace_back,直接在容器内构造
people.emplace_back("Alice", 25); // 只有一次构造
people.emplace_back("Bob", 30); // 只有一次构造
// 使用push_back传入临时对象
people.push_back(Person("Charlie", 35)); // 构造 + 移动
// 使用push_back传入左值
Person dave("Dave", 40);
people.push_back(dave); // 拷贝
// 遍历
for (const auto& p : people) {
std::cout << p.name << ": " << p.age << "\n";
}
return 0;
}
emplace_back只触发了构造,没有任何拷贝或移动。而push_back传入临时对象时,会先构造临时对象,再移动进容器。这就是完美转发带来的性能优势。
避坑指南
最后,分享几个我实际开发中遇到的坑:
- 移动后源对象的状态:移动后的对象必须处于「有效但未指定」的状态。我一般把它置为空,这样析构和赋值都不会出问题。
- 异常安全:在
reserve中,如果移动构造抛异常,原来的数据已经被销毁了,那就麻烦了。所以标准库要求移动构造是noexcept的。 - 自赋值:移动赋值和拷贝赋值都要处理自赋值的情况。虽然自赋值很少见,但一旦发生,后果很严重。
好了,这个SimpleVector虽然简单,但已经包含了移动语义和完美转发的核心用法。你可以在它的基础上继续扩展,比如添加insert、erase等方法。记住,写容器类时,时刻想着「这个操作会不会产生不必要的拷贝?」——这就是移动语义要解决的根本问题。
shrink_to_fit方法,你会怎么实现?注意,要利用移动语义来减少内存拷贝哦。
公众号:蓝海资料掘金营,微信deep3321