《C++深度解构06》手撕 string —— 从底层内存管理到深浅拷贝彻底吃透

文章目录

  • [一、 类框架搭建与整体结构设计](#一、 类框架搭建与整体结构设计)
    • [1. 核心成员变量与常量定义](#1. 核心成员变量与常量定义)
    • [2. 全缺省构造函数与析构函数](#2. 全缺省构造函数与析构函数)
    • [3. 成员访问接口](#3. 成员访问接口)
  • [二、 容量控制:动态扩容基石](#二、 容量控制:动态扩容基石)
  • [三、 增删查改(CRUD)闭环实现](#三、 增删查改(CRUD)闭环实现)
  • [四、 迭代器与范围 for 的底层模拟](#四、 迭代器与范围 for 的底层模拟)
    • [1. 原生指针充当迭代器](#1. 原生指针充当迭代器)
    • [2. 范围 for 的底层替换机制](#2. 范围 for 的底层替换机制)
  • 五、进阶:深拷贝的"传统写法"与"现代写法"
      • [1. 传统写法:亲力亲为](#1. 传统写法:亲力亲为)
      • [2. 现代写法:资源置换](#2. 现代写法:资源置换)
      • [3. `string_test03`:现代深拷贝安全验证](#3. string_test03:现代深拷贝安全验证)
  • [七、 知识全景复盘与核心速记](#七、 知识全景复盘与核心速记)

📌 相关专栏与源码导航:

在日常开发中,直接使用标准库的 std::string 本质上只是在调用标准库封装好的 API。知其然更需知其所以然,想要彻底搞懂 C++ 动态内存分配、深浅拷贝防坑、迭代器底层映射以及异常安全性,最扎实的方式莫过于亲手还原一个属于自己的 string 类。

一、 类框架搭建与整体结构设计

为了防止命名与全局命名空间中的 std::string 产生冲突,我们首先将整个实现置于命名空间 hy 内部。

1. 核心成员变量与常量定义

  • char* _str:指向堆区动态字符数组的指针。
  • size_t _size:当前存储的有效字符数量 (不包含结尾的 \0)。
  • size_t _capacity:当前容器的有效容量空间 (不包含为 \0 额外预留的那 1 个字节)
cpp 复制代码
namespace hy
{
    class string
    {
    private:
        // C++11 类内缺省值:防野指针与析构灾难的核心保障
        char* _str = nullptr;
        size_t _size = 0;
        size_t _capacity = 0;

       const static size_t npos = -1;
    };

    // 静态成员变量类外初始化:-1 隐式转换为无符号数即 size_t 的最大值 0xFFFFFFFF...
    size_t string::npos = -1;
}

特例: \colorbox{#FFEAEA}{\color{#C22828}\text{特例:}} 特例:

静态成员变量需要在类外初始化,但是有一个特例可以直接在类内初始化,const static size_t npos = 1

细节把控 :

这里的 _str = nullptr 设定极为关键。在后面引入"现代写法拷贝构造"时,临时对象与未初始化的对象进行资源置换(swap),如果当前对象是野指针,被换出去的临时对象在被析构时就会触发内存非法访问崩溃。


2. 全缺省构造函数与析构函数

cpp 复制代码
// 全缺省构造函数
string(const char* str = "")
    :_str(new char[strlen(str) + 1])
    ,_size(strlen(str))
    ,_capacity(_size)
{ 
    strcpy(_str, str);
}
  • 缺省空串的处理 :缺省参数传 "" 而非 nullptr。空常量字符串本身自带一个 \0,strlen("") 计算结果为 0,程序安全开辟 0 + 1 = 1 字节空间并存放 \0。如果传 nullptr,strlen会直接触发空指针解引用崩溃。
cpp 复制代码
// 析构函数
~string()
{
    delete[] _str;
    _str = nullptr;
    _size = _capacity = 0;
}
  • 析构的干净利落 :动态数组统一使用 delete[] 回收,并手动将 _str 置空、容量清零,杜绝悬空指针。

3. 成员访问接口

对外暴露只读或只取状态的 API,保证封装性:

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

size_t c_size()
{
    return _size;
}

size_t c_capacity()
{
    return _capacity;
}

优点: \colorbox{#FFEAEA}{\color{#C22828}\text{优点:}} 优点: 对外只提供只读接口!,防止外部篡改数据,保证了数据安全性


二、 容量控制:动态扩容基石

字符串最强大的地方在于其长度是动态可变的。这一能力完全依托于底层的内存调度者 ------ reserve。

cpp 复制代码
void string::reserve(size_t n)
{
    if (n > _capacity)
    {
        // 预留 1 个字节空间专门用来存放 '\0'
        char* tmp = new char[n + 1];
        strcpy(tmp, _str);

        // 释放原有旧堆空间
        delete[] _str;
        _str = tmp;
        _capacity = n;
    }
}
  • 空间换取机制 :new char[n + 1] 确保了无论外部申请多大的空间,结尾字符 \0 永远有属于自己的合法席位。

  • 扩容倍数策略 :在后续的尾插操作中,如果空间满载,遵循 初次给 4 字节、后续 2 倍阶梯扩容 的原则:

    cpp 复制代码
    reserve(_capacity == 0 ? 4 : _capacity * 2);

注意: \colorbox{#FFEAEA}{\color{#C22828}\text{注意:}} 注意:仅在申请空间大于当前容量时才扩容,默认不缩容(与库保持一致),并且需要注意别忘记释放原空间,防止内存泄漏!

错误示范: \colorbox{#E53935}{\color{#FFFFFF}\text{ 错误示范: }} 错误示范:

reserve只有扩容的功能,传入参数多少他扩大到多少,不能越权

cpp 复制代码
	void string::reserve(size_t len)
	{
		if (len > _capacity)
		{
			size_t newcapacity = len > _capacity * 2 ? len : _capacity * 2;
			char* tmp = new char[newcapacity + 1];

			strcpy(tmp, _str);
			delete[] _str;
			_str = tmp;
			_capacity = newcapacity;
		}
	}

虽然这样也完成了扩容,但是和库函数相比,reserve有了自主决定扩大多少的权力,应该是我们传入多少它扩大多少,而不是让他判断


三、 增删查改(CRUD)闭环实现

1. 字符与字符串追加

a.单字符尾插(push_back)

cpp 复制代码
void string::push_back(char str)
{
    if (_size == _capacity)
    {
        reserve(_capacity == 0 ? 4 : _capacity * 2);
    }

    _str[_size++] = str;
    _str[_size] = '\0'; // 必须手动维护字符串合法结尾
}

b.字符串追加(append)

cpp 复制代码
void string::append(const char* str)
{
    size_t len = strlen(str);
    if (_size + len > _capacity)
    {
        reserve(_size + len); // 精准预分配空间
    }

    for (int i = 0; i < len; i++)
    {
        _str[_size++] = str[i];
    }

    _str[_size] = '\0';
}

写完一个功能我们就测试一下,方便问题的查找 \colorbox{#FFEAEA}{\color{#C22828}\text{写完一个功能我们就测试一下,方便问题的查找}} 写完一个功能我们就测试一下,方便问题的查找

观察得到函数功能正常,我们便可以开始后面函数的编写了

c.运算符重载复用

通过复用底层方法,使操作符接口简洁明了:

cpp 复制代码
void operator+=(char str)
{
    push_back(str);
}

void operator+=(const char* str)
{
    append(str);
}

验证了字符尾插、字符串尾部拓展以及复合运算符 += 的复用与扩容逻辑。


2. 指定位置插入(insert)

插入算法最容易出错的环节在于数据向后平移时的下标越界。

a.插入单个字符

先来看我们最常容易犯得错误:

❌❌❌错误示范: \colorbox{#FFEAEA}{\color{#C22828}\text{❌❌❌错误示范:}} ❌❌❌错误示范:

我们测试一下发现程序卡死崩溃,那是为什么呢?这就需要我们通过调试一步一步找到问题

通过调试我们发现,当end为0时本应该是最后一次进入循环,但是进入循环后end-- 本应该为-1,可是它变成了非常大的正数

因为end为无符号整型,所以-1被无符号整型接收后,成了最大值,并且编译器通过这个下标访问到了非法地址,所以崩溃报错

🔹解决办法①:强转end的类型为int,防止整型提升(但是这样就没能和库保持一致)

🔹 解决办法②:问题根源出现在end与pos相等这个阶段,所以我们将end + 1 ,整体交换向后移了一个单位即从**_strend + 1 = _strend 换成 _strend = _strend - 1**

✅✅✅正确写法: \colorbox{#FFEAEA}{\color{#C22828}\text{✅✅✅正确写法:}} ✅✅✅正确写法:

cpp 复制代码
void string::insert(size_t pos, char ch)
{
    assert(pos <= _size); // 断言保护,pos == _size 时等价于尾插

    if (_size == _capacity)
    {
        reserve(_capacity == 0 ? 4 : _capacity * 2);
    }

    // 严防无符号数下溢导致死循环:设置 end 初始为 _size + 1
    size_t end = _size + 1;
    while (pos < end)
    {
        _str[end] = _str[end - 1];
        end--;
    }
    _str[pos] = ch;
    _size++;
}
  • 避坑要点 :若将 end 初始化为 _size 并使用_strend + 1 = _strend,循环终止条件很容易写成 while (end >= pos)。一旦 pos 为 0,无符号整型 end-- 减至 0 之后再减 1 会下溢变为极大的正数(4294967295),直接陷入死循环并越界。因此,将 end 置为 _size + 1,控制 pos < end 终止是最稳妥的写法。

b.插入字符串

与插入单个字符一致,需要注意无符号整型问题 \colorbox{#E53935}{\color{#FFFFFF}\text{ 与插入单个字符一致,需要注意无符号整型问题 }} 与插入单个字符一致,需要注意无符号整型问题

cpp 复制代码
void string::insert(size_t pos, const char* ch)
{
    assert(pos <= _size);

    size_t len = strlen(ch);
    if (_size + len > _capacity)
    {
        reserve(_size + len);
    }

    // 平移腾出 len 个字节的空间
    size_t end = _size + len;
    while (pos + len <= end)
    {
        _str[end] = _str[end - len];
        end--;
    }

    // 将待插入内容填入空缺
    for (int i = 0; i < len; i++)
    {
        _str[pos + i] = ch[i];
    }

    _size += len;
}

测试小技巧: \colorbox{#FFEAEA}{\color{#C22828}\text{测试小技巧:}} 测试小技巧:

在进行数据的增删查改功能测试时,我们不应只单单随机测试一组,而是最少测试三组,分别是数据中、首、尾,尤其是首尾的边界数据最容易出现问题


3. 删除数据(erase)

进行内容删除时,我们并不是真正的删除释放数据,而是将有效数据范围缩小,_size决定了数据的边界,只要将_size缩小,外界就无法访问到被"剔除"的数据。

cpp 复制代码
void string::erase(size_t pos)
{
    assert(pos < _size);

    // 截断式清空:将目标位置置为 \0 并缩减 _size
    _str[pos] = '\0';
    _size = pos;
}

测试一下:

可以非常直观的观察到在我们所给下边位置放置了 \0


4. 查找与子串截取(find & substr)

cpp 复制代码
// 查找单个字符
size_t string::find(char ch, size_t pos = 0)
{
    assert(pos < _size);
    for (size_t i = pos; i < _size; i++)
    {
        if (_str[i] == ch)
        {
            return i;
        }
    }
    return pos;
}

// 借力 C 库函数 strstr 匹配子串
size_t string::find(const char* ch, size_t pos)
{
    assert(pos < _size);
    
    char* re = strstr(_str + pos, ch);
    if(re != nullptr)
    {
			return re - _str;
		}
		return npos;
}

// 截取子串
string string::substr(size_t pos, size_t len)
{
    assert(pos < _size);
    // 如果要求的长度超出剩余有效字符,则截取到末尾即可
    if (len > _size - pos)
    {
        len = _size - pos;
    }

    string tmp;
    tmp.reserve(len); // 提前预开辟,避免多次 push_back 产生无谓扩容

    for (size_t i = 0; i < len; i++)
    {
        tmp.push_back(_str[pos + i]);
    }

    return tmp;
}

核心避坑 \colorbox{#E53935}{\color{#FFFFFF}\text{ 核心避坑 }} 核心避坑 :

在find字符串中,需要判断strstr是否找到,如果未找到不返回npos的话,外部会拿空指针解引用,造成非法访问。


四、 迭代器与范围 for 的底层模拟

在 STL 的设计架构中, 迭代器(Iterator)扮演着"连接容器与通用算法的桥梁" ,它对容器的底层存储形态(链表,树等)进行高度封装,使得外界在使用所有容器时,都可以像操作原生指针一样进行操作(遍历,比较等)

1. 原生指针充当迭代器

由于 string 类底层是一段连续分配的动态堆内存字符数组(物理地址线性递增),原生指针天然具备指针算术运算(++、--)、解引用获取数据(*)、以及求相对偏移量(-)的所有特性。

因此,在手撕 string 容器时,我们可以直接使用原生指针 char* 和 const char* 作为正向迭代器的实现载体。

cpp 复制代码
namespace hy
{
    class string
    {
    public:
        // 1. 迭代器类型别名定义
        typedef char* iterator;
        typedef const char* const_iterator;

        // 2. 普通对象迭代器(可读可写)
        iterator begin()
        {
            return _str;
        }

        iterator end()
        {
            return _str + _size; // 指向有效数据末尾字符的下一个位置(即 '\0' 处)
        }

        // 3. const 对象迭代器(只读保护)
        const_iterator begin() const
        {
            return _str;
        }

        const_iterator end() const
        {
            return _str + _size;
        }

        // ... 其余成员变量与成员函数
    };
}

核心避坑 :必须成对提供普通与 const 版本 \colorbox{#E53935}{\color{#FFFFFF}\text{ 核心避坑 :必须成对提供普通与 const 版本 }} 核心避坑 :必须成对提供普通与 const 版本

初学者在模拟实现时极易写出如下"语法能通过,但设计出现严重逻辑后门"的代码:

// ❌ 错误示范:仅提供带 const 修饰但返回普通指针的接口

iterator begin() const { return _str; }

iterator end() const { return _str + _size; }

架空 const 防护 :当函数外部传参为 const string& st 时,调用者的核心是严格只读。但若此时返回的是普通 iterator(即 char*),外部拿到指针后便能直接执行 *it = 'X' 篡改常量对象内部的字符,直接打破了封装的保护契约

迭代器的使用,底层我们可以认为迭代器和指针类似,使用方法上所以也相似

2. 范围 for 的底层替换机制

范围 for 的底层完全没有所谓的黑魔法,它是由编译器在语法分析阶段机械化展开生成的"宏式替换"。

|--------------------------------------------------|
| ⚠️ 注意:使用范围for时,编译器自动调用迭代器,而非新语法,而是无条件的机械式的替换 |

范围for两大铁律 \colorbox{#FFEAEA}{\color{#C22828}\text{范围for两大铁律}} 范围for两大铁律

1.函数命名死板绑定 :范围 for 严格且唯一识别名为全小写的 begin() 与 end() 接口。若要将接口名随意改写(如 Begin() 或 End()),范围 for 将立即报出找不到匹配标识符的编译硬错误。

2.const 属性自适应 :遍历对象若为 const string& st,编译器在底层解构时会自动调用 st.begin() const,直接推导出 const_iterator,从而自然锁死写入权限


五、进阶:深拷贝的"传统写法"与"现代写法"

默认浅拷贝(值拷贝)会导致两把指针指向同一块堆内存,引发析构时重复释放(Double Free) 因此,深拷贝是自定义容器不可逾越的坎。

1. 传统写法:亲力亲为

cpp 复制代码
// 传统拷贝构造
string(const string& st)
{
    _str = new char[st._capacity + 1];
    strcpy(_str, st._str);
    _size = st._size;
    _capacity = st._capacity;
}

// 传统赋值重载
string& operator=(const string& s)
{
    if (this != &s)
    {
        delete[] _str; // 释放自身原有堆内存
        _str = new char[s._capacity + 1];
        strcpy(_str, s._str);

        _size = s._size;
        _capacity = s._capacity;
    }
    return *this;
}
  • 隐患剖析 :传统赋值重载中,若 delete[] _str 后,后续 new char[...] 发生内存不足抛出异常,此时当前对象原有的数据已被销毁,指针变成野指针,破坏了对象的合法状态(缺乏强异常安全性保证)。

2. 现代写法:资源置换

自定义 swap 函数
cpp 复制代码
void string::swap(string& tmp)
{
    std::swap(_str, tmp._str);
    std::swap(_size, tmp._size);
    std::swap(_capacity, tmp._capacity);
}

思考 :为什么不直接在外部调用全局的 std::swap(s1, s2)?

全局的 std::swap 底层依赖于:

cpp 复制代码
string c(a); // 拷贝构造 1 次深拷贝
a = b;       // 赋值重载 1 次深拷贝
b = c;       // 赋值重载 1 次深拷贝

这样会无端多触发 3 次深拷贝 与大量的堆区内存分配开销。而成员函数 swap 仅直接交换内部的指针和数值,时间复杂度恒定为 O ( 1 ) O(1) O(1)。

现代拷贝构造
cpp 复制代码
string(const string& st)
{
    string tmp(st._str); // 借用构造函数开辟空间并装填数据
    swap(tmp);           // 窃取临时对象的资源
}
  • tmp 是一个局部对象,借由构造函数开辟堆空间。
  • 通过 swap 将 tmp 的资源换给 *this。
  • 离开作用域时,tmp 自动调用析构函数,顺带销毁了 *this 原先持有的 nullptr(释放空指针在 C++ 中是合法且安全的空操作)。
现代赋值重载(传值传参的极致优雅)
cpp 复制代码
// s1 = s2;
string& operator=(string s) // 注意此处是传值!不是引用传参!
{
    swap(s);
    return *this;
}

这是整个手撕过程中最精妙的一笔:

  1. 自动拷贝 :形参 s 没有加引用,在函数调用时,编译器会自动调用拷贝构造函数生成一份副本。
  2. 交换资源 :将临时副本 s 的堆区指针直接与 this->_str 调换。
  3. 安全自毁 :函数执行完毕后,形参 s 作为局部对象出作用域,自动触发析构函数,临时副本 s 接管了 *this 原有的旧堆内存,出作用域自动销毁

3. string_test03:现代深拷贝安全验证

cpp 复制代码
void string_test03()
{
    string st1 = "hello world"; // 隐式类型转换构造
    cout << st1.c_str() << endl;

    string st2;
    st2 = st1;                  // 触发传值现代赋值重载
    cout << st2.c_str() << endl;
}

程序正常退出无断言崩溃,在没有内存泄漏的前提下,验证了 swap 配合传值赋值模式的测试。



七、 知识全景复盘与核心速记

为方便复盘与面试速记,本篇手撕 string 的核心底层技术要点归纳如下:

核心模块 工业级规范 / 关键机制 致命隐患与避坑要点
内存边界 空间按 n + 1 开辟,预留 1 字节专存 \0 越界写入 \0 或扩容漏算 1 字节导致堆破坏
下标移动 严格控制无符号边界(如 end = _size + 1; while(end > pos)) size_t end 减至 0 后继续 end-- 造成无符号整型下溢,引发死循环与段错误
深拷贝策略 传统手动申请与拷贝;现代借助构造与 swap 偷取资源所有权 默认浅拷贝引发多指针共享同一块堆内存,触发 Double Free 崩溃
迭代器设计 底层直接封装 char* 与 const char* 成对重载 遗漏 const_iterator 会破坏常量只读契约,导致数据被意外篡改
范围 for 编译期语法糖,机械展开为小写 begin() / end() 遍历 依赖于全小写函数命名,底层自动适配对象的 const 属性

"更深处扎根,每一行底层代码的推演,都在构筑无可替代的技术护城河。"
Crafted with passion by Hhy
当前更新时间:{{ 2026 / 11 / 7 }} | 此博文系博主学习复盘,欢迎指正


本文专栏由 Hhy 维护。如果这篇文章曾带给你启发,请考虑关注我的 CSDN 博客或 GitHub 仓库以获取最新技术分享。

相关推荐
starzy199016 分钟前
深入理解Scala函数定义:从方法到函数的类型跨越
开发语言·scala
starzy199022 分钟前
深入理解Scala递归函数:从栈溢出到尾调用优化
开发语言·scala
三*一31 分钟前
Mapbox‑GL JS 多边形折点编辑工具:实现原理、功能拆解与踩坑总结
开发语言·javascript·ecmascript
青少儿编程课堂41 分钟前
蓝桥杯青少组图形化省考报名倒计时·全国青少年信息素养大赛·电子学会图形化考级:校园黑白棋人机对战解析
c++·python·算法·bfs·信息学竞赛
YSL07012443 分钟前
C++基础
开发语言·c++
若亦_1 小时前
Java中关于this的三种用法
java·开发语言·intellij-idea
山甫aa1 小时前
锁一篇搞定:乐观锁、悲观锁、CAS、数据库锁与分布式锁【从零开始的 Web 后端学习】
java·数据库·分布式·后端·学习·web
m0_564876841 小时前
CMake学习
学习
2601_968793141 小时前
Python Requests 轻量自动化:无需浏览器,实现真人级账号浏览模拟
开发语言·python·自动化