string类的模拟实现
目录
构造函数
1.无参构造函数
1.1.错误写法
1.2.正确写法
2.带参构造函数
2.1.错误写法
(1)写法1
(2)写法2
2.2.正确写法
3.全缺省构造函数
3.1.模拟实现 string 类构造函数时成员变量按 _size、_capacity、_str 顺序初始化的原因剖析
3.2.错误写法
(1)写法1
(2)写法2
(3)写法3
问题详细分析
3.3.正确写法
(1)注意事项
(2)代码实现
拷贝构造
1.浅拷贝拷贝构造函数的危害
(1)问题:模拟实现 string 类时浅拷贝导致的析构异常问题
(2)测试
(3)调试
2.自定义实现深拷贝拷贝构造函数
(1)测试
(2)一定要自定义实现深拷贝拷贝构造函数的情况
赋值重载函数
1.思路分析
2.代码实现
2.1.写法1(不建议用该写法):先释放旧空间,然后开新空间,再指向新空间,最后拷贝。
(1)代码分析
2.2.写法2(建议使用该写法):先开新空间,然后拷贝,再释放旧空间,最后指向新空间。
(1)代码分析
3.一定要自定义实现深拷贝赋值重载函数的情况
operator[]、迭代器
1.operator[]重载函数
1.1.const operator[]
1.2.非const(普通)operator[]
2.迭代器
2.1。迭代器的本质与模拟实现
2.2.迭代器的定义
2.3.迭代器函数
(1)非 const 迭代器函数
(2)const 迭代器函数
2.4.迭代器的使用格式
2.5.范围 for 与迭代器的关系
3.string 类对象的三种常见遍历方式
3.1.非const string类对象
3.2.const string 类对象
3.3 测试
string 类比较运算符重载
1.比较思路
2.成员函数实现
(1)operator>
(2)operator==
(3)operator>=
(4)operator<
(5)operator<=
(6)operator!=
3.测试
reserve与resize
1.reserve:开空间 + 对新开辟空间不初始化
1.1.不考虑缩容的实现策略(推荐之选)
1.2.错误代码
1.3.考虑缩容的实现方式(谨慎使用)
2.resize:开空间 + 对新开辟空间初始化 或者 删除数据 + 不缩容
2.1.注意事项
2.2.思路分析
2.3.代码实现
3.注意事项
3.1.resize 和 reserve 与 C 语言 realloc 函数的关系
3.2.resize 和 reserve 的应用场景
插入函数
1.void push_back(char ch),功能:尾插1个字符。
1.1.写法1
1.2.写法2
2.void append(const char* str),功能:尾插字符串。
2.1.写法1
2.2.写法2
3.string& operator+=(char ch),功能:尾插1个字符。
4.string& operator+=(const char* str),功能:尾插字符串。
5.string& insert(size_t pos, char ch),功能:在任意位置插入1个字符。
5.1.错误代码
(1)测试
(2)四种解决方法
5.2.正确代码
5.2.1.思路
(1)整体思路
(2)思路步骤
5.2.2.代码实现
6.string& insert(size_t pos, const char* str),功能:在任意位置插入字符串。
6.1.错误代码
(1)四种解决方法
6.2.正确代码
6.2.1.思路
(1)整体思路
(2)思路步骤
6.2.2.代码实现
(1)说明不建议把while (end > pos + len - 1)写成while(end >= pos + len)的原因
删除函数
查找函数find
1.size_t find(char ch, size_t pos = 0),功能:查找单个字符
(1)代码实现思路
(2)代码实现
2.size_t find(const char* str, size_t pos = 0),功能:查找子字符串
(1)代码实现思路
(2)代码实现
(3)strstr的模拟实现
交换函数swap
清空函数clear
流插入、流提取(全局函数)
1.流插入函数ostream& operator<<(ostream& out, const string& s)
(1)流插入函数的3种写法
①写法1:operator[]
②写法2:范围for
③写法3:迭代器
(2)错误写法
2.流提取函数istream& operator>>(istream& in, string& s)
1.流提取函数operator>>实现过程分析
(1)流提取函数需要解决的几个问题及相应解决方式
(2)流提取函数operator>>代码实现过程
2.流提取函数operator>>代码
模拟实现string类的整个工程
构造函数
注意:为了防止std库提供的string类和我们自己模拟实现的string类发生命名冲突,则此时我们需要在命名空间域bit中模拟实现string类。
1.无参构造函数
1.1.错误写法
string()
:_str(nullptr), _size(0), _capacity(0)
{}
(1)在模拟实现string类的无参构造函数时,不能将成员变量指针 _str
初始化为 nullptr
①原因1
string
类一般会提供一个 c_str()
成员函数,其功能是返回一个指向以 '\0'
结尾的 C 字符串的指针。这个指针可以用于传递给 C 标准库中处理字符串的函数,如 strlen
、strcpy
等。
如果在构造函数中将 _str
初始化为 nullptr
,那么调用 c_str()
函数时,它会返回 nullptr
。后续代码若使用 c_str()
函数的返回值进行字符串操作,例如将其作为参数传递给 C 字符串相关的操作函数,就会引发严重问题。
C 标准库中的字符串处理函数(如 strlen
、strcpy
等)在设计时,期望接收一个有效的、以 '\0'
结尾的字符串指针。当传入 nullptr
时,这些函数会尝试对空指针进行解引用操作。在大多数操作系统和编译器环境下,对空指针进行解引用是未定义行为,通常会导致程序崩溃。
- 测试:程序发生崩溃
- 崩溃原因:若自定义
string
类的无参构造函数将成员变量指针_str
初始化为nullptr
,当使用cout << s1.c_str()
打印字符数组时会出问题。因为s1.c_str()
返回char*
类型指针,若该指针为nullptr
,cout
在打印时会对其解引用,这会导致程序报错。因此,无参构造函数不能将_str
初始化为nullptr
,而应使用new
操作符开辟 1 字节空间存放字符串终止符'\0'
,并让_str
指向该空间。
②原因2:
string
类表示一个字符串对象,即使在无参构造 string 对象时没有显式提供字符串内容,该对象也应表示一个空字符串,而不是一个 “无字符串” 的状态。空字符串是一个长度为 0 但仍然存在的字符串,有一个有效的'\0'
终止符。若将_str
初始化为nullptr
,这就意味着该对象不代表任何字符串,这与string
类所表达的即使为空也代表一个字符串实体的语义不一致。- C++ 标准库中的
std::string
在无参构造时会创建一个空字符串对象。为了让自定义的string
类在使用习惯和语义上与标准库保持一致,在无参构造时应将_str
初始化为指向一个包含'\0'
的有效内存区域,而不是nullptr
。
1.2.正确写法
//无参构造函数
string()
:_str(new char[1])
, _size(0)
, _capacity(0)
{
_str[0] = '\0';
}
//注:1.调用无参构造函数初始化string类对象就是把对象初始化成空字符串,但是空字符串
//实际只存放字符串终止符'\0'这一个字符,所以这里用new操作符开辟1个字符大小的动态
//字符数组。
//2.实现无参构造函数时,在初始化列表中不能把_str初始化成空指针。
//3.即使new char 和 new char[1]都是new一个字符空间的大小,但是一定要使用
//new char[1]开辟动态字符数组的空间而不是使用new char开辟空间,因为string类的
//底层实现是个连续物理空间的数组,同时为了匹配析构函数的delete []。
//4.在初始化列表中,千万不要使用_size的值初始化_capacity,因为成员变量声明的顺序决定
//初始化列表中成员变量初始化的顺序,所以_capaicty在_size的前面声明,当我们使用
//_capacity(_size)时在这里_capacity初始化的值就是随机值。
2.带参构造函数
注意:我们在模拟实现带参构造函数时,我们不考虑传入的参数const char* str是个空指针nullptr问题。
2.1.错误写法
(1)写法1
//带参构造函数
string(const char* str)
:_str(str)
, _size(strlen(str))
, _capacity(strlen(str))
{}
①问题 1:把 const 指针str
赋值给成员变量指针_str
使得指针_str
的权限放大
问题分析:传入的参数str
是一个const char*
类型的指针,这意味着通过str
指针不能修改其所指向的内容。而成员变量_str
是一个普通的char*
指针,将const char*
类型的str
直接赋值给_str
,会使得_str
可以修改原本被声明为不可修改的内容,从而导致权限放大。这可能会破坏原始数据的常量性,引发未定义行为。
测试:无法通过编译
解决方案:为了避免权限放大问题,应该为_str
分配独立的内存空间,并将str
所指向的内容复制到该内存中。这样_str
指向的是一个全新的、可修改的副本,不会影响原始数据。
②问题2:在带参构造函数的初始化列表中,将成员变量指针 _str
直接初始化为常量字符串的地址,存在两个严重问题,如下所示。
- 权限问题:传入的
str
通常指向常量字符串,存储在常量区,常量区的数据具有只读属性。将_str
直接指向常量区的字符串,会使_str
也只能进行只读操作,而string
类的设计通常需要对存储的字符串进行修改操作,这就导致后续无法对_str
指向的内容进行写操作,违反了string
类可修改内容的设计初衷。 - 扩容问题:
string
类在使用过程中,当存储的字符串长度超过当前分配的内存空间时,需要进行扩容操作。若_str
直接指向常量区的字符串,常量区的内存是由系统管理且不可动态分配和释放的,这就使得无法对_str
指向的空间进行扩容操作,从而无法满足string
类动态增长的需求。
解决方案:为了解决上述问题,正确的做法是先为 _str
分配独立的动态内存空间,然后将常量字符串的内容复制到该内存空间中。这样,_str
指向的是可读写的动态内存,既可以对其内容进行修改,也可以在需要时进行扩容操作。
③问题3:在初始化列表中连续使用两个strlen
函数对成员变量进行初始化,效率太低
问题分析:strlen
函数的时间复杂度是 O(n),其中 n 是字符串的长度。在初始化列表中连续调用两次strlen
函数,会对同一个字符串进行两次遍历,增加了不必要的时间开销,降低了代码的执行效率。
解决方案:为提高效率,避免重复调用 strlen 函数,可先在初始化列表中先调用_size(strlen(str))完成对成员变量 _size 的初始化,然后在函数体内,用_capacity = _size来给成员变量_capacity赋予一个初始值。
(2)写法2
class string
{
public:
//带参构造函数
string(const char* str)
: _str(new char[strlen(str) + 1])
, _size(strlen(str))
, _capacity(_size)
{
strcpy(_str, str);
}
private:
char* _str;
size_t _capacity;
size_t _size;
};
①问题1:成员变量声明顺序影响初始化列表初始化顺序导致潜在错误
问题描述:在 C++ 中,类成员变量的初始化顺序是由成员变量在类中声明的顺序决定的,而不是由它们在初始化列表中出现的顺序决定。在给定的代码里,_capacity 在 _size 之前声明,所以在构造函数执行时,_capacity 会先于 _size 进行初始化。然而,_capacity 的初始化依赖于 _size 的值,此时 _size 尚未被正确初始化,就会导致 _capacity 得到未定义的值(即此时_capacity 是个随机值),从而引发错误。
测试:编译器调用构造函数后,_capacity被初始化为一个随机值。
解决方案:
为了避免因成员变量声明顺序影响初始化顺序而导致的错误初始化,有以下几种方法:
- 避免在初始化列表中使用依赖关系:当所有成员变量都在初始化列表中进行初始化时,每个成员变量的初始值不应该依赖于其他未初始化的成员变量,而是应该单独进行初始化。
- 分开初始化:如果确实需要使用一个成员变量来初始化另一个成员变量,可以将其中一个成员变量在初始化列表中初始化,另一个在构造函数体内进行初始化;或者将两个成员变量都放在构造函数体内进行初始化。
②问题2:重复调用 strlen
函数导致初始化效率低下
问题描述:strlen
函数的时间复杂度是 O(n),其中 n 是字符串的长度。在初始化列表中连续调用两次 strlen
函数,会对同一个字符串进行两次遍历,增加了不必要的时间开销,降低了代码的执行效率。
解决方案:为提高效率,避免重复调用 strlen 函数,可先在初始化列表中先调用_size(strlen(str))完成对成员变量 _size 的初始化,然后在函数体内,用_capacity = _size来给成员变量_capacity赋予一个初始值,最后在构造函数体内为成员变量 _str
分配大小为 _capacity + 1
的内存空间,并将 str
的内容复制到 _str
指向的内存中。
2.2.正确写法
//带参构成函数
string(const char* str)
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_capacity + 1];//注:_capacity是存放有效字符的容量。
strcpy(_str, str);//注:strcpy会把源字符串str的终止符'\0'拷贝给目标字符串_str。
}
//注:
//1.在模拟实现string类时,当我们对所有成员变量进行初始化时,建议在初始化列表只初始化_size,
//而其他成员变量的初始值在构造函数体内部通过赋值操作赋予初始值。
//2._size的值是不包括字符串终止符'\0'的,但是实际指针_str指向的动态字符数组中是存放终止
//符'\0',所以这里要使用new操作符动态开辟_capaicty + 1大小的字符数组,但是_capacity的值是
//不统计终止符'\0'。
测试:
3.全缺省构造函数
注意:全缺省构造函数是用来代替无参构造函数、带参构造函数的。
3.1.模拟实现 string
类构造函数时成员变量按 _size
、_capacity
、_str
顺序初始化的原因剖析
_size
是基础信息:_size
代表字符串的实际字符数量(不包含字符串结束符 '\0'
)。在构造 string
对象时,首先要明确传入字符串的长度,这是后续操作的基础。因为后续的内存分配、数据复制等操作都依赖于这个长度信息。例如,使用 strlen
函数计算传入字符串的长度并赋值给 _size
,可以精确知道字符串包含多少个有效字符。
_capacity
依赖于 _size:_capacity
表示为存储字符串所分配的内存空间大小。_capacity
需要参考 _size
的值,因为我们希望分配的内存空间至少能够容纳当前的字符串。一般情况下,当 _size
为 0 时,为避免频繁的内存分配操作,会给 _capacity
一个初始的非零值;当 _size
不为 0 时,_capacity
可以初始化为 _size
。所以,只有先确定了 _size
,才能合理地确定 _capacity
的值。
_str
依赖于 _capacity:_str
是一个指向动态分配内存的指针,用于存储字符串内容。为了存储完整的字符串,包括字符串结束符 '\0'
,需要分配的内存空间大小为 _capacity + 1
。所以,在确定了 _capacity
之后,才能准确地分配所需的内存空间,并将该空间的起始地址赋值给 _str
。
避免资源浪费和错误
- 避免内存分配不足:如果不先确定
_size
就直接分配内存,可能会导致分配的内存空间不足以存储整个字符串,从而引发数据截断或越界访问等问题。通过先计算_size
,再根据_size
确定_capacity
并分配内存,可以确保分配的内存空间足够容纳字符串。 - 避免内存浪费:如果不根据
_size
合理确定_capacity
,可能会分配过多的内存空间,造成资源浪费。按照先_size
后_capacity
的顺序,可以根据实际需要分配合适大小的内存空间。
3.2.错误写法
(1)写法1
//全缺省构造函数
string(const char* str = nullptr)
:_size(strlen(str))
{
_capacity = _size == 0 ? 3 : _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
问题:全缺省构造函数参数默认值设置为 nullptr
导致未定义行为
- 问题描述:在全缺省构造函数中,参数
str
的默认值设置为nullptr
,会导致在初始化列表中调用strlen(str)
时出现空指针解引用,从而引发未定义行为(通常表现为程序崩溃)。
测试:当用户调用 string s1;
时,构造函数会使用默认值 nullptr
初始化 str,
会导致在初始化列表中调用 strlen(str)
时出现空指针解引用,从而引发程序崩溃。
(2)写法2
//全缺省构造函数
string(const char* str = '\0')
:_size(strlen(str))
{
_capacity = _size == 0 ? 3 : _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
问题:全缺省构造函数参数使用字符 '\0'
导致空指针解引用问题
- 问题描述:在全缺省构造函数中,将参数
str
的缺省值设置为字符'\0'
会引发严重的程序错误。由于字符'\0'
与const char*
类型不匹配,会发生隐式类型转换,最终str
会被赋值为空指针nullptr
。而在初始化列表中调用strlen(str)
时,会对空指针str
进行解引用操作,这会导致未定义行为,通常表现为程序崩溃。
问题详细分析
- 类型不匹配与隐式类型转换:
- 构造函数参数
str
的类型是const char*
,而'\0'
是一个字符类型(char
)。这两者类型不兼容,因此会触发隐式类型转换。 - 字符
'\0'
会先进行整形提升,从char
类型提升为int
类型,其值为 0。 - 然后这个整数值 0 会被隐式转换为
void*
类型的空指针nullptr
,并赋值给str
。所以最终const char* str = '\0'
等价于const char* str = nullptr
。
- 构造函数参数
- 空指针解引用问题:
strlen
函数的作用是计算字符串的长度,它需要一个有效的指向以'\0'
结尾的字符数组的指针作为参数。- 当
str
为nullptr
时,调用strlen(str)
会尝试对空指针进行解引用操作,这是未定义行为。在实际运行中,这种操作往往会导致程序崩溃。
测试:
解决方案:为了避免上述问题,应该将构造函数参数 str
的缺省值设置为一个空字符串 ""
,而不是字符 '\0'
。因为空字符串 ""
是一个有效的 const char*
类型的指针,指向一个只包含字符串结束符 '\0'
的字符数组,这样调用 strlen("")
会正常返回 0,不会出现空指针解引用的问题。
(3)写法3
string(const char* str = "")
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
void push_back(char ch)
{
if (_size + 1 > _capacity)
{
reserve(_capacity * 2);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
①问题:string
类构造函数初始化 _capacity
为 0 导致插入扩容异常问题分析
- 问题描述:在自定义
string
类中,若构造函数将_capacity
的初始值设为 0,当调用push_back
、append
、operator+=
、insert
等插入操作且需要扩容时,会出现严重问题。因为扩容逻辑通常是基于当前_capacity
的值进行倍数或累加扩容(如reserve(2 * _capacity)
或reserve(_capacity + len)
),当_capacity
为 0 时,2 * _capacity
仍为 0,这会导致扩容失败。扩容失败后直接插入数据,会造成越界访问,非法访问内存,最终导致程序崩溃。
问题详细分析
- 构造函数影响:原构造函数
string(const char* str = "") :_size(strlen(str)) { _capacity = _size; ... }
中,当传入空字符串""
时,_size
为 0,进而_capacity
也为 0。 - 插入扩容问题:
- 插入单个字符:
push_back
和部分insert
场景采用reserve(2 * _capacity)
扩容,若_capacity
为 0,扩容后容量仍为 0,后续插入数据会越界。 - 插入字符串:
append
和部分insert
场景采用reserve(_capacity + len)
扩容,虽然即使_capacity
为 0,_capacity + len
通常不为 0 能正常扩容,但整体逻辑的一致性被破坏,且代码复杂度增加。
- 插入单个字符:
测试:
②解决方案:
方案一:在插入函数中判断并初始化 _capacity
在插入单个字符的函数(如 push_back
、insert
)中,插入前判断 _capacity
是否为 0,若为 0 则赋予一个初始值(如 3),再进行扩容操作。
void push_back(char ch)
{
if (_size + 1 > _capacity)
{
if (_capacity == 0)
_capacity = 3;
reserve(_capacity * 2);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
此方案的优点是可以在不修改构造函数的情况下解决问题,但缺点是增加了插入函数的复杂度,且每个插入函数都需要添加判断逻辑,代码冗余。
方案二:直接修改构造函数
在构造函数中,当传入空字符串时,给 _capacity
一个合理的初始值(如 3),避免 _capacity
为 0 的情况。
string(const char* str = "")
:_size(strlen(str))
{
_capacity = _size == 0 ? 3 : _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
此方案的优点是从根本上解决了问题,保持了代码的简洁性和一致性,后续插入函数无需额外处理 _capacity
为 0 的情况。推荐使用此方案。
综上所述,采用直接修改构造函数的方案更为合适,它能有效避免因 _capacity
初始值为 0 带来的插入扩容问题,同时简化代码逻辑。
3.3.正确写法
(1)注意事项
//全缺省构造函数
string(const char* str = "\0")
:_size(strlen(str))
{
_capacity = _size == 0 ? 3 : _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
问题:全缺省构造函数参数默认值使用 "\0"
导致冗余与潜在逻辑问题
- 问题描述:在C++中,全缺省构造函数
string(const char* str = "\0")
的参数str
的缺省值被设置为"\0"
。然而,这种设置存在冗余,因为"\0"
实际上等价于空字符串""
。在C++中,空字符串""
已经隐式包含了字符串终止符'\0'
,因此将缺省值设置为"\0"
是不必要的,甚至可能引起误解,即这种冗余可能导致代码阅读者产生困惑,误以为"\0"
有特殊含义或需要显式处理。
测试:
(2)代码实现
//全缺省构造函数
string(const char* str = "")//缺省值:空字符串
:_size(strlen(str))
{
_capacity = _size == 0 ? 3 : _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
拷贝构造
1.浅拷贝拷贝构造函数的危害
#include <iostream>
using namespace std;
namespace bit
{
class string
{
public:
//全缺省构造函数
string(const char* str = "")//缺省值:空字符串
:_size(strlen(str))
{
_capacity = _size == 0 ? 3 : _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
//编译器自动生成默认拷贝构造
//调用默认拷贝构造函数(执行浅拷贝)
//析构函数
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
const char* c_str()
{
return _str;
}
private:
char* _str;
size_t _capacity;
size_t _size;
};
}
int main()
{
bit::string s1("hello world");
bit::string s2(s1);
cout << "s1:" << s1.c_str() << endl;
cout << "s2:" << s2.c_str() << endl;
return 0;
}
(1)问题:模拟实现 string
类时浅拷贝导致的析构异常问题
- 问题描述:在模拟实现
string
类时,string
类的成员变量_str
是一个指向动态分配内存的指针,用于存储字符串内容。当使用编译器自动生成的默认拷贝构造函数时,会执行浅拷贝操作,即只复制指针的值,而不会为新对象分配独立的内存空间。这会导致多个string
类对象的_str
指针指向同一块动态分配的内存。当这些对象出作用域时,析构函数会被调用,对同一块内存进行多次释放,从而引发程序崩溃。此外,浅拷贝还会导致一个对象对_str
指向内容的修改会影响到其他对象。
问题详细分析
- 浅拷贝的本质:编译器生成的默认拷贝构造函数只是简单地将一个对象的成员变量值复制到另一个对象。对于
_str
指针,它只是复制了指针的值,使得两个对象的_str
指向同一块内存。 - 析构函数的问题:每个
string
类对象在出作用域时都会调用析构函数,析构函数中会使用delete[]
释放_str
指向的内存。由于浅拷贝导致多个对象的_str
指向同一块内存,就会出现对同一块内存多次释放的情况,这是未定义行为,通常会导致程序崩溃。 - 数据修改的影响:因为多个对象的
_str
指向同一块内存,所以一个对象对字符串内容的修改会直接影响到其他对象,这不符合string
类的设计初衷。
(2)测试
(3)调试
调试步骤如下:
①调用析构函数释放s2对象占用的资源
(注意:根据栈后进先出的特性,而s2是后入栈的,所以s2会先出作用域后先调用析构函数释放s2对象占用的资源)
②调用析构函数释放s1对象占用的资源
直接在析构函数处发生报错。
(4)解决方案:为了解决浅拷贝带来的问题,需要自定义实现深拷贝的拷贝构造函数。深拷贝会为新对象分配独立的内存空间,并将原对象的字符串内容复制到新的内存空间中。代码如下:
//自定义实现深拷贝拷贝构造函数
string(const string& s)
:_size(s._size)
, _capacity(s._capacity)
{
_str = new char[s._capacity + 1];
//注:s._capacity + 1中的1字节是开辟给字符串终止符'\0'存放的空间。
strcpy(_str, s._str);
}
2.自定义实现深拷贝拷贝构造函数
//自定义实现深拷贝拷贝构造函数
string(const string& s)
:_size(s._size)
, _capacity(s._capacity)
{
_str = new char[s._capacity + 1];
//注:s._capacity + 1中的1字节是开辟给字符串终止符'\0'存放的空间。
strcpy(_str, s._str);
}
解析:
- 在模拟实现
string
类时,因为其成员变量_str
涉及动态内存分配,所以必须自定义实现深拷贝的拷贝构造函数。 - 若不自定义深拷贝构造函数,而使用编译器生成的默认拷贝构造函数,会执行浅拷贝操作。浅拷贝仅复制对象成员变量的值,对于指针类型的
_str
,只是复制了指针本身,即新对象和原对象的_str
会指向同一块动态分配的内存空间。 - 当这些
string
类对象出作用域时,每个对象都会调用析构函数。析构函数中会使用delete[]
释放_str
指向的内存。由于浅拷贝导致多个对象的_str
指向同一块内存,就会出现多次释放同一块动态内存空间的情况,这属于未定义行为,通常会导致程序崩溃。 - 此外,由于多个对象的
_str
指向同一块内存,一个对象对该内存中字符串内容的修改会直接反映到其他对象上,即一个对象的修改会影响其他对象,这不符合string
类对象相互独立的设计原则。 - 而自定义的深拷贝构造函数会为新对象的
_str
分配独立的内存空间,并将原对象字符串的内容复制到新分配的内存中。这样,新对象和原对象就有各自独立的内存区域,彼此的修改互不影响,且在对象析构时,也不会出现重复释放同一块内存的问题,保证了程序的正确性和稳定性。
(1)测试
(2)一定要自定义实现深拷贝拷贝构造函数的情况
①情况1:类自定义了析构函数,且该析构函数的作用是释放类中指针成员所指向的动态分配的资源(如动态内存)时,一定要自定义实现深拷贝拷贝构造函数。
解析:因为如果使用编译器默认生成的浅拷贝拷贝构造函数,多个对象的指针成员会指向同一块动态分配的资源,对象析构时会导致对同一块资源的重复释放,这是未定义行为,可能会使程序崩溃。
②情况2:类的对象包含指向动态分配内存的指针成员变量时,通常一定要自定义实现深拷贝拷贝构造函数。
解析:因为如果使用编译器默认生成的浅拷贝拷贝构造函数,新对象和原对象的指针成员会指向同一块动态分配的内存,一方面,当这些对象析构时,会对同一块内存进行多次释放,导致程序崩溃;另一方面,一个对象对该内存中数据的修改会影响到其他对象,这不符合对象独立性的设计原则。通过自定义深拷贝拷贝构造函数,为新对象分配独立的内存空间,并将原对象的数据复制到新的内存中,可以避免这些问题。
赋值重载函数
1.思路分析
(1)从空间角度分析
在实现string
类的赋值重载函数时,从空间角度考虑,需要处理目标对象(设为s1
)和源对象(设为s3
)之间不同的大小关系,主要包含以下三种情况:
s1.size() < s3.size()
:即目标对象当前所占空间小于源对象。s1.size() > s3.size()
:即目标对象当前所占空间大于源对象。s1.size() = s3.size()
:即目标对象和源对象所占空间大小相等。
如果对这三种情况分别进行单独处理,会使代码变得复杂。尤其是在s1.size() > s3.size()
且两者差值较大的情况下,比如s1
拥有 10000 个空间单位,而s3
仅有 10 个空间单位,若单独编写处理逻辑,在执行s1 = s3
这样的赋值操作时,会导致s1
的大量空间被浪费。
因此,更好的做法是采用统一的处理方式来应对这三种情况:
- 首先,使用
new[]
操作符为目标对象s1
开辟一块足够容纳源对象s3
数据的新空间。这样可以确保无论源对象的大小如何,目标对象都有合适的空间来存储数据。 - 然后,利用
strcpy
函数将源对象s3
的数据拷贝到目标对象s1
新开辟的空间中。通过这种方式,实现了数据从源对象到目标对象的转移。 - 最后,使用
delete[]
释放目标对象s1
原来的旧空间。这样可以避免内存泄漏,合理管理内存资源。
(2)注意事项
- 当两个对象进行赋值操作时,如果它们长度差值很大,一般不考虑去缩容。
- 赋值重载函数和拷贝构造函数的实现思路是类似的。
- 拷贝构造函数和赋值重载函数的区别:拷贝构造函数是用一个已经存在的对象去初始化另一个新创建的对象;而赋值重载函数是对两个已经存在的对象进行赋值操作。
2.代码实现
2.1.写法1(不建议用该写法):先释放旧空间,然后开新空间,再指向新空间,最后拷贝。
string& operator=(const string& s)
{
//注:这里显示写if (this != &s)语句的判断是为了防止自己赋值给自己,
//因为自己赋值给自己是没有必要的。例如s1 = s1。
if (this != &s)
{
//写法1:先释放旧空间,然后开新空间,再指向新空间,最后拷贝。
delete[] _str;//先释放旧空间
_str = new char[s._capacity + 1];//然后开辟新空间,再让_str指向新空间
strcpy(_str, s._str);//最后拷贝
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
(1)代码分析
①问题1:这种写法存在风险,如果没有 if (this != &s)
语句的判断,当执行自己赋值给自己(如 s1 = s1
)时,会先释放 _str
指向的空间,然后再试图访问该已释放的空间进行后续操作,导致程序出现未定义行为。例如:s1 = s1,赋值完后的打印结果就是随机值。如下图所示:
②问题2:不建议使用该写法 的主要原因如下:该写法先释放_str
指向的旧空间,再执行_str = new char[s._capacity + 1]
为其开辟新空间。当new
操作因内存不足等原因失败时,会抛出异常。由于旧空间已提前释放,此时_str
成为悬空指针 。若在main
函数中用try - catch
捕获异常,异常处理完成后,程序后续若再访问_str
,就会因悬空指针导致错误;并且,因为先释放了旧空间,new
失败时源对象原本_str
指向的旧空间状态已被破坏,可能影响程序其他部分的运行。
2.2.写法2(建议使用该写法):先开新空间,然后拷贝,再释放旧空间,最后指向新空间。
string& operator=(const string& s)
{
//注:这里显示写if (this != &s)语句的判断是为了防止自己赋值给自己,
//因为自己赋值给自己是没有必要的。例如s1 = s1。
if (this != &s)
{
//写法2:先开新空间,然后拷贝,再释放旧空间,最后指向新空间。
char* tmp = new char[s._capacity + 1];//先开新空间
strcpy(tmp, s._str);//然后拷贝
delete[] _str;//再释放旧空间
_str = tmp;//_str最后指向新空间
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
(1)代码分析
这种写法相对更安全。即使没有 if (this != &s)
语句的判断,当执行自己赋值给自己(如 s1 = s1
)时,由于是先开辟了新空间并拷贝数据,源对象 _str
指向的旧空间在 delete[]
操作前始终保持不变,不会被破坏。并且,当 new
操作失败抛出异常时,原对象的 _str
指针仍然指向原来有效的空间,不会出现悬空指针的问题,外部的 try - catch
可以正常捕获并处理异常 。例如,s1 = s1,赋值完后的打印结果并不是随机值。如下图所示:
3.一定要自定义实现深拷贝赋值重载函数的情况
(1)情况1:当类中存在指针成员,且该指针指向动态分配的内存(如使用new
操作符分配的内存)时,必须自定义深拷贝赋值重载函数。
解析:若使用编译器默认生成的赋值重载函数(执行浅拷贝),会导致多个对象的指针成员指向同一块动态内存。这样一来,在对象析构时,同一块内存会被多次释放,造成程序崩溃;并且一个对象对内存中数据的修改,也会影响到其他对象。
(2)情况2:如果类已经自定义了析构函数来释放动态资源,同时又有拷贝构造函数实现了深拷贝,为了保证对象赋值操作时的一致性和正确性,也需要自定义深拷贝赋值重载函数。
解析:若不自定义实现深拷贝赋值重载函数且使用默认浅拷贝,就会与析构函数和拷贝构造函数的逻辑产生冲突,导致资源管理混乱。
operator[]、迭代器
1.operator[]重载函数
1.1.const operator[]
//注意:该函数可以被const对象、非const对象(即普通对象)调用。
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
- 函数声明:
const char& operator[](size_t pos) const
,该函数返回一个指向const char
类型的引用,这意味着通过该函数获取到的字符不能被修改,保证了数据的只读性。最后的const
关键字表明这是一个const成员函数,即函数内部不会修改类的成员变量。 - 参数:
pos
表示要访问的字符在字符串中的位置,类型为size_t
,它是一个无符号整数类型,通常用于表示数组或字符串的索引。 - 函数体:
assert(pos < _size);
使用断言assert
来检查传入的位置pos
是否在有效范围内(小于字符串的实际长度_size
)。如果pos
超出范围,程序会终止并给出错误提示,这样可以防止非法访问内存。return _str[pos];
返回字符串中指定位置pos
处的字符的引用。因为函数是const成员函数,所以返回的是const char&
类型,以确保不会通过返回值修改字符串内容。
- 适用场景:该函数可以被
const
对象和非const
对象调用。当对象为const
时,只能调用const成员函数,这个版本就提供了读取字符的能力;对于非const
对象,也可以调用const成员函数来实现只读访问。
1.2.非const(普通)operator[]
//注意:该函数只能给非const对象调用。
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
- 函数声明:
char& operator[](size_t pos)
,该函数返回一个指向char
类型的引用,这使得通过该函数获取到的字符是可以被修改的。 - 参数:
pos
表示要访问的字符在字符串中的位置,类型为size_t
,它是一个无符号整数类型,通常用于表示数组或字符串的索引。 - 函数体:
- 同样使用断言
assert(pos < _size);
检查位置的有效性,防止越界访问。 return _str[pos];
返回字符串中指定位置pos
处的字符的引用,由于返回的是char&
类型,所以可以对返回的字符进行修改操作,例如string s(“hello world”);;s[2] = 'x';。
- 同样使用断言
- 适用场景:该函数只能被非
const
对象调用。因为如果const
对象调用此函数,就可能会修改对象的状态,这与const
对象的只读性质相违背。
2.迭代器
迭代器是一种用于遍历容器中元素的工具,它提供了统一的访问方式,类似于指针,但又不仅仅局限于指针形式。在自定义 string
类中实现迭代器,可以方便地对字符串中的字符进行遍历操作。
注意:迭代器不一定是指针,在VS中迭代器不是指针,但在Linux中迭代器就是指针。
2.1。迭代器的本质与模拟实现
迭代器是一个广义上的概念,它并不等同于指针。虽然指针可以作为迭代器的一种实现形式,但迭代器还可以通过更复杂的类结构来实现,以提供诸如双向遍历、随机访问等不同的功能特性。在我们自定义的string
类中,为了简化实现过程,将迭代器模拟成指针的形式。这样做既符合我们对字符串字符逐个访问的需求,也能利用指针的一些特性,比如指针的算术运算,来方便地移动迭代器的位置。
2.2.迭代器的定义
//定义非const迭代器(普通迭代器),用于非const对象,可以修改指向的字符
typedef char* iterator;
//定义const迭代器,用于const对象,不可以修改指向的字符
typedef const char* const_iterator;
这里将迭代器模拟成指针的形式实现。iterator 类型类似于可修改指向内容的指针,const_iterator 类型类似于指向常量的指针,指向的内容不可修改。
2.3.迭代器函数
为了使用迭代器遍历字符串,需要实现 begin()
和 end()
函数。
(1)非 const 迭代器函数
//非const迭代器(普通迭代器):begin()、end()
iterator begin()
{
return _str;
}
iterator end()
{
//返回最后一个有效数据的下一个位置(即返回字符串终止符'\0'的位置)
return _str + _size;
}
begin()
函数返回指向字符串首字符的迭代器,end()
函数返回指向字符串末尾('\0'
位置)的迭代器。
(2)const 迭代器函数
//const迭代器:begin()、end()
//注:const迭代器(指针)自己本身的值是可以修改,只不过是const迭代器指向的内容不可以修改。
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
//返回最后一个有效数据的下一个位置
return _str + _size;
}
这两个函数是const成员函数,用于 const
对象,返回的 const_iterator
类型迭代器只能用于读取字符,不能修改。
2.4.迭代器的使用格式
2.5.范围 for 与迭代器的关系
C++ 中的范围 for 语句,其底层实现依赖于迭代器。范围 for 会自动调用容器的 begin() 和 end() 函数获取迭代器,然后遍历元素。例如:
string s("hello world");
for (auto ch : s)
{
cout << ch << " ";
}
等同于:
string s("hello world");
for (string::iterator it = s.begin(); it != s.end(); ++it)
{
cout << *it << " ";
}
如果自定义 string 类的迭代器成员函数名不是标准的 begin() 和 end()(例如写成了 Begin() 和 End() ),范围 for 在底层查找迭代器时就会失败,导致编译报错。因为范围 for 语句在底层是根据固定的成员函数名 begin() 和 end() 去查找迭代器来替代范围 for 的代码逻辑的。如下所示:
3.string
类对象的三种常见遍历方式
在 C++ 中,string
类用于处理字符串,为了方便对字符串中的字符进行访问和操作,提供了多种遍历方式。以下将分别针对非const string
类对象和const string
类对象,详细介绍operator[]
、迭代器以及范围for
这三种常见的遍历方式。
注意事项:
- const 对象的限制:
const string
类对象只能调用const operator[]
和const
迭代器相关函数,因为这些函数不会修改对象的状态,符合const
对象的只读属性。 - 非 const 对象的调用规则:非
const string
类对象既可以调用const
版本的operator[]
和迭代器函数,也可以调用非const
版本的。编译器优先选择调用非const
版本的函数,因为非const
版本可以提供更灵活的读写操作。只有当不存在非const
版本的函数,且操作是只读时,编译器才会选择调用const
版本的函数。 这样可以确保在满足需求的情况下,尽可能地利用非const
函数的特性,同时也保证了const
对象的安全性。
3.1.非const string
类对象
非const string
类对象允许对字符串进行读取和写入操作。因为这类对象没有被const
修饰,所以可以调用能修改字符串内容的成员函数。
(1) operator[]
string
类重载了[]
运算符,使得我们能够像访问数组元素一样访问字符串中的字符。这种方式直观且符合我们对数组操作的习惯。
#include <iostream>
using namespace std;
void test_string3()
{
//创建一个非const string类对象
string s1("hello world");
//1.1 写操作
//通过operator[]遍历并修改字符串中的每个字符,这里将每个字符的ASCII码值加1
for (size_t i = 0; i < s1.size(); ++i)
{
s1[i]++;
}
//1.2 只读操作
//通过operator[]遍历并输出字符串中的每个字符
for (size_t i = 0; i < s1.size(); ++i)
{
cout << s1[i] << " ";
}
cout << endl;
}
在上述代码中,s1[i]
不仅可以获取对应位置的字符(只读),还可以对其进行赋值操作(写)。在写操作的循环中,对每个字符进行了修改;在只读操作的循环中,只是输出字符。
(2)迭代器
迭代器是一种广义上的指针,它为遍历容器元素提供了统一的方式。string
类提供了iterator
类型的迭代器,用于非const
对象的遍历。
#include <iostream>
using namespace std;
void test_string3()
{
//创建一个非const string类对象
string s1("hello world");
//2.1 写操作
//获取字符串s1的起始迭代器
string::iterator it = s1.begin();
while (it != s1.end())
{
//修改迭代器指向的字符,这里将每个字符的ASCII码值减1
(*it)--;
//移动迭代器到下一个字符
++it;
}
//2.2 只读操作
//将迭代器重置到字符串s1的起始位置
it = s1.begin();
while (it != s1.end())
{
//输出迭代器指向的字符
cout << *it << " ";
//移动迭代器到下一个字符
++it;
}
cout << endl;
}
这里使用s1.begin()
获取起始迭代器,它指向字符串的第一个字符;s1.end()
获取结束迭代器,它指向字符串最后一个字符的下一个位置(即字符串终止符'\0'
的位置)。在写操作中,通过*it
修改字符;在只读操作中,通过*it
读取字符。
(3)范围for
范围for
是 C++11 引入的一种简洁的遍历方式,其底层实现依赖于迭代器。对于string
类对象,使用范围for
可以方便地遍历字符串中的每个字符。
#include <iostream>
using namespace std;
void test_string3()
{
//创建一个非const string类对象
string s1("hello world");
//3.1 写操作
//这里使用auto& ch,ch是字符串中字符的引用,可对字符进行修改
for (auto& ch : s1)
{
ch++; //对范围for遍历到的每个字符进行修改
}
//3.2 只读操作
//这里使用auto ch,ch是字符串中字符的副本,不能修改原字符串的字符
for (auto ch : s1)
{
cout << ch << " "; //输出每个字符
}
cout << endl;
}
在范围for
中,auto& ch
表示引用遍历到的每个字符,因此可以进行写操作;若只是想要只读,可以省略引用符号&
,此时ch
是字符的拷贝(即副本),对ch
的修改不会影响原字符串。
3.2.const string 类对象
const string
类对象表示字符串内容不可修改,这是因为const
修饰的对象具有只读属性,只能调用不会修改对象状态的成员函数,也就是const
成员函数。当我们在传参时,若使用引用传参且不想修改参数,通常会用const
修饰参数,例如非成员函数Print
的形参为const string& s
,这样可以避免在函数内部意外修改传入的字符串。
(1)operator[]
const string
类对象只能调用const operator[]
,该函数返回的是const char&
,这意味着只能用于读取字符,而不能对字符进行修改。
#include <iostream>
using namespace std;
void Print(const std::string& s)
{
//只读操作
//通过const operator[]遍历并输出字符串中的每个字符
for (size_t i = 0; i < s.size(); ++i)
{
cout << s[i] << " ";
}
cout << endl;
}
(2)迭代器
对于const string
类对象,需要使用const_iterator
进行遍历。const_iterator
本身可以移动,用于逐个访问字符串中的字符,但它指向的内容不能修改,以此保证const
对象的只读性。
#include <iostream>
using namespace std;
void Print(const std::string& s)
{
//只读操作
//获取字符串s的const_iterator,调用的是const版本的begin()函数
string::const_iterator it = s.begin();
while (it != s.end())
{
// 输出迭代器指向的字符
cout << *it << " ";
// 移动迭代器到下一个字符
++it;
}
cout << endl;
}
(3)范围for
范围for
底层通过调用迭代器实现,对于const string
类对象,实际上使用的是const_iterator
。在范围for
循环中,遍历到的字符相当于const char
类型,只能进行读取操作。
#include <iostream>
using namespace std;
void Print(const std::string& s)
{
//只读操作
//这里的ch相当于const char类型,只能读不能写
for (auto ch : s)
{
cout << ch << " ";
}
cout << endl;
}
3.3 测试
string
类比较运算符重载
在 C++ 中,自定义的string
类可以通过重载比较运算符来实现字符串之间大小的比较。以下详细介绍string
类中关于比较大小的成员函数。
1.比较思路
string
类在比较大小时,核心依据是字符串中字符的 ASCII 码值。具体的比较过程是从字符串的第一个字符开始,逐个字符进行对比。只要在某一位置上,两个字符串对应字符的 ASCII 码值存在差异,就可以确定它们的大小关系;只有当两个字符串的长度相同且每个对应位置的字符都完全一样时,这两个字符串才相等。需要着重强调的是,这里的比较与字符串对象的 _size
(实际字符个数)和 _capacity
(预先分配的存储容量)的值并无关联。
总的来说,string
类比较大小是基于字符串中字符的 ASCII 码值,从字符串的第一个字符开始逐个比较,而不是比较字符串对象的_size
(字符串长度)和_capacity
(容量)的值。
2.成员函数实现
(1)operator>
bool operator>(const string& s) const
{
//借助C标准库中的strcmp函数来执行两个字符串的比较操作
//当strcmp函数的返回值大于0,表明当前字符串对象的_str大于传入对象s的_str
return strcmp(_str, s._str) > 0;
}
该函数用于判断当前字符串对象(*this
)是否大于传入的字符串对象s
。它通过调用strcmp
函数来比较两个字符串的内容,若返回值大于 0,则当前字符串更大。
(2)operator==
bool operator==(const string& s) const
{
//当strcmp函数的返回值等于0,说明当前字符串对象的_str与传入对象s的_str完全一致
return strcmp(_str, s._str) == 0;
}
此函数用于判断两个字符串是否相等。当strcmp
函数的返回值为 0 时,说明两个字符串的每个对应位置的字符都相同,即字符串相等。
(3)operator>=
bool operator>=(const string& s) const
{
//写法1:复用operator>和operator==
//return *this > s || *this == s;
//写法2:复用operator>和operator==,逻辑表达更直观
return *this > s || s == *this;
}
该函数用于判断当前字符串对象是否大于或等于传入的字符串对象。可以通过复用operator>
和operator==
函数来实现,即只要当前字符串大于s
或者等于s
,就返回true
。
这里需要注意const
修饰的问题。如果operator==
函数没有被声明为const
成员函数(例如写成bool operator==(const string& s)
),那么当在operator>=
函数内部使用表达式s == *this
时,由于s
是const string
类对象,而此时的operator==
函数期望的左操作数是非const
的string
类对象,这就会导致权限放大问题,进而引发编译错误。所以,只要成员函数不修改成员变量的值,最好用const
来修饰*this
,以保证函数的安全性和正确性。
(4)operator<
bool operator<(const string& s) const
{
//通过复用operator>=,对其返回值取反,实现小于关系的判断
return!(*this >= s);
}
该函数用于判断当前字符串对象是否小于传入的字符串对象。通过复用 operator>=
函数,并对其返回值取反,从而实现小于关系的判断逻辑。即如果当前字符串不大于等于 s
,那么它就小于 s
。
(5)operator<=
bool operator<=(const string& s) const
{
//复用operator>,对其返回值取反,完成小于等于关系的判断
return!(*this > s);
}
此函数用于判断当前字符串对象是否小于或等于传入的字符串对象。通过复用 operator>
函数,并对其返回值取反,来达成小于等于关系的判断。也就是说,只要当前字符串不大于 s
,就意味着它小于或等于 s
。
(6)operator!=
bool operator!=(const string& s) const
{
//复用operator==,对其返回值取反,实现不等于关系的判断
return!(*this == s);
}
该函数用于判断两个字符串是否不相等。通过复用 operator==
函数,并对其返回值取反,实现不等于关系的判断。即如果两个字符串不相等,operator!=
函数就返回 true
。
3.测试
reserve与resize
1.reserve:开空间 + 对新开辟空间不初始化
reserve
函数的核心功能在于为字符串对象预先开辟指定大小的内存空间,不过它并不会对这片新开辟的空间进行初始化操作。
1.1.不考虑缩容的实现策略(推荐之选)
- 设计思路:在标准库的实现中,
reserve
函数并不会执行缩容操作。这主要是因为缩容操作不仅会带来额外的性能开销,还可能潜藏着风险。以顺序表这种数据结构为例,它采用连续的内存空间来存储数据。倘若在删除数据后贸然进行缩容,那么在后续插入数据时,极有可能频繁触发扩容操作,从而导致程序性能急剧下降。鉴于此,在我们自定义实现reserve
函数时,通常也会遵循这一原则,不考虑缩容情况。 - 代码实现
//reserve:不考虑缩容。 //注:从reserve的底层实现可知,reserve只能改变capacity的值,而不能改变size的值。 //string类的插入函数(push_bakc、append、insert)底层都是调用reserve进行扩容的。 void reserve(size_t n) { //进行条件判断,只有当期望开辟的空间大小n大于当前对象已有的容量_capacity时, //才会进入扩容流程。 if (n > _capacity) { //注意:对于string类对象来说,无论在什么时候使用new申请开辟多大空间存放有效数据, //都最好额外开多 1个字节的空间给字符串终止符'\0'进行存放。 //使用new操作符为新空间分配内存,多分配1个字节是为了存储字符串的终止符'\0', //确保字符串的完整性。 char* tmp = new char[n + 1]; //先开辟新空间。 //借助strcpy函数,将原字符串中的数据完整地拷贝到新开辟的空间中 strcpy(tmp, _str); //再把旧空间中的数据拷贝到新空间中。 //通过delete[]操作符,释放掉原有的内存空间,避免内存泄漏 delete[] _str; //最后释放旧空间。 //将_str指针重新指向新开辟的空间,使得后续对字符串的操作都基于新的内存区域 _str = tmp; //指向新空间 //更新当前对象的容量属性_capacity为新的空间大小n,以便准确记录当前可容纳的字符数量 _capacity = n; //更新容量_capacity的大小。 } }
- 注意:当单独调用
reserve
函数时,从理论层面来讲,是可以实现显式缩容的,也就是让reserve
的参数n
的值小于当前对象的capacity
值。然而,为了与标准库的行为保持高度一致,同时规避可能出现的性能问题,我们在代码中特意添加了if (n > _capacity)
这样的判断语句,以此来杜绝缩容情况的发生。
1.2.错误代码
- 错误代码展示
void reserve(size_t n) { char* tmp = new char[n + 1]; strcpy(tmp, _str); //当n < _capacity时,这行代码可能导致程序崩溃。 delete[] _str; //而崩溃原因是指针tmp发生越界访问。 _str = tmp; _capacity = n; }
- 错误原因:当出现
n < _capacity
的情况时,也就意味着发生了缩容现象。此时,指针tmp
所指向的新空间的容量要小于指针_str
所指向的旧空间的容量。在执行strcpy(tmp, _str)
这一语句时,它会试图将旧空间中的所有数据原封不动地拷贝到新空间中。但由于新空间容量不足,这就不可避免地会导致tmp
指针出现越界访问的情况,进而引发程序崩溃,造成严重的运行时错误。 - 解决方案:当处于缩容状态(即 n < _capacity )时,我们不能继续使用 strcpy(tmp, _str) 这种简单粗暴的方式来拷贝数据。正确的做法是采用 strncpy(tmp, _str, n) 函数,它能够精确地控制拷贝的字符数量为 n 个,从而有效地避免了越界问题的出现,确保程序的稳定性。详细代码参考 考虑缩容的实现策略中的代码。
1.3.考虑缩容的实现方式(谨慎使用)
- 代码实现
void reserve(size_t n) { //条件判断,如果申请的容量n与当前已有的容量_capacity完全一致,那么就无需进行任何 //额外操作,直接返回即可 if (n == _capacity) { return; } //为新的内存空间分配足够的内存,同样预留1个字节用于存储字符串终止符'\0' char* tmp = new char[n + 1]; //根据申请的容量n与当前容量_capacity的大小关系,分情况处理数据拷贝 if (n < _capacity)//缩容 { //使用strncpy函数进行数据拷贝,并在拷贝完成后手动添加字符串结束符'\0', //以保证字符串的正确格式 strncpy(tmp, _str, n); tmp[n] = '\0'; } else//扩容 { //当n大于等于_capacity时,直接使用strcpy函数将原字符串数据完整拷贝到新空间 strcpy(tmp, _str); } //释放掉原来占据的内存空间,及时回收资源 delete[] _str; //释放旧空间. //更新_str指针指向新分配的内存空间 _str = tmp;//指向新空间. //更新当前对象的容量属性为新的申请容量n _capacity = n;//跟新容量_capacity大小. }
- 特别说明:尽管这种方式成功实现了缩容功能,但鉴于缩容操作可能引发的性能问题,在实际的编程应用中,一般并不建议采用这种实现方式。
2.resize:开空间 + 对新开辟空间初始化 或者 删除数据 + 不缩容
resize
函数的功能是开辟空间并对空间进行初始化,或者删除数据,通常也不考虑缩容。
2.1.注意事项
- 不考虑缩容的原因:缩容操作是有代价的,操作系统没有提供原地缩容的方式,使得缩容操作往往是异地进行的。
resize
函数是围绕reserve
函数来实现的,而reserve
函数不支持缩容,所以resize
函数也遵循不缩容的原则。注意:当resize进行扩容时,底层是通过调用reserve来完成扩容的。 - 使用场景:在
string
类中,resize
函数的使用频率相对较低,更多地在顺序表类数据结构中使用。
2.2.思路分析
resize
函数需要根据不同情况进行处理。
(1)情况 1:不缩容 + 删除数据(n < _size
):此时需要删除部分数据,但由于不考虑缩容,我们只需要在索引n
的位置添加字符串终止符'\0'
,表示数据截断,而不需要对容量_capacity
进行处理。
(2)情况 2:插入数据(n > _size
)
- 当
_size != 0
时:- 扩容 + 初始化(插入数据):如果
n > _capacity >= _size
,即需要扩容。通过调用reserve
函数来改变当前对象的容量_capacity
,并将未存放有效数据的空间初始化为resize
函数的参数ch
。 - 不扩容 + 初始化(插入数据):如果
_size < n < _capacity
,不需要改变容量,但要将未存放有效数据的空间初始化为ch
。
- 扩容 + 初始化(插入数据):如果
- 当
_size = 0
时,开空间 + 初始化(插入数据):此时resize
函数的功能就是开空间并插入数据(初始化)。
(3)情况 3:无需操作(n = _size
):这种情况下,不需要对字符串对象进行任何操作。
2.3.代码实现
//resize的功能是:开空间(扩容) + 初始化 或者 删除数据 + 不缩容。
//注:从resize底层实现可知,resize可以改变capacity和size的值。
void resize(size_t n, char ch = '\0')
{
if (n < _size) //删除数据
{
//只保留前n个数据
_size = n;
//在下标n的位置添加字符串终止符'\0'
_str[_size] = '\0';
}
else if (n > _size) //插入数据
{
if (n > _capacity) //需要扩容
{
reserve(n); //调用reserve函数进行扩容
}
//初始化写法1:
//将从_size位置开始到n位置的空间初始化为参数ch
for (size_t i = _size; i < n; ++i)
{
_str[i] = ch;
}
//初始化写法2:
//memset(_str + _size, ch, n - _size);
_size = n;
//在新的末尾位置添加字符串终止符'\0'
_str[_size] = '\0';
}
//n == _size的情况不需要处理
}
3.注意事项
3.1.resize 和 reserve 与 C 语言 realloc 函数的关系
C++ 中的 resize
和 reserve
并不能简单地完全代替 C 语言的 realloc
扩容函数 。
- 功能差异:
realloc
函数主要用于重新分配内存块,它可以改变已分配内存块的大小,既可以扩容也可以缩容,并且当原内存块附近空间不足时,会将原数据拷贝到新的内存区域。而reserve
函数在 C++ 的string
类等容器中,主要是预留指定大小的空间,一般不考虑缩容(标准库实现通常不缩容,自定义实现时缩容也不推荐);resize
函数除了可以改变空间大小,还能对新空间进行初始化,或者在缩小长度时截断数据,但同样通常不进行缩容操作。 - 应用场景差异:
realloc
是 C 语言中对动态分配内存进行管理的函数,适用于普通的动态内存块操作;resize
和reserve
是 C++ 中容器类(如string
类)的成员函数,是针对容器内部数据存储的空间管理,它们与容器的其他特性(如迭代器、元素访问等)紧密相关。
总的来说,resize
和 reserve
是 C++ 中针对容器类的空间管理函数,与 C 语言的 realloc
有所不同,并且它们在对象初始化后,能够以特定的方式为对象开辟和管理空间。
3.2.resize 和 reserve 的应用场景
- reserve:主要用于在对象初始化之后,根据预计存储的数据量来预先开辟足够的空间,避免在后续插入元素时频繁触发重新分配内存的操作,从而提高程序性能。例如在一个
string
对象中,如果已知即将插入大量字符,提前调用reserve
预留空间,可以减少内存重新分配和数据拷贝的次数 。 - resize:不仅可以在对象初始化之后开空间,还能对新开辟的空间进行初始化,填充特定的值。另外,当传入的参数小于当前数据长度时,它还能起到截断数据的作用。比如对
string
对象调用resize
增大长度时,会开辟新空间并将新增部分初始化为指定字符;调用resize
减小长度时,会截断数据。
插入函数
1.void push_back(char ch),功能:尾插1个字符。
1.1.写法1
//尾插1个数据(即尾插1个字符)
void push_back(char ch)
{
//在尾插前,判断是否要进行扩容
//表达式_size + 1 > _capacity中的_size表示尾插前当前对象存放有序数据个数,
//_size + 1表示尾插后当前对象存放有效数据个数,_capacity表示尾插前当前对象存放有效数据最大容量。
if (_size + 1 > _capacity)//或者写成if (_size == _capacity)
{
reserve(_capacity * 2);//注意:由于字符串对象只需改变成员变量_capacity的值而不需要改变成员变量_size的值,
//所以这里选择使用reserve进行扩容而不是选择resize进行扩容。而且当插入1个字符时,这里应选择把空间扩成2 * _capacity
//的大小 或者 选择扩成1.5倍;但是在尾插1个字符串时空间不能扩容成2 * _capacity,因为会存在空间不足或者空间大量浪费的问题。
//注:这里不能用realloc进行扩容,因为我们一开始是使用new开辟字符数组动态空间,若一开始是用malloc开辟字符数组动态空间的话则我们就可以
//使用realloc进行扩容。
}
_str[_size] = ch;//直接在当前对象的最后一个有效数据的后一个位置尾插1个字符
++_size;//尾插完成后,更新_size的值
_str[_size] = '\0';//注意:完成尾插数据后一定要在当前对象下标_size的位置尾插字符串终止符'\0'来表示字符串的结束。
//若是不尾插'\0'则在打印时会打印出不是我们想要的结果。
}
1.2.写法2
void push_back(char ch)
{
insert(_size, ch);//调用string& insert(size_t pos, char ch)
}
2.void append(const char* str),功能:尾插字符串。
2.1.写法1
//尾插多个数据(即尾插1个字符串)
void append(const char* str)
{
//统计要插入字符串的长度len以便计算需要扩容的空间大小。
size_t len = strlen(str);
//解析_size+len表示当前对象尾插len个数据后的长度。表达式_size+len > _capacity若当前对象尾插len个字符后
//当前对象存放的有效数据个数_size+len大于当前对象的_capacity容量大小时则在尾插前要对当前对象进行扩容后才
//进行尾插字符串的操作。
if (_size+len > _capacity)
{
reserve(_size + len);//注意:当我们尾插一个字符串时是不建议扩2倍的_capacity的,
//因为2*_capacity的值可能还没有要尾插的字符串的长度len要大。所以这里建议使用reserve
//把空间扩成_size + len的大小。
}
//尾插字符串写法1:
//使用strcpy从下标_str + _size位置开始往后尾插1个字符串
strcpy(_str + _size, str);
//注意:由于我们使用strcpy来完成尾插字符串,而且strcpy会把源字符串终止符'\0'也拷贝给
//目标字符串,所以拷贝完成后我们无需手动在当前对象下标_size位置添加空字符 ('\0') 来表示字符串的结束。
//尾插字符串写法2:
//strcat(_str, str);//解析:char * strcat ( char * destination, const char * source )用于字符串拼接。它的功能是将源字符串source
//拷贝到目标字符串destination的末尾,并自动在拼接后的字符串末尾添加一个空字符 ('\0') 来表示字符串的结束。
//注意:这里不推荐使用strcat和strncpy来完成尾插字符串的操作。原因如下:
//(1)这里不推荐使用strcat而推荐使用strcpy完成尾插的原因:
//strcat需要找到目标字符串的终止符'\0'的位置并从该位置开始追加(即拷贝)源字符串,当目标字符串很长时会使得strcat的性能严重降低。相反,strcpy可以直接
//指定从目标字符串的终止符'\0'位置开始往后追加(即拷贝)源字符串。总的来说,strcpy不用找'\0'而strcat要找'\0'才导致strcat尾插字符串的性能比strcpy尾
//插字符串的性能低,所以我们才不用strcat完成尾插字符串的操作。
//(2)这里不推荐使用strncpy的原因是:尾插字符串时不需要指定尾插源字符串中的多少个字符到目标字符串中。
//尾插函数append是需要把整个源字符串尾插到目标字符串中,所以此时只需用strcpy完成即可,而不需要使用strncpy来完成。
//尾插完成后,更新当前对象存放有效数据个数_size的值
_size += len;
}
2.2.写法2
void append(const char* str)
{
insert(_size, str);//调用string& insert(size_t pos, const char* str)
}
3.string& operator+=(char ch),功能:尾插1个字符。
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
4.string& operator+=(const char* str),功能:尾插字符串。
string& operator+=(const char* str)
{
append(str);
return *this;
}
5.string& insert(size_t pos, char ch),功能:在任意位置插入1个字符。
5.1.错误代码
//错误代码
string& insert(size_t pos, char ch)
{
//断言:尾插前,判断插入位置的合法性
assert(pos <= _size);
//在尾插前,判断是否进行扩容
if (_size + 1 > _capacity)
{
reserve(2 * _capacity);//调用reserve扩2倍_capacity
}
//该挪动数据写法的问题:当pos = 0时程序会发生崩溃,而崩溃原因是死循环。而发生死循环的
//原因是:由于end是size_t类型,当end = -1时end就是一个很大的正数,则此时继续执行
//while (end >= pos)。总的来说,导致死循环的关键是size_t end = -1,所以只要保证循环条件中
//的end不能等于-1则当pos = 0时程序就不会发生死循环导致的程序崩溃问题。
//问题代码
//挪动数据:从后往前。
size_t end = _size;//end指向终止符'\0'。
while (end >= pos)//注意:由于'\0'是字符串结束标志,所以在挪动数据时一定不要把终止符'\0'覆盖
//掉,所以我们必须从下标_size(即终止符'\0'位置)开始从后往前挪动数据。
{
_str[end + 1] = _str[end];
--end;
}
//插入数据
_str[pos] = ch;
//插入成功后更新当前对象存放有效数据个数_size的值
++_size;
return *this;
}
(1)测试
(2)四种解决方法
string& insert(size_t pos, char ch)
{
//断言:尾插前,判断插入位置的合法性
assert(pos <= _size);
//在尾插前,判断是否进行扩容
if (_size + 1 > _capacity)
{
reserve(2 * _capacity);//调用reserve扩2倍_capacity
}
//挪动数据的4种解决方法
//解决方法1
size_t end = _size;//end指向终止符'\0'。
while (end >= pos)
{
_str[end + 1] = _str[end];
if (end == 0)
break;//当end为0时跳出循环,避免死循环
--end;
}
//解决方法2
//size_t end = _size;//end指向终止符'\0'。
//注意:下面的npos使用的是在bit命名空间域的string类内部声明的静态const成员变量。
//while (end >= pos && end != npos)//修改循环条件,增加end!= npos判断
//{
// _str[end + 1] = _str[end];
// --end;
//}
//解决方法3
//int end = (int)_size;//将end定义为int类型,并进行类型转换
//while (end >= (int)pos)//对pos也进行强制类型转换为int类型进行比较
//{
// _str[end + 1] = _str[end];
// --end;
//}
//解决方法4
//size_t end = _size + 1;//end指向字符串终止符'\0'的下一个位置。
//while (end > pos)//当size_t end = 0时退出while循环,从而防止while死循环的发生。
//{
// _str[end] = _str[end - 1];
// --end;
//}
//插入数据
_str[pos] = ch;
//插入成功后更新当前对象存放有效数据个数_size的值
++_size;
return *this;
}
①解决方法1
- 原理及实现方式:
在利用while (end >= pos)
循环进行挪动数据时,在循环内部代码end--
执行之前单独使用if(end == 0) break;
语句。这样做的目的是当end
的值减到0
时,直接跳出循环,避免出现因为size_t
类型无符号特性,使得原本期望的end
为负数(实际变成很大正数)而导致的死循环情况。
- 缺点:
代码的可读性较差。对于其他阅读代码的人来说,如果没有额外的注释详细解释,很难直观理解if(end == 0) break
这句代码在此处的具体作用以及解决的是什么潜在问题。代码的可维护性也会受到影响,后续若有其他人对这段代码进行修改或者扩展,容易忽略这条语句的特殊用途,从而引入新的错误。
②解决方法2
- 原理及实现方式:
把while (end >= pos)
写成while (end >= pos && end!= npos)
。这里利用了npos
这个特定的值(通常在string
类相关实现中表示一个特殊的 “无位置” 等类似含义,比如常用来表示查找失败等情况),通过增加end!= npos
这个条件限制,确保end
在满足大于等于pos
的同时,不会取到可能导致死循环的特殊值(比如在出现类似越界等情况导致其变成不符合预期的很大正数时),以此来避免死循环的发生。
③解决方法3
- 原理及实现方式:
在while
循环的循环条件中每次比较都强制类型转换一下,即把size_t end = _size; while (end >= pos)
写成int end = _size; while (end >= (int)pos)
。这样做是考虑到size_t
类型是无符号类型,在进行比较等操作时,其无符号特性可能导致不符合预期的结果(如end
为负数时实际变成很大正数)。通过将end
定义为int
类型,并对pos
进行强制类型转换为int
,使得比较操作按照有符号整数的规则进行,从而避免因为类型不一致导致的死循环问题。 - 缺点:
虽然这种方式在一定程度上解决了类型导致的死循环隐患,但也引入了新的潜在风险。因为强制类型转换可能会导致数据截断或者不符合预期的类型转换结果,尤其是当_size
的值很大超过int
类型所能表示的范围时,可能会出现数据错误。并且,这种写法使得代码依赖于特定的类型转换操作,如果后续代码结构或者相关变量的类型定义发生变化,可能需要重新审视和修改这部分类型转换逻辑,对代码的稳定性和可扩展性有一定影响。 - 注意:在运算符(操作符)的左右两边有两个变量,若是这两个变量的类型不一样就会发生整形提升或者类型转换,而有符号会转换为无符号。
- 在 C 和 C++ 等编程语言中,当运算符(操作符)左右两边出现两个不同类型的变量参与运算时,编译器会按照特定的规则进行类型转换操作,这其中涉及到整形提升以及不同类型间的转换情况。整形提升:小于 int 类型的整数(如 char、short)参与运算时,常先转换为 int 或 unsigned int 类型。不同类型间转换:遵循算术转换规则,像按 long double、double、float 优先级进行类型统一,整数运算时若一边有符号一边无符号,常将有符号转为无符号且取范围大的类型;赋值操作中也会按规则把表达式类型转为变量类型进行赋值。
④解决方法4
核心思路是当 pos = 0
时只要防止 while
循环条件中的 end = -1
才能避免发生死循环。
5.2.正确代码
5.2.1.思路
(1)整体思路
- 首先要检查插入位置
pos
的合法性,确保其在当前字符串有效字符范围之内(通过断言来实现)。 - 判断插入字符后当前字符串的长度是否会超出已分配的容量,如果超出则需要对字符串的存储空间进行扩容操作,以容纳新插入的字符以及原有的字符。
- 将插入位置及之后的现有字符依次向后挪动一位,为要插入的字符腾出位置,这个挪动过程要注意避免覆盖字符串结束标志
\0
。 - 在腾出的位置(也就是
pos
位置)插入指定的字符ch
。 - 最后更新字符串中有效字符的数量(
_size
),并返回当前对象自身(支持链式调用)。
(2)思路步骤
- 检查插入位置合法性(通过断言实现):
使用assert
宏来确保传入的插入位置pos
是合法的,即pos
要小于等于当前字符串中有效字符的数量_size
。如果pos
超出这个范围,说明插入位置不合理,程序会直接报错并终止(在调试版本中,发布版本会忽略断言),这一步骤是为了避免后续操作出现越界等错误情况。 - 判断是否需要扩容:
比较插入字符后字符串预期的长度(_size + 1
,因为插入了一个新字符)和当前字符串的容量_capacity
。如果预期长度大于容量,意味着现有的存储空间不够存放插入新字符后的字符串了,此时需要调用reserve
函数来扩容。这里的reserve
函数应该是预先定义好的用于扩展字符串容量的函数,它会按照一定策略(此处是扩大为原来容量的 2 倍)来分配新的足够的内存空间,并将原字符串内容复制到新空间中。 - 挪动数据:
这是比较关键的步骤,其目的是为插入的字符腾出位置。从字符串末尾开始,依次将每个字符向后移动一位,直到挪动到插入位置pos
为止。挪动顺序是从后往前,这样可以避免覆盖还未挪动的数据,而且特别要注意先挪动字符串结束标志\0
,以确保字符串结构的完整性,挪动过程通过一个while
循环来实现,循环条件控制着挪动的范围和结束时机。 - 插入数据:
在腾出的pos
位置上,将传入的字符ch
赋值给_str[pos]
,这样就完成了字符的插入操作。 - 更新有效字符数量并返回对象自身:
由于插入了一个新字符,所以要将记录字符串有效字符数量的_size
加 1,以反映字符串当前的实际长度。最后返回*this
,使得该函数可以支持链式调用,比如可以连续地调用多个insert
函数或者和其他字符串操作函数链式使用。
5.2.2.代码实现
//正确代码
//在下标pos位置插入1个字符
//注意:由于std库提供的insert函数的参数pos的类型是size_t,所以我们自定义实现Insert函数时参数pos也应该保持是size_t类型。
//总的来说,下标pos都喜欢用size_t(无符号)充当类型。
string& insert(size_t pos, char ch)
{
//断言:尾插前,判断插入位置的合法性
assert(pos <= _size);
//在尾插前,判断是否进行扩容
if (_size + 1 > _capacity)//若尾插后,当前对象存放有效数据个数_size + 1大于当前对象的容量_capacity,则进行扩容。
{
reserve(2 * _capacity);//调用reserve扩2倍_capacity
}
//挪动数据
size_t end = _size + 1;//end指向字符串终止符'\0'的下一个位置。
//注意:由于'\0'是字符串结束标志,所以在挪动数据时一定不要把终止
//符'\0'覆盖掉,所以我们从后往前挪动数据时一定是先挪动终止符'\0'的。
//注:while(end > pos +len - 1),由于len = 1 则while(end > pos + 1 - 1) 等价于 while (end > pos)。
while (end > pos)//当size_t end = 0时退出while循环,从而防止while死循环的发生。
{
_str[end] = _str[end - 1];
--end;
}
//插入数据
_str[pos] = ch;
//插入成功后更新当前对象存放有效数据个数_size的值
++_size;
return *this;
}
6.string& insert(size_t pos, const char* str),功能:在任意位置插入字符串。
6.1.错误代码
//错误代码
string& insert(size_t pos, const char* str)
{
assert(pos <= _size);
size_t len = strlen(str);
if (_size + len > _capacity)
{
reserve(_size + len);
}
//问题代码
//挪动数据(注:该挪动数据的方法对于pos = 0会出现b死循环)
size_t end = _size;
while (end >= pos)
{
_str[end + len] = _str[end];
end--;
}
//解析:在这段代码里的 while (end >= pos) 循环中,当 pos 取值为 0 时,如果按照循环的
//逻辑一直执行下去,end 会不断自减,最终会变成一个无符号类型的很大的值(因为 size_t 是
//无符号整数类型,无符号数的运算特性使得它从 0 减 1 会变成其所能表示的最大值),进而导
//致 while 循环的条件 end >= pos 一直为真,出现死循环的情况。
strncpy(_str + pos, str, len);
_size += len;
return *this;
}
(1)四种解决方法
string& insert(size_t pos, const char* str)
{
assert(pos <= _size);
size_t len = strlen(str);
if (_size + len > _capacity)
{
reserve(_size + len);
}
//挪动数据的4种解决方法
//解决方法1
size_t end = _size;//end指向终止符'\0'。
while (end >= pos)
{
_str[end + len] = _str[end];
if (end == 0)
break;//当end为0时跳出循环,避免死循环
--end;
}
//解决方法2
//size_t end = _size;//end指向终止符'\0'。
//注意:下面的npos使用的是在bit命名空间域的string类内部声明的静态const成员变量。
//while (end >= pos && end != npos)//修改循环条件,增加end!= npos判断
//{
// _str[end + len] = _str[end];
// --end;
//}
//解决方法3
//int end = (int)_size;//将end定义为int类型,并进行类型转换
//while (end >= (int)pos)//对pos也进行强制类型转换为int类型进行比较
//{
// _str[end + len] = _str[end];
// --end;
//}
//解决方法4
//挪动数据
size_t end = _size + len;
//从后往前挪动数据
while (end > pos + len - 1)
//或者写成while(end >= pos + len),但是不建议写成这样,因为while(end >= pos + len)
//循环会遇到pos = len = 0这种极端情况从而导致死循环。
{
_str[end] = _str[end - len];
--end;
}
//插入字符串
strncpy(_str + pos, str, len);
_size += len;
return *this;
}
注意:建议采用解决方法4。
6.2.正确代码
6.2.1.思路
(1)整体思路
- 首先要验证插入位置
pos
的合法性,确保其处于当前字符串有效字符范围之内,通过断言语句来进行检查,避免后续操作出现越界等错误情况。 - 计算要插入的字符串的长度,根据插入后的预期长度(原字符串长度加上插入字符串长度)与当前字符串容量进行对比,判断是否需要对字符串的存储空间进行扩容,以确保有足够空间容纳插入后的完整字符串。
- 将插入位置及其之后的现有字符依次向后挪动相应的长度,为要插入的字符串腾出足够的空间,挪动过程要特别注意避免出现死循环等异常情况,且要保证字符串结构的完整性。
- 使用合适的字符串拷贝函数(这里选用
strncpy
)将待插入的字符串拷贝到腾出的位置上。 - 最后更新字符串中有效字符的数量(
_size
),并返回当前对象自身,方便支持链式调用等操作。
(2)思路步骤
- 检查插入位置合法性(通过断言实现):
利用assert
宏来确认传入的插入位置pos
是合理的,即pos
要小于等于当前字符串中有效字符的数量_size
。若pos
超出此范围,意味着插入位置不符合逻辑,程序在调试版本下会直接报错并终止,以此保证后续操作基于合法的位置前提。 - 计算插入字符串长度并判断是否扩容:
使用strlen
函数获取要插入的字符串str
的长度,记为len
。接着比较插入字符串后整个字符串预期的长度(原字符串有效字符数量_size
加上插入字符串长度len
)与当前字符串的容量_capacity
。如果预期长度大于容量,说明现有存储空间不足以存放插入后的字符串,此时需要调用reserve
函数进行扩容,确保有足够空间来容纳后续插入的字符串以及原有的字符内容。 - 挪动数据:
这是核心步骤之一,目的是为插入的字符串腾出连续的空间。从字符串末尾开始,按照一定的规则将现有字符向后移动相应的长度,挪动顺序是从后往前,这样能避免覆盖还未挪动的数据,且要防止出现死循环等错误情况,通过一个while
循环来控制挪动的范围和结束时机。 - 拷贝插入数据:
使用strncpy
函数将待插入的字符串str
拷贝到已经腾出的位置(从_str + pos
开始的位置)上,拷贝的长度为要插入字符串的长度len
。之所以选择strncpy
而不是strcpy
,是因为strcpy
会把字符串结束标志\0
一起拷贝到目标位置,可能会破坏当前字符串对象的结构,而strncpy
可以按照指定长度进行拷贝,避免这个问题。 - 更新有效字符数量并返回对象自身:
由于插入了长度为len
的字符串,所以需要将记录字符串有效字符数量的_size
增加len
,以准确反映插入操作后字符串的实际长度。最后返回*this
,便于实现链式调用,比如连续多次调用insert
函数或者和其他字符串操作函数连贯使用。
6.2.2.代码实现
//正确代码
//在pos位置插入字符串
string& insert(size_t pos, const char* str)
{
//断言:尾插前,判断插入位置的合法性
assert(pos <= _size);
//统计要插入字符串的长度,以便于计算要扩容的大小
size_t len = strlen(str);
//在尾插前,判断是否进行扩容
if (_size + len > _capacity)//若尾插后,当前对象存放有效数据个数_size + len
//大于当前对象的容量_capacity,则进行扩容。
{
reserve(_size + len);//调用reserve进行扩容
}
//挪动数据
size_t end = _size + len;
//从后往前挪动数据
while (end > pos + len - 1)
//或者写成while(end >= pos + len),但是不建议写成这样,因为while(end >= pos + len)
//循环会遇到pos = len = 0这种极端情况从而导致死循环。
{
_str[end] = _str[end - len];
--end;
}
//利用strncpy来拷贝插入数据
strncpy(_str + pos, str, len);//注意:由于strcpy会把终止符'\0'拷贝到目标字符中,所以
//只能使用strncpy来完成在pos位置插入字符串而且不会把
//终止符'\0'插入当前对象中。
//插入成功后更新当前对象存放有效数据个数_size的值
_size += len;
return *this;
}
(1)说明不建议把while (end > pos + len - 1)写成while(end >= pos + len)的原因
避免死循环情况:
如果写成 while (end >= pos + len)
,在极端情况下,当 pos
和 len
都等于 0
时就会出现死循环。因为初始时 end
的值也是根据 _size + len
来确定的,若 pos
和 len
都是 0
,那么 end
初始值也可能是 0
(例如原字符串为空字符串时,_size
为 0
,加上 len
为 0
,end
就是 0
),此时循环条件 end >= pos + len
(也就是 0 >= 0
)会一直满足,循环就无法停止,导致程序陷入死循环,这显然不符合预期且会造成程序崩溃等问题。
而使用 end > pos + len - 1
这个条件,即使在 pos
和 len
都为 0
的极端情况下,pos + len - 1
的值为 -1
(在无符号类型 size_t
的运算规则下,实际结果是一个很大的正值,因为无符号类型不存在负数表示),而 end
(初始值至少为 0
)必然 小于 无符号的 -1 这个值,循环会正常结束,避免了死循环这种异常情况的发生,保证了程序逻辑的正确性。
删除函数
erase
函数支持两种删除模式:
- 全删模式:从指定位置
pos
开始删除到字符串末尾的所有字符。 - 部分删除模式:从
pos
开始删除指定长度len
的字符。
//erase功能:用于删除从指定位置pos开始往后的len个字符,但是也不一定是删除len个数据,
//因为erase存在两种情况:全删、部分删除。
//注:如果len为默认值npos,则删除从pos位置到字符串末尾的所有字符。
string& erase(size_t pos, size_t len = npos)
{
//断言,确保传入的起始位置pos在当前字符串有效长度范围之内,避免越界访问。
//因为如果pos超出了有效长度范围(大于等于_size),后续操作会访问到非法内存区域,
//可能导致程序出错甚至崩溃,通过assert可以在开发阶段尽早发现这类错误,保障程序
//正常执行。
assert(pos < _size);
//判断要删除长度的2种情况:
//情况1:全删,从pos位置开始往后的所有有效数据都删除。
//全删思路:直接将字符串 pos 位置的字符设置为字符串结束符 '\0',相当于截断了
//后面的数据,然后把字符串的有效长度 _size 更新为 pos,表示从开始到 pos 位置
//是当前字符串的有效部分(注:有效部分不包括 pos 位置原本的数据了,因为已经设
//置为 '\0')。
//解析:这里 if 语句判断条件中,必须写上 len == npos 这一条件。原因在于,
//通常 npos 被定义为整型最大值,当 len 取值为 npos 时,在判断条件里的
//pos + len >= _size 这一表达式进行计算就会出现溢出情况。所谓溢出,就是
//pos + len这一表达式的计算结果超出了整型能够表示的最大范围,进而导致异常即
//pos + len 的实际计算结果出现 “绕回” 现象,变成了等同于 pos + len = pos < _size
//的情况。原本按正常逻辑应该是要执行 if 语句的全删操作,但由于上述异常,却会
//错误地执行 else 语句的部分删除操作,这与 len为 npos 时应执行全删操作的
//预期不符。所以,为了准确区分全删(即 len 为 npos 时)和部分删除这两种情况,
//确保逻辑的正确性,在 if 语句的判断条件中就必须明确写上 len == npos 这一条件。
if (len == npos || pos + len >= _size)
{
//将指定位置pos处的字符设为'\0',以此截断字符串,相当于把pos之后的字符
//都“删除”了。因为在C风格字符串中,'\0' 标志着字符串的结束,将此处设为 '\0' 后,
//后续对字符串的处理就会认为到这里就结束了,达到截断效果。
_str[pos] = '\0';
//更新字符串的有效长度为pos,即表示现在字符串只包含从开头到pos位置的字符了。
//样做是为了准确反映经过全删操作后字符串实际包含的有效字符数量,便于后续其他
//与字符串长度相关操作的正确执行。
_size = pos;
}
else//情况2:部分删除,从pos位置开始往后len长度的有效数据都删除。
{
//利用strcpy把从pos + len位置开始的字符串内容(包含结束符'\0')拷贝到pos位置
//开始处,这样就覆盖了从pos位置开始长度为len的字符,实现了删除中间len长度字符的
//效果。strcpy函数会从源地址(_str + pos + len)开始逐个字符拷贝,直到遇到源
//字符串的结束符 '\0',然后将拷贝的内容覆盖到目标地址(_str + pos)开始的
//内存区域。
strcpy(_str + pos, _str + pos + len);
//注意:当需要将一块内存中的数据拷贝到另一块内存区域,并且可能存在源内存区域和目标
//内存区域有重叠的情况,为了确保能正确拷贝数据且防止丢失源字符串数据,则可以使用
//memmove函数。memmove函数会处理好源和目标内存重叠的情况,保证数据拷贝的完整性和准
//确性,而如果使用memcpy函数在这种重叠情况下可能会导致未定义行为和数据丢失等问题。
//但是这里strcpy是从前往后覆盖不会造成源内存区域的数据丢失,所以这里就不必使用
//memmove完成自己拷贝自己。
//更新字符串的有效长度,减去刚刚删除的长度len。由于已经删除了长度为len的一段字符,
//所以要对字符串的有效长度进行相应调整,确保_size能准确表示当前字符串实际剩余的
//有效字符数量。
_size -= len;
}
//函数最后返回当前对象的引用(*this),这样的设计使得该函数能够支持连续调用的操作方式。
//例如,可以像 s.erase(1).insert(2, "abc"); 这样在一次调用erase函数之后紧接着调用其他
//成员函数,符合string类操作方便、灵活的使用习惯,便于对字符串进行一系列连贯的操作。
return *this;
}
查找函数find
1.size_t find(char ch, size_t pos = 0),功能:查找单个字符
(1)代码实现思路
该函数用于在字符串中从指定位置 pos
开始查找给定的单个字符 ch
。其基本思路是通过遍历字符串中从 pos
开始到字符串末尾的每个字符,逐个与要查找的字符 ch
进行比较,一旦找到匹配的字符,就返回该字符在字符串中的位置索引;如果遍历完剩余部分都没有找到匹配字符,则返回 npos
表示未找到。
(2)代码实现
size_t find(char ch, size_t pos = 0)
{
//断言:确保传入的起始查找位置pos在字符串有效长度范围内,避免越界访问。
//如果 pos 超出有效范围(大于等于 _size),后续对字符串的访问就可能出
//现越界等错误情况,使用断言可以在开发阶段尽早发现这类问题,保证程序正常运行。
assert(pos < _size);
//从pos位置开始遍历字符串,直到字符串末尾
for (size_t i = pos; i < _size; ++i)
{
//检查当前位置的字符是否与要查找的字符ch相等
if (_str[i] == ch)
{
//如果相等,返回当前位置索引,表示找到了该字符
return i;
}
}
//如果遍历完都没找到,返回npos,表示未找到
return npos;
}
2.size_t find(const char* str, size_t pos = 0),功能:查找子字符串
(1)代码实现思路
这个重载的 find
函数用于在字符串中从指定位置 pos
开始查找给定的子字符串 str
。它采用了 strstr
函数来进行查找操作。思路是让 strstr
函数在原字符串从 pos
开始的部分中查找子字符串 str
,如果找到了,就根据找到的子字符串在原字符串中的位置返回相应的索引;若没找到,则返回 npos
表示未找到该子字符串。
(2)代码实现
size_t find(const char* str, size_t pos = 0)
{
//断言:确保传入的起始查找位置pos在字符串有效长度范围内,避免越界访问。
assert(pos < _size);
//使用strstr函数在从原字符串pos位置开始的部分查找子字符串str。strstr函数会
//返回指向找到的子字符串在原字符串中首次出现位置的指针,若没找到则返回nullptr。
char* p = strstr(_str + pos, str);
if (p == nullptr)
{
//如果没找到子字符串,返回npos,表示未找到
return npos;
}
else
{
//如果找到了,通过指针相减计算出子字符串在原字符串中的位置索引并返回。
//这里的计算原理是利用指针相减得到两个指针所指向内存位置之间的偏移量
//(以元素个数为单位,在这里也就是字符个数,刚好对应字符串中的位置索引)。
return p - _str;
}
}
(3)strstr的模拟实现
①代码实现思路
这个my_strstr函数的主要实现思路是通过两层循环来模拟在一个字符串中查找另一个子串的过程。外层循环用于遍历主字符串(str1
)的每一个字符位置,作为可能的子串起始位置;内层循环则从外层循环确定的每个起始位置开始,逐个字符地与要查找的子串(str2
)进行比较,判断是否能完整匹配子串,如果能匹配则返回相应位置指针,如果遍历完整个主字符串都没找到匹配的子串,就返回 NULL
。
②代码实现
//暴力查找。
//该函数的时间复杂度为O(n * m) ,其中n是主字符串 str1 的长度,m是子串 str2 的长度。
//自定义的字符串查找函数my_strstr,功能类似于标准库中的strstr函数。
//my_strstr功能:用于在字符串str1中查找子串str2,如果找到则返回子串
//在str1中首次出现的位置指针,找不到返回NULL。
char* my_strstr(const char* str1, const char* str2)
{
//s1用于在str1中逐个字符比较的指针,初始化为str1。
const char* s1 = str1;
//s2用于在str2中逐个字符比较的指针,初始化为str2。
const char* s2 = str2;
//p用于遍历str1的指针,初始化为str1。
const char* p = str1;
//外层循环,只要p指向的字符不是字符串结束符'\0',就持续遍历str1。
while (*p)
{
//每次开始新一轮查找时,让s1指向当前p所指向的位置,准备从这里开始和子串比较。
s1 = p;
//每次开始新一轮查找时,让s2重新指向子串str2的开头,准备进行字符比较。
s2 = str2;
//内层循环,用于逐个字符比较s1和s2。只要s1和s2指向的字符都不是'\0'且
//字符相等,就继续比较下一个字符。
while (*s1 != '\0' && *s2 != '\0' && (*s1 == *s2))
{
s1++;
s2++;
}
//如果s2指向的字符到达了'\0',说明已经完整匹配了子串str2。此时返回p,
//也就是子串在str1中首次出现的位置指针。
if (*s2 == '\0')
{
return p; //找到了
}
//如果内层循环结束没有找到子串,将p指向下一个字符位置,继续下一轮查找。
p++;
}
//外层循环结束后,如果始终没有找到子串,返回NULL表示未找到。
return NULL; //找不到子串。
}
③时间复杂度分析
该函数的时间复杂度为 O(n * m),其中 n 是主字符串 str1
的长度, m 是子串 str2
的长度,分析如下:
- 最好情况:最好的情况是在主字符串的开头就找到了子串,只需要进行 m 次比较( m为子串长度),时间复杂度可以看作是 O(m) ,这种情况效率较高,但它只是一种特殊的最优情况。
- 最坏情况:假设每次在主字符串中匹配子串时,都是匹配到子串的最后一个字符才发现不匹配,然后主字符串的匹配起始位置往后移一位继续匹配。例如主字符串是 “aaaaaaaab”(长度为 n),子串是 “aaab”(长度为 m),对于主字符串中的每个位置都要尝试去匹配一次子串,每次匹配子串最多需要比较 m 次,总共需要比较的次数大约就是 n * m 次,所以在最坏情况下时间复杂度就是 O(n * m)。
- 平均情况:在平均情况下,通过概率分析等手段也可以推导出其时间复杂度依然是接近 O(n * m)的量级。
综上所述,该 my_strstr
函数整体的时间复杂度为 O(n * m),,相比一些更高效的字符串匹配算法(如 KMP 算法时间复杂度 O(n + m)),在处理较长的主字符串和子串时效率会相对较低。
交换函数swap
1.代码实现思路
swap
函数的目的是高效地交换两个string
对象的内部状态(注:内部状态指的是成员变量)。通过直接交换它们对应的关键成员变量(字符数组指针、容量和实际长度)来实现字符串对象内容的互换,这样可以避免进行大量的字符逐个拷贝等复杂操作,达到快速交换两个string
对象的效果。
2.代码实现
//自定义的swap函数,用于交换两个string对象的内部状态。
void swap(string& s)
{
//注意:下面调用的是std库提供的swap函数,需要明确指定std命名空间,
//避免与自定义的string类成员函数swap产生命名冲突,否则会导致编译报错。
//调用std库中的swap函数交换两个string对象的字符数组指针,使得两个对象指向对方原
//来所指向的字符数组,这样就完成了字符串底层数据存储部分的交换。
std::swap(_str, s._str);
//调用std库中的swap函数交换两个string对象的容量值,使得每个对象能正确知晓自己所
//管理的字符串容量情况。
std::swap(_capacity, s._capacity);
//调用std库中的swap函数交换两个string对象的字符串实际长度值,以便后续操作能获取
//正确的长度信息。
std::swap(_size, s._size);
}
3.在 C++ 中,对比 std
库的 swap
函数(swap(s1, s2)
形式)和 string
类成员函数 swap
(s1.swap(s2)
形式)那个更加高效?
swap - C++ Reference (cplusplus.com)
- 当使用
swap(s1, s2)
(调用 std 库的 swap 函数)时,其函数体内部会先调用深拷贝的拷贝构造函数创建string类的临时对象,然后再调用深拷贝的赋值重载函数来执行临时对象、s1、s2 这3者间的赋值操作进而完成两个string类对象s1与s2的交换。但是深拷贝的拷贝构造函数和赋值重载函数的调用操作是比较耗时的。尤其是当string类对象所管理的字符串较长时,深拷贝的拷贝构造函数和赋值重载函数会涉及大量字符数据的复制等复杂操作,这无疑会显著降低程序的执行效率。 - 当使用
s1.swap(s2)
(调用 string 类的成员函数swap)时,成员函数swap的工作方式是调用std库提供的swap函数来交换两个string对象s1、s2的内置类型成员变量(指针_str、容量_capacity、长度_size等)的值来完成两个string类对象s1与s2的交换。这样就避免调用拷贝构造和赋值重载函数来完成 std 库的 swap 函对自定义类型的string类对象直接的交换,string 类的成员函数swap只是简单地交换几个整型或者指针类型的成员变量的值,相对开销小很多,所以效率更高。 - 综上所述,string 类的成员函数 swap(采用
s1.swap(s2)
这种调用形式)要比调用 std 库的 swap 函数更加高效。
总结:
-
对于
swap(s1, s2)
,其内部先通过深拷贝的拷贝构造函数创建string
类临时对象,接着用深拷贝的赋值重载函数和借助临时对象并通过赋值操作来完成s1
与s2的
交换。当字符串较长时,深拷贝相关操作(拷贝构造、赋值重载)涉及大量字符数据复制极为耗时,会致使程序效率降低。 -
而
s1.swap(s2)
是借助std
库的swap
函数交换string
对象的内置类型成员变量(如_str
、_capacity
、_size
等)的值来实现s1
与s2的
交换,避免了拷贝构造和赋值重载函数调用,开销小,效率更高。
清空函数clear
clear函数的功能介绍:在 C++ 的 string 类中,clear 函数主要功能是清空字符串中的内容,即将字符串长度设置为 0,使字符串变为空字符串,如同将容器中的物品全部倒空一般,因此可形象地称为 “清空函数”,其核心作用在于清除 string 对象所存储的字符序列,使字符串长度归零。
void clear()
{
_str[0] = '\0';
_size = 0;
}
流插入、流提取(全局函数)
流插入重载函数并不一定需要声明为友元函数才能访问string
类私有的成员变量。因为我们可以借助string
类公有的成员函数来间接访问私有的成员变量。例如,通过string
类提供的范围for
、迭代器、operator[]
等接口,就能够访问string
类中由私有成员变量指针_str
管理的字符数组中的元素。同时,为了遵循 C++ 的使用习惯,流插入和流提取重载函数通常被定义为全局函数,而非成员函数。这样在使用cout << s
(s
为自定义string
类对象)时,更符合我们日常的代码书写习惯。
1.流插入函数ostream& operator<<(ostream& out, const string& s)
注意,流插入重载函数是按照字符串对象中有效数据个数_size
的值来打印当前字符串,而不是像普通 C 字符串那样按照终止符'\0'
来打印。这是因为自定义string
类中可能存在有效字符后跟着其他未初始化内存的情况,若按'\0'
打印可能会输出多余的乱码内容,按_size
打印能确保只输出有效的字符串内容。
(1)流插入函数的3种写法
①写法1:operator[]
//写法1:调用operator[]
ostream& operator<<(ostream& out, const string& s)
{
for (size_t i = 0; i < s.size(); ++i)
{
out << s[i];
}
return out;
}
②写法2:范围for
//写法2:范围for
ostream& operator<<(ostream& out, const string& s)
{
for (auto ch : s)
{
out << ch;
}
return out;
}
③写法3:迭代器
//写法3:迭代器
ostream& operator<<(ostream& out, const string& s)
{
string::const_iterator it = s.begin();
while (it != s.end())
{
out << *it;
it++;
}
return out;
}
(2)错误写法
ostream& operator<<(ostream& out, const string& s)
{
out << s.c_str();
return out;
}
2.流提取函数istream& operator>>(istream& in, string& s)
1.流提取函数operator>>实现过程分析
(1)流提取函数需要解决的几个问题及相应解决方式
- 问题 1:读取终止问题
- 问题描述:在自定义实现
operator>>
函数时,需要模拟标准库中该operator>>
函数遇到空格或换行符就结束读取的功能。然而,直接使用in >> ch
时,由于cin
等输入流对象无法识别缓冲区中的空格或换行符作为读取结束的标志,在while
循环中使用in >> ch
读取字符时,就无法从缓冲区中读取到空格或换行符来正常终止循环,从而导致死循环。这是因为C/C++
规定,cin
或scanf
在从缓冲区读取数据时,不会将缓冲区中数据和数据之间的空格或换行符作为读取内容,而是将其视为数据间隔,导致无法读取到这些字符来终止读取操作。 - 解决方式:为了解决这个问题,可以使用 C++ 的
istream
类成员函数get()
来读取包括空格字符或换行符在内的任意字符。get()
函数能够准确读取缓冲区中的每一个字符,从而可以根据读取到的空格或换行符来正常终止读取循环。不建议使用 C 语言的getchar()
函数,因为 C 语言和 C++ 的缓冲机制不同,C 语言的缓冲机制效率较低。
- 问题描述:在自定义实现
- 问题 2:旧数据清理问题
- 问题描述:当流提取函数
operator>>
的右操作数是非空的string
类对象s
时,如果不先清理s
中的旧数据,在对s
进行流提取操作后打印s
,可能会打印出随机值。这是因为旧数据仍然存在于s
中,新读取的数据会与旧数据混合,导致输出结果不准确。 - 解决方式:无论
operator>>
的右操作数s
是空还是非空的string
类对象,在进行流提取操作之前,都应先调用s
的clear()
成员函数来清理旧数据,确保s
中只包含新读取的数据。
- 问题描述:当流提取函数
- 问题 3:频繁扩容问题
- 问题描述:当使用
operator>>
执行流提取操作,且插入的字符串很长时,自定义实现的operator>>
函数中频繁使用s += ch
进行字符插入,会导致string
对象频繁扩容。每次扩容都涉及内存的重新分配和数据的拷贝,这会严重降低代码性能。 - 解决方式:定义一个静态字符数组
buffer
(例如大小为 128 字节)来提前存放从输入端输入的字符串。在读取字符时,先将字符存入buffer
数组中,当buffer
数组满(达到其最大索引,如 127)或者遇到空格或换行符时,再将buffer
中的字符串一次性通过operator+=(const char* str)
添加到string
对象s
中。这样可以减少string
对象的扩容次数,提高性能。同时,静态数组在出了作用域后会自动销毁,无需手动管理内存释放。
- 问题描述:当使用
(2)流提取函数operator>>代码实现过程
在模拟实现string
类时,流提取函数operator>>
的实现会遇到一些问题。下面将按照逐步解决问题的思路,对代码进行整理和讲解。
①初始代码及出现的问题
在实现自定义string
类的流提取函数istream& operator>>(istream& in, string& s)
时,最初可能会写出如下代码:
istream& operator>>(istream& in, string& s)
{
char ch;
in >> ch;
while (ch != ' ' && ch != '\n')
{
s += ch;
in >> ch;
}
return in;
}
这段代码看似可以从输入流中读取字符并添加到string
对象s
中,但存在以下问题:
- 问题 1:
cin
或scanf
在读取缓冲区数据时,将空格和换行符视为数据间隔,不会读取它们。这就导致in >> ch
无法读取到缓冲区中的空格或换行符,使得while
循环无法正常终止,流提取操作会持续进行。 - 问题 2:如果在流提取操作前未清空string类对象s中的旧数据,且插入数据操作s += ch时根据调试发现没有在字符串尾部添加字符串终止符'\0'(注意:插入函数的实现没有错误,这个问题应该出现在流提取函数实现上),那么在使用cout打印s时,可能会输出随机值。
- 问题 3:当插入的字符串很长时,频繁使用
s += ch
会导致string
对象频繁扩容,不仅浪费空间,还会降低代码性能。
②问题 1 的解决方案
注意:C/C++规定,当使用scanf或者cin在输入端持续读取多个数据时,在输入端中数据和数据
之间必须有空格字符 或者 换行字符来区分多个数据,而数据和数据之间的多个空格字符 或者
换行符只是数据和数据之间的间隔。
为了解决问题 1,我们需要一种能够读取包括空格和换行符在内的任意字符的方法。istream
类的get()
成员函数可以满足这个需求,修改后的代码如下:
istream& operator>>(istream& in, string& s)
{
//注意:这里不能使用C++的函数接口getline(),因为getline()只有遇到换行符'\n'
//才会结束读取,使得getline不符合流提取operator>>函数在遇到空格字符 或者 换行符
//就会结束流提取操作。
char ch = in.get();//get()可以读取包括空格字符 或者 换行符在内的任意字符。
//所以这里使用get()函数来解决in >> ch无法读取空格字符
//或者 换行符导致while循环无法正常终止的问题。
//注意:get()函数是istream类的成员函数。
while (ch != ' ' && ch != '\n')
{
s += ch;
ch = in.get();
}
return in;
}
通过使用get()
函数,每次读取一个字符,无论是普通字符、空格还是换行符,都能被正确读取,从而使while
循环可以根据空格或换行符正常终止。
③问题 2 的解决方案
为了解决问题 2,在对流提取操作之前,需要先清理string
类对象s
中的旧数据,可通过调用s.clear()
函数来实现,修改后的代码如下:
istream& operator>>(istream& in, string& s)
{
//问题2的解决方式:
s.clear();//在对string类对象s进行流提取操作之前,不管对象s是空还是非空,
//都用clear函数清理string类对象s的旧数据。
char ch = in.get();
while (ch != ' ' && ch != '\n')
{
s += ch;
ch = in.get();
}
return in;
}
这样在每次流提取操作前,s
中的旧数据都会被清除,避免了打印时出现随机值的问题。
④问题 3 的解决方案
为了解决问题 3,避免频繁扩容带来的性能问题,我们定义一个静态字符数组buff
来提前存放从输入端输入的字符串,当满足一定条件时,再一次性将数组中的字符串添加到string
对象s
中,最终的代码如下:
istream& operator>>(istream& in, string& s)
{
s.clear();//在执行流提取操作之前,清理掉旧数据
char ch = in.get();//get()函数可读取包括空格字符 或者 换行符在内的任意字符。
//注意:这个静态字符数组buffer的大小是自己定义的,所以这里就定义为128字节大小。
char buff[128];//问题3的解决方式:定义一个静态字符数组来提前存放从输入端输入的
//字符串,这样当我们在当前对象s插入数据时不是使用operator+=进行
//1个数据1个数据进行插入进而导致插入多个数据时会调用多次
//string& operator+=(char ch)会导致频繁扩容,而是使用
//operator+=给当前对象s直接插入1个字符串进而只会调用1次
//string& operator+=(const char* str)进而即使存在扩容也只会
//扩容1次进而避免频繁扩容带来的代码性能下降的问题。
//注意:静态数组的好处是出了作用域之后就会自动销毁。
size_t i = 0;
//注:cin遇到空格或者换行就会结束读取。
//operator>>流提取函数只有遇到空格字符 或者 换行符才会结束流提取操作。
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
//buffer每满一次就往当前string类对象s尾端加数据。即当下标i = 127时说明此时
//静态字符数组buffer已经存放除了空格字符和换行符以外的127个字符了,同时也
//说明此时静态字符数组buffer已经已经满了,则此时就把静态字符数组中存放的字符串
//尾插到前string类对象s中。
//注意:这个if语句的目的是字符数组buff满了之后就把字符数组中存放的字符串加
//载到当前string类对象中。
if (i == 127)
{
buff[127] = '\0';
//在当前string类对象s的尾端插入存放在静态字符数组buff中从输入端
//输入的字符串.
s += buff;
i = 0;//重置
}
ch = in.get();
}
//最后,若静态字符数组buffer没有满,也是往当前string类对象s尾端加数据。
//注意:这个if语句的作用是:防止while循环结束后,字符数组buff中还有数据没有
//加载到当前string类对象s中。
if (i != 0)
{
buff[i] = '\0';
//在当前string类对象s的尾端插入存放在静态字符数组buff中从输入端输入的字符串
s += buff;
}
return in;
}
通过这种方式,减少了string
对象的扩容次数,提高了代码性能。同时,在循环结束后,也确保了静态字符数组中剩余的字符能够正确添加到string
对象中。
2.流提取函数operator>>代码
istream& operator>>(istream& in, string& s)
{
//先清空传入的string对象s,确保它之前存储的内容被清除,准备接收新输入的数据.
s.clear(); //在执行流提取操作之前,清理s中的旧数据.
//从输入流in中获取一个字符,这里的ch将用于逐个读取输入的字符来构建字符串.
char ch = in.get();//使用get()函数读取包括空格或换行符在内的任意字符.
//定义一个大小为128的静态字符数组buff,用于临时存储从输入流中读取到的字符,
//之所以选择合适的固定大小(这里是128),是为了避免频繁动态内存分配,同时
//又能一次处理一定长度的字符序列,避免逐个字符操作带来的效率问题,后续会将
//这个临时数组中的内容添加到自定义的string类对象s中.
char buff[128];
size_t i = 0; //用于记录当前已经往buff数组中存入了多少个字符,作为数组的索引.
//只要读取到的字符不是空格也不是换行符,就将该字符存入buff数组中.
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
//当buff数组快要存满(已经存入了127个字符,因为索引从0开始计数)时,
//需要将buff中的内容添加到string对象s中,并重置索引i为0,准备继续往
//buff中存储后续字符.
if (i == 127)
{
buff[127] = '\0'; //手动添加字符串结束标志'\0',使其成为合法的C字符串.
s += buff;//将buff中的字符串添加到自定义的string对象s中。利用“+=”
//操作符来调用operator+=成员函数来用于拼接字符串.
i = 0;//重置索引,准备继续存储后续字符.
}
ch = in.get(); //继续从输入流中读取下一个字符.
}
//如果循环结束后,buff数组中还有剩余的字符(即不是刚好存满128个字符的情况),
//则需要将剩余的这些字符也添加到string对象s中.
if (i != 0)
{
buff[i] = '\0'; //添加字符串结束标志'\0',使其成为合法的C字符串.
s += buff;//将buff中的剩余字符串添加到string对象s中.
}
return in;//传引用返回:返回输入流对象,支持链式输入操作,如cin >> str1 >> str2.
}
(1)代码实现思路
这段代码的主要目的是实现从输入流(例如cin
,即标准输入流)中读取连续的字符,直到遇到空格或者换行符为止,将这些连续的字符构建成一个字符串存储在自定义的string
类对象中。整体思路是按字符逐个读取输入流中的内容,先暂存到一个临时的字符数组中,待满足一定条件(如临时数组快满或者读取到分隔符)后,再将临时数组中的字符添加到自定义的string
对象里。
(2)代码实现步骤
- 初始化操作:调用
s
的clear()
成员函数,清除s
原有的内容,为接收新输入的数据做好准备。同时,定义静态字符数组buff
用于临时存储输入的字符,并初始化记录字符个数的索引变量i
为 0。 - 循环读取字符并存入临时数组:使用
while
循环,通过in.get()
不断从输入流中读取字符ch
。只要ch
既不是空格也不是换行符,就将其存入buff
数组中,并将索引i
自增 1。在存入字符的过程中,每次判断i
是否达到了buff
数组的最大可用索引(127)。如果达到了,就在buff
数组的末尾添加字符串结束标志'\0'
,然后将buff
中的字符串添加到string
对象s
中,并将i
重置为 0,准备继续存储后续字符。 - 处理剩余字符:当
while
循环结束(即遇到了空格或者换行符)后,检查buff
数组中是否还有剩余未添加到s
中的字符(即i
不为 0 的情况)。如果有,则在当前存入字符的末尾添加字符串结束标志'\0'
,然后将buff
中的剩余字符串添加到s
中。 - 返回输入流对象:最后返回输入流对象
in
,这样可以支持连续的输入操作,例如链式的输入操作cin >> str1 >> str2;
。
(3)静态字符数组buff
的作用
静态字符数组buff
在这里充当了一个临时的字符缓冲。它可以批量暂存从输入流中读取到的字符,避免了每次读取一个字符就直接向string
对象插入,从而减少了string
对象的扩容次数。当buff
数组存满或者遇到空格、换行符时,再一次性将其中的字符串添加到string
对象中。这样在一定程度上提高了从输入流构建字符串的效率,同时 128 字节的大小设置较为合适,不会因为过大浪费内存,也不会因为过小而频繁需要处理数组添加到string
对象的情况。
string类模拟实现的整个工程
#include<string.h>
#include<assert.h>
#include<iostream>
using namespace std;
namespace bit
{
class string
{
public:
//迭代器
typedef char* iterator;
typedef const char* const_iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
//构造函数
string(const char* str = "")
:_size(strlen(str))
{
_capacity = _size == 0 ? 3 : _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
//拷贝构造函数(深拷贝)
string(const string& s)
:_size(s._size)
, _capacity(s._capacity)
{
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
}
//赋值重载函数(深拷贝)
string& operator=(const string& s)
{
if (this != &s)
{
//拷贝数据写法1(不建议采用)
/*delete[] _str;
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;*/
//拷贝数据写法2:
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
//析构函数
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
const char* c_str()
{
return _str;
}
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
size_t size() const
{
return _size;
}
size_t capacity() const
{
return _capacity;
}
bool operator>(const string& s) const
{
return strcmp(_str, s._str) > 0;
}
bool operator==(const string& s) const
{
return strcmp(_str, s._str) == 0;
}
bool operator>=(const string& s) const
{
return *this > s || s == *this;
//或者写成 return *this > s || *this == s;
}
bool operator<(const string& s) const
{
return !(*this >= s);
}
bool operator<=(const string& s) const
{
return !(*this > s);
}
bool operator!=(const string& s) const
{
return !(*this == s);
}
void resize(size_t n, char ch = '\0')
{
if (n < _size)
{
//删除数据,保留前n个
_size = n;
_str[_size] = '\0';
}
else if (n > _size)
{
if (n > _capacity)
{
reserve(n);
}
size_t i = _size;
while (i < n)
{
_str[i] = ch;
++i;
}
_size = n;
_str[_size] = '\0';
}
}
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
void push_back(char ch)
{
//写法1:
/*if (_size + 1 > _capacity)
{
reserve(_capacity * 2);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';*/
//写法2:
insert(_size, ch);
}
void append(const char* str)
{
//写法1:
//size_t len = strlen(str);
//if (_size+len > _capacity)
//{
// reserve(_size + len);
//}
//strcpy(_str + _size, str);//或者写成:strcat(_str, str);
//_size += len;
//写法2:
insert(_size, str);
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
string& operator+=(const char* str)
{
append(str);
return *this;
}
string& insert(size_t pos, char ch)
{
assert(pos <= _size);
if (_size + 1 > _capacity)
{
reserve(2 * _capacity);
}
size_t end = _size + 1;
while (end > pos)
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = ch;
++_size;
return *this;
}
string& 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 + len;
while (end > pos + len - 1)
{
_str[end] = _str[end - len];
--end;
}
//拷贝插入数据
strncpy(_str + pos, str, len);
_size += len;
return *this;
}
string& erase(size_t pos, size_t len = npos)
{
assert(pos < _size);
if (len == npos || pos + len >= _size)
{
_str[pos] = '\0';
_size = pos;
}
else
{
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
return *this;
}
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_capacity, s._capacity);
std::swap(_size, s._size);
}
size_t 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 npos;
}
size_t find(const char* str, size_t pos = 0)
{
assert(pos < _size);
char* p = strstr(_str + pos, str);
if (p == nullptr)
{
return npos;
}
else
{
return p - _str;
}
}
void clear()
{
_str[0] = '\0';
_size = 0;
}
private:
char* _str;
size_t _capacity;
size_t _size;
static const size_t npos;
};
const size_t string::npos = -1;
//流插入
ostream& operator<<(ostream& out, const string& s)
{
for (auto ch : s)
{
out << ch;
}
return out;
}
//流提取
istream& operator>>(istream& in, string& s)
{
s.clear();
char ch = in.get();
char buff[128];
size_t i = 0;
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
if (i == 127)
{
buff[127] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i != 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
}