前言
std::string是 C++ 最常用的类之一,模拟实现它(常被称为my_string或String)是理解「资源管理类」的经典练习:它同时涉及动态数组、三/五法则、拷贝与移动、异常安全、迭代器失效和 C 字符串接口。写一遍这样的类,比背十条规则更能看清所有权是怎么流转的。
模拟实现不是要替代std::string。标准库的实现包含短字符串优化(small string optimization, SSO)、更精细的扩容策略和大量边界处理,生产代码应直接使用std::string。本文的目标是讲清一个「能用」的最小实现需要守住哪些不变量,以及哪些地方最容易出错。
常见的误解是「字符串类只要有一个char*和一个长度就够了」。真正需要维护的是三个量:数据指针、当前长度、当前容量;并且要保证数据以'\0'结尾、容量不小于长度、移动后的源对象可以被安全析构。下面按成员、特殊成员函数、扩容与追加、访问接口四部分展开。代码以 C++17 为准,未在本机编译或运行。
一、三个成员与四条不变量
一个不带短字符串优化的字符串类通常持有三个成员:
| 成员 | 含义 | 说明 |
|---|
data_ | 指向堆上的字符数组 | 至少分配capacity_ + 1字节 |
size_ | 当前字符数,不含结尾的'\0' | size_ <= capacity_ |
capacity_ | 当前可容纳的字符数,不含结尾的'\0' | 扩容后只增不减(本实现) |
四条不变量必须始终成立:
data_为nullptr,或者指向一块至少capacity_ + 1字节的数组。本实现只在「被移动后」出现nullptr。data_[size_] == '\0',保证c_str()返回合法的 C 字符串。size_ <= capacity_。- 长度为
size_的内容位于data_[0]到data_[size_ - 1],区间之外不参与逻辑。
为什么容量要不含结尾的'\0'?这样capacity()与size()使用同一套计数方式,比较直观;分配时统一多申请一个字节。也有人把容量定义成包含'\0',只要全类保持一致即可,但混用两套定义是 bug 的温床。
c_str()返回的指针指向内部缓冲区,不转移所有权。只要字符串被修改(尤其是触发扩容)或销毁,这个指针就可能失效;调用方不能长期保存它,更不能delete[]它。
二、特殊成员函数:拷贝、移动与赋值
默认构造分配一个字节并写入'\0',长度和容量都为 0。从const char*构造时先strlen求长度,再分配len + 1字节并连同结尾一起复制。这里必须检查空指针:对nullptr调用strlen是未定义行为。
拷贝构造做深拷贝:按源对象的容量分配新缓冲区,复制size_ + 1字节。移动构造直接接管指针、长度和容量,然后把源对象的三个成员置空。置空之后,源对象仍然可以析构(delete[] nullptr合法)、可以重新赋值;但不要读取它的内容。为了让被移动对象也能被安全地拷贝,拷贝构造对data_ == nullptr做了兜底。
拷贝赋值采用「复制并交换」:先构造临时副本,成功后再交换。如果复制过程中抛出std::bad_alloc,原对象完全没变,满足强异常安全保证;自赋值也自然安全。移动赋值先释放自己的缓冲区,再接管源对象的三个成员并置空源对象。
析构函数只做delete[] data_。注意必须与new char[...]配对,不能用delete,也不能用free。
// 适用于 C++17
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <ostream>
#include <stdexcept>
#include <utility>
class String {
public:
String() : data_(new char[1]{'\0'}), size_(0), capacity_(0) {}
String(const char* s) {
if (s == nullptr) {
throw std::invalid_argument("String: null pointer");
}
size_ = std::strlen(s);
capacity_ = size_;
data_ = new char[capacity_ + 1];
std::memcpy(data_, s, size_ + 1);
}
String(const String& other)
: data_(new char[other.capacity_ + 1]),
size_(other.size_),
capacity_(other.capacity_) {
if (other.data_ != nullptr) {
std::memcpy(data_, other.data_, size_ + 1);
} else {
data_[0] = '\0';
}
}
String(String&& other) noexcept
: data_(other.data_), size_(other.size_), capacity_(other.capacity_) {
other.data_ = nullptr;
other.size_ = 0;
other.capacity_ = 0;
}
String& operator=(const String& other) {
if (this != &other) {
String tmp(other); // 失败时 *this 不变
swap(tmp);
}
return *this;
}
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
capacity_ = other.capacity_;
other.data_ = nullptr;
other.size_ = 0;
other.capacity_ = 0;
}
return *this;
}
~String() {
delete[] data_;
}
void swap(String& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
std::swap(capacity_, other.capacity_);
}
void reserve(std::size_t newCapacity) {
if (newCapacity <= capacity_) {
return;
}
char* newData = new char[newCapacity + 1];
if (data_ != nullptr) {
std::memcpy(newData, data_, size_ + 1);
} else {
newData[0] = '\0';
}
delete[] data_;
data_ = newData;
capacity_ = newCapacity;
}
void push_back(char ch) {
if (size_ == capacity_) {
reserve(capacity_ == 0 ? 1 : capacity_ * 2);
}
data_[size_] = ch;
++size_;
data_[size_] = '\0';
}
String& append(const char* s) {
if (s == nullptr) {
throw std::invalid_argument("String::append: null pointer");
}
const std::size_t len = std::strlen(s);
// 若 s 指向本对象内部缓冲区,扩容后原指针会失效,先记下偏移。
// 用整数地址比较,避免对无关指针做关系比较。
const std::uintptr_t base =
reinterpret_cast<std::uintptr_t>(data_);
const std::uintptr_t addr =
reinterpret_cast<std::uintptr_t>(s);
const bool inside =
data_ != nullptr && addr >= base && addr < base + size_ + 1;
const std::size_t offset =
inside ? static_cast<std::size_t>(addr - base) : 0;
reserve(size_ + len);
if (inside) {
s = data_ + offset;
}
std::memmove(data_ + size_, s, len + 1); // 允许源与目标重叠
size_ += len;
return *this;
}
String& operator+=(const String& other) {
return append(other.c_str());
}
String& operator+=(char ch) {
push_back(ch);
return *this;
}
char& operator[](std::size_t index) { return data_[index]; }
const char& operator[](std::size_t index) const { return data_[index]; }
char& at(std::size_t index) {
if (index >= size_) {
throw std::out_of_range("String::at");
}
return data_[index];
}
const char& at(std::size_t index) const {
if (index >= size_) {
throw std::out_of_range("String::at");
}
return data_[index];
}
const char* c_str() const noexcept {
return data_ != nullptr ? data_ : "";
}
std::size_t size() const noexcept { return size_; }
std::size_t capacity() const noexcept { return capacity_; }
bool empty() const noexcept { return size_ == 0; }
char* begin() noexcept { return data_; }
char* end() noexcept { return data_ != nullptr ? data_ + size_ : nullptr; }
const char* begin() const noexcept { return data_; }
const char* end() const noexcept {
return data_ != nullptr ? data_ + size_ : nullptr;
}
private:
char* data_;
std::size_t size_;
std::size_t capacity_;
};
inline std::ostream& operator<<(std::ostream& os, const String& s) {
return os << s.c_str();
}三、扩容、追加与异常安全
reserve(n)的行为是「保证容量至少为 n,不改变长度」。实现分三步:分配新缓冲区、复制旧内容(含结尾)、释放旧缓冲区并更新成员。如果new抛出,前两步没有发生,原对象不变,因此reserve提供强保证。注意复制的是size_ + 1字节,结尾的'\0'也要带过去。
push_back在size_ == capacity_时扩容。本实现采用「容量为 0 时扩到 1,否则翻倍」的策略。翻倍不是标准规定,std::string的具体增长策略由实现决定;这里只是保证均摊 O(1)。理论上capacity_ * 2可能溢出std::size_t,真实实现应加上界检查。
append处理了两个容易忽略的问题。第一,s可能指向本对象内部(例如s.append(s.c_str())),如果先扩容再strlen或直接使用旧指针,就会读到已释放内存。代码先把指针转成整数地址,判断是否落在本缓冲区范围内,记下偏移,扩容后再把s重新指向新缓冲区。第二,源和目标可能重叠,因此用memmove而不是memcpy。memmove允许重叠,memcpy在重叠时是未定义行为。
size_ + len也可能溢出。工业级实现会检查上界并抛std::length_error;本实现为保持简短没有加,读者在自己的代码里应当补上。
| 操作 | 是否可能扩容 | 异常安全 | 迭代器/指针失效 |
|---|
push_back | 是 | 强保证 | 扩容时全部失效 |
append | 是 | 强保证 | 扩容时全部失效 |
reserve | 是 | 强保证 | 扩容时全部失效 |
operator[] | 否 | 不检查边界 | 不失效 |
at | 否 | 越界抛out_of_range | 不失效 |
c_str | 否 | noexcept | 修改或析构后失效 |
四、下标、迭代器与 c_str 的边界
operator[]不做边界检查,越界访问是未定义行为;at检查index >= size_,越界抛std::out_of_range。两者都返回引用,可以读也可以写,但返回的引用在扩容后可能悬垂。不要在持有元素引用的情况下调用push_back或append。
begin()/end()返回原始指针作为迭代器。它们只在缓冲区未重新分配时有效;任何可能扩容的操作都会让它们失效。被移动后的对象data_为nullptr,begin()和end()都返回空指针,此时不能解引用,但可以用于「空范围」的判断。
c_str()返回内部缓冲区,保证以'\0'结尾。它不分配新内存,也不转移所有权;调用方只要在字符串被修改或销毁后继续使用这个指针,就是悬垂指针。需要长期保存内容时,应复制到自己的缓冲区。
本实现没有短字符串优化,所有非空字符串都至少有一次堆分配;也没有实现find、substr、比较运算符等接口。这些不影响对资源管理主线的理解,真正使用时请优先选择std::string。
常见坑点
- ❌ 构造或追加后忘记写结尾的
'\0'。
✅ 每次改变长度后都在data_[size_]写入'\0',复制时连结尾一起复制。
- ❌ 拷贝赋值先
delete[] data_再从other复制,自赋值时读到已释放内存。
✅ 先判this != &other,或先构造副本再交换。
- ❌
new char[...]配delete,或new配free。
✅ 数组形式的new[]必须配delete[],分配与释放方式严格对应。
- ❌ 扩容后继续使用旧的
c_str()指针或迭代器。
✅ 任何可能扩容的操作之后重新获取指针/迭代器。
- ❌ 移动构造只复制指针,不把源对象置空。
✅ 接管后立即把源对象的data_置nullptr、size_与capacity_置 0。
- ❌
append用memcpy处理可能指向自身的源字符串。
✅ 先记录偏移、扩容后重定位,并使用允许重叠的memmove。
- ❌ 用
operator[]访问index == size_的位置并当作有效字符。
✅ 下标范围是[0, size_);结尾的'\0'只供c_str()使用。
- ❌ 把
c_str()的返回值保存下来,跨过字符串修改继续使用。
✅ 需要持久化时复制内容;c_str()指针只保证在下一次修改前有效。
总结
| 主题 | 关键做法 |
|---|
| 成员 | data_、size_、capacity_,容量不含'\0' |
| 不变量 | 结尾有'\0',size_ <= capacity_,缓冲区足够 |
| 拷贝 | 深拷贝,按容量分配,复制size_ + 1字节 |
| 移动 | 偷指针并置空源对象,标noexcept |
| 赋值 | 复制并交换,保证强异常安全 |
| 扩容 | 先分配、再复制、后释放,失败时原对象不变 |
| 追加 | 处理自引用与重叠,使用memmove |
| 访问 | []不检查,at抛异常,指针在扩容后失效 |
模拟string的难点不在字符操作,而在「缓冲区何时分配、何时复制、何时释放、何时失效」。把这四条不变量写进每个成员函数,再用拷贝并交换和先分配后提交保证异常安全,这个类就具备了正确的地基。生产环境仍然使用std::string,但这份练习会让你在阅读标准库实现时知道该看什么。