注:本文中的代码的运行结果都是本人在VS2022下测试的,有些代码在不同的平台下运行会有不同的结果!
目录
1. 为什么要有string类?
2. string类的模拟实现
3. 一些有关深浅拷贝的知识补充
4. 写实拷贝(了解)
1. 为什么要有string类?
C语言中,字符串是以'\0'结尾的一些字符的集合,为了操作方便,C标准库中提供了一些str系列的库函数, 但是这些库函数与字符串是分离开的,不太符合OOP的思想,而且底层空间需要用户自己管理,稍不留神可能还会越界访问。
在OJ中,有关字符串的题目基本以string类的形式出现,而且在常规工作中,为了简单、方便、快捷,基本 都使用string类,很少有人去使用C库中的字符串操作函数。
2. string类的模拟实现
话不多说,直接上代码
#include<iostream>
#include<assert.h>
using namespace std;
//为了和库里的string进行一个区分,所以需要namespace进行一下封装
namespace kk
{
class string
{
public:
typedef char* iterator; //底层是指针的迭代器(注:VS的迭代器底层并不是指针,但是g++下的迭代器的底层是指针)
typedef const char* const_iterator; //这里还需要一个const版本的迭代器给const的成员使用
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
//string()
// :_str(nullptr) //这里不能这样给_str初始化,不然下面在进行很多操作的时候,会出现对nullptr解引用的问题
// 解决方法一:
// :_str(new char[1]) //这里为什么不 new char 呢?
// 因为:下面的析构函数那里释放的时候你要是 new char 那析构函数岂不是要写的很复杂
// 毕竟:另一个构造函数中有进行new
// ,_size(0)
// ,_capacity(0)
//{
// _str[0] = '\0';
//}
/*string(const char* str)
:_str(str)
,_size(strlen(str))
,_capacity(strlen(str))
{}*/
//string(const char* str) //这里的string要这样写,不然下面的私有成员变量_str类型问题无法解决
// :_size(strlen(str))
//{
// _capacity = _size;
// _str = new char[_capacity + 1];
// strcpy(_str, str);
//}
//补充:为什么这里不直接写一个全缺省的构造函数呢?
// 答:假设你想要写一个全缺省的构造函数
//那么:你是不是想要这样写?
/*string(const char* str = nullptr)
: _str(str)
, _size(strlen(str))
, _capacity(strlen(str))
{}*/
//但是:这显然是会出错的
//因为:strlen函数会对传过来的这个指针进行解引用
//不过:你也可以把它们(_size、_capacity)都写到{}里面去,用一个条件判断来区分这种nullptr的情况
// 但:显然这很傻逼,还不如写成两个构造函数
//所以:那么要是想写成一个构造函数该咋写呢?
//string(const char* str = "\0") //注意:这个不能这样写:string(const char* str = '\0')
//因为:'\0'这个是字符'\0',它会被隐式转换成 int 类型的 0 传过去,0不就还是空指针吗?
//所以:这样写,程序依然会崩溃于对空指针的解引用
//但实际上也没必要这样写(有点多此一举了),可以这样写
string(const char* str = "") //因为,常量字符串,即使你什么都没写,它也默认会自带一个'\0'
:_size(strlen(str))
{
_capacity = _size == 0 ? 4 : _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
//传统的拷贝构造函数写法
//string(const string& str) //注:拷贝构造函数也有初始化列表
// :_size(str._size)
// ,_capacity(str._capacity)
//{
// _str = new char[_capacity + 1];
// strcpy(_str, str._str);
// //_str = str._str; //这样写不行,这可不是拷贝,而是把str._str的地址直接给了_str
//}
//现代的拷贝构造函数写法
string(const string& s)
//...理论上这里要对string的内置类型初始化一下,不然下面会报错,但是VS编译器自作主张帮你初始化好了
//不过为了以防万一,你最好还是给下面的内置类型都搞个缺省值比较好,不然其他编译器可能就会报错了
{
string tmp(s.c_str());
swap(tmp);
}
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
const char* c_str() const
{
return _str;
}
//注:这里应该需要两个[],一个给普通对象用,一个给const对象用
const char& operator[](size_t pos) const
{
assert(pos < _size); //这里的断言是很有必要的,因为编译器对越界的检查是很不稳定的,很多时候都无法检查出越界的行为
return _str[pos];
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
//赋值的现代写法
string& operator=(string tmp) //注意这里不能用引用,用引用的话里面就不能这样写,否则会导致原本的用来赋值的s的数据都没了
{
swap(tmp);
//注:如果参数是用的引用的话,这里面就跟拷贝构造里的写法一样
return *this;
}
//赋值的传统写法
//string& operator=(const string& s)
//{
// if (this != &s) //如果是自己给自己赋值的情况,就不能走下面的程序了,不然数据都丢失了
// {
// _size = s._size;
// _capacity = s._capacity;
// //这里对于_str的赋值,需要考虑到_str和s._str的大小情况
// //然后呢比较它们两的大小又太麻烦了
// //所以这边直接这样做
// //delete[] _str;
// //_str = new char[_capacity + 1];
// //strcpy(_str, s._str);
// //但是:这样写实际上也不太好,因为万一new失败了咋办?你还把原本的对象给破坏了
// //所以:最好这样写,先new再delete
// char* tmp = new char[_capacity + 1];
// delete[] _str;
// _str = tmp;
// strcpy(_str, s._str);
// //这样即使new失败了,也不会影响到原本的对象
// }
//当然,你也可以选择这样写
/*_size = s._size;
_capacity = s._capacity;
char* tmp = new char[_capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;*/
//这样连上面的判断都可以省略掉了
/* return *this;
}*/
size_t size() const //这应该要加个const,才能允许const成员对象去调用它
{
return _size;
}
size_t capacity() const
{
return _capacity;
}
//这里的各种比较直接复用库里的int strcmp(const char* _str1,const char* _str2)
//strcmp的返回值:>0表示 _str1 > _str2
// =0表示 _str1 == _str2
// <0表示 _str1 < _str2
bool operator>(const string& s) const
{
return strcmp(_str, s._str) > 0;
//不去使用strcmp,自己写的话
//代码如下:(就是模拟实现一下strcmp)
/*const_iterator it = s.begin();
const_iterator itt = (*this).begin();
while ((itt != (*this).end()) && (it != s.end()))
{
if (*itt > *it)
{
return 1;
}
if (*itt < *it)
{
return -1;
}
++itt;
++it;
}
return 0;*/
}
bool operator>=(const string& s) const
{
return !(strcmp(_str, s._str) < 0);
//这里也可以去复用你写好的 > 和 ==
//return *this > s || *this == s;
}
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 strcmp(_str, s._str) < 0;
}
bool operator<=(const string& s) const
{
return !(strcmp(_str, s._str) > 0);
}
//注:对于这种不修改成员变量数据的函数,都建议要加上const修饰
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)
{
if (_size + 1 > _capacity)
{
reserve(_capacity * 2); //2倍扩容
}
_str[_size++] = ch;
//这里要记得把'\0'弄回去,因为上面的ch是存到了原本的'\0'的位置,把原本的'\0'给覆盖掉了
_str[_size] = '\0';
}
//注:实际上这里的push_back还有一个问题:如果一开始初始化时_capacity为0怎么办?(就是像这样创建对象string s1)
// 这会导致每次过去扩容都无法真的成功扩容
// 这时候再往里插入数据就会出问题
//那该怎么办呢?
//答:
//解决方法一:在初始化的那里,把_capacity这样初始化_capacity = _size == 0 ? 4 : _size,而不是就简单的_capacity=_size
// 这里给4给3都行,反正不是给0就行
//解决方法二:在扩容的时候判断一下,如果_capacity == 0,那么就传个常量值过去,而不是_capacity * 2
void append(const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity)
{
reserve(_size + len); //就扩到它所需要的大小
}
strcpy(_str + _size, str); //补充:strcpy的一个特点是会把要复制的字符串(str)的'\0'也复制过去
//补充一个问题:为什么这里不使用strcat(_str,str)?明明感觉这看起来更方便啊?
// 答:因为strcat需要它自己去找目标字符串(_str)的末尾'\0'
// 这很龊
// 万一你这个目标字符串(_str)很长呢?岂不是要找半天?
_size += len;
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
string& operator+=(const char* str)
{
append(str);
return *this;
}
void resize(size_t n, char ch = '\0') //注:库里的resize设计的有点冗余了,它设计了两个接口,实际上一个接口就够用了
{
if (n < _size)
{
_size = n;
_str[_size] = '\0';
}
else if (n > _size) //n>_capacity或者n>_size的情况都在这里
{
while (n > _capacity)
{
reserve(2 * _capacity);
}
for (size_t i = _size; i < n; ++i)
{
_str[i] = ch;
}
_size = n;
_str[_size] = '\0'; //原本的'\0'被覆盖了,记得补上一个'\0'
}
//n == _size,不需要处理
}
void insert(size_t pos, char ch) //在某个位置插入一个字符
{
assert(pos <= _size);
if (_size + 1 > _capacity)
{
reserve(_capacity * 2);
}
//size_t end = _size; //把这里的end改成 int 类型并且在比较时加个强转 int 也可以处理下面的问题
//while (end >= pos) //挪动数据
//{
// _str[end + 1] = _str[end];
// --end;
// if (end == 0) //因为end是size_t的类型,再减下去就变成整型的最大值了,会爆,所以这里要判断一下
// {
// _str[end + 1] = _str[end];
// break; //然后跳出
// }
//}
//虽然,上面的问题有那两种解决方法,但是都不推荐这样解决
//因为,这可能不太方便于理解,毕竟如果没有注释的话,你第一眼看到这段代码,根本就不明白为什么它是这样写的
//所以,我们应该这样做
size_t end = _size + 1;
while (end > pos) //取消掉 == ,因为无符号数都是 >=0 的
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = ch;
++_size;
}
//这里评价一下库里的insert
// 首先,库里的insert插入字符是需要指定个数的,就是你要说明你要插入几个字符
//一开始,感觉这很蠢,你都知道要插入几个字符了,那为何不直接把这几个字符合成成一个字符串插入进去呢
// (插入一个字符还好说,插入好几个字符,岂不是每次插入的时候都要去挪动数据,这不很浪费时间吗?)
// 简直油饼
// 但是,仔细想想,可能真的存在那种你不知道要插入几个字符的情况,这时候也没办法把这几个字符合成一个字符串
// 所以,存在即合理吧
void insert(size_t pos, const char* str) //在某个位置插入一个字符串
{
assert(pos <= _size);
size_t len = strlen(str); //这里要是使用sizeof会把'\0'也插入进去,那就错了
if (len == 0) //如果是在0的位置插入一个"",下面就会死循环,得处理一下这种情况
{
return;
}
if (_size + len > _capacity)
{
reserve(_capacity * 2);
}
//挪动数据
size_t end = _size + len;
while (end > pos + len - 1) //上面插入一个字符的情况
{
_str[end] = _str[end - len];
--end;
}
//这也是一种挪动数据的思路,也可以走
/*size_t end = _size;
for (size_t i = 0; i < _size + 1; ++i)
{
_str[end + len] = _str[end];
--end;
}*/
//拷贝插入 //两种方式
memcpy(_str + pos, str, len);
//strncpy(_str + pos, str, len);
_size += len;
}
void erase(size_t pos, size_t len = npos)
{
assert(pos <= _size);
//if (len == npos || pos + len >= _size) //这样写实际上还是有溢出的风险,
//它只解决了len==npos溢出的情况,但还有其他的类似情况没解决
//举个栗子:
//如果 len = npos-1; pos = 6; _size = 11;
//那岂不是 len + pos = 4 < size
//这不就是出问题了嘛
//所以:应该这样写
if (len >= _size-pos)
{
_str[pos] = '\0';
_size = pos;
}
else
{
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
}
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) const
{
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) const
{
assert(pos < _size);
char* p = strstr(_str + pos, str);
if (p == nullptr)
{
return npos;
}
else
{
return (size_t)(p - _str);
}
}
string substr(size_t pos = 0, size_t len = npos)
{
string sub;
if (len >= _size - pos)
{
for (size_t i = pos; i < _size; ++i)
{
sub += _str[i];
}
}
else
{
for (size_t i = pos; i < pos + len; ++i)
{
sub += _str[i];
}
}
return sub;
}
void clear()
{
_str[0] = '\0';
_size = 0;
}
static size_t npos;
private:
//const char* _str;//因为上面有一个构造函数的参数是const,所以这里要写成const,否则初始化列表时会出错(权限放大的问题)
//注:一开始感觉这样写才对,但后面会发现这个_str是不能加const修饰的,
// 不然你后面要进行对字符串内容进行各种的增删查改都做不了
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
};
size_t string::npos = -1;
//补充一个易错的问题:
//流插入、流提取的重载一定要是友元函数吗?
//答:不一定,我们也可以去实现GetSize()、GetCapacity()...等函数来获取私有的成员变量的目的
ostream& operator<<(ostream& out, const string& s)
{
//out << s.c_str(); //这是不行的
//举个栗子:
//std::string s1("hello world");
//s1 += '\0';
//s1 += "cccccccccc";
//cout << s1 << endl; //这会将后面的"cccccccccc"也打印出来
//cout << s1.c_str() << endl; //这个打印是遇到'\0'就终止
//所以:我们重载的 << 里面不能是去打印c_str()
for (auto ch : s)
{
out << ch; //out就是cout的别名
}
return out;
}
istream& operator>>(istream& in, string& s)
{
s.clear(); //如果不先进行清理,测试某些情况时会出错,而且库里的 >> 本来就会在输入时先清空之前的内容
char ch;
/*in >> ch;
while (ch != '\n' && ch != ' ')
{
s += ch;
in >> ch;
}*/
//这样写,实际上是会出问题的,它会一直死循环,为什么呢?
//答:>> 会把' '或者'\n'当作字符之间的分隔符,它不会将这两个字符从缓冲区中提取出来
//所以,该怎么解决呢?
//(注:这里不能用C语言的getchar,因为C语言的getchar和C++这里的 >> 用的不是同一块缓冲区)
//ch = in.get(); //get() 就不会去区分你是什么字符了,不管你是' '还是'\n'都一样读进去
//while (ch != '\n' && ch != ' ') //把这里的条件改成while (ch != '\n'),就是getline()的效果了
//{
// s += ch;
// ch = in.get();
//}
//这个版本也只是基础版,还有其他的一些点做的不太好
//比如:频繁的 += 就会导致要频繁的扩容
//这样效率不好
//优化版
ch = in.get();
char buff[128];
size_t i = 0;
while (ch != '\n' && ch != ' ')
{
buff[i++] = ch;
if (i == 127)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i != 0)
{
buff[i] = '\0';
s += buff;
}
//这样能一定程度上减少一下扩容的次数
//注:这不能用reserve,因为你不好确定要reserve多少(开小了没效果,开大了可能浪费)
// 而且reserve是在堆上开的空间,你string的对象在,那块空间就会一直被占用着
//但:这里的数组buff则是在栈上的,这个函数结束buff占用的空间就还回去了,而且在栈上申请空间会略快于去堆上开空间
return in;
}
void test_c_str1()
{
string s1;
string s2("hello world");
cout << s1.c_str() << endl; //如果上面的构造函数没有进行修改,这边就会出现各种错误
//这句话会报错(对nullptr指针进行了解引用),C++自身的 << 会进行类型的自动识别
//虽然:你这里返回的是一个字符指针
//但是:<< 会把它识别为const char* 这样的字符串类型
// << 会对它去进行解引用
cout << s2.c_str() << endl;
s2[0]++; //如果上面的构造函数没有进行修改,这句代码无法通过
cout << s2.c_str() << endl;
}
void test_c_str2()
{
string s1("hello world");
string s2(s1); //注:如果上面没有去显示写一个拷贝构造函数,那么这里会出现两个问题
// 1、对s2的修改会影响到s1
// 2、程序最后会运行崩溃
//原因:因为编译器默认生成的构造函数进行的是浅拷贝(值拷贝)
s1[0]++;
cout << s2.c_str() << endl;
string s3;
s3 = s2;
cout << s3.c_str() << endl;
}
void Print(const string& s)
{
for (size_t i = 0; i < s.size(); ++i)
{
cout << s[i] << " ";
}
cout << endl;
for (auto ch : s)
{
cout << ch << " ";
}
cout << endl;
}
void test_for()
{
string s1("hello world");
for (size_t i = 0; i < s1.size(); ++i)
{
cout << s1[i] << " ";
}
cout << endl;
Print(s1);
string::iterator it1 = s1.begin();
while (it1 != s1.end())
{
cout << *it1 << " ";
++it1;
}
cout << endl;
for (auto ch : s1) //这里也能使用范围for的原因是:范围for的底层就是迭代器,我们上面已经写好了迭代器了,它就能用
{
cout << ch << " ";
}
cout << endl;
//注意:如果你把 begin 和 end 的名字稍微改了一下,那么范围for都会不能用了,因为它被规定了只会识别 begin 和 end
// ex:你把begin改成Begin,上面的范围for就用不了了
}
void test_cmp()
{
string s1("hello world");
string s2("hello world");
string s3("world");
cout << (s3 > s2) << endl;
cout << (s1 == s2) << endl;
cout << (s1 >= s2) << endl;
}
void test_push_back_append_insert()
{
string s1("hello world");
s1.push_back(' ');
s1.push_back('k');
s1.push_back('k');
s1.push_back(' ');
s1.append("love love love");
cout << s1.c_str() << endl;
s1 += '\n';
s1 += 'k';
s1 += 'k';
s1 += ' ';
s1 += "love love love";
cout << s1.c_str() << endl;
string s2("hello world");
s2.insert(0, 'k');
cout << s2.c_str() << endl;
}
void test_resize()
{
string s1("hello world");
s1.resize(5);
cout << "s1.size():" << s1.size() << endl;
cout << "s1.capacity():" << s1.capacity() << endl;
cout << "s1.resize(5):" << s1.c_str() << endl;
string s2("hello world");
s2.resize(28, 'c');
cout << "s2.size():" << s2.size() << endl;
cout << "s2.capacity():" << s2.capacity() << endl;
cout << "s2.resize(28, 'c'):" << s2.c_str() << endl;
}
void test_insert()
{
string s1("hello world");
s1.insert(3, 'c');
cout << "s1.insert(3, 'c'):" << s1.c_str() << endl;
std::string s2("hello world");
s2.insert(3, 1, 'c');
cout << "std::s2.insert(3, 1, 'c'):" << s2 << endl;
string s3("hello world");
s3.insert(3, "ccc");
cout << "s3.insert(3, \"ccc\"):" << s3.c_str() << endl;
}
void test_erase()
{
std::string s1("hello world");
s1.erase(3, 2);
cout << "std::s1.erase(3):" << s1 << endl;
string s2("hello world");
s2.erase(3, 2);
cout << "s2.erase(3):" << s2.c_str() << endl;
}
void test_out()
{
string s1("hello world");
s1 += '\0';
s1 += "cccccccccc";
cout << s1 << endl;
//注:这里的打印结果到底啥样实际上是未知的,
// 因为编译器的不同,版本的不同,其对打印'\0'时的处理不一样,有的是打印成空格,有的是直接不打印'\0'(这里就是不打印)
}
void test_in()
{
/*string s1;
cin >> s1;
cout << s1 << endl;*/
string s2("hello world");
s2 += '\0';
s2 += "cccccccc";
cout << s2 << endl;
cin >> s2;
cout << s2 << endl; //如果你用不加clear的 >> 的话,这里的打印会出现乱码,
//因为'\0'这个字符可能会跟你后来输入的字符组合一下就变成了一些奇奇怪怪的乱码
//举个栗子:
/*string s3("hello world");
s3 += "\015666";
cout << s3 << endl;*/ //这个打印出来的是 666lo world
}
void test_substr()
{
string url("https://cplusplus.com/reference/clibrary/");
string protocol; //协议
string domain; //域名
string uri; //资源
size_t i1 = url.find(':');
if (i1 != string::npos)
{
protocol = url.substr(0, i1 - 0);
cout << "protocol:" << protocol << endl;
}
size_t i2 = url.find('/', i1 + 3);
if (i2 != string::npos)
{
domain = url.substr(i1 + 3, i2 - i1 - 3);
cout << "domain:" << domain << endl;
}
uri = url.substr(i2 + 1, url.size() - i2 - 1);
cout << "uri:" << uri << endl;
}
}
注:本文的string类的模拟实现只模拟了string类的一些常用的接口,不常用的接口或者有些没有什么实际意义的接口就没有进行模拟实现。
3. 一些有关深浅拷贝的知识补充
3.1 什么是浅拷贝?
浅拷贝:也称位拷贝,编译器只是将对象中的值拷贝过来。如果对象中有管理资源,最后就会导致多个对象共享同一份资源,当一个对象销毁时就会将该资源释放掉,而此时另一些对象不知道该资源已经被释放,以为还有效,所以当继续对资源进项操作时,就会发生访问违规的问题。
举个栗子:
就像一个家庭中有两个孩子,但父母只买了一份玩具,两个孩子愿意一块玩,则万事大吉,万一不想分享就 你争我夺,玩具损坏。
3.2 什么是深拷贝?
深拷贝:给每个对象都独立分配一份资源,不用和其他对象共享一份资源,解决了浅拷贝中因多个对象共享一块资源而导致的访问违规的问题。
举个栗子:父母给每个孩子都买一份玩具,各自玩各自的就不会有问题了。
4. 写实拷贝(了解)
写时拷贝说白了就是一种拖延症,是在浅拷贝的基础之上增加了引用计数的方式来实现的。
引用计数:用来记录资源使用者的个数。在构造时,将资源的计数给成1,每增加一个对象使用该资源,就给 计数增加1,当某个对象被销毁时,先给该计数减1,然后再检查是否需要释放资源,如果计数为1,说明该 对象时资源的最后一个使用者,将该资源释放;否则就不能释放,因为还有其他对象在使用该资源。
举个栗子:
//g++下,string是通过写时拷贝实现的,string对象总共占4个字节(32位下),内部只包含了一个指针,
//该指针将来指向一块堆空间,内部包含了如下字段:
//1、空间总大小
//2、字符串有效长度
//3、引用计数
//4、指向堆空间的指针,用来存储字符串。
string s1;
string s2(s1); //在g++里这个拷贝就跟VS(PJ版)里的不一样,你这样写,它s2和s1实际上是指向同一块空间的
//然后,计数就是用来说明有几个指针指向了这同一块空间的
s2.insert(0,1,'x'); //只有当你去往这个对象中写入东西的时候,它才会真的去开一块新的空间,然后进行一下深拷贝,再往这个新空间里存东西
总的来说就是:
写时拷贝就是在赌你会不会往这个新的拷贝出来的对象中写入东西,不写入它就没给你开新空间,有写入才去开新的空间。
本文到此就结束了,文中有何不足之处请指出,感谢的您的阅读。