C++入门STL容器string底层剖析

我们来了解一下string的底层是怎么工作的,我们还是需要三个文件

底层讲解

类的整体结构与成员定义

cpp 复制代码
namespace box 
{
    class string 
    {
    public:
        typedef char* iterator; // 迭代器本质:对于连续空间,指针就是最好的迭代器
        typedef const char* const_iterator;

    private:
        char* _str;       // 指向堆区的指针,存放字符串内容
        size_t _size;     // 有效字符个数(不含 \0)
        size_t _capacity; // 最大容量(不含 \0)
        static const size_t npos; // 静态常量,表示不存在的位置(通常是 -1)
    };
}
  • 指针即迭代器 :初学者常觉得迭代器很神秘,但其实 string 底层是连续数组,我们要移动位置只需指针 ++。所以 begin() 返回首地址,end() 返回末尾 \0 的地址,这样就足以支撑起所有的算法操作。

  • npos 的奥秘size_t 是无符号数。给它赋值 -1,它会在内存中溢出变成全 1,即 4294967295。这代表了一个"理论上不存在的最大位置",常用于查找失败的返回值

构造与析构:如何安全地管理内存

cpp 复制代码
// 构造函数:支持带参和空构造(通过全缺省实现)
string(const char* str = "") 
{
    _size = strlen(str);
    _capacity = _size;
    _str = new char[_capacity + 1]; // +1 是为了存末尾的 '\0'
    strcpy(_str, str);              // 拷贝内容
}

// 析构函数:RAII 思想
~string() 
{
    delete[] _str;     // 释放堆区内存
    _str = nullptr;    // 指针置空,好习惯
    _capacity = _size = 0;
}
  • 为什么开空间要 +1? 这是一个经典陷阱。strlen 算的是看得见的字符,比如 "abc" 是 3。但内存里必须有 \0 才能标识字符串结束。如果你不开这第 4 个字节,strcpy 就会越界写入,导致程序崩溃。

  • RAII 原则 :对象创建即分配内存,对象销毁即自动回收。有了析构函数,我们就再也不用像 C 语言那样担心忘记 free 导致的内存泄漏了

深拷贝与"现代写法"(拒绝浅拷贝的崩溃)

这是面试最爱考的点:如果你直接赋值指针(浅拷贝),两个对象指向同一块内存,析构时会释放两次,程序直接报错

cpp 复制代码
void swap(string& s) 
{
    std::swap(_str, s._str); // 交换底层指针
    std::swap(_size, s._size);
    std::swap(_capacity, s._capacity);
}

// 拷贝构造函数(现代写法)
string(const string& s) : _str(nullptr), _size(0), _capacity(0) 
{
    string tmp(s._str); // 1. 先调用构造函数造出一个临时的 tmp
    swap(tmp);          // 2. 将 tmp 的劳动果实和自己交换
} // 3. 临时对象 tmp 销毁时,顺便带走并释放了你原来的旧内存

"偷梁换柱"法 :我们不再手动写 newstrcpy,而是让编译器先帮我们构造一个 tmp。通过 swap,我们拿到了正确的数据,而 tmp 拿到了我们的"空壳子"。这种写法代码极短,且天然具备异常安全性

扩容与插入(无符号溢出的致命陷阱)

cpp 复制代码
void string::insert(size_t pos, char ch) 
{
    assert(pos <= _size); // 边界检查
    if (_size == _capacity) 
    {
        reserve(_capacity == 0 ? 4 : _capacity * 2); // 2倍扩容逻辑
    }

    size_t end = _size + 1; // 1. 指向末尾 '\0' 的后一个位置
    while (end > pos) 
    {     // 2. 只有 end 大于 pos 时才继续
        _str[end] = _str[end - 1]; // 3. 将前面的字符往后挪
        --end;
    }
    _str[pos] = ch; // 4. 在腾出的空位放入新字符
    ++_size;
}
  • 无符号数溢出(Big Pitfall) :很多初学者会写 while (end >= pos)。假设 pos 是 0(在最前面插入),当 end 挪动完 0 号位置字符后变为 0,循环继续。执行 --end 时,01 会因为是 size_t 类型而溢出变成一个巨大的正数!循环永远停不下来。

  • 解决之道 :代码中让 end 初始位置比数据多挪一位,判断条件改为 end > pos。这样当 end 等于 1 时,它会搬动 0 号位字符到 1 号位,然后 end 减为 0 退出循环,完美避开了溢出

输入输出流(get() 的妙用)

cpp 复制代码
istream& operator>>(istream& in, string& s) 
{
    s.clear(); // 1. 进来先清空原有的旧数据
    char ch = in.get(); // 2. 核心:必须用 get() 才能读取空格
    while (ch != ' ' && ch != '\n') 
    {
        s += ch;
        ch = in.get();
    }
    return in;
}

为什么不能直接 in >> ch 因为 operator>> 在流读取时会默认跳过空格、制表符和换行。如果你想模拟 std::cin 遇到空格停止的行为,就必须用 in.get()。它像一只"细致的触角",不管是什么字符都会抓回来,让你能够手动判断何时停止

进阶修改:insert 与 erase 的"空间位移"

在处理字符串插入和删除时,最核心的逻辑就是数据的挪动

cpp 复制代码
void string::insert(size_t pos, const char* str)
 {
    assert(pos <= _size);
    size_t len = strlen(str);
    if (_size + len > _capacity) 
    {
        // 扩容逻辑:至少扩容到刚好能装下
        reserve(_size + len > 2 * _capacity ? _size + len : 2 * _capacity);
    }

    // 1. 搬家:为新字符串腾出 len 个位置
    size_t end = _size + len; 
    while (end > pos + len - 1) 
    { // 边界控制:挪动到 pos 停止
        _str[end] = _str[end - len];
        --end;
    }
    // 2. 填充:把 str 拷贝到腾出来的空位上
    for (size_t i = 0; i < len; i++) 
    {
        _str[pos + i] = str[i];
    }
    _size += len;
}
  • 逻辑重点 :这不再是一个字符的挪动,而是"跨步搬运"。_str[end - len] 的写法保证了数据能一次性后移 len 个单位。

  • 避坑指南 :注意循环终止条件 pos + len - 1。这代表了最后一个待挪动字符的新位置,稍微算错 1 个位就会导致数据覆盖错误或越界

erase:高效的"覆盖式"删除

cpp 复制代码
void string::erase(size_t pos, size_t len) 
{
    if (len >= _size - pos)
     {
        // 情况 A:后面全删光
        _str[pos] = '\0';
        _size = pos; // 注意这里 size 直接更新为 pos
    } else {
        // 情况 B:只删中间一段,后面部分前移
        strcpy(_str + pos, _str + pos + len); 
        _size -= len;
    }
}
  • 小技巧 :如果我们要删掉 pos 之后的所有字符,最快的方法不是逐个删,而是直接把 _str[pos] 置为 \0

  • strcpy 的妙用 :在删除中间部分时,直接利用 strcpy 将后面的子串覆盖到 pos 位置,省去了写 for 循环搬运的麻烦

查找与截取:find 与 substr

find:字符与子串的定位

cpp 复制代码
size_t string::find(const char* str, size_t pos) 
{
    // 借用 C 语言库函数 strstr 在 _str+pos 开始查找
    const char* ret = strstr(_str + pos, str);
    if (ret == nullptr) 
    {
        return npos;
    }
    // 指针减指针得到下标
    return ret - _str;
}

指针运算 :这是 C++ 程序员的必备技能。ret 是找到子串的首地址,_str 是整个字符串的首地址,两者相减得到的差值就是子串在原字符串中的索引位置

substr:子串截取(涉及匿名对象返回)

cpp 复制代码
string string::substr(size_t pos, size_t len)
 {
    if (len > _size - pos) 
    {
        len = _size - pos; // 修正长度,防止越界
    }
    string sub;
    sub.reserve(len);
    for (size_t i = 0; i < len; i++) 
    {
        sub += _str[pos + i]; // 逐个追加
    }
    return sub; // 这里会触发拷贝构造或移动构造
}

异常安全性 :我们先通过 reserve 预留空间,避免在循环中频繁扩容

关系运算符:让类支持"像"数字一样比较

cpp 复制代码
bool operator<(const string& s1, const string& s2) 
{
    return strcmp(s1.c_str(), s2.c_str()) < 0;
}
bool operator==(const string& s1, const string& s2) 
{
    return strcmp(s1.c_str(), s2.c_str()) == 0;
}
// 剩余的 >, <=, >=, != 全部通过逻辑复用实现
bool operator<=(const string& s1, const string& s2) 
{
    return s1 < s2 || s1 == s2;
}

逻辑复用(Code Reuse) :这是一个资深程序员的自我修养。只要写好了 <==,剩下的四个比较运算符都可以通过这二者组合出来。这不仅减少了代码量,还保证了逻辑的一致性

这一篇难度较大,建议搭配文档学习:https://legacy.cplusplus.com/reference/string/string/?kw=string

相关推荐
军训猫猫头2 小时前
7.带输入参数的线程启动 C# + WPF 完整示例
开发语言·前端·c#·.net·wpf
爱学习的小可爱卢2 小时前
算法—Java Map 核心方法与实战场景指南
java·开发语言·算法
会编程的土豆2 小时前
【数据结构与算法】栈的应用
数据结构·c++·算法
豆豆2 小时前
建站系统怎么选?2026年SaaS平台与开源CMS对比分析
java·开发语言·开源·cms·网站建设·网站制作·网站开发
神仙别闹2 小时前
基于C++实现的简单的SMTP服务器
服务器·开发语言·c++
程序设计基础课组2 小时前
codeblock找不到MINGW64编译器怎么办?
c++·codeblocks
xcjbqd02 小时前
Qt Quick中QML与C++交互详解及场景切换实现
c++·qt·交互
阿拉斯攀登2 小时前
20 个 Android JNI + CMake 生产级示例
android·java·开发语言·人工智能·机器学习·无人售货柜