30. 综合案例:实现一个支持移动语义和完美转发的自定义容器

终于到了最后一章。说实话,前面讲了那么多移动语义和完美转发的理论,如果不亲手写一个容器,总觉得少了点什么。我个人习惯是,学完一个新特性,一定要找个实际场景把它用起来,不然很快就忘了。

这一章,我们就来实现一个轻量级的自定义容器——SimpleVector。它要支持移动语义,还要能完美转发元素。你想想看,这其实就是把前面29章的知识点串起来,做一个实战演练。

容器的设计目标

我不会搞一个特别复杂的容器,那样反而会分散注意力。我们的SimpleVector只需要做到以下几点:

  • 支持动态内存管理
  • 支持拷贝构造和拷贝赋值
  • 支持移动构造和移动赋值
  • 支持完美转发式的emplace_back
  • 支持迭代器

说白了,就是标准库std::vector的一个极简版本。但麻雀虽小,五脏俱全。

框架设计:先画个图

在写代码之前,我习惯先画一张结构图,理清楚各个模块之间的关系。下面这张图展示了SimpleVector的核心架构:

SimpleVector 核心架构 SimpleVector<T> T* data_ (原始内存指针) size_t size_ (当前元素数量) size_t capacity_ (当前容量) 核心操作 拷贝语义 SimpleVector(const SimpleVector&) operator=(const SimpleVector&) 移动语义 SimpleVector(SimpleVector&&) operator=(SimpleVector&&) 完美转发 emplace_back(Args&&...) push_back(T&&) 三个模块共同构成一个高效、安全的容器

第一步:基础骨架

我们先搭一个基础框架出来。嗯,这里要注意,内存管理一定要谨慎,我在项目中见过太多因为忘记释放内存导致的崩溃了。

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_);
}
关键点:拷贝构造中使用了placement new来在已分配的内存上构造对象。这是标准库容器的标准做法,避免了不必要的默认构造。

第三步:移动构造与移动赋值

移动语义才是我们的重头戏。你想想看,如果一个临时对象要拷贝给另一个容器,那多浪费啊。移动语义就是把这个临时对象的资源「偷」过来。

// 移动构造
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虽然简单,但已经包含了移动语义和完美转发的核心用法。你可以在它的基础上继续扩展,比如添加inserterase等方法。记住,写容器类时,时刻想着「这个操作会不会产生不必要的拷贝?」——这就是移动语义要解决的根本问题。

延伸思考:如果让你给这个容器添加一个shrink_to_fit方法,你会怎么实现?注意,要利用移动语义来减少内存拷贝哦。

公众号:蓝海资料掘金营,微信deep3321