C++ STL 完整入门笔记[2]:手撕简易string类,吃透标准库string底层原理

前言

C++ 标准库std::string是我们日常开发最常用的容器之一,但很多同学只会调用接口,不清楚它底层存储、迭代器、比较运算符的实现逻辑。 本文结合课堂笔记手绘原理图,从零拆解简易版string,搞懂三大核心底层逻辑:堆内存存储结构、迭代器本质、关系运算符重载 ,同时对比标准库basic_string模板设计。

一、std::string 底层模板原型

标准库的string并不是单独类,而是模板basic_string的实例化:

复制代码
template < class charT,
          class traits = char_traits<charT>,
          class Alloc = allocator<charT>
        > class basic_string;

// string 等价于 basic_string<char>
using string = basic_string<char>;
// wstring 等价于 basic_string<wchar_t>
using wstring = basic_string<wchar_t>;

配套同体系容器vector也是模板实现,两者底层都是连续堆内存数组 ,区别在于string专门处理字符序列,末尾自带\0兼容 C 语言const char*

参考官方文档:string - C++ Reference

二、简易 string 类存储结构(手绘原理图解析)

1. 成员变量定义

我们自定义daju::string只保留三大核心成员,和标准库底层设计对齐:

复制代码
namespace daju
{
    class string
    {
    private:
        size_t _size;      // 有效字符个数,不含末尾'\0'
        size_t _capacity;  // 堆内存总容量
        char* _str;        // 指向堆上字符数组
    };
}

2. 内存分配原理图解读

  1. 字面量"hello world\0"存放在只读常量区

  2. 构造函数内通过new char[]开辟内存,把常量区字符串拷贝到堆;

  3. 成员指针_str指向堆内存首地址;

  4. _size记录有效字符长度,_capacity记录堆数组总容量,堆数组末尾强制补\0,保证c_str()能返回兼容 C 的字符串。

3. 带参构造函数实现

复制代码
string::string(const char* str)
    :_size(strlen(str))
{
    _capacity = _size;
    // 多开1字节存放'\0'
    _str = new char[_size + 1];
    // 拷贝常量区字符串到堆
    strcpy(_str, str);
}

无参构造单独分配 1 字节内存存放空终止符:

复制代码
string::string()
    :_str(new char[1] {'\0'})
    ,_size(0)
    ,_capacity(0)
{}

4. 兼容 C 接口 c_str ()

对外返回堆字符数组首地址,兼容所有接收const char*的 C 函数:

复制代码
const char* string::c_str() const
{
    return _str;
}

必须加const修饰函数:常对象也能调用该接口,符合标准库设计规范。

三、string 迭代器底层本质(极简实现)

很多人以为迭代器是复杂类,对于string来说,迭代器本质就是 char \ 指针*。

1. 迭代器核心代码

复制代码
// 迭代器重定义为char*
typedef char* iterator;

// begin:返回首字符地址
iterator begin()
{
    return _str;
}

// end:返回有效字符末尾下一个位置
iterator end()
{
    return _str + _size;
}

2. 两种遍历方式原理

方式 1:范围 for 循环(语法糖,底层自动调用 begin/end)
复制代码
daju::string s2("hello");
for (auto ch : s2)
{
    cout << ch << " ";
}
方式 2:原生迭代器循环,直观体现指针操作
复制代码
daju::string::iterator it1 = s1.begin();
while (it1 != s1.end())
{
    cout << *it1 << " ";
    ++it1;
}

原理图逻辑: begin()指向_str[0]end()指向_str[_size](末尾\0前一位),循环条件it != end()避免越界。

四、关系运算符重载 ==、>、< 底层逻辑

1. 标准库运算符重载分类

标准库对string关系运算符分 6 组重载,覆盖 4 种匹配场景:

  1. string vs string

  2. string vs const char*

  3. const char* vs string

  4. const char* vs const char*

2. 核心比较规则(重点)

字符串比较不是比较长度,是逐字符 ASCII 码对比 ,逻辑和 C 库strcmp完全一致:

  • 从第 0 位开始,依次对比两个字符串对应字符 ASCII;

  • 第一个不相等的字符,ASCII 大的字符串整体更大;

  • 若短字符串是长字符串前缀,则更长的字符串更大。

示例:

复制代码
string s1("xxx");
string s2("xxx11");
const char* str = "23222";

s1 == s2;   // false
s1 == str;  // false
str = s1;   // 赋值,非比较

3. 关键设计细节:== 运算符不能只做成员重载

如果只把operator==写成类内成员函数,只能支持string == const char*,无法实现const char* == string; 所以标准库将所有关系运算符全局重载,同时提供两套参数版本,覆盖正反比较场景。

五、当前简易版遗留缺陷(拓展优化方向)

我们当前只实现基础框架,生产级string还需要补充这些功能:

  1. 析构函数new[]必须配套delete[]释放堆内存,否则内存泄漏
复制代码
~string()
{
    delete[] _str;
    _str = nullptr;
    _size = _capacity = 0;
}
  1. 深拷贝拷贝构造 + 赋值重载 默认浅拷贝会让两个对象共用同一块堆内存,析构时重复释放触发崩溃,必须手动开辟新堆空间拷贝字符。

  2. 扩容接口 reserve /resize 当前构造capacitysize相等,不支持尾插字符;标准库会预分配冗余容量减少频繁new/delete开销。

  3. 增删接口:push_backoperator+=erasesubstr

六、总结

  1. std::string底层基于basic_string<char>模板,堆内存连续数组存储字符,末尾带\0兼容 C 字符串;

  2. 简易 string 迭代器本质就是原生char*begin()取首地址,end()取有效字符后一位;

  3. 关系运算符重载逻辑依托strcmpASCII 逐位比较,和字符串长度无关;

  4. 手写简易 string 能彻底理解容器内存管理、迭代器、运算符重载三大 C++ 核心知识点,为后续学习vector打下基础。