1. 动态内存管理类StrVec的实现解析
在C++中,动态内存管理是一个核心话题。本节我们将深入探讨如何实现一个类似std::vector的类StrVec,它不使用标准库容器,而是自己管理动态内存。这个实现涉及多个关键概念和技术点。
1.1 类的基本结构与设计思路
StrVec类的基本结构如下:
cpp复制class StrVec {
public:
StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr) {}
StrVec(const StrVec &); // 拷贝构造函数
StrVec &operator=(const StrVec &); // 拷贝赋值运算符
~StrVec(); // 析构函数
void push_back(const std::string &); // 添加元素
size_t size() const { return first_free - elements; }
size_t capacity() const { return cap - elements; }
std::string *begin() const { return elements; }
std::string *end() const { return first_free; }
void reserve(size_t n);
void resize(size_t n);
void resize(size_t n, const std::string &s);
private:
std::allocator<std::string> alloc; // 内存分配器
void chk_n_alloc(); // 检查是否需要重新分配内存
std::pair<std::string *, std::string *> alloc_n_copy(const std::string *, const std::string *);
void free(); // 释放内存
void reallocate(); // 重新分配内存
std::string *elements; // 指向数组首元素
std::string *first_free; // 指向第一个空闲元素
std::string *cap; // 指向数组尾后位置
};
这个设计有几个关键点:
- 使用三个指针管理内存:elements指向首元素,first_free指向第一个空闲位置,cap指向容量末尾
- 使用std::allocator而非直接new/delete,更灵活且与标准库一致
- 实现了完整的拷贝控制成员(构造、拷贝、赋值、析构)
1.2 内存管理核心实现
1.2.1 内存分配与释放
cpp复制void StrVec::free() {
if (elements) {
// 逆序销毁元素
for (auto p = first_free; p != elements; )
alloc.destroy(--p);
alloc.deallocate(elements, cap - elements);
}
}
std::pair<std::string*, std::string*>
StrVec::alloc_n_copy(const std::string *b, const std::string *e) {
auto data = alloc.allocate(e - b);
return {data, std::uninitialized_copy(b, e, data)};
}
free()函数负责释放内存,注意它先销毁对象再释放内存。alloc_n_copy使用allocator分配内存并用uninitialized_copy构造对象。
1.2.2 重新分配内存策略
cpp复制void StrVec::reallocate() {
auto newcapacity = size() ? 2 * size() : 1;
auto newdata = alloc.allocate(newcapacity);
auto dest = newdata;
auto elem = elements;
for (size_t i = 0; i != size(); ++i)
alloc.construct(dest++, std::move(*elem++));
free();
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}
reallocate实现了经典的"加倍"策略:当空间不足时,分配当前大小两倍的新内存,使用移动语义转移元素,避免不必要的拷贝。
1.3 关键成员函数实现
1.3.1 push_back的实现
cpp复制void StrVec::push_back(const std::string &s) {
chk_n_alloc(); // 确保有空间
alloc.construct(first_free++, s);
}
push_back首先检查是否需要扩容,然后在第一个空闲位置构造新元素。注意这里使用的是拷贝构造。
1.3.2 reserve和resize的实现
cpp复制void StrVec::reserve(size_t n) {
if (n <= capacity()) return;
auto newdata = alloc.allocate(n);
auto dest = newdata;
auto elem = elements;
for (size_t i = 0; i != size(); ++i)
alloc.construct(dest++, std::move(*elem++));
free();
elements = newdata;
first_free = dest;
cap = elements + n;
}
void StrVec::resize(size_t n, const std::string &s) {
if (n < size()) {
while (first_free != elements + n)
alloc.destroy(--first_free);
} else if (n > size()) {
if (n > capacity()) reserve(n);
while (first_free != elements + n)
alloc.construct(first_free++, s);
}
}
reserve确保容量至少为n,resize调整元素数量,多出的元素用s初始化。
2. 右值引用与移动语义
2.1 右值引用基础
右值引用(&&)是C++11引入的重要特性,它允许我们标识临时对象,从而实现高效的资源转移。
关键概念:
- 左值:有持久状态的对象,可以取地址
- 右值:临时对象,即将被销毁
- 将亡值(xvalue):通过std::move或返回右值引用的函数获得
cpp复制std::string s1 = "hello";
std::string s2 = std::move(s1); // s1现在是xvalue
2.2 移动构造函数与移动赋值
为StrVec添加移动操作:
cpp复制StrVec::StrVec(StrVec &&s) noexcept
: elements(s.elements), first_free(s.first_free), cap(s.cap) {
s.elements = s.first_free = s.cap = nullptr;
}
StrVec &StrVec::operator=(StrVec &&rhs) noexcept {
if (this != &rhs) {
free();
elements = rhs.elements;
first_free = rhs.first_free;
cap = rhs.cap;
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
移动操作的关键点:
- 直接"窃取"源对象的资源
- 将源对象置于可析构状态
- 声明为noexcept,这对标准库容器很重要
2.3 noexcept的重要性
noexcept声明对移动操作至关重要,特别是对于标准库容器:
- vector在扩容时会优先使用移动操作(如果noexcept)
- 如果移动可能抛出异常,vector会退回到拷贝操作
- 移动操作通常不分配资源,理论上不应抛出异常
3. 拷贝并交换技术
3.1 统一赋值运算符实现
拷贝并交换是一种优雅的实现赋值运算符的技术:
cpp复制HasPtr& operator=(HasPtr rhs) { // 按值传递
swap(*this, rhs);
return *this;
}
这种实现的优势:
- 自动处理自赋值
- 提供强异常安全保证
- 一个函数同时处理拷贝和移动赋值
3.2 与独立移动赋值的比较
独立实现移动赋值运算符可能更高效:
cpp复制HasPtr& operator=(HasPtr &&rhs) noexcept {
if (this != &rhs) {
delete ps;
ps = rhs.ps;
i = rhs.i;
rhs.ps = nullptr;
rhs.i = 0;
}
return *this;
}
比较:
- 拷贝并交换:代码简洁,但可能有额外交换开销
- 独立移动赋值:更直接高效,但需要更多代码
4. 实际应用与性能考量
4.1 在容器中的应用
标准库容器如vector会充分利用移动语义:
cpp复制std::vector<StrVec> v;
v.push_back(StrVec()); // 调用移动构造函数
当vector扩容时:
- 如果移动操作是noexcept,使用移动转移元素
- 否则,使用拷贝保证异常安全
4.2 性能优化技巧
- 对资源管理类总是实现移动操作
- 移动操作声明为noexcept
- 考虑使用拷贝并交换简化代码
- 避免不必要的std::move,可能阻止返回值优化
5. 完整实现示例
5.1 StrVec的完整代码
cpp复制// StrVec.h
#ifndef STRVEC_H
#define STRVEC_H
#include <memory>
#include <string>
#include <utility>
class StrVec {
public:
StrVec() : elements(nullptr), first_free(nullptr), cap(nullptr) {}
StrVec(const StrVec&);
StrVec(StrVec&&) noexcept;
StrVec& operator=(const StrVec&);
StrVec& operator=(StrVec&&) noexcept;
~StrVec();
void push_back(const std::string&);
void push_back(std::string&&);
size_t size() const { return first_free - elements; }
size_t capacity() const { return cap - elements; }
std::string* begin() const { return elements; }
std::string* end() const { return first_free; }
void reserve(size_t);
void resize(size_t);
void resize(size_t, const std::string&);
private:
std::allocator<std::string> alloc;
void chk_n_alloc() { if (size() == capacity()) reallocate(); }
std::pair<std::string*, std::string*> alloc_n_copy(const std::string*, const std::string*);
void free();
void reallocate();
std::string* elements;
std::string* first_free;
std::string* cap;
};
#endif
5.2 使用示例
cpp复制StrVec getVec() {
StrVec v;
v.push_back("temporary");
return v; // 触发移动构造
}
int main() {
StrVec v1;
v1.push_back("hello");
v1.push_back("world");
StrVec v2 = v1; // 拷贝构造
StrVec v3 = std::move(v1); // 移动构造
v2 = v3; // 拷贝赋值
v3 = getVec(); // 移动赋值
v3.reserve(100); // 预留空间
v3.resize(50, "default"); // 调整大小
}
6. 经验总结与注意事项
在实际实现动态内存管理类时,有几个关键点需要注意:
- 资源所有权转移:移动操作后,必须确保源对象处于有效但可析构状态
- 异常安全:移动操作通常应为noexcept,拷贝操作应保证强异常安全
- 自赋值处理:赋值运算符必须正确处理自赋值情况
- 内存泄漏:确保所有分配的内存都有对应的释放
- 测试覆盖:特别测试边界条件(空容器、自赋值、移动后使用等)
一个常见的陷阱是在移动操作后意外使用源对象。虽然标准要求移动后的对象处于有效状态,但其内容是不确定的。最佳实践是移动后立即赋予新值或不再使用。
移动语义虽然强大,但不应滥用。只有在确实需要转移资源所有权时才使用std::move,对于简单的内置类型(如int、指针等),移动并不会带来性能提升,反而可能降低代码可读性。