C++ string类的解析式高效实现
GitHub地址
1. 引言:字符串处理的复杂性
在C++
标准库中,string
类作为最常用的容器之一,其内部实现复杂度远超表面认知。本文将通过一个简易仿照STL
的string
类的完整实现,揭示其设计精髓。我们将从内存管理、操作优化等维度,逐步构建一个简单支持核心功能的string
类。
2. 基础架构设计
2.1 成员变量声明
- 为了和标准库中的
string
区分,我们把自己实现的string
封装在m_string
这个命名空间中 string
的底层是存放字符的顺序表,因此我们采用顺序表的结构来实现- 基本结构如下:
namespace m_string {
class string {
public:
//成员函数...etc...我们逐一实现
//迭代器,运算符重载等...
private:
size_t _size; // 当前有效字符数
size_t _capacity; // 存储容量(不含结束符'\0'),会进行扩容
char* _str; // 动态数组的指针
public:
//静态成员变量 类内声明、类外(定义)初始化
const static size_t npos;
};
//类内声明、类外初始化 特殊标记值
const size_t string::npos = -1; //建议静态成员变量,声明和定义分离
}
设计要点:
- _size的大小代表了当前空间的内容,已存放的字符的个数:包括字符和\0
- 三成员架构是顺序表结构的经典设计:
_size
:记录有效数据个数_capacity
:管理当前的最大容量,扩容后容量更新,注意_capacity
不包含末尾的\0
_str
:指针指向堆内存
- 静态常量
npos
的类外定义需特别注意,要在类外进行初始化。令size_t
类型的npos
值为-1
,用来表示整型的最大值 - 成员变量设为访问权限设为
private
, 对外提供public
的成员函数,符合面向对象中封装的思想
2.2 迭代器实现
STL中的迭代器,可能是指针,也可能不是指针
string的迭代器本质上是char,因为string本质上就是一个字符数组,天生适合用指针来访问*
public:
// 将char* 封装成 iterator 迭代器
typedef char* iterator;
typedef const char* const_iterator;
// 普通对象的迭代器
// 普通对象的迭代器如果加了const, 会导致非const对象只能返回const_iterator,失去修改元素的能力(违反直觉)。
iterator begin() {
return _str; //数组名是第一个元素的地址
}
iterator end() {
//指针相加 _str + _size 得到最后一个元素的下一个位置的指针
return _str + _size;
}
//const对象的迭代器
const_iterator begin() const {
return _str;
}
const_iterator end() const {
return _str + _size;
}
begin()
方法要返回数组的第一个位置的指针end()
方法要返回数组最后一个元素的下一个位置- 普通版本和
const
版本需分别实现
实现分析:
- 迭代器本质是原生指针的封装
- 访问权限是
public
, 调用begin/end
方法返回相应的迭代器。 - 通过
const
重载实现常量迭代器 - 与标准库的迭代器体系完全兼容
3. 构造函数与析构函数
3.1 基础构造函数
v1
// 初始实现(存在问题)
// 初始化列表是按照成员变量在类中声明的次序进行初始化的
string(const char* str = "\0")
: _size(strlen(str))
, _capacity(_size) //利用_size来初始化_capacity
, _str(new char[_capacity+1])
{
strcpy(_str, str); // 中间含\0时会被截断
}
问题发现:
- 使用
strcpy()
会因"hello world\0hello Linux"
这样中间含有'\0'
的字符串导致数据丢失。 - 初始化列表是按照成员变量在类中声明的次序完成初始化的,与初始化列表中实现的顺序无关。
- 如果有人调整了初始化列表的初始化顺序或成员变量的声明顺序,那么利用
_size
来初始化_capacity
会发生未定义行为。
- 如果有人调整了初始化列表的初始化顺序或成员变量的声明顺序,那么利用
优化版本:
v2
// 参数列表是 用c风格的字符串 const char* 进行构造。c风格的字符串默认以\0为结束符
// 因此传入 "hello world\0hello Linux" 这样中间含有'\0'的字符串,导致数据丢失是调用者的问题
string(const char* str = "") { //默认构造会构造一个""空字符串
_size = strlen(str);
_capacity = _size; //capacity表示可以存放的下的字符个数
_str = new char[_capacity + 1]; //开空间+1 要存放'\0'
//strcpy(_str, str); //拷贝数据,遇到中间含有\0的字符串会有Bug
//strcpy默认会 拷贝\0, 因此使用memcpy,需要将拷贝的字节数+1,考虑\0的位置
memcpy(_str, str, _size + 1);
}
思路:
strlen(str)
计算传入字符串的长度,并赋值给_size
_capacity = _size,
,初始化时,默认使容量和有效字符和数相等,构造时不额外开空间_str = new char[_capacity + 1]
,为字符串分配空间,_capacity + 1
为字符串结尾的\0
预留位置memcpy(_str, str, _size + 1)
,最后将源字符串中的数据拷贝至新开辟的空间。
优化点:
使用
memcpy()
替代strcpy()
,memcpy()
是拷贝内存中的数据,遇到\0
不会结束参数缺省值改为空字符串
""
均为内置类型,不使用初始化列表进行初始化,而是在构造函数内部完成初始化,消除初始化列表顺序依赖
3.2 析构函数
~string() {
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
析构函数的思路和实现较为简单
- 释放管理字符数组的空间
_str
,注意释放数组要使用delete []
- 管理字符数组的空间的指针
_str
置空 - 将
_size
和_capacity
置零
3.3 拷贝构造函数
往期文章对深拷贝的简单总结介绍:
https://blog.csdn.net/2301_80064645/article/details/145593384?fromshare=blogdetail&sharetype=blogdetail&sharerId=145593384&sharerefer=PC&sharesource=2301_80064645&sharefrom=from_link
传统实现:
手动开辟释放内存。
// 拷贝构造 要实现深拷贝,开一块新的空间,拷贝数据,并初始化。
// 新开一块空间,并进行深拷贝防止两个指针指向同一块空间
string(const string& str) {
_str = new char[str._capacity + 1]; // _capacity不包含\0, +1 考虑 \0
//strcpy(_str, str._str);
memcpy(_str, str._str, str._size + 1);
_size = str._size;
_capacity = str._capacity;
}
拷贝构造函数需要实现深拷贝。如果是浅拷贝的话,字符串指针会存下同一块空间的地址,析构时会对同一块空间析构两次,会引发错误
- 开辟新空间,
new char[str._capacity + 1]
,大小为_capacity + 1
memcpy
拷贝原数据到新空间,拷贝大小为_size + 1
,确保字符串末尾的\0
也被拷贝。- 更新
_size
和_capacity
现代写法:
// 交换两个string对象成员变量的内容
void swap(string& str) {
std::swap(_str, str._str); //不能直接交换两个对象
std::swap(_size, str._size);
std::swap(_capacity, str._capacity);
}
string(const string& s)
:_str(nullptr)
,_size(0)
,_capacity(0)
{
string tmp(s.c_str()); //用s的数据 调用构造函数 新构造一个 局部tmp对象, 具有不同的字符数组 地址
this->swap(tmp); //交换tmp和s2内的三个成员变量,
//交换后tmp内的_str为nullptr,局部对象出了函数作用域销毁后,析构tmp对象
//构造tmp时使用s.c_str()初始化,而c_str返回以\0结尾的C风格字符串
//若s._str中间含有\0,tmp的构造会截断数据,导致拷贝不完整
}
s2(s1)
- 将s2初始化为
_str(nullptr)
,_size(0)
,_capacity(0)
- 用
s1.c_str()
构造一个和s1
一样的局部对象tmp
,字符串数据的内容一样,但空间和地址不同。 - 交换
s2
和tmp
中成员变量的值,- 交换前:
tmp._str
指向和s1
内容一样的一块新空间。拷贝构造时,s2
待初始化,没有空间 - 交换后:
s2._str
指向之前tmp._str
管理的那块空间,tmp._str
指向待初始化的那块空间
- 交换前:
- 之后局部对象出了函数作用域销毁后,析构待初始化
tmp
对象,析构前tmp
内的数据为nullptr 0 0
优势对比:
方法 | 异常安全 | 代码复用 | 可维护性 |
---|---|---|---|
传统实现 | 不安全 | 低 | 差 |
现代写法 | 强保证 | 高 | 优秀 |
4. 赋值运算符的进化
4.1 传统实现
// s1 = s3
string& operator=(const string& str) {
if (this != &str) {
char* newSpace = new char[str._capacity + 1]; //开空间
memcpy(newSpace, str._str, str._size + 1); //拷数据
delete[] _str; //被赋值前可能为非空string,因此要释放原空间
_str = newSpace;
_size = str._size;
_capacity = str._capacity;
}
return *this;
}
赋值运算符要实现深拷贝,赋值后,两个
string
对象要拥有两块独立的空间,并对赋值前的那块空间进行机构if (this != &str)
:自己给自己赋值时直接跳过。传统实现,手动开空间,拷贝数据
更改指针前,析构原空间
delete[] _str
被赋值前可能为非空string,因此要释放原空间_str = newSpace
;
更新
_size
和_capacity
返回
*this
,满足连续赋值的需求
潜在风险:
new
可能抛出异常导致原对象损坏
4.2 现代实现
// s1 = s3
string& operator=(const string& str) {
if (this != &str) {
string tmp(str); //反正tmp为局部对象,出了作用域也要销毁,不如让他销毁时,顺便把s1的空间析构了
// s1 想要tmp管理的那块空间
std::swap(_str, tmp._str); //不能直接交换两个对象,否则会引发无穷赋值
std::swap(_size, tmp._size);
std::swap(_capacity, tmp._capacity);
//可以直接
//this->swap(tmp);
}
return *this;
}
s1 = s3
深拷贝创建局部对象
tmp
,反正tmp
为局部对象,出了作用域也要销毁,不如让他销毁时,顺便把s1
的空间析构了交换
tmp
和this
对象的成员变量,更改指针指向。- 构造的
tmp
和str
相同,且为深拷贝构造 this->swap(tmp)
之后,s1
接管了tmp
的数据,tmp
接管了s1
的数据。
- 构造的
返回
*this
,满足连续赋值的需求函数结束后,局部对象
tmp
销毁,调用析构,释放赋值前的旧空间(s1)
。
4.3 终版实现
// s1 = s3
string& operator=(string tmp) { //直接利用函数参数,深拷贝s3,函数结束后,形参自动析构
this->swap(tmp); // 将s3的拷贝和s1 也就是 *this 交换
return *this; // 返回*this, 也就是 s1
}
- 既然现代实现要构造局部对象,那不妨直接在形参中使用值传递的方式构造局部对象,形参
tmp
是右值实参的拷贝- 自定义类型对象在值传参时,会默认被要求去调用拷贝构造函数。
- 实参
s3
值传递,传递给形参tmp
。string tmp = s3
。局部对象tmp
中有和s3
一样的数据
this->swap(tmp);
:直接和局部对象交换资源。- 函数结束后,形参对象
tmp
销毁,调用析构,释放s1
的旧空间。
革命性改进:
- 参数值传递,自动调用拷贝构造
swap
操作保证强异常安全- 代码量减少60%
- 自动清理旧资源
5. 容量管理策略
5.1 reserve
///可以用reserve预留空间,来实现扩容
// reserve实现的均是异地扩容
//考虑特殊情况的话,memcpy会更好
void reserve(size_t request_capacity) { //request_size指的是要新存放的字符的个数
if (request_capacity > _capacity) { //请求的空间大于_capacity时,才扩容
char* newSpace = new char[request_capacity + 1]; //多开一个空间存放\0
//strcpy(newSpace, _str); //new是异地扩容
memcpy(newSpace, _str, _size + 1);
delete[] _str;
_str = newSpace;
_capacity = request_capacity;
}
}
扩容思路:
- 期望容量
request_capacity
大于当前容量_capacity
时,才进行扩容 - 采取异地扩容策略,
newSpace
存放新空间的地址 - 使用
memcpy
而非strcpy
。strcpy
拷贝数据时,遇到\0
就终止了,遇到中间含有\0
的字符串会带来意外的结果。- 使用
memcpy
来拷贝空间中的所有数据,包括中间的\0
- 将原来的字符串空间释放:
delete[] _str;
- 将新空间的地址赋值给管理字符数组的指针:
_str = newSpace;
- 更新容积:
_capacity = request_capacity
扩容策略:
- 异地扩容保证数据完整性
- 精确计算拷贝字节数
(_size+1)
,此字节数是数组中有效字符的个数 - 典型应用场景:
push_back
时的容量检查
5.2 resize
void resize(size_t newSize, char ch = '\0') {
if (newSize < _size) {
_size = newSize;
_str[_size] = '\0';
}
else {
//reserve会判断newSize和_capacity的大小,超过扩容,等于时不做处理
reserve(newSize);
//扩容后进行初始化
for (size_t i = _size; i < newSize; ++i)
_str[i] = ch;
_size = newSize;
_str[_size] = '\0';
}
}
双模式操作:
- 收缩(
if逻辑
):直接截断if (newSize < _size)
:判断指定newSize
是否小于当前size
- 小于的话,直接截断,更新
_size = newSize;
- 再将字符串最后一个字符的下一个位置设为
\0
,_str[_size] = '\0';
- 扩展(
else逻辑
):填充指定字符- 期望大小大于当前
_size
,通过reserve(newSize)
进行扩容- 进入
else
逻辑后,newSize
是>= _size
,等于时reserve(newSize)
不做处理。 - 大于时会进行扩容
- 进入
- 扩容后,对下标在
_size
及之后的位置进行初始化,用字符ch
进行填充 - 初始化后,更新
_size
,将末尾位置设置\0
- 期望大小大于当前
6. 字符串操作优化
6.1 push_back
+=
的本质是调用了push_back
,因此先实现push_back
void push_back(char ch) {
//先考虑扩容
if (_size == _capacity) {
//二倍扩容,并防止空构造的字符串
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size++] = ch; //放字符
_str[_size] = '\0'; //加上\0
}
+=
操作可以直接复用push_back
,push_back
是在字符串末尾插入一个字符- 插入前考虑数组是否还有容量,
_size == _capacity
时表示容量已满 reserve
扩容,_capacity == 0 ? 4 : _capacity * 2
- 若
push_back
前为空串,则分配空间容量为4
。 - 若
push_back
前为已有数据且容量已满,则二倍扩容
- 若
- 更新状态
- 插入字符
- 在末尾放上
\0
;
6.2 append
void append(const char* str) {
size_t len = strlen(str);
if(_size + len > _capacity){
//至少扩容到 _size + len
reserve(_size + len);
}
memcpy(_size, str, len + 1); //拷贝大小为 len + 1, 要拷贝\0
_size += len;
}
- 计算待插入的字符串的长度
- 检查容量,
if(_size + len > _capacity)
为真,代表需要扩容 - 扩容至少扩容到
_size + len
- 拷贝数据到
_size
位置开头的空间 - 拷贝大小为
len+1
,为\0
预留空间 - 最后更新
_size
的大小
6.3 operator+=
利用+=
可以方便的在字符串后面追加字符或字符串
+=字符
string& operator+=(const char ch){
push_back(ch);
return *this;
}
+=字符串
string& operator+=(const char* str){
append(str);
return *this;
}
+=字符/字符串
直接复用push_back
和append
即可- 为了满足连续
+=
的功能,需要返回当前对象,也就是*this
6.3 insert实现
6.3.1 insert字符串
void insert(size_t pos, const char* str) {
assert(pos <= _size); //_size位置是字符串的末尾,可以在字符串的末尾插入
assert(str != nullptr); // 确保str非空
size_t len = strlen(str);
//扩容
if (_size + len > _capacity)
reserve(_size + len);
size_t end = _size; //从末尾的 \0 开始挪动
//如果在 0 位置插入 end最后会变成 size_t -1 也就是npos 整形的最大值
while (end >= pos && end != npos) {
_str[end + len] = _str[end];
--end;
}
for (size_t i = 0; i < len; ++i)
_str[pos + i] = str[i];
_size += len;
_str[_size] = '\0'; //添加字符串结束标志
}
- 越界检查,确保待插入字符串
str
为非空 - 计算待插入串的长度,根据长度判断是否需要扩容并扩容,至少要扩容到
_size + len
长度 size_t end = _size
,size
是\0
的下标,挪动时从末尾的\0
开始挪动end + len
是需要挪动的字符调整后的下标位置end
类型为size_t
,跳出循环时,end
的值 为pos - 1
- 当
pos
为0
时,size_t end = -1
的值为整型的最大值,是大于pos
的。为了避免进入死循环,需增加循环条件为end >= pos && end != npos
- 当
_str[pos + i] = str[i]
:挪动数据过后,再将待插入字符串逐个字符插入
- 更新状态
_size += len
:更新大小_str[_size] = '\0'
:添加字符串结束标志
6.3.2 insert n个字符
- 从
pos
位置开始,插入n
个相同的字符
//让插入的那个字符的下标变成pos
void insert(size_t pos, size_t n, char ch) {
assert(pos <= _size); //_size位置是字符串的末尾,可以在字符串的末尾插入
//扩容
if (_size + n > _capacity)
reserve(_size + n);
//挪动数据
//当传入的pos为0时,end会变成-1,end是size_t,-1会变成整形的最大值,会一直进入循环
//size_t end = _size;
//int end = _size;
//while (end >= (int)pos) { //运算符两端 两个操作数类型不一致时,会进行 提升
// // 一般是范围小的向范围大的进行提升
// _str[end + n] = _str[end];
// --end;
//}
size_t end = _size;
while (end >= pos && end != npos) {
_str[end + n] = _str[end];
--end;
}
for (size_t i = 0; i < n; ++i) {
_str[pos + i] = ch;
}
_size += n;
_str[_size] = '\0'; //添加字符串结束标志
}
- 越界检查,确保待插入位置有效
- 根据待插入字符的个数,根据个数判断是否需要扩容并扩容,至少要扩容到
_size + len
长度 size_t end = _size
,_size
是\0
的下标,挪动时从末尾的\0
开始挪动end + n
是需要挪动的字符调整后的下标位置end
类型为size_t
,跳出循环时,end
的值 为pos - 1
- 当
pos
为0
时,size_t end = -1
的值为整形的最大值,是大于pos
的。为了避免进入死循环,需增加循环条件为end >= pos && end != npos
- 当
_str[pos + i] = ch
:挪动数据过后,再将待插入字符循环依次插入
- 更新状态
_size += n
:更新大小_str[_size] = '\0'
:添加字符串结束标志
6.3.3 insert总结
在实现的过程中,我们不难发现,实现在pos
位置插入n
个字符和插入一个字符串
的思路高度相似
插入字符时
:每个字符已经通过参数传入,挪动数据后直接赋值即可插入字符串时
:每个字符需要从长度为len
的字符串中取
insert多个字符和insert字符串,除了细节问题,逻辑上没有任何区别!
6.4 erase
//从pos位置开始删,向后删除len个字符,不传参默认全部删完
void erase(size_t pos, size_t len = npos) {
assert(pos <= _size);
if (len == 0)
return;
// 删完的情况 : pos + len == _size 时,pos + len位置放的是\0,大于时才全部删完
if (len == npos || pos + len > _size) { //这两种都是删完的情况
_str[pos] = '\0';
_size = pos;
}
// 删除一部分的情况
else {
size_t end = pos + len; // 跳过要删除的三个字符,end从需要挪动的第一个字符开始
//_str[end] == '\0'时(end == _size),可以把'\0'也挪过来,也可以不挪,最后统一设置\0
while (end < _size) {
_str[pos++] = _str[end++];
}
_size -= len;
}
_str[_size] = '\0'; //统一设置结尾符
}
删除策略:
- 尾部删除:直接截断
- 中间删除:前向覆盖
思路分析:
- 对
pos
进行越界检查 - 先判断要全部删完的情况
len == npos || pos + len > _size
时,代表要删完pos
及之后的字符,直接截断- 直接将
pos
位置设为\0
- 修改
_size
为pos
- 直接将
else
是删除完从pos
开始的len
个字符的情况:- 跳过要删除的三个字符,
pos+len
位置是需要挪动的第一个字符,开始依次向前挪动数据,到_size
(\0)结束 - 更新状态:
_size -= len
_
- 跳过要删除的三个字符,
- 最后统一设置结束符
_str[_size] = '\0'
7. 运算符重载的艺术
7.1 比较类运算符
<和==
bool operator<(const string& s) const {
int ret = memcmp(_str, s._str, std::min(_size, s._size)); //前面小于后面时,返回值小于0
return ret == 0 ? _size < str._size : ret < 0;
}
bool operator==(const string& str) const {
return _size == str._size //两个字符串相等,其长度一定相等,优先比较长度
&& memcmp(_str, str._str, _size) == 0; //再比较其内容是否相等
}
实现技巧:
- 使用
memcmp()
提升比较效率 - 长度优先比较策略
<逻辑:
只比较两字符串同等长度的内存(内容),用更小的那个长度进行比较
将比较结果存入
ret
中ret == 0
时,代表两字符串共有长度的部分相同,此时_size < str._size
为真时才是小于(共有长度相同,谁短谁就小)。ret != 0
时,比较的结果memcmp
已经帮我们比较好了ret < 0
代表前面小于后面ret > 0
代表前面大于后面
==逻辑
- 只有长度相等的字符串才有可能相等
- 长度相等后,再用
memcmp(_str, str._str, _size) == 0
判断是否相等return _size == str._size && memcmp(_str, str._str, _size) == 0
- 两个字符串相等,其长度一定相等。
- 优先比较长度,如果长度不相等,会触发
&&的截断特性
- 再用
memcmp
比较其内容是否相等
- 优先比较长度,如果长度不相等,会触发
其他逻辑复用<和==
// <= 小于或等于
bool operator<=(const string& str) const {
return *this < str || *this == str;
}
// > 小于等于取反
bool operator>(const string& str) const {
return !(*this <= str);
}
// >= 大于或等于
bool operator>=(const string& str) const {
return (*this < str);
}
// != ==取反
bool operator!=(const string& str) const {
return !(*this == str);
}
7.2 []重载
// []重载
// const对象调const版本[] 普通对象调普通版本[]
char& operator[](size_t pos) { //返回引用,读写版本
assert(pos < _size);
return _str[pos];
}
const char& operator[](size_t pos) const { //只读版本
assert(pos < _size);
return _str[pos];
}
char&
做返回值实现了普通对象可读可写的效果- 用
assert(pos < _size)
检查[]
访问是否越界 []重载
返回传入下标位置的字符的引用即可- 为
const对象
实现const版本[]
,普通对象实现普通版本[]
7.3 流运算符重载
流插入<<
// c的字符数组,以\0为终止算长度
// string不看\0,以size为终止算长度
ostream& operator<<(ostream& out, const m_string:: string& s) {
//ostream这个类做了一件事,做了防拷贝,因此 ostream 类对象 做参数或返回值时要用引用
//实现方式 1
//out << s.c_str(); // 调用c_str() 可以直接打印
//out << s.c_str; // 访问s._str 实现字符串打印
// 以上两种方式,打印 c_str 遇到 "hello world\0hello Linux" 中间含有\0的字符串时,是错误的
// 流插入的要求是,有多少内容,打印多少内容 因此不能遇到\0终止
// 使用一个一个遍历字符的方式 实现打印
/*for (int i = 0; i < s.size(); ++i)
out << s[i];*/
for (auto& e : s)
out << e;
return out;
}
成员函数的第一个形参是
this
,为了符合<<
的习惯,期望第一个形参应当是流对象。根据实现方式的不同,<<
要重载成全局函数或友元函数1. out << s.c_str()
:调用c_str()
可以直接用C风格的字符串打印2. out << s.c_str
:访问s._str
实现字符串打印,此时<<
需要重载为友元函数,访问私有变量s.c_str
- 以上两种方式,打印
c_str
遇到"hello world\0hello Linux"
中间含有\0
的字符串时,是错误的。因为流插入的要求是,有多少内容,打印多少内容 因此不能遇到\0终止
最终我们
使用for循环一个一个遍历字符的方式,实现打印
。传统的for
循环和范围for
都可以实现const m_string:: string& s
:对只读对象加const
限定ostream
做了特殊处理,做了防止值拷贝,因此ostream
类对象做参数或返回值时要传引用函数返回
ostream& out
对象,实现连续<<
输出流
流提取>>
v1实现功能版
istream& operator>>(istream& in, m_string::string& str) {
//一个一个字符读
/*char ch;
in >> ch;*/
//用in.get() 读
str.clear(); // 对同一个string进行多次输入时,每次输入前要进行初始化 // 如果不初始化,会出现字符串堆叠
char ch = in.get();
//以空格或换行分割字符串
while (ch != ' ' && ch != '\n') {
str += ch;
//in >> ch;
ch = in.get();
}
return in;
}
- 我们在输入数据时,是用空格和换行符分隔多个值的。
- 而
cin
和scanf
在读取数据时,也是以空格和换行符对多个值进行分隔的,因此,注定cin
和scanf
默认读取不到空格和换行
istream
类对象的get()
方法来解决这个问题,get()
可以读入任意的字符,包括空格和换行。- 我们可以通过对循环读取的条件的控制,来实现
空格分隔
或\n分隔
多个值
- 我们可以通过对循环读取的条件的控制,来实现
str.clear()
:可能会向同一个string
对象中多次输入内容,为了防止数据重复,每次输入前要进行初始化(用clear清空
)。while (ch != ' ' && ch != '\n')
- 此循环条件,遇到空格
' '
或\n
时,不进入循环,因此是以空格和换行符分隔多个值的 str += ch
:将读到的字符,依次追加到str
末尾实现字符串的读取ch = in.get()
:追加后接着读取下一个字符
- 此循环条件,遇到空格
- 最后返回
in
对象,实现连续读取
我们V1
实现的方式存在一些问题:
- ==我们输入的第一个字符不能是空格和换行!==V2版本解决这样的问题
- 多次
+=
会调用push_back
,存在面对长字符串时多次扩容的性能损耗 V2
版本将解决以上问题
v2最终优化版
istream& operator>>(istream& in, m_string::string& s) {
s.clear(); //每次读取要先清空缓冲区,防止 多次输入数据时,数据重叠
char ch = in.get();
char buff[127] = { '\0' };
//清空 第一个有效数据 来临前的空格和换行
while (ch == ' ' || ch == '\n')
ch = in.get(); // 读了之后,直接去读下一个字符,就可以表示清除了
int i = 0;
//while (ch != '\n') { // 这种写法,类似于getline的实现,以换行符作为多个字符串的分隔,可以读到空格
while (ch != ' ' && ch != '\n') { //这种写法读不到字符串中的空格或换行
//可以选择 使用 \n 还是 ' '作为字符串的分隔符
//s += ch; //+=,当输入的字符串非常大时,会不断扩容,利用buff减少扩容的次数
buff[i++] = ch;
if (i == 127) {
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
// i != 0 说明里面还有数据
if (i != 0) {
buff[i] = '\0';
s += buff;
}
return in;
}
每次读取前清空存放数据的字符串
char ch = in.get()
char buff[127] = { '\0' }
,用get
一个一个读取所有的字符。char buff[127]
利用一个缓冲区来减缓+=
的频繁扩容问题while (ch == ' ' || ch == '\n') ch = in.get();
清空第一个有效字符来临前的空格和换行- 第一个有效字符来临前时,遇到空格或换行都不读入
ch = in.get()
:遇到非有效的空格和换行,读了之后,直接去读下一个字符,就可以表示清除了
用
i
来记录当前字符的个数,通过i
映射到缓冲区的下标两种不同条件的
while
循环,可以控制 使用\n
还是空格或\n
作为字符串的分隔符while (ch != '\n')
这种写法,类似于getline
的实现,以换行符作为多个字符串的分隔,可以读到空格while (ch != ' ' && ch != '\n')
:一般实现,这种写法,空格和换行都是字符串的分隔符,因此读不到字符串中的空格或换行
buff[i++] = ch
:将读到的每个字符填充到提前开辟的缓冲区buff
中i == 127
时,代表缓冲区已满,将buff[127]
位置元素设为字符串结尾\0
s += buff
:再将缓冲区内的字符串追加到s
中i = 0
:最后将i
置零,重新向缓冲区中填充数据
in.get
接着读取剩余字符循环结束后,
i != 0
时,说明 i 未到达127,buff
内还存在有数据buff[i] = '\0'
:设置字符串结尾s += buff
:将缓冲区中的数据追加到string
中
通过添加
buff
缓冲区减少了扩容次数
关键设计:
- 输出流直接遍历输出
- 输入流采用缓冲区减少扩容
- 跳过前导空白符空格和\n
8. 查找字符和子串
find字符
//查找单个字符
size_t find(char ch, size_t pos = 0) const {
assert(pos < _size);
for (size_t i = pos; i < _size; ++i) {
if (_str[i] == ch)
return i;
}
return npos;
}
- 检查断言
pos < _size
- 循环遍历,找到了的话返回下标
- 全部遍历结束时,没找到,返回
npos
find子串
//查找子串,返回子串的下标
size_t find(const char* str, size_t pos = 0) const {
if (!str || pos > _size) // 处理空指针和越界pos
return npos;
const char* ptr = strstr(_str + pos, str);
return ptr ? ptr - _str : npos;
}
str
为nullptr
或pos > _size
返回npos
- 调用C语言的库函数
strstr
,从_str + pos
位置开始找,ptr
接收函数的返回值 ptr
不为空代表找到了,return ptr - _str
,数组中,指针-指针,得到下标ptr
为空时代表未找到,返回npos
9. 其他重要接口
c_str()与clear()
//返回c_str const 修饰this指针指向的对象,可以让普通对象和const对象都可以调用
//函数内不修改对象,建议加上const
const char* c_str() const { //返回数组名
return _str;
}
//清空数据
void clear() {
_str[0] = '\0';
_size = 0;
}
- 返回C语言风格的字符串
const char*
,与C风格的字符串形成良好的兼容。 - 清空数据,代表数组中没有字符,则第一个元素直接设为字符串的结束标记
\0
- 再将
_size
设为0
size()与capacity()
//const 修饰 this指针指向的对象,可以让普通对象和const对象都可以调用
size_t size() const { //直接返回字符串当前的大小
return _size;
}
size_t capacity() const { //返回当前string对象的容积
return _capacity;
}
- const 修饰 this指针指向的对象,可以让
普通对象
和const
对象都可以调用 size()
直接返回当前已有的字符的个数,返回_size
capacity()
直接返回当前字符数组的最大容量,用_capacity
表示- 函数内不修改当前对象,加上
const
10. 结语
通过从零构建高性能字符串类的实践,我们深刻理解了STL容器的设计哲学。该实现以三成员架构(size/capacity/str
)为核心,采用深拷贝确保数据独立性,通过swap
技巧实现异常安全的赋值操作。现代写法通过参数值传递自动完成资源转移,将代码复杂度降低60%。容量管理采用二倍扩容策略与精准内存预分配,平衡了空间效率与时间性能。运算符重载通过内存级比较和流缓冲优化,实现了接近原生指针的访问效率。查找算法巧妙复用C库函数,在保证正确性的前提下提升执行效能。整个设计严格遵循RAII原则,通过迭代器封装实现STL兼容,最终在功能完备性与运行效率之间达到精妙平衡。
以上就是本文的所有内容了,如果觉得文章写的不错,还请留下免费的赞和收藏,也欢迎各位大佬在评论区交流
分享到此结束啦
一键三连,好运连连!