从零实现C++字符串类:深入理解动态内存管理与STL设计

📅 2026/7/16 5:18:23 👁️ 阅读次数 📝 编程学习
从零实现C++字符串类:深入理解动态内存管理与STL设计

1. 项目概述:为什么我们要亲手实现一个简易的string?

在C++的世界里,std::string就像空气和水一样,无处不在,以至于我们常常忽略了它的复杂性。很多朋友在面试时被问到“string的底层实现”,或者在学习STL源码时,面对那层层叠叠的模板和内存管理,感到一阵头大。我自己在带新人、做技术评审时也发现,能熟练使用string的开发者很多,但能清晰说出其内部如何管理内存、如何处理拷贝、迭代器失效时机的人,却少之又少。

这个项目,就是带大家从零开始,实现一个简化版的MyString。我们的目标不是复刻标准库中那个经过千锤百炼、极度优化、支持各种编码的std::string,而是通过“造轮子”的过程,深入理解几个核心问题:动态内存如何管理?拷贝构造和赋值操作符如何避免浅拷贝?如何设计一个符合STL风格的接口?这个过程,远比死记硬背“string的size和capacity区别”要深刻得多。当你亲手处理过内存分配、拷贝、移动这些底层细节后,再去看标准库的源码,或者去理解其他容器(如vector)的实现,就会有一种豁然开朗的感觉。这不仅是C++进阶的必经之路,更是夯实基础、写出健壮高效代码的关键。

2. 核心设计思路:我们的MyString要长什么样?

在动手写代码之前,我们必须先想清楚设计蓝图。一个最基本的字符串类,需要哪些核心部件?我们参考std::string的常用功能,但做大幅简化,聚焦于最核心的机制。

2.1 数据存储与内存管理策略

这是设计的基石。std::string通常采用一种叫做“短字符串优化(SSO)”的技术,对于很短的字符串,直接存储在对象内部的缓冲区,避免堆内存分配的开销。为了简化,我们的MyString先不实现SSO,采用更直观的“指针+堆内存”模型。

核心成员变量设计:

class MyString { private: char* m_data; // 指向存放字符串内容的堆内存首地址 size_t m_size; // 当前字符串的实际长度(不包含结尾的'\0') size_t m_capacity; // 当前分配的内存块能容纳的字符数(至少为m_size+1) };

为什么需要三个成员?m_data是资源的拥有者,指向动态数组。m_size是逻辑长度,即strlen的结果。m_capacity是物理容量,它总是大于等于m_size + 1(多出的1用于存放字符串终止符'\0')。维护m_capacity是为了实现高效的appendpush_back操作,避免每次添加字符都重新分配内存(即“摊还常数时间复杂度”)。

初始内存分配策略:我们设定一个初始容量,比如16。当构造一个空字符串时,我们并非让m_datanullptr,而是立即分配一块小内存(如16字节),并将第一个字节设为'\0'。这样做的好处是,后续的append操作可以直接使用这块内存,直到它被填满。这是一种非常常见的优化,减少了初期频繁分配的开销。

2.2 关键接口与STL风格对齐

我们的MyString需要提供一组符合直觉的接口,让用惯了std::string的人也能轻松上手。主要分为几类:

  1. 构造与析构:默认构造、C风格字符串构造、拷贝构造、移动构造、析构函数。
  2. 容量相关size(),capacity(),empty(),reserve(),clear()
  3. 元素访问operator[](重载const和非const版本),at()(带边界检查),c_str(),data()
  4. 修改操作append(),push_back(),operator+=,assign(),insert(),erase()
  5. 字符串操作find(),substr(),compare()
  6. 迭代器:为了支持范围for循环和STL算法,我们至少需要实现begin()end()

在设计时,我们要特别注意异常安全强异常保证。例如,在reserveappend中重新分配内存时,如果分配失败,应保持原字符串不变。这通常通过“先分配新内存,拷贝数据,再释放旧内存,最后更新指针”的顺序来实现。

3. 基础骨架与内存管理实现

有了设计思路,我们开始搭建类的骨架,并实现最核心也最容易出错的部分:资源管理(RAII)。

3.1 类声明与私有助手函数

首先,我们给出MyString的类声明,并预先声明几个内部使用的私有助手函数。这些函数不暴露给用户,但能极大简化公共接口的实现,避免代码重复。

#include <cstring> // for strlen, memcpy, etc. #include <stdexcept> // for std::out_of_range #include <algorithm> // for std::swap (C++11前), std::min class MyString { public: // 类型别名,模仿STL using iterator = char*; using const_iterator = const char*; using size_type = size_t; // 1. 构造函数族 MyString(); // 默认构造 explicit MyString(const char* str); // 从C字符串构造 MyString(const MyString& other); // 拷贝构造 MyString(MyString&& other) noexcept; // 移动构造 (C++11) // 其他构造(如指定数量的字符)暂略 // 2. 析构函数 ~MyString(); // 3. 赋值操作符 MyString& operator=(const MyString& other); // 拷贝赋值 MyString& operator=(MyString&& other) noexcept; // 移动赋值 (C++11) MyString& operator=(const char* str); // 4. 容量相关 size_type size() const { return m_size; } size_type length() const { return m_size; } // 与size()相同 size_type capacity() const { return m_capacity; } bool empty() const { return m_size == 0; } void reserve(size_type new_cap); void clear(); // 5. 元素访问 char& operator[](size_type pos); const char& operator[](size_type pos) const; char& at(size_type pos); const char& at(size_type pos) const; const char* c_str() const { return m_data; } const char* data() const { return m_data; } // 6. 修改操作 MyString& append(const char* str); MyString& append(const MyString& str); void push_back(char ch); MyString& operator+=(const char* str) { return append(str); } MyString& operator+=(const MyString& str) { return append(str); } // 7. 迭代器 iterator begin() { return m_data; } const_iterator begin() const { return m_data; } const_iterator cbegin() const { return m_data; } iterator end() { return m_data + m_size; } const_iterator end() const { return m_data + m_size; } const_iterator cend() const { return m_data + m_size; } // 8. 字符串操作(简化版) size_type find(char ch, size_type pos = 0) const; MyString substr(size_type pos, size_type len = npos) const; int compare(const MyString& other) const; // 静态成员常量,模仿std::string::npos static const size_type npos = -1; private: char* m_data; size_type m_size; size_type m_capacity; // 私有助手函数 void _init(); // 初始化成员变量为安全状态 void _free(); // 安全释放内存 void _copy_from(const char* str, size_type len); // 从指定字符串拷贝len个字符 void _reallocate(size_type new_cap); // 重新分配内存到指定容量 // 增长策略:当需要更多空间时,新容量计算规则 size_type _calculate_growth(size_type new_size) const; };

3.2 构造、析构与拷贝控制:资源管理的核心

这部分是C++类的灵魂,直接决定了类的行为是否正确、高效。

默认构造函数与_init助手:

void MyString::_init() { m_data = nullptr; m_size = 0; m_capacity = 0; } MyString::MyString() { _init(); // 即使为空字符串,我们也分配一个最小容量,方便后续操作。 reserve(16); // 内部会处理m_data为nullptr的情况 m_data[0] = '\0'; // 确保是空字符串 }

_init函数将成员置为安全状态(特别是m_datanullptr)。默认构造后,我们立即reserve一小块内存,这是一种积极的优化,避免了第一次push_back时立即分配。

从C字符串构造:

MyString::MyString(const char* str) { _init(); if (str) { size_type len = strlen(str); _copy_from(str, len); } else { // 如果传入nullptr,我们将其视为空字符串 reserve(16); m_data[0] = '\0'; } }

这里必须检查str是否为nullptr,因为strlennullptr是未定义行为。健壮的实现应该处理这种边界情况。

拷贝构造函数:深拷贝的典范

MyString::MyString(const MyString& other) { _init(); _copy_from(other.m_data, other.m_size); }

拷贝构造的核心是“深拷贝”。我们不是简单地复制指针(那会导致两个对象共享同一块内存,析构时重复释放),而是分配一块新的、大小足够的内存,然后把对方的数据逐个字节拷贝过来。_copy_from函数封装了这个逻辑。

移动构造函数(C++11):性能提升的关键

MyString::MyString(MyString&& other) noexcept : m_data(other.m_data), m_size(other.m_size), m_capacity(other.m_capacity) { // “窃取”对方资源 other.m_data = nullptr; // 至关重要!使other处于可安全析构的状态 other.m_size = 0; other.m_capacity = 0; }

移动构造是C++11引入的“资源偷窃”语义。它直接将other内部的指针“抢”过来,然后将other置为空状态。这个过程没有内存分配和拷贝,效率极高。noexcept关键字告诉编译器这个操作不会抛出异常,这对于标准库容器(如vector)在扩容时选择移动而非拷贝至关重要。

析构函数:资源释放

void MyString::_free() { if (m_data) { delete[] m_data; // 一定要匹配 new char[] m_data = nullptr; } } MyString::~MyString() { _free(); }

析构函数的职责单一而明确:释放对象拥有的资源。_free函数封装了释放逻辑,并确保m_data在释放后被置为nullptr,防止成为悬垂指针。

拷贝赋值运算符:处理自赋值

MyString& MyString::operator=(const MyString& other) { if (this != &other) { // 1. 防止自赋值 a = a // 2. 分配新内存并拷贝数据(强异常保证) char* new_data = new char[other.m_capacity]; std::memcpy(new_data, other.m_data, other.m_size + 1); // 包含'\0' // 3. 释放旧资源 _free(); // 4. 接管新资源 m_data = new_data; m_size = other.m_size; m_capacity = other.m_capacity; } return *this; }

拷贝赋值运算符是面试高频考点。关键点有四:1)自赋值检查a = a,如果不检查,在第2步分配新内存后,第3步就把自己的数据释放了,导致错误。2)强异常保证:先分配新内存并拷贝成功,再释放旧内存。这样即使分配失败抛出std::bad_alloc,原对象状态也不会改变。3)释放旧资源。4)返回*this以支持链式赋值

移动赋值运算符:

MyString& MyString::operator=(MyString&& other) noexcept { if (this != &other) { _free(); // 释放自己的旧资源 // 接管对方资源 m_data = other.m_data; m_size = other.m_size; m_capacity = other.m_capacity; // 置空对方 other.m_data = nullptr; other.m_size = 0; other.m_capacity = 0; } return *this; }

思路与移动构造类似,但需要先释放自身原有资源。同样需要检查自赋值(虽然移动自身的场景极少,但为了一致性)。

3.3 内存分配策略与reserve实现

动态数组的核心在于如何高效地管理容量。std::vectorstd::string通常采用指数增长策略,例如每次容量不足时,新容量 = 旧容量 * 1.5 或 2。这保证了多次push_back操作的摊还时间复杂度为O(1)。

_calculate_growth增长策略:

MyString::size_type MyString::_calculate_growth(size_type new_size) const { // 常见策略:至少翻倍,但不超过最大值 const size_type old_cap = m_capacity; const size_type max_cap = std::numeric_limits<size_type>::max(); if (old_cap > max_cap - old_cap / 2) { // 已经很大,接近上限,直接请求所需大小 return new_size; } // 几何增长:至少增长50% const size_type geometric = old_cap + old_cap / 2; if (geometric < new_size) { return new_size; // 如果几何增长仍不够,则直接满足需求 } return geometric; }

这个函数决定了当我们需要更多空间时,应该申请多大的新容量。我们采用“至少增长50%”的策略,这是一个在内存利用率和性能之间较好的平衡点。

_reallocate重新分配:

void MyString::_reallocate(size_type new_cap) { if (new_cap <= m_capacity) { return; // 无需重新分配 } // 实际分配的容量需要至少能容纳 new_size 个字符 + 1个终止符 // 但new_cap参数通常已经考虑了这一点(由reserve或append调用者保证) // 为了健壮性,我们确保至少为1 size_type alloc_cap = (new_cap > 0) ? new_cap : 1; char* new_data = new char[alloc_cap]; // 可能抛出std::bad_alloc // 拷贝现有数据(包括终止符) if (m_data) { std::memcpy(new_data, m_data, m_size + 1); } else { // 如果原来没有数据,确保新缓冲区以空字符开头 new_data[0] = '\0'; } // 释放旧内存,更新成员 delete[] m_data; m_data = new_data; m_capacity = alloc_cap; // m_size 保持不变 }

_reallocate是内存管理的核心。它负责分配新内存、拷贝数据、释放旧内存。注意异常安全:new char[...]可能失败,如果失败,m_data指向的旧内存依然有效,满足了强异常保证。

reserve公共接口:

void MyString::reserve(size_type new_cap) { if (new_cap > m_capacity) { _reallocate(new_cap); } // 如果 new_cap <= m_capacity,标准规定这是一个非绑定收缩请求。 // 我们的简化实现忽略收缩请求。标准库的shrink_to_fit负责收缩。 }

reserve是给用户的承诺:“我保证接下来的操作至少不需要重新分配,直到字符串长度超过new_cap”。它是一个性能优化工具。

_copy_from助手函数:

void MyString::_copy_from(const char* str, size_type len) { // len 是不包含'\0'的长度 if (len == 0) { reserve(16); m_data[0] = '\0'; m_size = 0; return; } // 确保容量足够,多分配1字节给终止符 reserve(len); std::memcpy(m_data, str, len); m_data[len] = '\0'; // 手动添加终止符 m_size = len; }

这个函数被多个构造函数和赋值运算符调用,它统一处理了“分配足够内存 -> 拷贝数据 -> 设置终止符 -> 更新大小”这个流程,是代码复用的典范。

4. 关键成员函数的实现与细节

基础框架搭好后,我们来实现那些让MyString真正有用的功能。

4.1 元素访问:operator[]at

char& MyString::operator[](size_type pos) { // 不进行边界检查,行为类似数组,访问越界是未定义行为(UB) return m_data[pos]; } const char& MyString::operator[](size_type pos) const { return m_data[pos]; } char& MyString::at(size_type pos) { if (pos >= m_size) { throw std::out_of_range("MyString::at: pos out of range"); } return m_data[pos]; } const char& MyString::at(size_type pos) const { if (pos >= m_size) { throw std::out_of_range("MyString::at: pos out of range"); } return m_data[pos]; }

这里体现了C++标准库的设计哲学:operator[]追求效率,不检查边界,信任程序员;at()追求安全,进行边界检查,如果越界则抛出std::out_of_range异常。我们提供了const和非const版本,以支持对常量和非常量对象的访问。

4.2 修改操作:appendpush_back

append是构建字符串的主要手段。

MyString& MyString::append(const char* str) { if (!str || *str == '\0') { return *this; // 追加空字符串,直接返回 } size_type other_len = strlen(str); if (other_len == 0) { return *this; } size_type new_size = m_size + other_len; if (new_size + 1 > m_capacity) { // +1 for '\0' // 需要扩容 size_type new_cap = _calculate_growth(new_size + 1); // 计算新容量 _reallocate(new_cap); } // 将新字符串拷贝到现有字符串的末尾 std::memcpy(m_data + m_size, str, other_len); m_size = new_size; m_data[m_size] = '\0'; // 设置新的终止符 return *this; } MyString& MyString::append(const MyString& str) { // 复用const char*版本,避免重复代码 return append(str.m_data); } void MyString::push_back(char ch) { if (m_size + 1 >= m_capacity) { // 需要预留一个位置给'\0' reserve(_calculate_growth(m_size + 2)); // m_size+1+1 } m_data[m_size] = ch; ++m_size; m_data[m_size] = '\0'; // 始终维护终止符 }

append的逻辑是:计算新长度 -> 检查容量是否足够 -> 不够则扩容 -> 拷贝数据 -> 更新大小和终止符。注意,我们总是保证m_datam_size索引位置是'\0',这样c_str()才能正常工作。

一个关键细节:在push_back中,我们判断条件是m_size + 1 >= m_capacity。因为m_capacity是包含终止符的总容量。如果当前m_size是5,m_capacity是8,那么实际用于存储字符的空间是7个(索引0-6),索引7是终止符。当我们push_back第6个字符时,需要占用索引6,并把新的终止符放在索引7,这刚好够用(5+1=6 < 8)。当push_back第7个字符时,就需要扩容了(6+1=7 >= 8)。这个边界条件需要仔细处理。

4.3 字符串操作:find,substr,compare

这些函数实现了字符串的核心算法。

find查找字符:

MyString::size_type MyString::find(char ch, size_type pos) const { if (pos >= m_size) { return npos; } const char* result = static_cast<const char*>(std::memchr(m_data + pos, ch, m_size - pos)); if (result) { return result - m_data; // 指针相减得到索引 } return npos; }

我们使用了C标准库的memchr函数,它在指定内存块中查找一个字节。这比手动写循环更高效,且代码简洁。注意处理pos参数和npos返回值。

substr获取子串:

MyString MyString::substr(size_type pos, size_type len) const { if (pos > m_size) { throw std::out_of_range("MyString::substr: pos out of range"); } // 计算实际要拷贝的长度 size_type actual_len = std::min(len, m_size - pos); MyString result; if (actual_len > 0) { result._copy_from(m_data + pos, actual_len); } return result; // 依赖移动语义或RVO(返回值优化) }

substr需要处理边界:如果pos超出范围,抛出异常;如果请求的长度len超过了从pos到末尾的长度,则只拷贝到末尾。这里我们巧妙地复用了内部的_copy_from函数。注意返回值是MyString对象,现代C++编译器会进行RVO(返回值优化)或移动构造,避免不必要的拷贝。

compare比较字符串:

int MyString::compare(const MyString& other) const { // 使用标准库的memcmp进行逐字节比较 int cmp_result = std::memcmp(m_data, other.m_data, std::min(m_size, other.m_size)); if (cmp_result != 0) { return cmp_result; } // 如果公共部分相等,则长度长的字符串更大 if (m_size < other.m_size) return -1; if (m_size > other.m_size) return 1; return 0; // 完全相等 }

compare的返回值规则与C标准库的strcmp一致:小于0表示*this小于other;等于0表示相等;大于0表示*this大于other。比较逻辑是:先比较公共长度部分,如果分出胜负则返回;如果公共部分相等,则长度更长的字符串更大。

4.4clearshrink_to_fit思路

void MyString::clear() { // 逻辑上清空字符串,但不释放内存 m_size = 0; if (m_data) { m_data[0] = '\0'; } }

clear只重置逻辑大小,不改变容量。这是为了后续可能的重用,避免重复分配内存。

一个完整的string类还应该实现shrink_to_fit,它请求减少容量以匹配大小。在我们的简化实现中,可以留作练习。其思路是:如果m_capacity远大于m_size + 1(比如超过某个阈值),则重新分配一块刚好够用的内存,拷贝数据,释放旧内存。

5. 迭代器、运算符重载与完整使用示例

为了让我们的MyString用起来更像标准库的string,我们需要实现迭代器和一些常用的运算符。

5.1 迭代器实现

我们在类定义中已经声明了iteratorconst_iterator分别是char*const char*。这是可行的,因为char*本身满足随机访问迭代器的所有要求(可递增、递减、解引用、相减等)。因此,begin()end()的实现非常简单:

// 已在类内声明 // iterator begin() { return m_data; } // const_iterator begin() const { return m_data; } // iterator end() { return m_data + m_size; } // const_iterator end() const { return m_data + m_size; }

有了迭代器,我们的MyString就可以用于范围for循环和许多STL算法:

MyString str = "Hello"; for (char ch : str) { std::cout << ch; } std::reverse(str.begin(), str.end()); // 反转字符串

5.2 流操作符重载

为了支持像std::cout << strstd::cin >> str这样的操作,我们需要重载<<>>运算符。注意,这些函数通常是非成员函数。

#include <iostream> #include <istream> #include <ostream> // 输出运算符 std::ostream& operator<<(std::ostream& os, const MyString& str) { if (str.data()) { // 安全起见,检查指针 os << str.data(); } return os; } // 输入运算符(简化版,读取一个单词) std::istream& operator>>(std::istream& is, MyString& str) { str.clear(); // 先清空目标字符串 char ch; // 跳过前导空白字符 while (is.get(ch) && std::isspace(static_cast<unsigned char>(ch))) { // 什么也不做,只是跳过 } if (!is) { return is; // 输入失败 } // 将第一个非空白字符放回流中,因为我们用get()取出来了 is.unget(); // 读取直到遇到空白字符 while (is.get(ch) && !std::isspace(static_cast<unsigned char>(ch))) { str.push_back(ch); } return is; }

输入运算符的实现相对复杂,需要处理空白字符和输入错误。这里实现的是一个简化版本,它读取一个“单词”(非空白字符序列)。更完整的实现还需要考虑缓冲区大小、错误状态等,类似于std::getline

5.3 关系运算符重载

我们可以基于compare成员函数,轻松实现全套关系运算符(==,!=,<,<=,>,>=)。这些也通常是非成员函数。

bool operator==(const MyString& lhs, const MyString& rhs) { return lhs.compare(rhs) == 0; } bool operator!=(const MyString& lhs, const MyString& rhs) { return !(lhs == rhs); } bool operator<(const MyString& lhs, const MyString& rhs) { return lhs.compare(rhs) < 0; } bool operator<=(const MyString& lhs, const MyString& rhs) { return lhs.compare(rhs) <= 0; } bool operator>(const MyString& lhs, const MyString& rhs) { return rhs < lhs; } bool operator>=(const MyString& lhs, const MyString& rhs) { return rhs <= lhs; }

注意,我们只完整实现了==<,其他运算符通过这两个推导出来,这符合C++标准库的惯例,可以减少代码重复。

5.4 一个完整的使用示例与测试

最后,我们写一段简单的测试代码,验证MyString的核心功能。

#include <iostream> #include <cassert> void test_my_string() { std::cout << "=== 测试 MyString 基础功能 ===\n"; // 1. 默认构造与C字符串构造 MyString s1; assert(s1.empty() && s1.size() == 0); std::cout << "s1 (默认构造): \"" << s1 << "\"\n"; MyString s2 = "Hello"; assert(s2.size() == 5); assert(s2.compare("Hello") == 0); std::cout << "s2 (C字符串构造): \"" << s2 << "\"\n"; // 2. 拷贝构造与拷贝赋值 MyString s3 = s2; // 拷贝构造 assert(s3 == s2); s3 = "World"; // 拷贝赋值 (从C字符串) assert(s3 == "World"); std::cout << "s3 (赋值后): \"" << s3 << "\"\n"; // 3. 移动语义 (C++11) MyString s4 = std::move(s3); // 移动构造 assert(s4 == "World"); assert(s3.empty() || s3.c_str() == nullptr); // 移动后源对象为空 std::cout << "s4 (从s3移动而来): \"" << s4 << "\"\n"; std::cout << "s3 (移动后): \"" << s3 << "\"\n"; // 4. 元素访问 assert(s2[0] == 'H'); s2[0] = 'h'; assert(s2 == "hello"); try { s2.at(10); // 应该抛出异常 assert(false); // 不应该执行到这里 } catch (const std::out_of_range& e) { std::cout << "异常捕获成功: " << e.what() << "\n"; } // 5. 修改操作 s2.append(" World"); assert(s2 == "hello World"); s2.push_back('!'); assert(s2 == "hello World!"); std::cout << "s2 (追加后): \"" << s2 << "\"\n"; // 6. 容量管理 size_t old_cap = s2.capacity(); s2.reserve(100); assert(s2.capacity() >= 100); assert(s2 == "hello World!"); // reserve不应改变内容 std::cout << "s2 capacity: " << old_cap << " -> " << s2.capacity() << "\n"; // 7. 字符串操作 assert(s2.find('W') == 6); assert(s2.find('z') == MyString::npos); MyString sub = s2.substr(6, 5); assert(sub == "World"); std::cout << "s2.substr(6,5): \"" << sub << "\"\n"; // 8. 迭代器与范围for std::cout << "遍历s2: "; for (char ch : s2) { std::cout << ch << ' '; } std::cout << '\n'; // 9. 流操作 MyString s5; std::cout << "请输入一个单词: "; // 在实际测试中可能需要注释掉,因为会阻塞自动化测试 // std::cin >> s5; // std::cout << "你输入的是: " << s5 << '\n'; std::cout << "=== 所有测试通过 ===\n"; } int main() { test_my_string(); return 0; }

6. 深入探讨:我们的实现与std::string的差距

通过以上实现,我们完成了一个功能相对完整的简易string类。但它与真正的std::string还有巨大差距,理解这些差距正是进阶学习的方向:

  1. 短字符串优化(SSO):现代std::string实现(如GCC的libstdc++,Clang的libc++)普遍采用SSO。对于短字符串(通常15或22字节以内),直接将其存储在对象内部的缓冲区,而不分配堆内存。这极大地提升了小字符串操作的性能(避免了堆分配开销)和缓存局部性。实现SSO需要更精巧的内存布局设计,通常使用一个union来区分“短字符串模式”和“长字符串模式”。

  2. 模板化与自定义分配器std::string实际上是std::basic_string<char>的别名。basic_string是一个模板类,可以接受不同的字符类型(如wchar_t,char16_t,char32_t)和字符特性(char_traits),以及自定义的内存分配器(Allocator)。这使得它极其灵活。我们的实现写死了charnew/delete

  3. 异常安全等级:我们实现了基本的强异常保证(例如在reserve和赋值中)。但标准库的实现需要考虑所有操作在所有地方的异常安全,这非常复杂。

  4. 迭代器失效规则:我们的简易实现中,任何可能引起内存重新分配的操作(如append导致扩容)都会使所有迭代器、指针、引用失效。标准库的string有更精确的规定,并且SSO的存在使得某些操作可能不会使迭代器失效。

  5. 更丰富的接口:我们只实现了最核心的接口。std::string还有insert,erase,replace,find_first_of,find_last_of,rfind,getline友元函数,与数值的转换(stoi,to_string)等数十个成员函数和非成员函数。

  6. 性能优化:标准库的实现经过了极致的优化,会使用平台特定的内存操作指令(如memcpy的内联汇编版本)、循环展开、避免不必要的拷贝等。

7. 常见问题与避坑指南

在实现和使用自定义字符串类的过程中,我踩过不少坑,也见过很多新手容易犯的错误:

  1. 忘记处理拷贝构造和赋值运算符(规则三/五):这是经典错误。如果你定义了析构函数来释放动态内存,那么几乎肯定需要定义拷贝构造函数和拷贝赋值运算符(规则三),在C++11后,最好也定义移动构造函数和移动赋值运算符(规则五)。否则编译器生成的默认版本是浅拷贝,会导致双重释放(double free)或内存泄漏。

  2. 自赋值问题:在拷贝赋值运算符中,必须检查if (this != &other)。否则a = a这样的操作会先释放自己的内存,然后试图从已释放的内存中拷贝数据。

  3. 异常安全:在重新分配内存(如reserve)时,应该先分配新内存、拷贝数据,成功后再释放旧内存。如果先释放旧内存再分配新内存,一旦分配失败,对象就处于无效状态(资源已释放但指针未置空),违反了强异常保证。

  4. 空指针与零长度:始终要小心处理nullptr。在构造函数中,如果传入的C字符串是nullptr,是应该抛出异常,还是将其视为空字符串?标准库的std::string构造函数接受nullptr是未定义行为。在我们的实现中,我们选择将其视为空字符串以增强健壮性,但这可能与标准库行为不一致。

  5. 容量与大小的关系:时刻记住capacity()返回的是存储空间的总大小(包括预留的),而size()是当前字符串的实际长度。在push_backappend时,判断是否需要扩容的条件是new_size + 1 > capacity(),因为你需要一个位置放新的字符,还需要一个位置放终止符'\0'

  6. 终止符\0的管理:我们的m_data指向的缓冲区必须始终以'\0'结尾,这样c_str()才能返回一个合法的C风格字符串。在appendpush_backclearresize等任何改变字符串内容的操作后,都必须手动在m_data[m_size]的位置设置'\0'

  7. 迭代器失效:要清楚地文档化或意识到,哪些操作会使迭代器失效。在我们的实现中,任何可能导致_reallocate被调用的操作(如append导致扩容)都会使之前获取的所有迭代器、指针、引用失效。这是使用动态容器时必须牢记的。

亲手实现一遍这个简易的MyString,你会对C++的RAII、资源管理、拷贝控制、异常安全、迭代器概念有质的理解。下次当你再使用std::string时,你看到的将不再是一个黑盒,而是一个清晰的内存模型和一系列精心设计的选择。这才是进阶学习的意义所在。