上篇,我们已经了解了string的基本内容功能了,今天我们来具体模拟实现一下来助力为我们更加深刻地理解string的知识。由于string的接口很多,这里只是模拟常见的几个。
本篇使用到了多次多个字符串的函数,可以先复习复习:
ps:为什么接下来我们使用的是memcpy/memcmp开头的字符串函数,而不使用str开头的字符串函数?
因为strcpy/strcmp开头的是以‘\0’为中止结束字符,而我们的字符串并不是以字符串'\0'结束的,而是根据_size()的大小而决定的。所以我们接下来使用的基本mem开头,除非对'\0'没有影响的地方会使用一下str开头的
我们在C++第一篇(入门基础)中讲到了namespace的命名空间定义,这里我们就使用到了,避免与string里面的命名 冲突。
基本框架:
namespace bai
{
//创建类
class string
{
private:
char*_str;
size_t _size;
size_t _capacity;
};
}
讲解:为什么会使用到上面的几个成员变量呢?
1._str是毫无疑问的,string嘛,字符串肯定需要的.
2.另外,这个框架是不是有点熟悉,它好像顺序表的定义?是的,确实跟顺序表很相似,只不过顺序表是整形,这里是字符串而已。
3.我们在模拟过程中是不是要知道字符串的大小长度,所以有个_size
4.也要判断空间是否满的情况,需要用到_capacity(最大容量)。
我们类是有6个默认构造的
四个默认构造:
构造函数:
namespace bai
{
//创建类
class string
{
public:
string(const char* str)
:_size(strlen(str))
,_capacity(_size)
, _str(new char[_capacity + 1])
{
strcpy(_str, str);
}
private:
char*_str;
size_t _size;
size_t _capacity;
};
}
当我们写出了这个初始化时,这是有问题的。为什么?
因为我们的初始化列表声明的顺序不是按照代码的顺序执行下来的,而是根据类的成员变量的声明顺序来执行的:也就是说,如下图:先执行3->1->2.
那么,我们再来看,执行3的时候,由于_capacity还没有初始化,还是个随机值,所以开出来的空间也是随机值,1和2是正常的。
所以,我们为了解决这个问题:
方法一:
namespace bai { //创建类 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 _size; size_t _capacity; }; }
方法二:改变成员变量声明的顺序
namespace bai { //创建类 class string { public: string(const char* str) :_size(strlen(str)) ,_capacity(_size) , _str(new char[_capacity + 1]) { strcpy(_str, str); } private: size_t _size; size_t _capacity; char* _str; }; }
通常我们会使用第二种,(第一种看这别扭)。
当我初始化一个无参的string时
我们的构造函数写法:
string() :_size(0) ,_capacity(0) ,_str(new char[1]) { _str[0] = '\0'; }
int main() { bai::string s1; return 0; }
注意:这里可不能写成
string() :_size(0) ,_capacity(0) ,_str(nullptr) { }
1. 内存管理问题:如果将 _str 初始化为 nullptr ,后续在对这个 string 对象进行操作(比如尝试访问 _str 指向的内容)时,会导致未定义行为。因为 nullptr (或 NULL )表示不指向任何有效的内存地址,当代码中出现类似 cout << _str; 这样的语句时,程序就会崩溃。而一个合理的默认构造的 string 对象,即使是空字符串,也应该有一个合法的内存空间来存放字符串结束标志 '\0' ,所以需要动态分配至少一个字节的内存来存储 '\0' ,像原来正确的写法 _str(new char[1]) 那样。
2. 后续操作的一致性:在 string 类的其他成员函数(如析构函数、拷贝构造函数、赋值运算符重载等)的实现中,通常会假设 _str 指向一个合法的内存空间(即使是长度为 0 的空字符串情况)。如果初始化为 nullptr ,那么在这些函数中就需要额外添加大量的特殊处理逻辑来判断 _str 是否为 nullptr ,这会使代码变得复杂且容易出错,破坏了代码的一致性和简洁性。
3. 符合语义:一个空的 string 对象从语义上来说应该是有一个表示空字符串的合法存储,而不是没有指向任何内存的 nullptr 指针。将 _str 初始化为指向包含 '\0' 的内存空间,更符合 string 类表示字符串的语义。
在 C++ 中,当使用 nullptr ,一般是在指针可能不指向任何有效对象的情况下,而对于表示字符串存储的指针 _str ,在默认构造时应该确保它指向一个合法的内存(哪怕只是存放 '\0' ) 。
此外,我们还可以将上面两部分内容合并成一起:
1.//string(const char* str = '\0') 2.//string(const char* str = nullptr) 3.//string(const char* str = "\0") string(const char* str = "") { _size = strlen(str); _capacity = _size; _str = new char[_capacity + 1]; strcpy(_str, str); }
分析为啥1-3不可以:
1.'0'是一个字符,而左边那里是字符串,类型不匹配。
2.如上面讲到的
3."\0",这里会重复了,因为这里"" 里面就会隐藏有一个\0了,如果再次另外添加,就是有两个\0。
拷贝构造:
这里的拷贝构造就不能直接赋值拷贝(浅拷贝),
而是需要深拷贝。
深拷贝:
1.动态开辟一个新的空间
2.复制拷贝原来类对象动态开辟空间的内容到新的空间
3.拷贝原来类对象其他内置类型成员的变量。
string(const string& s) { _str = new char[s._capacity + 1]; strcpy(_str, s._str); _size = s._size; _capacity = s._capacity; }
析构函数:
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
赋值重载函数
s1=s2
这里跟拷贝构造差不多。
这里有两种方法:
一:传统的方法:
string& operator=(const string& s)
{
if (this != &s)
{
开一个临时字符串数组
char* tmp = new char[s._capacity + 1];
拷贝数据到新的空间
memcpy(tmp, s._str, s._size+1);
释放原来的空间
delete[] _str;
指向新空间
_str = tmp;
更新_size大小
_size = s._size;
更新_capacity大小
_capacity = s._capacity;
}
return *this;
}
二:新方法:使用swap交换函数
string& operator=(const string& s)
{
if (this != &s)
{
string tmp(s);
std::swap(tmp._str);
std::swap(tmp._size);
std::swap(tmp._capacity);
}
return *this;
}
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
string& operator=(const string& s)
{
if (this != &s)
{
string tmp(s);
//this->swap(tmp);
swap(tmp);
}
return *this;
}
或者:
string& operator=(string tmp)
{
swap(tmp);
return *this;
}
需要注意的是:
不能下图那样,因为:重载参数传递是引用传参,我们使用=赋值,往往是不希望修改给之前的值的,如果这里传引用,就要构造一个temp来当这个工具人
string& operator=(const string& s) { if (this != &s) { std::swap(s._str); std::swap(s._size); std::swap(s._capacity); } return *this; }
string内的函数
返回_size个数大小
size_t size() const { return _size; }
因为_size的个数大小出了作用域也是固定不变的,所以加了个const。
c_str函数
因为我们再之后的过程中会调用到库里面的东西,且为了更好地跟c语言的一些接口配合
const char* c_str() const { return _str; }
出了作用域不会发生改变,所以加了const。防止权限放大。
reserve
void reserve(size_t n)
{
//判断空间是否满
if (n > _capacity)
{
char* tmp = new char[n + 1];
//strcpy(tmp, _str);
memcpy(tmp, _str, _size+1);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
这里跟赋值重载函数的传统方法几乎一样的,就不多解释了。
push_back尾插
void push_back(char ch)
{
if (_size == _capacity)
{
// 2倍扩容
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
append追加
void append(const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity)
{
// 至少扩容到_size + len
reserve(_size+len);
}
//strcpy(_str + _size, str);
memcpy(_str + _size, str, len+1);
_size += len;
}
operator+=
1.+字符
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
2.+字符串
string& operator+=(const char* str)
{
append(str);
return *this;
}
insert插入
1.插入一个字符
思路:
1.断言,pos下标位置是否合理合法
2.判断空间是否满?
3.定义一个pos来定位它的位置
4.然后把pos位置后面的数据依次挪动后n位
5.挪动完之后,依次把数据插到pos位置
讲解:
void insert(size_t pos, size_t n, char ch)
{
assert(pos <= _size);
if (_size +n > _capacity)
{
// 至少扩容到_size + len
reserve(_size + n);
}
// 添加注释最好
size_t end = _size;
while (end >= pos ) //解释1
{
_str[end + n] = _str[end];
--end;
}
for (size_t i = 0; i < n; i++)
{
_str[pos + i] = ch;
}
_size += n;
}
当我们有了上面的思路时,模拟出了已经没有太大问题了,但是还是有些细节需要注意的:
即当我们把pos=0时,会出现问题,程序会崩溃?
这是因为,我们这里所定义的是size_t无符号整形,当pos=0时,循环end移动到0位置,--后end不会变成-1,而是无符号的整形的最大值,所以end会永远大于pos,循环无法停止,会造成死循环。
那么,我们又怎么取解决这种问题呢?
1.类型改变(强制类型转化)
写法一: 挪动数据 int end = _size; while (end >= (int)pos) //解释1:如果没有强制转变成int,会造成整形提升,也是会出现问题的 整形的-1是比无符号整形size_t的0要大的 { _str[end + n] = _str[end]; --end; }
写法二:
写法二: // 添加注释最好 size_t end = _size; while (end >= pos && end != npos) //解释1:nops是静态成员变量,赋值为-1 { _str[end + n] = _str[end]; --end; }
2.插入一个字符串
有了上面的讲解后,插入一个字符串的思路也是大概一致的。(就不多讲解了)
void insert(size_t pos, const char* str)
{
assert(pos <= _size);
//求插入字符串的长度
size_t len = strlen(str);
if (_size + len > _capacity)
{
// 至少扩容到_size + len
reserve(_size + len);
}
size_t end = _size;
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;
}
erase删除
思路:
1.断言(判断pos位置是否合理合法)
2.删除的话分为两种情况:
1)从pos的位置一直删到结尾
i)直接把’\0‘赋值给pos位置。
j)更新_size大小
2)从pos的位置没有删到结尾,即小于size大小
i)找到从pos位置删到最后的那个位置为end。
j)挪动数据
k)更新_size大小
void erase(size_t pos, size_t len = npos)
{
assert(pos <= _size);
if (len == npos || pos + len >= _size)
{
//_str[pos] = '\0';
_size = pos;
_str[_size] = '\0';
}
else
{
size_t end = pos + len;
while (end <= _size)
{
_str[pos++] = _str[end++];
}
_size -= len;
}
}
find查找
查找一个字符:
查找的思路很简单
思路:
1.断言(判断pos位置是否合法合理)
2.依次查找,如果查找字符与字符串的字符相等即找到了。否则没有
3.我们一般没有找到的话返回nops(即-1);
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;
}
查找一个字符串
1.这里使用到了strstr(这是寻找相同的字符,并从相同的那里开始往下打印)
size_t find(const char* str , size_t pos = 0)
{
assert(pos < _size);
const char* ptr = strstr(_str + pos, str);
if (ptr)
{
return ptr - _str;
}
else
{
return npos;
}
}
substr
思路:
1.断言(判断pos的位置合理性合法性)
2.获取有效长度:
举个例子:
pos=0,len=10,而你的字符串长度size一个才9,所以有效长度就是9,不可能是10.
3.创建string变量来存储字符串。
4.即通过遍历的形势一一存储到temp
5.最后返回temp
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;
}
resize
思路:分三种情况:
1.n<个数 ,
2.个数<n<最大容量,
3.个数>最大容量
而2和3是可以合并的,都可以先reserve,虽然第二种情况达不到需要扩容,但是你看看我们实现的reserve,它设置了条件的(_size==_capacity)才进入,所以,第二种实际只是进入函数,并没有真正实现,所以2和3可以成一种情况
情况一:我们知道这个函数的定义是如果小于size,就直接将后面是删去
所以,我们的思路:
直接将_size变为n,并将这个位置变为\0。
情况2:
我们知道这个函数的定义是如果大于size,会把_size后面的数据初始化为某个字符。
有了这个了解,我们需要做的就是遍历,把字符填进去。
最后更新_size即完成。
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';
}
}
clear清除
void clear()
{
_str[0] = '\0';
_size = 0;
}
运算符重载总规!!
operator[](可修改)
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
operator[](不可修改)
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
operator+=(加一个字符)
ps:因为+=运算符的返回值是+=后的结果类对象,所以+=运算符重载返回值是string&
思路:
1.直接用上面我们写过的push_back插入
2.最后返回this指针
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
operator+=(加一个字符串)
思路:
1.这相当于插入一个字符串,用我们之前写过的append函数即可。
2.最后返回this指针。
string& operator+=(const char* str)
{
append(str);
return *this;
}
运算符的比较
ps:接下来的都用了const
原因:我们返回的this指针是不能被修改的,所以用了const
operator<
思路:
1.我们比较字符串时比较的是它们的ACALL码,并不是比较的是它们的长度
2.那么,我们就需要一遍一遍的遍历字符串比较
3.现在,来看正常的情况比较:遍历比较,一旦发现遍历到的字符与另一个字符不一样,就返回得出结果。
但是呢?上面的情况,两个字符串前面部分都是一样的话(如下),这里会有以下的三种情况:
第一种:"hello" "hello" false----这种直接=返回错误没有太大问题
第二种:"helloxx" "hello" false
第三种:"hello" "helloxx" true所以,看到上面的情况,我们是不是遍历完最小的字符串长度后,是不是还要检查它们两个的长度是否一样?才能真正判断正确?
注意:这里不能够使用比较它的_size+1,因为’\0‘不一定算小字符,有一些汉族比'\0'还要小
所以,得出了我们的第一种写法:
bool operator<(const string& s)
{
size_t i1 = 0;
size_t i2 = 0;
while (i1 < _size && i2 < s._size)
{
if (_str[i1] < s._str[i2])
{
return true;
}
else if (_str[i1] > s._str[i2])
{
return false;
}
else
{
++i1;
++i2;
}
}
if (i1 == _size && i2 != s._size)
{
return true;
}
else
{
return false;
}
//return i1 == _size && i2 != s._size;
return _size < s._size;
}
第二种写法:我们使用字符串函数帮忙解决:memcmp(比较字符串大小)
bool operator<(const string& s) const
{
如果==,就返回0,不是就返回1.
int ret = memcmp(_str, s._str, _size < s._size ? _size : s._size);
// "hello" "hello" false
// "helloxx" "hello" false
// "hello" "helloxx" true
解决返回0的情况:
return ret == 0 ? _size < s._size : ret < 0;
}
operator==
思路:
我们判断的是字符串是否相等,所以我们先比较一下它们的长度是否相等,如果相等了,在取比较两个字符串大小即可
bool operator==(const string& s) const
{
return _size == s._size
&& memcmp(_str, s._str, _size) == 0;
}
operator<=
思路:
运算符重载的比较,只要先写出<或者==这两种情况的话。其他的情况使用复用就可很快得出了。
bool operator<=(const string& s) const { return *this < s || *this == s; }
operator>
bool operator>(const string& s) const
{
return !(*this <= s);
}
operator>=
bool operator>=(const string& s) const
{
return !(*this < s);
}
operator!=
bool operator!=(const string& s) const
{
return !(*this == s);
}
operator<<流插入
我们在这篇文章中已经写过了关于日期类的流插入和流提取了。
那么,现在对于string类,又该怎么进行流提取呢?
思路:我们只需要把string类中的字符一一插入到ostream流插入中即可。最后将流插入对象为返回值返回。
1.我们之前写过的知道,如果我们的流插入放到类对象里面,使用习惯很不符合我们正常的做法。所以移到类外面。(若需要使用到内置(私有)成员即使用有元函数即可)
2.需要注意的是:
1)ostream必须使用&返回,因为如果不使用&,就会是变成形参,会进行拷贝(ostream是不能进行拷贝操作的!)
ostream& operator<<(ostream& out, const string& s)
{
第一种:写法
for (size_t i = 0; i < s.size(); i++)
{
out << s[i];
}
第二种:写法
for (auto ch : s)
{
out << ch;
}
return out;
}
c_str和ostream流插入的区别:
c的字符数组,以\0为终止算长度
string不看\0,以size为终止算长度
bai::string s("hello C++");
s += '\0';
s += "!!!!!";
cout << s.c_str() << endl;
cout << s << endl;
//结果是:
hello C++
hello C++!!!!!
流提取operator>>
思路:
1.函数返回一个 istream 的引用,接收两个参数:一个输入流对象 in 和一个 string 对象 s 的引用。
2.清空 string 对象 s 的内容,确保读取新字符串前它是空的。
3.使用 in.get() 读取一个字符,然后循环检查该字符是否为空格或换行符。如果是,则继续读取下一个字符,直到遇到非空格和非换行符的字符。
4.定义一个字符数组 buff 作为缓冲区,用于存储读取的字符。在循环中,只要读取的字符不是空格和换行符,就将其存入缓冲区 buff 。当缓冲区快满( i == 127 )时,将缓冲区内容添加到 string 对象 s 中,并重置缓冲区索引 i 为0。
5.循环结束后,如果缓冲区中还有未处理的字符( i != 0 ),则将其添加到 string 对象 s 中。
6.返回输入流对象 in ,以便支持链式调用,例如 cin >> s1 >> s2; 。
istream& operator>>(istream& in, string& s)
{
s.clear();
char ch = in.get();
// 处理前缓冲区前面的空格或者换行
while (ch == ' ' || ch == '\n')
{
ch = in.get();
}
//in >> ch;
char buff[128];
int i = 0;
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
if (i == 127)
{
buff[i] = '\0';
s += buff;
i = 0;
}
//in >> ch;
ch = in.get();
}
if (i != 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
};
好了,string模拟到此结束了。
最后到了我们的鸡汤环节:
没有所谓失败,除非你不再尝试。