std::basic_string
std::basic_string 是 C++ 标准库中定义的一个模板类,它用于表示字符串。C++ 中的 `std::string` 实际上是 `std::basic_string<char>` 的一个特化版本。也就是说,`std::string` 是 `std::basic_string` 这个模板类的一个具体实现,专门用于处理以字符为基础的字符串。
通过类模板实例化的出了string还是wstring,u16string和u32string;
其中u16string表示一个字符16个字节;
u32string表示一个字符32个字节;
GTP总结:
string类的模拟实现:
为了与标准库做区分,我们可以自定义一个命名空间,在命名空间内实现string类!
实现string的构造函数
- _str不能直接直接等于参数中的str(_str是char*,str是const char*,如果直接等于会出现权限的放大,且如果传入的参数是常量字符串,那么_str此时无法进行修改!)
- size和capacity都不会考虑/0的结果;
- 类中成员变量的声明应该和初始化列表的顺序一致;
最终我们实现第一版的string的构造函数:
string(const char* str)
:_size(strlen(str))
,_capacity(_size)
,_str(new char[_capacity+1])
{
strcpy(_str, str);
}
对于没有参数的默认构造函数:
string()
:_size(0)
,_capacity(0)
//, _str(nullptr)
, _str(new char[1])
{
_str[0] = '/0';
}
- C 风格字符串的要求:C 风格字符串是由字符数组组成的,并且以空字符 ('\0') 结束。_str 必须指向有效的内存,以便能表示一个字符串。
- 一个有效的空字符串应该至少包含一个字符:这个空字符 ('\0')。避免空指针解引用:如果 _str 被初始化为 nullptr,当任何试图访问 _str 的成员方法(例如 c_str())被调用时,程序将试图解引用空指针,造成未定义行为(runtime error 或 segmentation fault)。
接下来我们可以尝试利用缺省参数将两个string写到一个函数里面:(常量字符串末尾自带/0)
//string(const char* str = "/0")
string(const char* str = "")
:_size(strlen(str))
,_capacity(_size)
,_str(new char[_capacity+1])
{
strcpy(_str, str);
}
这两种写法都可以:第一种相当于里面有两个/0;第二种相当于里面有一个/0,但是最好还是采用第二种写法!
实现string的析构函数
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
const char *c_str()
{
return _str;
}
实现string的一些接口函数:
c_str
const char *c_str() const
{
return _str;
}
size
size_t size()const
{
return _size;
}
一般希望c_str和size后面参数+上const:使得const对象和普通对象都可以传递!
operator[]
首先,size和c_str可以只有一个版本(不需要const版本!)因为这两个函数只是返回参数,并不会对成员变量进行修改(只读不写)!
但是[]需要提供两个版本,因为[]需要提供修改变量的功能!
且编译器会选择最合适的版本:如果有两种实现,那次此时普通的变量会去调用普通的函数,被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];
}
遍历对象
因为前面我们实现了size函数,我们可以直接用for循环来遍历对象:
for (size_t i = 0; i < s1.size(); i++)
{
s1[i]++;
}
for (size_t i = 0; i < s1.size(); i++)
{
cout << s1[i] << " ";
}
//const只能读取不能修改
const hello::string s3("hello world");
s3[0];
其中要注意的是:const只能读取,不能修改!(且const会匹配最适合自己的函数!)
接下来我们尝试使用迭代器实现遍历:
string里面的迭代器中:begin指向第一个字符串,end指向有效字符的下一个位置:/0不是有效字符;
在这里:string的迭代器实际上就是一个char*的指针!
因此,我们尝试实现以下迭代器:、
typedef char* iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
使用迭代器完成遍历的任务:
hello::string::iterator it = s1.begin();
while (it !=s1.end())
{
cout << *it << " ";
++it;
}
cout << endl;
}
除此之外,还可以使用范围for实现任务:
for (auto ch : s1)
{
cout << ch << " ";
}
cout << endl;
虽然我们的类没有实现范围for,但是我们还可以使用,这是因为范围for底层就是迭代器,使用范围for的时候,系统自动调用迭代器!
且如果将自定义的iterator修改名字,例如End,此时范围for就找不到对应的迭代器!(是一种傻瓜式的底层应用!-- 将迭代器换名字End,系统还是会自动去找end而出错!)
在底层的汇编中:还是调用对应的迭代器函数!
接下来我们实现一个const版本的迭代器:
typedef const char* const_iterator;
const_iterator begin()const
{
return _str;
}
const_iterator end()const
{
return _str + _size;
}
使用const遍历对象只能读不能改写,如下所示:
const hello::string s3("hello world");
s3[0];
hello::string::const_iterator cit = s3.begin();
while (cit != s3.end())
{
cout << *cit << "";
++cit;
}
cout << endl;
在这里,如果我们需要实现push_back和append,那么我们需要先实现resreve!即先完成扩容的功能!
实现reserve
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
实现push_back
void push_back(char ch)
{
if (_size == _capacity)
{
// 2倍扩容
// 如果初始为空字符串,那么给4;否则为原来的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);
_size += len;
}
同理!在我们实现了上述三种接口函数之后,我们可以复用功能来实现+=的运算符重载!
实现opeartot+=
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
string& operator+=(const char*str)
{
append(str);
return *this;
}
在这里我们通过函数重载实现operator+=!
实现Insert
这里我们实现两种类型的insert:在pos位置插入n个字符和在pos位置插入毅哥字符串!
实现insert之前我们需要考虑下面几点:
- pos位置是否在[0,size]之内;
- 首先先进行扩容;
- 数据向后面移动;
接下来我们考虑向pos位置插入n个字符的情况:
void insert(size_t pos, size_t n, char ch)
{
assert(pos <= _size);
// 扩容
if (_size + n >_capacity)
{
reserve(_size + n);
}
// 挪动数据
// pos位置插入一个数据
// (size-pos)个数据整体向后面移动1位;
size_t end = _size;
while (pos <= end)
{
_str[end + n] = _str[end];
end--;
}
// 插入数据
for (size_t i = 0; i < n; i++)
{
_str[pos + i] = ch;
}
}
其中,因为我们在pos位置插入数据,因此我们需要把pos位置之后的数据向后移动,即将(size-pos)个数据向后移动n位;
最终我们可以得到上面的第一代实现!
但是上面的函数有一个问题:当pos = 0时,此时pos位置永远小于end!程序会进入死循环!
这个地方,end是size_t的,pos也是size_t的,当pos为0时,end >=0 是永远成立的,虽然end在一直- -。其实每次到0的时候又变成一个非常大的数。就死循环了!
如果只把end变为int类型,当end = -1的时候还会进入循环!
int end = 0;
这是因为有符号和无符号进行比较,此时有符号会转化为无符号的数字,然后再进行比较!
此时可以考虑将end和pos都改为int类型,但是库里面的实现都是size_t类型!因为我们还是尽量按照库的标准来实现!
解决方法一:
强转类型:
int end = _size;
while ((int)pos <= end)
此时是两个整形进行比较;
解决方法二:
通过定义npos:
private:
size_t _size;
size_t _capacity;
char* _str;
static size_t npos;
此时我们在类外定义npos的值:
静态成员变量不能在类中给出缺省值,静态成员变量不属于某个类中,不走初始化列表;
但是,注意:
const修饰的静态成员变量可以在类中给缺省值(只有整形)!
private:
size_t _size;
size_t _capacity;
char* _str;
const static size_t npos = -1;
size_t string::npos = -1;
但是double类型的const static修饰的成员变量又不能在类中给缺省值!
size_t end = _size;
while (pos <= end && pos != npos)
{
_str[end + n] = _str[end];
end--;
}
在循环处增添判断条件:pos<=end && pos!= npos
此时如果pos为空则不会进入循环,而直接进行插入!
解决方法三:
size_t end = _size + n;
while (pos < end)
{
_str[end] = _str[end - n];
end--;
}
通过使end = _size+1;则循环判断条件没有=的时候,当pos = end的时候循环结束!
接下来我们考虑插入字符串的类型:
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 + len;
while (pos < end)
{
_str[end] = _str[end - len];
end--;
}
// 插入数据
for (size_t i = 0; i < len; i++)
{
_str[pos + i] = str[i];
}
_size += len;
}
这里字符串类型和字符类型的思想基本一致!
实现earse
如果我们需要实现earse我们需要考虑两种情况:
- 直接将pos位置后面的字符全部删除;
- 删除pos位置后来的有限个字符;
void earse(size_t pos, size_t len = npos)
{
assert(pos <= _size);
if (len == npos || pos + len >= _size)
{
_str[pos] = '\0';
_size = pos;
}
else
{
size_t end = pos + len;
while (end <= _size)
{
_str[pos++] = _str[end++];
}
_size = _size - len;
}
}
实现find
find函数我们主要实现两种:从pos位置开始,查找一个字符和
从pos位置开始,查找字符串!找到后返回下标;
如果没找到,则返回npos;
查找一个字符:
size_t find(char ch, size_t pos)
{
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)
{
assert(pos < _size);
const char* ptr = strstr(_str + pos, str);
if (ptr) // 此时查找到了对应的字符串
{
return ptr - _str;
}
else
{
return npos;
}
}
此时我们巧妙用C语言的strstr函数来实现:如果查找到对应的字符串,则会返回查找的字符串的指针;
实现substr
从pos位置开始取一个长度为n的字串,如果找到则返回字串,返回类型为string;
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+len; i++)
{
tmp += _str[i];
}
return tmp;
}
但是对于上面代码,存在一个问题:最后返回临时变量tmp,但是tmp是一个自定义类型,需要调用拷贝构造函数(然后继续调用析构函数),但是此时我们没有写对应的拷贝构造函数,所以默认是浅拷贝,返回的临时对象所在的空间已经被销毁,再调用对应的接口函数会出现问题!
解决方法:构造一个深拷贝!
string(const string& s)
{
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
开辟一个同样大的空间,然后再将值拷贝过去!
实现resize
resize官方给了两种类型:我们可以通过缺省参数将两种类型合并一起!
接下来我们考虑三种情况:
假如_size = 10,_capacity = 15;
分别考虑上述三种情况:
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';
}
}
实现流插入<<
流插入和流提取的函数我们要写在类外面,我们我们想要第一个参数为ostream/istream;
ostream设置的有防拷贝,因此返回类型和传参类型必须是返回类型,防止我们使用拷贝构造(后面会详细讲)
下面为流插入的代码实现:
ostream& operator<<(ostream& out,const string &s)
{
for (size_t i = 0; i < s.size(); i++)
{
out << s[i];
}
return out;
}
注意点:这里的ostream是std里面的,所以我们用的时候需要将命名空间展开或者指定作用域!
这里打印C类型的字符串和打印string类型的区别:C类型字符串本质是打印const char*,遇到 \0 就停止,但是打印s是在使用循环,打印完size()就停止!
下面两种情况下打印出现差别: c_str()遇到 \0 就停止,即使后面又添加了字符也不会打印,但是流插入依然会打印!(vs13系列对待中间\0会按照空格打印;vs19以后会直接不打印)
s._str这种打印方式不可取!(内置成员变量为私有的!)
因此,上述我们实现的函数,当我们开辟一个空间,想把原来的内容拷贝进去,我们使用的是strcpy,但是strcpy遇到 \0 就中止,\0后面如果还有数据则无法拷贝,因此我们建议替换为memcpy!
复习下memcpy的用法:
void *memcpy(void *str1, const void *str2, size_t n)
参数
- str1 -- 指向用于存储复制内容的目标数组,类型强制转换为 void* 指针。
- str2 -- 指向要复制的数据源,类型强制转换为 void* 指针。
- n -- 要被复制的字节数。
尤其是拷贝构造:需要将strcpy换成memcpy (应该将string全部内容都拷贝过来,如果使用strcpy则中间遇到 \0 就停止 --- string对象中间包含又 \0);
总结:
- c的字符数组,以 \0 为终止算长度;
- string不看 \0,以size为终止算长度;
实现流提取>>
将向终端控制台输入的数据提取出来!
输入的数据不能+const!因为我们输入的数据会传入终端控制台,+const数据不能修改;
问题一:能否直接输入s._str?
不能!首先_str是私有的成员变量,我们不能使用;其实,如果我们想进行输入s._str,那么这个变量应该占用多少空间呢?直接进行输入的话系统无法给出确切的空间大小!
问题二:流提取 / scanf怎么进行输入数据的分割?
遇到' '或者'\n'自动进行分割!(默认不会读取空格 / 换行)
注意点:这里我们不能使用>>!因为>>不会区分空格和换行,会使得程序一直进行,没办法停止!
在这里我们使用get()函数,get函数的作用是遇到' '和'\0'就会自动停止!
get 是istream 中的成员函数,用于读取单个字符
istream& operator>>(istream& in, string& s)
{
//char ch;
char ch = in.get();
in>>ch;
while (ch != ' ' && ch != '\n')
{
s += ch;
//in >> ch;
ch = in.get();
}
return in;
}
输出的结果遇到空格或者 \0 会自动停止,与库中的实现的效果一样!(且这里的+=会自动完成扩容)
但是上面的代码还存在一些问题,我们先看一些库里面的实现:
void test10()
{
std::string s;
cin >> s;
cout << s;
cin >> s;
cout << s;
}
我们发现两次连续的输入,库里面的流输入会将之前的内容清空!
接下来尝试我们自己的实现:
void test10()
{
shy::string s;
cin >> s;
cout << s << endl;;
cin >> s;
cout << s << endl;;
}
如果连续进行输入,库里面的实现是对之前的内容进行覆盖!
但是我们自己实现的函数中,内容并没有被覆盖!
因此我们自己完成一个清除数据的接口函数:
void clear()
{
_str[0] = '\0';
_size = 0;
}
然后再对之前的流提取进行修改:
istream& operator>>(istream& in, string& s)
{
s.clear();
//char ch;
char ch = in.get();
//in>>ch;
while (ch != ' ' && ch != '\n')
{
s += ch;
//in >> ch;
ch = in.get();
}
return in;
}
改进点:
当我们使用+=的时候,如果我们初始给定的字符串很长,那么函数会持续扩容,使得整体效率不高!接下来我们来查看一下:在扩容函数中增加打印语句查看调用多少次
输入上面这么长的字符串,经历了6次扩容,效率较低!
解决方法:使用一个数组;
istream& operator>>(istream& in, string& s)
{
s.clear();
char ch = in.get();
//创建一个类似桶的东西存储数据
char buff[128];
int i = 0;
while (ch != ' ' && ch != '\n')
{
// 用通存储数据
buff[i++] = ch;
if (i == 127)
{
// 此时桶装满了,将数据导出,再重接接数据
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
// 此时桶还没装满,提前退出
if (i != 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
可以发现,此时输入超长的字符串,也只进行了两次扩容;
接下来还有一个问题:
当我们先输入空格或者换行的时,接下来输入的内容无法显示:
这是因为get函数的作用是遇到' '和'\0'就会自动停止!
我们试验std库的实现,发现可以打印
接下来我们对自己的库进行改进:
istream& operator>>(istream& in, string& s)
{
s.clear();
char ch = in.get();
// 处理缓存区前面的空格或者换行
while (ch == ' ' && ch == '\n')
{
char ch = in.get();
}
//创建一个类似桶的东西存储数据
char buff[128];
int i = 0;
while (ch != ' ' && ch != '\n')
{
// 用通存储数据
buff[i++] = ch;
if (i == 127)
{
// 此时桶装满了,将数据导出,再重接接数据
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
// 此时桶还没装满,提前退出
if (i != 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
实现比较<
我们给出第一种写法:
bool operator<(const string& s)
{
return strcmy(_str, s._str) < 0;
}
这个代码看似没有问题,但是如果字符串中间又 \0 的话,那么无法正常进行比较!
因此我们使用memcpy来实现,复习下memcpy函数:
int memcmp(const void *str1, const void *str2, size_t n)
参数
- str1 -- 指向内存块的指针。
- str2 -- 指向内存块的指针。
- n -- 要被比较的字节数。
返回值
- 如果返回值 < 0,则表示 str1 小于 str2。
- 如果返回值 > 0,则表示 str1 大于 str2。
- 如果返回值 = 0,则表示 str1 等于 str2。
但是这里使用memcmp比较会出现一个问题:按照谁的_size大小来比较?
应该按照两个字符串中较短的来进行比较!
但是如果按照小的进行比较,那么上述情况又会出现问题!
因此,这里我们可以不借用库中的函数,自己来实现:
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++;
}
}
// 出循环后,此时说明两端字符串肯定有一段执行完了
// 可能有下面三种情况:
// "hello" "hello" --> false
// "hello" "helloxxx" -->true
// "helloxxx" "hello --->false
// 写法一:
//if (i1 == _size && i2 != s._size)
//{
// return false;
//}
//else
//{
// return true;
//}
// 写法二:
return _size < s._size;
}
接下来我们再来提供一个复用库函数来实现:
bool operator<(const string& s)
{
// 内存比较:按照两个字符串中长度较短的进行比较
// s1 < s2
bool ret = memcmp(_str, s._str, _size < s._size ? _size : s._size);
// 可能有下面三种情况:
// "hello" "hello" --> false
// "helloxxx" "hello --->false
// "hello" "helloxxx" -->true
return ret == 0 ? _size < s._size:ret<0;
// 其中_size<s._size代表上面三种情况
// ret < 0 ---> 如果按内存比较s1<s2 == ret<0
}
实现其他比较运算符
实现了上面的<运算比较的时候,我们可以进行服用实现其他函数:
bool operator==(const string& s)const
{
return _size == s._size && memcmp(_str, s._str, _size);
}
bool operator<=(const string& s)const
{
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);
}
且因为比较运算符都是可读的,我们可以加上const进行修饰!(const 对象和普通对象都可以调用)
实现赋值运算符=
进行s1 = s3;此时如果是浅拷贝,那么s1指向的空间直接指向s3,最终同一块空间进行两次析构而报错;
此时正确的做法是:s1再开辟一段新的空间,将原来的空间释放掉,再将s3的内容拷贝过去;
与拷贝构造不同的是,拷贝构造直接开辟与原对象一样大的空间,再将数据拷贝过去;
传统的赋值运算符=写法:
string& operator=(const string& s)
{
if (*this != s)
{
char* tmp = new char[s._capacity + 1];
memcpy(_str, s._str, s._size + 1);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
现代的赋值运算符=写法:
现代赋值运算是借用拷贝构造,拷贝一个一样的对象,再将其值复制过去;
string& operator=(const string& s)
{
if (*this != s)
{
string tmp(s);
std::swap(_str, tmp._str);
std::swap(_size, tmp._size);
std::swap(_capacity, tmp._capacity);
}
return *this;
}
tmp是一个局部对象,出了作用域就会被销毁!
考虑下面一种问题:能否这样子调用swap函数?
不可以!会造成死循环递归而导致栈溢出!
swap就会调用赋值运算符,而这里我们就是实现赋值运算符的!
实现swap交换
string库里面实现的有string类型的交换:
接下来我们尝试写一下:
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);
swap(tmp);
}
return *this;
}
写法四:
string& operator=(string& tmp)
{
swap(tmp);
return *this;
}
但是写法四中的参数不能是const修饰的,因为此时tmp会被修改!
这里的tmp是s3的深拷贝,然后再调用tmp进行转换;
此时s3的值不会发生改变;tmp的值会发生改变!