震 惊!string 类 底 层 代 码 全 解 析,看 完 你 会 怀 疑 自 己 以 前 学 了 假 的 C++!
本文将深入解析C++标准库string类的底层实现原理,通过手动模拟实现string类来理解动态内存管理、类设计核心逻辑。文章从string类的基础结构出发,详细拆解构造、拷贝、扩容等功能的底层实现,重点解析浅拷贝与深拷贝等关键概念。内容包括构造函数与析构函数的实现细节、内存分配策略、拷贝构造函数的深拷贝实现方式,以及如何通过写时拷贝优化性能。通过本文,读者将能从"会用"进阶到
震 惊!string 类 底 层 代 码 全 解 析,看 完 你 会 怀 疑 自 己 以 前 学 了 假 的 C++!
💻作 者 简 介:曾 与 你 一 样 迷 茫,现 以 经 验 助 你 入 门 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);//拷贝数据
}
- 缺 省 参 数 用 空 字 符 串 而 不 是 “\0”:常 量 字 符 串 本 身 已 包 含 终 止符 ‘\0’,strlen(" ") 会 返 回 0,符 合 默 认 初 始 化 需 求。
- 内 存 分 配 需 +1:预 留 ‘\0’ 的 位 置。
- 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; //临时对象会在函数结束后自动析构,释放原有资源
}
优 点
- 避 免 了 手 动 判 断 自 赋 值(this != &s)的 逻 辑。
- 利 用 值 传 递 自 动 完 成 拷 贝,通 过 交 换 实 现 赋 值。
- 临 时 对 象 析 构 时 自 动 释 放 当 前 对 象 的 旧 资 源,无 需 手 动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; //更新长度
}
关 键 逻 辑
- 插 入 位 置 合 法 性 检 查(pos <= _size)。
- 先 扩 容 再 移 动 数 据(避 免 多 次 扩 容)。
- 从 后 往 前 移 动 数 据(防 止 原 有 数 据 被 覆 盖)。
- 处 理 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 ( ) 的 区 别
- cout << s:输 出 _size 个 字 符(即 使 中 间 有 ‘\0’)。
- cout << s.c_str( ):输 出 到 第 一 个 ‘\0’ 为 止(C 风 格 字 符 串 逻 辑)。
写 时 拷 贝
浅 拷 贝 的 问 题
- 析 构 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;
}
总 结
- 在 Linux 环 境 下,前 两 次 地 址 相 同,说 明 拷 贝 构 造 函 数 是 浅 拷 贝,而 不 是 深 拷 贝,修 改 s2 后 地 址 发 生 改 变(写 时 拷 贝 触 发 深 拷 贝)。
- 在 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 类,更 能 培 养 “透 过 接 口 看 实 现” 的 编 程 思 维 - - - 这 正 是 从 “使 用 者” 到 “设 计 者” 的 重 要 一 步。
更多推荐
所有评论(0)