前两篇博客已经对string类进行了简单的介绍和应用,大家只要能够正常使用即可。
在面试中,面试官总喜欢让学生自己来模拟实现string类,
最主要是实现string类的构造、拷贝构造、赋值运算符重载以及析构函数。
为了更深入学习STL,下面我们就自己来模拟实现一下string的常用接口函数:
1. string默认成员函数
1.1 构造和析构
我们先试着来实现 string 的构造和析构:
整体框架:
string.h
#pragma once #include<iostream> #include<string> #include<assert.h> using namespace std; namespace rtx { class string { public: string(const char* s) { } ~string() { } private: char* _str; }; void test_string1() { string s1("hello world"); } }
Test.c:
#include "string.h" int main() { try { rtx::test_string1(); } catch (const exception& e) { cout << e.what() << endl; } return 0; }
这里为了和原有的 string 进行区分,我们搞一个命名空间给它们括起来。
我们的测试就放在简单提到的try catch上,然后该序号就能测试了。
构造函数是这样写吗?这样写的话拷贝构造能直接用默认生成的吗
string(const char* s) : _str(new char[strlen(s) + 1])// 开strlen大小的空间(多开一个放\0) { strcpy(_str, str); }
然后我们先实现析构,用 new[] 对应的 delete[] 来析构:
~string() { delete[] _str; _str = nullptr; }
放到上面的框架:编译通过
此时我们改一下测试用例 test_string1,如果我们要用 s1 拷贝构造一下 s2:
详细解析:
说明:上述string类没有显式定义其拷贝构造函数与赋值运算符重载,此时编译器会合成默认的,当用 s1 构 造 s2 时,编译器会调用默认的拷贝构造。最终导致的问题是, s1 、 s2 共用同一块内存空间,在释放时同一块 空间被释放多次而引起程序崩溃 , 这种拷贝方式,称为浅拷贝
1.2 深浅拷贝介绍
如何解决这样的问题呢?
我们 s2 拷贝构造你 s1,本意并不是想跟你指向一块空间!
我们的本意是想让 s2 有一块自己的空间,并且能使其内容是 s1 里的 hello world
这就是深拷贝。
所以这里就涉及到了深浅拷贝的问题,我们下面就来探讨一下深浅拷贝的问题。
浅拷贝:(直接把内存无脑指过去) 也称位拷贝,编译器只是将对象中的值拷贝过来。如果对象中管理资源,最后就会导致多个对象共享同一份资源,当一个对象销毁时就会将该资源释放掉,而此时另一些对象不知道该资源已经被释放,以为还有效,所以当继续对资源进项操作时,就会发生发生了访问违规。
深拷贝:(开一块一样大的空间,再把数据拷贝下来,指向我自己开的空间) 如果一个类中涉及到资源的管理,其拷贝构造函数、赋值运算符重载以及析构函数必须要显式给出。一般情况都是按照深拷贝方式提供。
1.3 拷贝构造的实现
我们之前实现日期类的时候,用自动生成的拷贝构造(浅拷贝)是可以的,
所以当时我们不用自己实现拷贝构造,让它默认生成就足够了。
但是像 string 这样的类,它的拷贝构造我们不得不亲自写:
string(const string& s) :_str(new char[s._capacity + 1]) { strcpy(_str, s._str); }
这就实现了深拷贝。
1.4 赋值的实现
现在有一个 s3,如果我们想把 s3 赋值给 s1:
void test_string1() { string s1("hello world"); string s2(s1); string s3("!!!"); s1 = s3; }
如果你不自己实现赋值,就和之前一样,会是浅拷贝,也会造成崩溃。
所以,我们仍然需要自己实现一个 operator= ,首先思路如下:
string& operator=(const string& s) { if (this != &s) { delete[] _str;// 释放原有空间 _str = new char[s._capacity + 1];// 开辟新的空间 strcpy(_str, s._str);// 赋值 _size = s._size; _capacity = s._capacity; } return *this; }
根据我们的实现思路,首先释放原有空间,然后开辟新的空间,
最后把 s3 的值赋值给 s1。为了防止自己给自己赋值,我们可以判断一下。
这时我们还要考虑一个难以发现的问题,如果 new 失败了怎么办?
抛异常!失败了没问题,也不会走到 strcpy,但问题是我们已经把原有的空间释放掉了,
走到析构那里二次释放可能会崩,所以我们得解决这个问题。
可以试着把释放原有空间的步骤放到后面:
string& operator=(const string& s) { if (this != &s) { char* tmp = new char[s._capacity + 1];// 开辟新的空间 strcpy(tmp, s._str);// 赋值到tmp delete[] _str;// 释放原有空间 _str = tmp;// tmp赋值到想要的地方,出去tmp就销毁了 _size = s._size; _capacity = s._capacity; } return *this; }
这样一来,就算是动态内存开辟失败了,我们也不用担心出问题了。
这是更标准的实现方式,我们先去开辟空间,放到临时变量 tmp 中,tmp 翻车就不会执行下面的代码,tmp 没有翻车,再去释放原有的空间,最后再把 tmp 的值交付给 s1,
这是非常保险的,有效避免了空间没开成还把 s1 空间释放掉的 "偷鸡不成蚀把米" 的事发生。
1.5 写时拷贝(了解)
写时拷贝就是一种拖延症,是在浅拷贝的基础之上增加了引用计数的方式来实现的。
引用计数:用来记录资源使用者的个数。在构造时,将资源的计数给成 1 ,每增加一个对象使用该资源,就给计数增加1 ,当某个对象被销毁时,
先给该计数减 1 ,然后再检查是否需要释放资源,如果计数为 1 ,说明该对象时资源的最后一个使用者,将该资源释放;否则就不能释放,因为还有其它对象在使用该资源。
写时拷贝技术实际上是运用了一个 “引用计数” 的概念来实现的。在开辟的空间中多维护四个字节来存储引用计数。
有两种方法:
①:多开辟四个字节(pCount)的空间,用来记录有多少个指针指向这片空间。
②:在开辟空间的头部预留四个字节的空间来记录有多少个指针指向这片空间。
当我们多开辟一份空间时,让引用计数+1,如果有释放空间,那就让计数-1,但是此时不是真正的释放,是假释放,等到引用计数变为 0 时,才会真正的释放空间。如果有修改或写的操作,那么也让原空间的引用计数-1,并且真正开辟新的空间。
写时拷贝涉及多线程等不好的问题,所以了解一下就行。
2. string 的部分函数实现
刚才我们为了方便讲解深浅拷贝的问题,有些地方所以没有写全。
我们知道string有这几个接口函数:
我们实现只是实现常用的,且length和size是一样的,我们现在增加一些成员:
private: char* _str; size_t _size; size_t _capacity; // 有效字符的空间数,不算\0
2.1 完整默认成员函数代码:
#pragma once #include<iostream> #include<string> #include<assert.h> using namespace std; namespace rtx { class string { public: string(const char* s) { _size =strlen(s);// 因为要算多次strlen 效率低 且放在初始化列表关联到声明顺序 所以不用初始化列表 _capacity = _size; _str = new char[_size + 1];// 开_size+1大小的空间(多开一个放\0) strcpy(_str, s); } string(const string& s) :_str(new char[s._capacity + 1]) , _size(s._size) , _capacity(s._capacity) { strcpy(_str, s._str); } string& operator=(const string& s) { if (this != &s) { char* tmp = new char[s._capacity + 1];// 开辟新的空间 strcpy(tmp, s._str);// 赋值到tmp delete[] _str;// 释放原有空间 _str = tmp;// tmp赋值到想要的地方,出去tmp就销毁了 _size = s.size(); _capacity = s._capacity; } return *this; } ~string() { delete[] _str; _str = nullptr; } private: char* _str; size_t _size; size_t _capacity; }; void test_string1() { string s1("hello world"); string s2(s1); string s3("!!!"); s1 = s3; } }
2.2 c_str() 的实现
c_str() 返回的是C语言字符串的指针常量,是可读不写的:
const char* c_str() const { return _str; }
返回const char*,因为是可读不可写的,所以我们需要用 const 修饰。
c_str 返回的是当前字符串的首字符地址,这里我们直接 return _str 即可实现。
测试一下:
void test_string1() { string s1("hello world"); string s2(s1); string s3("!!!"); s1 = s3; cout << s1.c_str() << endl; cout << s2.c_str() << endl; cout << s3.c_str() << endl; }
2.3 全缺省构造函数的实现
还要考虑不带参的情况,比如下面的 s4:
void test_string1() { string s1("hello world"); string s2(s1); string s3("!!!"); s1 = s3; cout << s1.c_str() << endl; cout << s2.c_str() << endl; cout << s3.c_str() << endl; string s4; }
无参构造函数:
string() : _str(new char[1]) , _size(0) , _capacity(0) { _str[0] = '\0'; }
一般的类都是提供全缺省的,值得注意的是,这里缺省值给的是 " "
有人看到指针 char* 可能给缺省值一个空指针 nullptr:
string(const char* str = nullptr)
也就相当于直接对这个字符串进行解引用了,这里的字符串又是空,所以会引发空指针问题。
所以我们这里给的是一个空的字符串 " ",常量字符串默认就带有 \0,这样就不会出问题:
string(const char* s = "") { _size =strlen(s);// 因为要算多次strlen 效率低 且放在初始化列表关联到声明顺序 所以不用初始化列表 _capacity = _size; _str = new char[_size + 1];// 开_size+1大小的空间(多开一个放\0) strcpy(_str, s); }
这样达到的效果和无参构造函数是一样的,且无参编译器不知道调用哪个,
所以我们就需把无参构造函数删了。
2.4 size() 和 operator[] 的实现
size()的实现:
size_t size() const { return _size; }
size() 只需要返回成员 _size 即可,考虑到不需要修改,我们加上 const。
operator[] 的实现:
char& operator[](size_t pos) { assert(pos < _size); return _str[pos]; }
直接返回字符串对应下标位置的元素,
因为返回的是一个字符,所以我们这里引用返回 char。
我们来测试一下,遍历整个字符串,这样既可以测试到 size() 也可以测试到 operator[] :
void test_string2() { string s1("hello world"); string s2; for (size_t i = 0; i < s1.size(); i++) { cout << s1[i] << " "; } cout << endl; s1[0] = 'x'; for (size_t i = 0; i < s1.size(); i++) { cout << s1[i] << " "; } cout << endl; }
普通对象可以调用,但是 const 对象呢?所以我们还要考虑一下 const 对象。
我们写一个 const 对象的重载版本:
const char& operator[](size_t pos) const { assert(pos < _size); return _str[pos]; }
因为返回的是 pos 位置字符的 const 引用,所以可读但不可写。
从C语言到C++_13(string的模拟实现)深浅拷贝+传统/现代写法(中):https://developer.aliyun.com/article/1513673