· CSDN的uu们,大家好。这里是C++入门的第十六讲。
· 座右铭:前路坎坷,披荆斩棘,扶摇直上。
· 博客主页: @姬如祎
· 收录专栏:C++专题
目录
1. string类的成员变量
2. 构造函数
3. 析构函数
4. const char* c_str() const
5. size_t size() const
6. char& operator[](size_t pos)
7. void reserve(size_ t n)
8. void push_back(char ch)
9. void append(const char* s)
编辑
10. operator+=
10.1 string& operator+=(char ch)
10.2 string& operator+=(const char* s)
11. insert()
编辑
11.1 void insert(size_t pos, size_t n, char ch)
11.2 void insert(size_t pos, const char* s)
12. void erase(size_t pos, size_t len = npos)
13. find()
13.1 size_t find(char ch, size_t pos = 0)
13.2 size_t find(const char* s, size_t pos = 0)
14. string substr(size_t pos = 0, size_t len = npos)
15. void resize(size_ t n, char ch = '\0')
16. void clear()
17. 比较运算符的重载
17.1 bool operator<(cons string& s) const
17.2 bool operator==(cons string& s) const
17.3 接下来全部都是复用啦
18. ostream& operator<<(ostream& out, const string& s)
19. istream& operator>>(istream& in, string& s)
20. 拷贝构造函数
20.1 传统写法
20.2 现代写法
21. void swap(string& s)
22. 赋值运算符重载
23. string迭代器的实现
在上一讲,我们学习了如何使用string类。这节课我们将用一种叫较为简单的方式模拟实现一个string类。目的是加深对string类的理解。
1. string类的成员变量
在string类的使用那一节我们就已经知道了string类其实维护了一个char数组,一个表示char数组实际大小的size和一个表示数组真实容量的capacity,因此,我们模拟实现的string类的成员变量就是这三个啦!
因为string维护的char数组不是静态的,因此string类维护的是一个char*的指针,size表征有效字符的个数。维护capacity方便扩容。
namespace Tchey
{
class string
{
private:
char* _str;
size_t _size;
size_t _capacity;
};
}
我们就定义出了一个基础的string类,在string类的定义外套一层明明空间主要是为了和库里面的string冲突哈!
2. 构造函数
我们也是看到string的构造函数是非常多的啊!我们就实现一个:string(const char* s)的版本就行啦!
该构造函数可以使用一个字符串常量构造一个string对象!我们在用传入的字符串常量构造string对象时,不能将形参s直接赋值给成员变量_str。而是需要在堆上开辟一块空间,然后将字符串常量的数据拷贝过去。同时初始化其他成员变量!
原因:
1:形参s指向的空间是常量区,不允许修改,后续对string对象的操作可能报错!
2:如果构造出来的string对象将空间释放,那么就会释放常量区的空间,非法操作!
string(const char* s)
{
int len = strlen(s); //计算字符串常量的大小
_str = new char[len + 1]; //开辟空间
strcpy(_str, s); //拷贝数据
_size = len; //修改其他成员变量
_capacity = _size;
}
这里开辟 len + 1的空间是为了 strcpy 拷贝 ‘\0’ 预留的空间哈!为什么要有 '\0' 呢?不是有一个 _size 维护了字符串的大小嘛?那是因为string 有一个 c_str 接口,没有 '\0' 的话,没法兼容C语言啦!
在定义string对象的时候,我们可能会这么定义!上面的构造函数显然不能行。那怎么办呢?
string s;
在string的使用那一节我们讲到,直接这样定义string对象,在内存中其实是在下标为 0 的位置存储了一个 '\0' 的!因此,可以在上面我们写的构造函数给一个缺省参数!像这样:
string(const char* s = "")
{
int len = strlen(s); //计算字符串常量的大小
_str = new char[len + 1]; //开辟空间
strcpy(_str, s); //拷贝数据
_size = len; //修改其他成员变量
_capacity = _size;
}
这样做是不是既简单右好理解呢?
3. 析构函数
构造函数写了,我们就可直接写出来析构函数啦!析构函数很好写:释放维护堆区空间的指针,将_size 和 _capacity 置为 0 即可!
~string()
{
delete _str;
_str = nullptr;
_size = 0;
_capacity = 0;
}
4. const char* c_str() const
这个函数返回C风格式的字符串嘛!很简单直接返回_str即可。后面的那个const修饰的是*this哈,代表成员变量不可修改!const对象与非const对象都可以调用!
const char* c_str() const
{
return _str;
}
5. size_t size() const
返回字符串有效字符的数量!返回string类维护的_size即可!
size_t size() const
{
return _size;
}
6. char& operator[](size_t pos)
为了使得我们的程序看起来比较健壮!因此我们可以检查一下pos的合法性,当我们的pos >= _size显然是不合法的!我们assert断言一下就可以啦!返回值就很好处理啦:返回pos位置的字符就行啦!注意到const的string对象也是可以调用operator[]的因此,我们还要实现一个const的版本:const char& operator[] const。方便const对象调用!const对象调用operator[]得到的字符不允许修改!
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
//const版本
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
7. void reserve(size_ t n)
这个函数是用来修改_str维护字符数组的真实容量的,我们需要做的就是:当n > _capacity的时候,开一块容量为n的空间,然后将原来空间的数据拷贝过去,然后在释放_str,在修改_str的指向,使其维护新开出来的空间!
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
memcpy(tmp, _str, _size + 1);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
这里为什么我们开辟的是 n + 1个字符的空间呢?主要是因为 '\0' 的缘故啊!因为我们的C++必须兼容C语言,所以string对象有效字符的结尾都必须有一个 '\0', 同理memcpy,copy的字节数也要比_size大一,也是因为'\0'的缘故!这里必须要用memcpy不允许使用strcpy,那是因为我们的字符串可能中间出现 '\0',用strcpy就会导致数据拷贝不完整!
8. void push_back(char ch)
该函数可以在string的末尾追加一个字符,模拟实现的时候注意扩容逻辑就行了!什么时候扩容:就是当_size >= _capacity 的时候,我们就需要扩容啦!扩容很好办,直接调用我们的reserve接口就行啦!
还有一个问题就是我们reserve的空间是多大呢?这就取决于你的实现啦!我比较习惯与扩容到原来的两倍!但是有一个魔鬼细节:如果扩容的时候原string的容量就是0 ,扩容成两倍不就是相当于没有扩容嘛,因此我们还需要做一个if条件判断!
void push_back(char ch)
{
if (_size >= _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size++] = ch;
_str[_size] = '\0';
}
9. void append(const char* s)
我们知道append的重载版本很多,为了简单,我们选择实现append一个常量字符串的版本。
首先,我们还是需要判断是否需要进行扩容!我们先求出 常量字符串的长度 len,如果 _size + len > _capacity 就说明需要扩容!最后,我们直接调用 strcpy 将常量字符串中的字符拷贝到str里面就可以啦!strcpy默认是会拷贝 '\0' 的所以没有任何问题哈!注意修改_size的大小哦!
void append(const char* s)
{
int len = strlen(s);
if (_size + len > _capacity)
{
reserve(_size + len);
}
strcpy(_str + _size, s);
_size += len;
}
10. operator+=
库里面重载了三个版本,我们实现两个版本就行啦,加等一个字符和加等一个常量字符串。
10.1 string& operator+=(char ch)
嘿嘿,很简单,直接调用 push_back() 就行啦!push_back() 会做好一切!
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
10.2 string& operator+=(const char* s)
同样地,我们直接调用append接口就行啦!
string& operator+= (const char* s)
{
append(s);
return *this;
}
11. insert()
我们看到库里面重载的版本真的很多呢!我们实现两个版本:在pos位置插入n个字符ch和在pos位置插入一个常量字符串。
11.1 void insert(size_t pos, size_t n, char ch)
1:检查pos位置的合法性,pos应该是要 <= _size 的。
2:判断是否需要扩容?当 _size + n > _capacity 就需要扩容啦!至于扩容到多大,取决于你,我们选择效仿上面append的扩容逻辑,就扩容到刚好够!
3:挪动数据,既然是在pos位置前面插入字符,我们肯定要腾出来 n 个字符的位置撒!不然怎么插入呢!
我们来看看移动前后的数组对比,再来看如何移动:假设我们要在 pos 位置插入 3 个 'b'
根据示意图:我们知道要将pos位置及其之后的所有字符向后移动3个下标!我们可以初始化一个变量:end(或者其他名字),指向_size的位置,因为我们的 '\0' 也要移动嘛。然后将下标为 end + n 的字符赋值为下标为 end 指向的字符,我们就完成了字符向后移动 3 个下标,然后让 end--。直到end < pos,因为pos位置的字符也需要移动嘛!
移动完成之后从pos位置开始向后填充 n 个字符就行啦!
但是,还是有一个问题!假如 pos == 0,在完成一个字符移动,end--之后与 pos 比较就会出问题。因为 int 类型 与 size_t 类型一起运算的时候会发生整形提升!int 会被提升为 size_t 从而原本 end == -1 整形提升之后 end 就会变成 无符号整形的最大值!从而导致循环无法结束!
解决办法:
1:在判断条件处将 pos 强转为 int。
2. 我们可以将 end == -1 的情况单独拿出来判断。我们在学习string的使用时不是引入了一个 npos 嘛,他就是有符号的 -1 无符号的最大值,现在我们也可以在自己的string类中定义一个。
静态成员变量的声明和初始化!不可以直接给缺省值:static size_t npos = -1;因为静态成员变量是属于类的!缺省值给的是初始化列表,静态成员不会通过初始化列表初始化!
有个奇怪的C++语法:const static size_t npos = -1;是能够编译通过的!只不过这个语法仅限于整形家族,是不是特别戳!
3:我们可以换一种移动字符的方式嘛,将end 初始化为 _size + n 然后将 end - n 的字符赋值给 end 也可以。这样就不会有特殊情况了!只不过代码有点奇怪!
我们选择第二种解决办法来实现我们的代码!
void insert(size_t pos, size_t n, char ch)
{
assert(pos <= _size);
if (_size + n > _capacity)
reserve(_size + n);
int end = _size;
while (end >= pos && end != npos)
{
_str[end + n] = _str[end];
end--;
}
for (int i = 0; i < n; i++)
_str[pos++] = ch;
_size += n;
}
11.2 void insert(size_t pos, const char* s)
有了前面插入n个字符的铺垫,这个函数就很好实现啦!
1:检查pos的合法性。
2:计算字符串常量的长度 len,判断len + _size 是否大于 _capacity,如果大于则需要扩容!
3:移动字符,移动的方法我们还是选择上面的第二种哈!
4:插入新的字符串!
void insert(size_t pos, const char* s)
{
assert(pos <= _size);
size_t len = strlen(s);
if (len + _size > _capacity)
{
reserve(len + _size);
}
int end = _size;
while (end >= pos && end != npos)
{
_str[end + len] = _str[end];
end--;
}
for (int i = 0; i < len; i++)
{
_str[pos++] = s[i];
}
_size += len;
}
12. void erase(size_t pos, size_t len = npos)
这个函数就是删除pos位置后面的 len 个字符!
1:检查 pos 的合法性。
2:如果 len 不传 或者 len 传得比较大!就是删除 pos 之后所有的字符!即,当 pos + len >= _size 的时候与不传 len 的时候是等效的!这个时候我们只需要将 pos 位置的字符修改为 '\0' 即可。然后更新 _size;
3:如果 len 比较小,那么我们就需要移动字符啦!
假设字符串是:"abcdefg" 我们要删除 pos = 1 后面的 3 个字符。我们可以使用 for 循环,初始化循环变量为 i,然后将 i + len 的字符移动到 i 的位置。很明显循环结束的条件就是当 i > _size - len.
移动完成之后更新_size 就可以啦!
void erase(size_t pos, size_t len = npos)
{
assert(pos < _size);
if (len == npos || pos + len >= _size)
{
_str[pos] = '\0';
_size = pos;
}
else
{
for (int i = pos; i <= _size - len; i++)
_str[i] = _str[i + len];
_size -= len;
}
}
13. find()
find函数我们实现两个版本:1. 从 pos 位置开始查找字符。2. 从 pos 位置开始查找字符串。
13.1 size_t find(char ch, size_t pos = 0)
这个函数就很好实现啦。我们先检查一下pos,然后从pos位置开始查找string中有没有这个字符就行啦!找不到返回npos!
size_t find(char ch, size_t pos = 0)
{
assert(pos < _size);
for (int i = pos; i < _size; i++)
if (_str[i] == ch)
return i;
return npos;
}
13.2 size_t find(const char* s, size_t pos = 0)
我们直接套用 C语言的库函数 strstr(),就可以了!注意参数的传入以及返回值的书写。strstr的原型:
我们要从 pos 位置开始找,因此参数1的实参应该怎么写:_str + pos。
strstr的返回值是一个char* 我们可以通过 指针减 指针来获得 查找成功的下标!
size_t find(const char* s, size_t pos = 0)
{
assert(pos < _size);
const char* ptr = strstr(_str + pos, s);
if (ptr)
{
return ptr - _str;
}
else
return npos;
}
14. string substr(size_t pos = 0, size_t len = npos)
从pos位置截取 len 个字符来构造返回一个新的string对象。
1:检查pos合法性,pos >= _size 都是不合法的!
2:如果 不传 len 或者说 pos + len > _size,就是截取 pos 之后的所有字符,此时我们可以修正截取的实际字符数量,令 n = _size - pos。
3:根据实际的字符数量,遍历字符构造字符串返回即可!我们可以在遍历的时候使用 +=。
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 s;
for (int i = 0; i < n; i++)
s += _str[pos++];
return s;
}
15. void resize(size_ t n, char ch = '\0')
这个函数的使用在 string 使用哪一节是讲过的了!
1:如果 n < _size 很简单,将_size位置的字符修改为 '\0' 即可!
2:如果 n >= _size,我们就需要考虑是否要扩容啦!如果 n > _capacity 需要扩容,否则不需要。因此,我们直接调用 reserve(n),在 reserve 的实现中我们是判断了 n 是否大于 _capacity 的,因此不用担心!
3:用 ch 初始化新的空间即可!
void resize(size_t n, char ch = '\0')
{
if (n < _size)
{
_str[_size] = '\0';
_size = n;
}
else
{
reserve(n);
for (int i = _size; i < n; i++)
_str[i] = ch;
_size = n;
_str[_size] = '\0';
}
}
16. void clear()
比较简单,我们只需要将下标为 0 的字符修改为:'\0',将_size 更新为 0 即可!
void clear()
{
_str[0] = '\0';
_size = 0;
}
17. 比较运算符的重载
17.1 bool operator<(cons string& s) const
字符串的比较一直都是按照 字典序 来比较的哈!不是按长度哦!
当 memcmp 的结果不等于0 的时候,如果结果 < 0,则返回 true;如果结果 > 0 返回false。
我们来看memcmp == 0的情况:
1:hello && helloXX,这种情况我们用 memcmp 比较,比较的字节数为两个string 对象中 size()较小的string中有效字符个数所占的字节数。memcmp 的结果为 0,但是因为 helloXX 的hello 后面还有字符,因此 hello < helloXX,返回true。
2:aaaXX && aaa,同样选择用 memcmp 比较size较小的string的字节数,结果依然是 0,但是因为 aaaXX 的 aaa 后面还有字符,因此 aaaXX > aaa, 返回false。
3:hello && hello,这种情况 memcmp 的结果依然是 0 ,但是两个string 后面肚饿没有字符,因此 hello == hello, 返回false。
所以,在 memcmp == 0的情况下,如果 _size < s._size,那么返回 true 否则返回 false。
这里你可能发问了,为什么不能用 strcmp 呢?原因就是可能出现这样的字符串:
aaa\0bbb && aaa\0ccc,strcpy的结果是0,两个string 对象的size也相等,用strcmp得到的结果就是 false,但起始结果是 true。
bool operator<(const string& s) const
{
int ret = memcmp(_str, s._str, _size < s._size ? _size : s._size);
return ret == 0 ? _size < s._size : ret < 0;
}
17.2 bool operator==(cons string& s) const
两个string相等的前提条件就是 他俩的size必须一样,因此如果 size 一样再 memcmp 就行啦!
bool operator==(const string& s) const
{
return _size == s._size && memcpy(_str, s._str, _size);
}
17.3 接下来全部都是复用啦
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);
}
18. ostream& operator<<(ostream& out, const string& s)
库里面的 string 是支持 cout << s << endl的。就是因为实现了 流插入运算符的重载!在C++中,ostream的实现是禁用了拷贝构造函数的,因此形参只能写 ostream 的引用。
还有就是重载流插入和流提取的时候都不要写成成员函数,如果写成成员函数,使用operator<<的顺序就不符合我们的书写习惯啦!一定要写在你定义的namespace内部,不写在你定义的namespace内部函数形参这么写也行:
ostream& operator<<(ostream& out, const Tchey::string& s)
因为可能存在 "aaaa\0bbbb" 这样的神奇字符串,因此我们的函数体不能直接写成:
out << s.c_str();
ostream& operator<<(ostream& out, const string& s)
{
for(int i = 0; i < s.size(); i++)
out << s[i];
return out;
}
19. istream& operator>>(istream& in, string& s)
这里的函数体千万不敢这样写:
in >> s.c_str();
第一:直接这样写并没有为 s 开辟空间,强行写入会引起内存错误,单只程序崩溃的!
第二:就算你提前开辟了空间,但是我们并不知道用户要输入多少字符哇,提前开多少空间呢?
因此,我们是不能直接这么搞的,需要一个字符一个字符得读!
我们定义一个char 变量 ch 用于接收输入的字符,每输入一个字符我们就让 s += ch,直到 ch == ' ', 或者 ch == ‘\0’ 的时候,结束输入(循环)。
但是这里有一个 魔鬼细节,如果用 cin 输入的话,是无法读入空格和换行的导致死循环,这个可以你自己在编译器上验证,编译器认为空格 和 换行 是用来区分不同的变量的输入的,因此 cin 读不进去 空格 和换行。我们可以用 cin.get() 这样就能读取到 空格 和换行啦。
于是我们写出了这样的代码:
没问题了吗?并不是!当我们尝试向一个string对象进行连续的 cin 时,它还是会保留上一次的输入,因此在输入之前还需要清楚 s 原有的字符!
还不够,我们对比 std::string 发现,输入一连串空格之后再输入其他字符,或者输入一连串换行之后再输入其它字符,是能够正确跳过空格和换行输入有效字符的,因此我们还需要清空缓冲区!
istream& operator>>(istream& in, string& s)
{
//清除s的其他字符
s.clear();
char ch = in.get();
//清空缓冲区
while (ch == ' ' || ch == '\n')
ch = in.get();
while (ch != ' ' && ch != '\n')
{
s += ch;
ch = in.get();
}
return in;
}
还有一些人认为,如果输入的字符数量很多,可能会导致 s 扩容次数太多,影响效率。因此搞出了一个数组,用来暂时存放输入的字符,等到这个字符满了,或者输入结束,再让 s += 这个字符数组。
istream& operator>>(istream& in, string& s)
{
//清除s的其他字符
s.clear();
char ch = in.get();
//清空缓冲区
while (ch == ' ' || ch == '\n')
ch = in.get();
char buff[128];
int index = 0;
while (ch != ' ' && ch != '\n')
{
if (index == 127)
{
buff[127] = '\0';
s += buff;
index = 0;
}
buff[index] = ch;
ch = in.get();
}
if (index != 0)
{
buff[index] = '\0';
s += buff;
}
return in;
}
20. 拷贝构造函数
哈哈,你肯定想知道为啥拷贝构造函数现在才写!肯定不是因为忘了!哈哈哈!
string 类中的对象维护了堆区的数据,因此我们要实现深拷贝!深拷贝之前就讲了很多啦!不再赘述!
20.1 传统写法
string(const string& s)
{
char* tmp = new char[s._capacity + 1];
memcpy(tmp, s._str, s._size + 1); //+1 是为了拷贝\0
delete _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
20.2 现代写法
现代写法之所以现代就是因为它现代!
string(const string& s)
{
if(this != &s)
{
string tmp(s.c_str());
std::swap(_str, tmp._str);
std::swap(_size, tmp._size);
std::swap(_capacity, tmp._capacity);
}
}
我们通过 s.c_str() 构造出来一个 tmp 对象,然后通过 swap 函数将 tmp 维护的空间和信息交给 *this 对象。又因为 tmp 是一个临时对象,出了作用域 tmp 就会销毁,调用析构函数,正好将 *this 维护的空间释放掉,简直就是依据两得!有点工具人的味道,哈哈哈!
21. void swap(string& s)
这个函数就是用来交换两个对象所维护的空间和信息的,实现这个函数我们能更加方便的使用现代写法!
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
22. 赋值运算符重载
有了现代写法,赋值运算符重载写起来那简直叫一个爽!
//传统写法
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 = s._size;
_capacity = s._capacity;
}
return *this;
}
//现代写法
string& operator=(string tmp)
{
swap(tmp);
return *this;
}
23. string迭代器的实现
迭代器在string中就是 char* 的指针,迭代器究竟是什么,等我们学到 list 再来理解!
首先我们要在 string 中定义一个迭代器类型!
然后我们就逐一来实现 begin() 和end() 起始很简单啊!
begin():返回 下标为 0 的元素的地址。
end():返回 _size 下标处的地址。
*,++,--,都不需要自己实现,因为指针是内置类型,并且 string 的物理空间连续。完全不需要自己动手!
注意实现两个版本:const 和 非 const 版本!
迭代器只要实现了,范围 for 就可以使用了,如果范围 for 不知道是什么的老铁可以复习一下:
21天学会C++:Day8----范围for与nullptr_姬如祎的博客-CSDN博客
范围for的底层就是 迭代器,范围for会被无脑替换成 迭代器遍历!
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
到这里,我们就实现了一个属于自己的string,模拟 std::string 实现的方法有很多。模拟只是为了加深对 string 的理解与应用!好啦!不见不散!