💻作 者 简 介:曾 与 你 一 样 迷 茫,现 以 经 验 助 你 入 门 C++。
💡个 人 主 页:@笑口常开xpr 的 个 人 主 页
📚系 列 专 栏:C++ 炼 魂 场:从 青 铜 到 王 者 的 进 阶 之 路

✨代 码 趣 语:写 时 拷 贝 是 ‘平 时 借 同 桌 的 作 业 抄,要 修 改 时 才 自 己 重 新 写’ - - - 没 修 改 时 省 事 儿,改 的 时 候 可 得 自 己 动 手。
💪代 码 千 行,始 于 坚 持,每 日 敲 码,进 阶 编 程 之 路。
📦gitee 链 接:gitee

在这里插入图片描述
         C++ 标 准 库 的 string 类 虽 好 用,但 多 数 人 仅 知 其 表。其 实,手 动 模 拟 实 现 string 类,是 理 解 动 态 内 存 管 理、类 设 计 核 心 逻 辑 的 最 佳 实 践。本 文 从 string 类 的 基 础 结 构 出 发,拆 解 构 造、拷 贝、扩 容 等 功 能 的 底 层 实 现,解 析 浅 拷 贝、深 拷 贝 等 关 键 概 念,帮 你 从 “会 用” 到 “懂 原 理”。


string 的 模 拟 实 现

原 因

         标 准 库 的 string 类 虽 然 强 大,但 直 接 使 用 它 时,我 们 往 往 只 知 其 然 不 知 其 所 以 然。通 过 手 动 实 现 string 类,我 们 可 以 理 解 动 态 内 存 管 理 的 核 心 逻 辑(内 存 分 配、释 放、扩 容),掌 握 类 的 拷 贝 构 造、赋 值 运 算 符 重 载 等 关 键 语 法,清 晰 字 符 串 操 作(增 删 查 改)的 底 层 实 现,学 会 处 理 边 界 情 况 和 异 常 场 景。

         一 个 基 础 的 string 类 需 要 管 理 三 个 核 心 成 员:存 储 字 符 串 的 内 存 地 址、当 前 字 符 串 长 度、当 前 已 分 配 的 容 量。

namespace str 
{
    class string 
    {
    private:
        char* _str;       //存储字符串的内存地址
        size_t _size;     //当前字符串长度(有效字符数)
        size_t _capacity; //已分配的容量(可存储的最大字符数,不含终止符)
        const static size_t _npos; //表示"未找到"的特殊值
    };
}

_size 与 _capacity 分 离
         _size 记 录 字 符 串 的 实 际 字 符 数,_capacity 记 录 已 分 配 内 存 能 容 纳 的 最 大 字 符 数,这 样 可 以 减 少 频 繁 扩 容 带 来 的 性 能 损 耗。

_npos 定 义
         作 为 静 态 常 量,其 值 为 -1(因 size_t 是 无 符 号 类 型,实 际 会 表 示 为 最 大 正 整 数),用 于 表 示 “查 找 失 败” 等 场 景。


构 造 函 数 与 析 构 函 数

         字 符 串 的 创 建 和 销 毁 是 string 类 的 基 础 功 能,我 们 需 要 处 理 两 种 常 见 场 景:常 量 字 符 串 初 始 化、默 认 初 始 化。


构 造 函 数

         传 统 实 现 中,我 们 可 能 会 分 别 定 义 默 认 构 造 和 带 参 构 造,但 通 过 缺 省 参 数 可 以 合 并 为 一 个 构 造 函 数。

string(const char* str = "")//常量字符串后面默认有\0
	: _size(strlen(str))
	, _capacity(strlen(str))
	, _str(new char[strlen(str) + 1])//开辟空间
{
	//c语言到\0终止,C++到_size结束
	//strcpy(_str, str);//拷贝数据
	memcpy(_str, str, _size + 1);//拷贝数据
}
  1. 缺 省 参 数 用 空 字 符 串 而 不 是 “\0”:常 量 字 符 串 本 身 已 包 含 终 止符 ‘\0’,strlen(" ") 会 返 回 0,符 合 默 认 初 始 化 需 求。
  2. 内 存 分 配 需 +1:预 留 ‘\0’ 的 位 置。
  3. memcpy 替 代 strcpy:若 字 符 串 中 包 含 ‘\0’,strcpy 会 提 前 终 止,而 memcpy 按 指 定 长 度 拷 贝 更 安 全。

析 构 函 数

         析 构 函 数 需 要 释 放 动 态 分 配 的 内 存,避 免 内 存 泄 漏。

~string() 
{
    if (_str) {  // 若指针有效
        delete[] _str;  // 释放数组内存
        _str = nullptr;  // 避免野指针
        _size = _capacity = 0;  // 重置状态
    }
}

拷 贝

浅 拷 贝

         浅 拷 贝 也 称 位 拷 贝,编 译 器 只 是 将 对 象 中 的 值 拷 贝 过 来。如 果 对 象 中 管 理 资 源,最 后 就 会 导 致 多 个 对 象 共 享 同 一 份 资 源,当 一 个 对 象 销 毁 时 就 会 将 该 资 源 释 放 掉,而 此 时 另 一 些 对 象 不 知 道 该 资 源 已 经 被 释 放,以 为 还 有 效,所 以 当 继 续 对 资 源 进 行 操 作 时,就 会 发 生 发 生 了 访 问 违 规。

         比 如 一 个 家 庭 中 有 两 个 孩 子,但 父 母 只 买 了 一 份 玩 具,两 个 孩 子 愿 意 一 块 玩,则 万 事 大 吉,万 一 不 想 分 享 就 你 争 我 夺,玩 具 损 坏。
在这里插入图片描述

在这里插入图片描述


深 拷 贝

         深 拷 贝 是 给 每 个 对 象 独 立 分 配 资 源,保 证 多 个 对 象 之 间 不 会 因 共 享 资 源 而 造 成 多 次 释 放 造 成 程 序 崩 溃。

         比 如 父 母 给 每 个 孩 子 都 买 一 份 玩 具,各 自 玩 各 自 的 就 不 会 有 问 题 了。
在这里插入图片描述


拷 贝 构 造 函 数 - - - 深 拷 贝

         string 类 包 含 动 态 分 配 的 内 存,默 认 生 成 的 拷 贝 构 造 和 赋 值 运 算 符 会 导 致 “浅 拷 贝” 问 题(多 个 对 象 共 享 同 一 块 内 存,析 构 时 重 复 释 放)。因 此 必 须 手 动 实 现 深 拷 贝。
         拷 贝 构 造 函 数 用 于 从 已 存 在 的 string 对 象 创 建 新 对 象,核 心 是 为 新 对 象 分 配 独 立 内 存 并 复 制 数 据。


方 法 1 - - - 直 接 分 配 内 存 拷 贝

string(const string& s)
{
	_str = new char[s._capacity + 1];
	//strcpy(_str, s._str);
	memcpy(_str, s._str, s._size + 1);
	_size = s._size;
	_capacity = s._capacity;
}

方 法 2 - - - 借 助 临 时 对 象 交 换

string(const string& s)
	:_str(nullptr)
	, _size(0)
	,_capacity(0)
{
	string tmp(s._str);
	swap(tmp);
}

         相 比 于 方 法 1,方 法 2 利 用 临 时 对 象 的 析 构 自 动 释 放 旧 内 存,避 免 手 动 释 放 可 能 出 现 的 错 误。
         由 于 字 符 串 中 可 能 会 有 ‘\0’,可 能 拷 贝 构 造 会 有 问 题。下 面 只 展 示 了 部 分 代 码。


使 用 方 法 1
string.h

string(const string& s)
{
	_str = new char[s._capacity + 1];
	//strcpy(_str, s._str);
	memcpy(_str, s._str, s._size + 1);
	_size = s._size;
	_capacity = s._capacity;
}

test.cpp

#include "string.h"
void test_string()
{
	str::string s3("xxxxxxxxxxx");
	s3 += '\0';
	s3 += "tttt";
	cout << s3 << endl;
	str::string s4(s3);
	cout << s4 << endl;
}
int main()
{
	test_string();
	return 0;
}

在这里插入图片描述


使 用 方 法 2
string.h

string(const string& s)
	:_str(nullptr)
	, _size(0)
	,_capacity(0)
{
	string tmp(s._str);
	swap(tmp);
}

test.cpp

#include "string.h"
void test_string()
{
	str::string s3("xxxxxxxxxxx");
	s3 += '\0';
	s3 += "tttt";
	cout << s3 << endl;
	str::string s4(s3);
	cout << s4 << endl;
}
int main()
{
	test_string();
	return 0;
}

在这里插入图片描述
         因 为 \0 的 存 在,方 法 2 可 能 会 出 现 bug,因 此 这 里 建 议 使 用 方 法 1。


赋 值 运 算 符 重 载

         赋 值 运 算 符 与 拷 贝 构 造 的 区 别 是 赋 值 操 作 的 目 标 对 象 已 经 存 在,需 要 先 释 放 其 原 有 资 源。

string& operator=(string tmp)  //传值时自动拷贝构造临时对象
{
    swap(tmp);     //交换当前对象与临时对象
    return *this;  //临时对象会在函数结束后自动析构,释放原有资源
}

优 点

  1. 避 免 了 手 动 判 断 自 赋 值(this != &s)的 逻 辑。
  2. 利 用 值 传 递 自 动 完 成 拷 贝,通 过 交 换 实 现 赋 值。
  3. 临 时 对 象 析 构 时 自 动 释 放 当 前 对 象 的 旧 资 源,无 需 手 动delete。

增 删 查 改

         string 类 的 核 心 价 值 在 于 提 供 便 捷 的 字 符 串 操 作。这 里 我 们 重 点 解 析 几 个 常 用 操 作 的 实 现 逻 辑。


扩 容 操 作

         字 符 串 需 要 动 态 扩 容 以 容 纳 更 多 字 符,reserve 和 resize 是 两 种 常 用 的 扩 容 接 口,但 功 能 不 同:
reserve:仅 预 留 容 量(不 改 变 实 际 字 符 数)
resize:改 变 字 符 串 长 度(不 足 时 填 充 指 定 字 符)

//预留容量(仅当n大于当前容量时扩容)
void reserve(size_t n) 
{
    if (n > _capacity) 
    {  
    	//避免不必要的扩容
        char* tmp = new char[n + 1];   //新内存(+1存'\0')
        memcpy(tmp, _str, _size + 1);  //复制原有数据
        delete[] _str;  //释放旧内存
        _str = tmp;
        _capacity = n;  //更新容量
    }
}
//调整长度(短则截断,长则填充)
void resize(size_t n, char ch = '\0') 
{
    if (n < _size) 
    {  
    	//缩短,直接截断
        _size = n;
        _str[_size] = '\0';  //更新终止符
    } 
    else 
    {  
    	//延长,填充字符
        reserve(n);  //确保容量足够
        for (size_t i = _size; i < n; i++) 
        {
            _str[i] = ch;    //填充指定字符
        }
        _size = n;
        _str[_size] = '\0';  //更新终止符
    }
}

         reserve 不 会 改 变 _size,也 不 会 初 始 化 新 增 的 内 存;resize 会 改 变 _ size,并 对 新 增 位 置 进 行 初 始 化。


插 入 操 作

         插 入 操 作 需 要 先 判 断 容 量 是 否 足 够,再 移 动 原 有 数 据 腾 出 位 置,最 后 插 入 新 内 容。

void insert(size_t pos, const char* str) 
{
    assert(pos <= _size);  //插入位置不能超过字符串长度
    size_t len = strlen(str);
    if (_size + len > _capacity) 
    {  
    	//容量不足时扩容
        reserve(_size + len);
    }
    //从后往前移动数据,避免覆盖
    size_t end = _size;
    while (end >= pos && end != _npos) 
    {
        //_npos避免下溢
        _str[end + len] = _str[end];
        end--;
    }
    //插入新字符串
    for (size_t i = 0; i < len; i++) 
    {
        _str[pos + i] = str[i];
    }
    _size += len;  //更新长度
}

关 键 逻 辑

  1. 插 入 位 置 合 法 性 检 查(pos <= _size)。
  2. 先 扩 容 再 移 动 数 据(避 免 多 次 扩 容)。
  3. 从 后 往 前 移 动 数 据(防 止 原 有 数 据 被 覆 盖)。
  4. 处 理 end 下 溢(当 pos = 0 时,end 减 到 -1,size_t 会 转 为 _npos)。

查 找 与 截 取

         字 符 串 查 找(find)和 截 取(substr)是 处 理 URL、路 径 等 场 景 的 常 用 功 能。

//截取子串(从pos开始,长度为len)
string substr(size_t pos = 0, size_t len = _npos) 
{
    assert(pos < _size);  //起始位置合法
    //计算实际截取长度(避免越界)
    size_t n = len;
    if (len == _npos || pos + len > _size) 
    {
        n = _size - pos;  //最多截取到字符串末尾
    }
    string tmp;  //存储结果
    tmp.reserve(n);  //预留容量
    for (size_t i = pos; i < pos + n; i++) 
    {
        tmp += _str[i];  //拼接子串
    }
    return tmp;
}
//查找子串(从pos开始)
size_t find(const char* str, size_t pos = 0) 
{
    assert(pos < _size);
    //利用C库函数strstr查找子串
    const char* ptr = strstr(_str + pos, str);
    if (ptr) 
    {
        return ptr - _str;  //转换为相对起始地址的偏移量
    } 
    else 
    {
        return _npos;  //未找到
    }
}

运 算 符 重 载

         为 了 让 string 类 像 原 生 类 型 一 样 使 用,需 要 重 载 [ ](访 问 字 符) 和 +=(拼 接 字 符 串)。

//普通对象:可读可写
char& operator[](size_t pos) 
{
    assert(pos < _size);
    return _str[pos];
}
// const对象:仅可读
const char& operator[](size_t pos) const 
{
    assert(pos < _size);
    return _str[pos];
}
//拼接字符
string& operator+=(char ch) 
{
    push_back(ch);  //复用push_back
    return *this;
}
//拼接字符串
string& operator+=(const char* str) 
{
    append(str);  //复用append
    return *this;
}

const 重 载 的 意 义
         确 保 const 对 象 只 能 读 取 字 符(不 能 修 改),普 通 对 象 可 以 读 写,符 合 const 正 确 性 原 则。


迭 代 器 与 范 围 for

         迭 代 器 是 容 器 的 通 用 遍 历 接 口,string 类 通 过 迭 代 器 支 持 范 围 for 循环。

// 迭代器定义(本质是字符指针的封装)
typedef char* iterator;
typedef const char* const_iterator;

//普通迭代器(可读可写)
iterator begin() 
{ 
	return _str; 
}
iterator end() 
{ 
	return _str + _size; 
}
//const迭代器(仅可读)
const_iterator begin() const 
{ 
	return _str; 
}
const_iterator end() const 
{ 
	return _str + _size; 
}

         有 了 迭 代 器,就 可 以 使 用 范 围 for 遍 历。如 果 没 有 迭 代 器,代 码 会 报 错。

string s("hello");
for (auto ch : s) 
{  
	//范围for会被编译器转换为迭代器遍历
    cout << ch << " ";
}

在这里插入图片描述
迭 代 器 设 计 逻 辑
         begin( ) 返 回 首 字 符 地 址,end( ) 返 回 尾 字 符 的 下 一 个 位 置(左 闭 右 开 区 间),这 是 C++ 容 器 的 通 用 设 计。


流 操 作

         为 了 支 持 cout << string 和 cin >> string,需 要 重 载 流 插 入 和 流 提 取 运 算 符。

//流插入(输出字符串)
ostream& operator<<(ostream& out, const string& s) 
{
    for (auto ch : s) 
    {  
    	//遍历输出每个字符(包括'\0'后的有效字符)
        out << ch;
    }
    return out;
}
//流提取(读取字符串,忽略前导空格)
istream& operator>>(istream& in, string& s) 
{
    s.clear();  //先清空原有内容
    char ch = in.get();  //读取单个字符(包括空格)
    //跳过空格和换行
    while (ch == '\n' || ch == ' ') 
    {
        ch = in.get();
    }
    //读取有效字符(遇到空格或换行停止)
    char buff[128];  //缓冲区减少频繁扩容
    int i = 0;
    while (ch != '\n' && ch != ' ') 
    {
        buff[i++] = ch;
        if (i == 127) 
        {  
        	//缓冲区满时写入string
            buff[i] = '\0';
            s += buff;
            i = 0;
        }
        ch = in.get();
    }
    //处理缓冲区剩余字符
    if (i != 0) 
    {
        buff[i] = '\0';
        s += buff;
    }
    return in;
}

流 插 入 与 c_str ( ) 的 区 别

  1. cout << s:输 出 _size 个 字 符(即 使 中 间 有 ‘\0’)。
  2. cout << s.c_str( ):输 出 到 第 一 个 ‘\0’ 为 止(C 风 格 字 符 串 逻 辑)。

写 时 拷 贝

浅 拷 贝 的 问 题

  1. 析 构 2 次。
  2. 一 个 对 象 修 改 会 影 响 另 一 个 (写 的 时 候 引 用 计 数 如 果 不 是 1,则 进 行 深 拷 贝,再 修 改)。

         写 时 拷 贝 就 是 一 种 拖 延 症,是 在 浅 拷 贝 的 基 础 之 上 增 加 了 引 用 计 数 的 方 式 来 实 现 的。


引 用 计 数

         引 用 计 数 用 来 记 录 资 源 使 用 者 的 个 数。在 构 造 时,将 资 源 的 计 数 给 成 1,每 增 加 一 个 对 象 使 用 该 资 源,就 给 计 数 增 加 1,当 某 个 对 象 被 销 毁 时,先 给 该 计 数 减 1,然 后 再 检 查 是 否 需 要 释 放资 源,如 果 计 数 为 1,说 明 该 对 象 时 资 源 的 最 后 一 个 使 用 者,将 该 资 源 释 放;否 则 就 不 能 释 放,因 为 还 有 其 他 对 象 在 使 用 该 资 源。


在 不 同 的 编 译 环 境 下 有 着 不 同 的 结 果。
Linux
在这里插入图片描述
VS2022

#include<iostream>
#include<string>
using namespace std;
int main()
{
	std::string s1("hello world");
	std::string s2(s1);
	//深拷贝
	printf("%p\n", s1.c_str());
	printf("%p\n", s2.c_str());
	s2[0]++;
	printf("%p\n", s1.c_str());
	printf("%p\n", s2.c_str());
	cout << sizeof(s1) << endl;
	return 0;
}

在这里插入图片描述


总 结

  1. 在 Linux 环 境 下,前 两 次 地 址 相 同,说 明 拷 贝 构 造 函 数 是 浅 拷 贝,而 不 是 深 拷 贝,修 改 s2 后 地 址 发 生 改 变(写 时 拷 贝 触 发 深 拷 贝)。
  2. 在 VS 编 译 器 中,直 接 进 行 的 是 深 拷 贝,没 有 进 行 浅 拷 贝。

_Buf 和 _Ptr

#include<iostream>
#include<string>
using namespace std;
int main()
{
	std::string s1("hello world");//size<16,字符串存在_Buf数组中
	std::string s2("hello worldxxxxxxxxxxxxxxxxx");//
	return 0;
}

总 结
         在 VS 编 译 器 中,当 size < 16,字 符 串 存 在 _Buf 数 组 中,当 size >= 16,字 符 串 在 _Ptr 指 向 的 堆 空 间 中。


在这里插入图片描述


总 结

         模 拟 实 现 string 类 的 过 程,本 质 是 拆 解 “字 符 串 管 理” 的 核 心 逻 辑:从 内 存 分 配 到 资 源 释 放,从 拷 贝 安 全 到 操 作 优 化,每 一 步 都 体 现 着 C++ 对 “功 能 与 性 能” 的 平 衡。而 写 时 拷 贝、存 储 策 略 等 细 节,更 让 我 们 看 到 标 准 库 设 计 的 精 妙。掌 握 这 些 底 层 逻 辑,不 仅 能 让 我 们 更 灵 活 地 使 用 string 类,更 能 培 养 “透 过 接 口 看 实 现” 的 编 程 思 维 - - - 这 正 是 从 “使 用 者” 到 “设 计 者” 的 重 要 一 步。

Logo

纵情码海钱塘涌,杭州开发者创新动! 属于杭州的开发者社区!致力于为杭州地区的开发者提供学习、合作和成长的机会;同时也为企业交流招聘提供舞台!

更多推荐