☰
C++模拟实现string的方法详解
2026/10/20 15:56:36 网站建设 项目流程

前言


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'扩容后只增不减(本实现)



四条不变量必须始终成立:



  1. data_为nullptr,或者指向一块至少capacity_ + 1字节的数组。本实现只在「被移动后」出现nullptr。

  2. data_[size_] == '\0',保证c_str()返回合法的 C 字符串。

  3. size_ <= capacity_。

  4. 长度为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。


常见坑点



  1. ❌ 构造或追加后忘记写结尾的'\0'。


✅ 每次改变长度后都在data_[size_]写入'\0',复制时连结尾一起复制。



  1. ❌ 拷贝赋值先delete[] data_再从other复制,自赋值时读到已释放内存。


✅ 先判this != &other,或先构造副本再交换。



  1. ❌new char[...]配delete,或new配free。


✅ 数组形式的new[]必须配delete[],分配与释放方式严格对应。



  1. ❌ 扩容后继续使用旧的c_str()指针或迭代器。


✅ 任何可能扩容的操作之后重新获取指针/迭代器。



  1. ❌ 移动构造只复制指针,不把源对象置空。


✅ 接管后立即把源对象的data_置nullptr、size_与capacity_置 0。



  1. ❌append用memcpy处理可能指向自身的源字符串。


✅ 先记录偏移、扩容后重定位,并使用允许重叠的memmove。



  1. ❌ 用operator[]访问index == size_的位置并当作有效字符。


✅ 下标范围是[0, size_);结尾的'\0'只供c_str()使用。



  1. ❌ 把c_str()的返回值保存下来,跨过字符串修改继续使用。


✅ 需要持久化时复制内容;c_str()指针只保证在下一次修改前有效。


总结




主题关键做法



成员data_、size_、capacity_,容量不含'\0'

不变量结尾有'\0',size_ <= capacity_,缓冲区足够

拷贝深拷贝,按容量分配,复制size_ + 1字节

移动偷指针并置空源对象,标noexcept

赋值复制并交换,保证强异常安全

扩容先分配、再复制、后释放,失败时原对象不变

追加处理自引用与重叠,使用memmove

访问[]不检查,at抛异常,指针在扩容后失效



模拟string的难点不在字符操作,而在「缓冲区何时分配、何时复制、何时释放、何时失效」。把这四条不变量写进每个成员函数,再用拷贝并交换和先分配后提交保证异常安全,这个类就具备了正确的地基。生产环境仍然使用std::string,但这份练习会让你在阅读标准库实现时知道该看什么。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询