C++初阶(11)string类的模拟实现
3. string类的模拟实现
上面已经对string类进行了简单的介绍,大家只要能够正常使用即可。在面试中,面试官总喜欢让学生自己来模拟实现string类。
按原生版本,要实现成basic_string<……>,就很复杂了,而且平常常用的也就string。简单实现string就好了。
最主要是实现string类的构造、拷贝构造、赋值运算符重载以及析构函数。
3.1 string.h
- 写的string类最好放在命名空间中隔离,避免与标准库的string冲突。
- 自己写的"string.h"和标准库的<string.h>不会冲突,因为"string.h"先去项目路径底下找。
//string.h#define _CRT_SECURE_NO_WARNINGS 1 //strcpy
#pragma once
#include<iostream>
#include<assert.h>
using namespace std;namespace bit
{class string//使用命名空间隔离一下,避免跟库里面的string类冲突{public:typedef char* iterator;//迭代器类似于指针,不一定就是指针,但是可以把它简单实现为指针typedef const char* const_iterator;iterator begin();iterator end();const_iterator begin() const;const_iterator end() const;//无参//string();//带参·全缺省string(const char* str = "");// 1.给nullptr不行// 2.给"\0"也不行——有两个\0,字符串后面默认跟着一个\0// 3.给'\0'也不行——char不能转换成const char*~string();//拷贝构造string(const string& s);//string& operator=(const string& s);string& operator=(string tmp);const char* c_str() const;size_t size() const;char& operator[](size_t pos);const char& operator[](size_t pos) const;void reserve(size_t n);void push_back(char ch);void append(const char* str);string& operator+=(char ch);string& operator+=(const char* str);void insert(size_t pos, char ch);//在pos位置插入一个字符void insert(size_t pos, const char* str);//在pos位置插入一个字符串void erase(size_t pos = 0, size_t len = npos);//从pos位置开始删除len个字符(声明给缺省值,当len大于_size-pos或没给len,有多少删多少,当没给pos,全删)size_t find(char ch, size_t pos = 0);size_t find(const char* str, size_t pos = 0);void swap(string& s);string substr(size_t pos = 0, size_t len = npos);//之前说这是一个系列,实现两个就好了,另外4个可复用bool operator<(const string& s) const;bool operator>(const string& s) const;bool operator<=(const string& s) const;bool operator>=(const string& s) const;bool operator==(const string& s) const;bool operator!=(const string& s) const;void clear();private://VS下设计得更复杂,设计了一个_buff数组// char _buff[16];char* _str = nullptr;size_t _size = 0;size_t _capacity = 0;// 静态成员变量如何初始化???// 静态成员变量在类里面声明,类外面定义const static size_t npos;//这里能不能这么写???(给缺省值)//const static size_t npos = -1;//可以这么写(√特例√):既是声明,也是定义//不建议写特例,建议按一般静态成员变量的方式来写//之前说//size_t _size = 0;这种给缺省值可以——成员变量会走初始化列表,缺省值就是给初始化列表,如果没有显式初始化就会调用这个缺省值//但是静态成员变量给缺省值就不可以——因为不走初始化列表//×不支持×//static size_t npos = -1;//——报错error C2864:bit::string::npos:带有类内初始化表达式的静态 数据成员 必须具有不可变的常量整型类型,或必须被指定为“内联”//但是这里的加上const就可以这么写——但是只有整型是这样,浮点型还是不行//×不支持×//const static double N = -1//——报错error C2864: bit::string::N: 带有类内初始化表达式的静态 数据成员 必须具有不可变的常量整型类型,或必须被指定为“内联”//为什么只有整型是特例:const static size_t size有用于作数组大小的时候,为了方便就保留了这一个特例};//const size_t string::npos = -1;istream& operator>> (istream& is, string& str);ostream& operator<< (ostream& os, const string& str);
}
3.2 string.cpp
其中“拷贝构造”、“赋值重载”分为:传统写法、现代写法。
在头文件把类的定义放到了命名空间中,在源文件中最好的处理就是也放到同一个命名空间中。
多个文件的同一个命名空间会合并。
类里面声明,类外面定义,要指定类域。
3.2.1 构造函数、析构函数
不能直接用参数str值拷贝给成员变量_str来初始化。
应该另开一样大的空间给_str初始化。
string的size和capacity都不把\0计算在内。
new默认不初始化,但是支持加{ }来进行初始化。
析构的free是允许free空指针的。
完成数据拷贝。
这个构造的问题在于连用3次相同的strlen调用,效率比较低:
sizeof:编译时根据内存规则计算大小;
strlen:运行时计算字符串长度;
经典错误:
初始化列表的初始化顺序和声明顺序一致(内存存储顺序),而与初始化列表的前后顺序无关。
这里先初始化_str,此时_size是随机值。
改声明顺序是可以,但是这种方式很挫,一旦哪天把声明顺序改回去,这个代码会立刻爆雷。
- 初始化列表的初始化顺序规则是需要知道,而不能利用的;
- 初始化列表的变量之间要尽量减少耦合度;
这里最好结合初始化列表、函数体一起来初始化,并不是一定要在初始化列表初始化。
(但是有3种类型必须走初始化列表)
string还需要一个无参的构造——默认构造
代码测试:
后定义的先析构,str2先delete[]析构,free可以free空指针。
但是加了这行打印,调用c_str获取到一个空指针,cout输出char*不会按指针打印,也就不会打印空指针,cout输出char*时会直接打印字符串,这里就会对空指针解引用,程序崩溃。
cout输出char*,会认为你是字符串,会按字符串打印,会对char*解引用。
cout << str2.c_str() << endl;//等价于cout << (const char*)nullptr << endl;
库里面的string是有空间的,没有字符,但存了一个\0,开了1个空间的。
写无参构造时,可以选择跟库里面的保持一致。
string::string()
{//_str = nullptr;//为了跟库里面保持一致,这里也需要开一个空间,存\0//以下两种写法等价//_str = new char('\0'); //用new,()是初始化_str = new char[1]{'\0'};//用new[],{}是初始化//等价于//_str = new char[1];//_str = '\0';_size = 0;_capacity = 0;
}
实践当中还是不建议这样写,带参构造、无参构造,可以合并成为一个带参全缺省。
就写一个构造——必然是要写带参全缺省构造:
- ①支持默认构造(不传参调用);
- ②支持传参用于初始化;
【注意】
- 无参构造、带参全缺省不能同时存在,要把无参构造注释掉。
- 缺省参数由声明给,定义的地方不写缺省值。
- strlen计算空串的结果是0,_str正好是开1字节的空间,_capacity和_size都是0。
- strcpy拷贝空串会把\0拷贝过去。
【注意】这里使用strlen、strcpy之前没有检测str非空。
3.2.2 c_str
刚开始直接实现流插入、流提取比较麻烦,可以先实现c_str,也能实现打印效果。
这里存在一个权限的缩小,_str是char*,返回值类型是const char*。
测试打印:
C++字符串string和C风格的字符串一样,都有\0,一方面是C++的string也需要结束标志,二来也是由于string有转成C字符串的需求,string也有\0就能很好地转换成C风格的字符串。
3.2.3 size()、operator[]
为了实现遍历效果,可以先实现这两个成员函数。
代码测试:
3.2.4 迭代器
通过迭代器,可以实现第二种遍历方式(迭代器)、第三种遍历方式(范围for)。
可以把测试代码也放入命名空间内,避免和之前的test_string1()冲突。
实际上,范围for的底层就是迭代器,现在不支持就是因为还没有实现迭代器。
string的迭代器的实现方式有两种:
- 简单版:使用内置类型char*
- 复杂版:使用自定义类型
这样子范围for就可以编译通过了,底层实际上是迭代器。
迭代器是一个:
- 是内部类;(C++不太喜欢用内部类)
- 或者是在类里面typedef的一个类型,属于这个类域;
用这个类型可以定义一个对象,同时begin和end的返回值都是这个类型。
这里这个iterator类型的使用,需要指明类域。
【注意】
- 不typedef,把所有的iterator改成char*,迭代器的方式、范围for的方式,依然可以正常使用,只是这时就不存在迭代器的概念了。
范围for只认begin和end——获取开始位置、获取结束位置。
s1的位置需要调用begin和end,只要begin和end有,不论返回值,e都能接收——e的类型是auto推导出来的。
问:所以这里既然char*可以很好地使用,为什么还要typedef ?
答:因为直接写成char*通用性差,并不是所有的迭代器都是char*,很多都不是。
迭代器体现的是一种“封装”的思想。
这里其实是第二种更深层次的“封装”——面向对象编程的3大特性之一
- 第一种封装:把数据和方法放到类里面,想给你访问的设置成公有,不想给你访问的设置成私有。
- 第二种封装:迭代器的真实类型在类里面,通过typedef后,将真实类型用iterator进行封装,呈现出来的接口就是string::iterator。
string::iterator不一定是char*,不同的平台迭代器的类型都不同,为了保证外部接口的一致性,就统一包装成string::iterator(这里也是一种封装,隐藏了底层的实现细节)
所以这里不能直接写成char*,在不同的编译器下,string::iterator都可以通用, 不用关心它真实的类型是什么,只管这么用:它可以像指针一样用,但它真的是指针吗???——>不一定。string::iterator it1 = s1.begin();
任何平台,它的库里面都会typedef一个xxx成为iterator,这个xxx具体是什么不确定。
STL规范了任何的容器所提供迭代器,都typedef成iterator,每个容器都有一个iterator,也不会重名,是因为每个类都有自己独立的域:string::iterator、vector::iterator、list::iterator……
各种容器底层的实现不一样,但是依靠 封装 屏蔽了底层的细节,向上层提供统一适用的接口
封装:屏蔽了底层实现细节,提供了一种“简单、通用”访问容器的方式。
这种“简单、通用”访问容器的方式——我们可以用,算法也可以用,算法通过迭代器就能访问修改容器的数据。
那这样写一个排序算法,就不必要针对某一个特定的数据结构,就可以接受迭代器作为参数,在函数内通过迭代器去访问容器内的数据。
实现算法的时候,就不用关系容器的底层到底是什么,你给我迭代器,我通过迭代器就能访问这个容器,能遍历,能修改。
这样算法和具体的数据结构就隔离开来了,这些通用的算法就不用写在每个容器里面,而可以写成函数模版,参数iterator可以在调用传参时具体推演具体的类型。
以前网银支付——各个银行的网银都不一样,有的需要激活U盾。
现在微信支付、支付宝支付——支付宝接通各个银行的接口,使用支付宝只管输密码,内部从工行扣钱、建行扣钱方式都不一样……
支付宝和微信帮助封装屏蔽了底层的细节,使得外接接口十分通用简单,只用扫码输密码即可支付。
在顶层调用都用iterator,而底层的具体实现细节,不同容器、同一个容器不同编译器,都不一样。
像链表的迭代器,使用自定义类型,那*(解引用)、++、--就都需要重载运算符。
而string的迭代器可以使用原生指针(char类型使用char*指针),就是因为string底层的物理结构是连续的,可以直接借助指针的加减,实现元素的迭代。
3.2.4.1 const迭代器
const迭代器不是迭代器本身不能修改,迭代器本身可以进行迭代。
const迭代器是迭代器指向的内容不能修改。
string的const迭代器可以使用const指针,const char*来实现。
对应地可以实现begin、end的const版本。对应地可以把之前[ ]重载的const版本补上
代码测试:
string的基本结构就设计得差不多了,接下来实现一下插入操作。
3.2.5 追加
3.2.5.1 reserve()、push_back()、append()
push_back插入一个字符:当前满了,就扩容2倍。
append插入一个C字符串:当前满了,不能简单扩容2倍,因为插入的字符串可能很长,单纯扩2倍可能不够。
这时候就可以先实现一个reserve,可以用于扩容,reserve一般不会缩容。
//请求保留空间——一般不会缩,比capacity大可以扩void string::reserve(size_t n){//给的值比当前容量大,就扩容,否则不作为——不会缩容if (n > _capacity){char* tmp = new char[n + 1]; //手动扩容——多开一个,留给\0,\0不算在capacity中//不用判断new失败,失败会抛异常strcpy(tmp, _str); //拷贝数据delete[] _str; //释放旧空间_str = tmp; //指向新空间_capacity = n;}}//c++就不使用realloc、malloc这一套了——不好用,对于自定义类型不会自动调用构造。//而C++又没有renew这样的东西,所以只能手动扩容。// 插入一个字符// 1.判断需不需要扩容// 2.尾插(注意补\0)void string::push_back(char ch){//扩容的前提条件:满了if (_size == _capacity) {//计算新的容量size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;//用空串初始化(这也是默认初始化),capacity会为0//扩容reserve(newcapacity); //直接使用reserve来扩容}//插入——已知:size是最后一个字符的下一个位置,即\0的位置_str[_size] = ch; //插入到size位置_str[_size + 1] = '\0'; //补\0++_size; //size迭代}void string::append(const char* str){size_t len = strlen(str);//扩容的前提条件:总长度大于当前容量if (_size + len > _capacity){reserve(_size + len);}//直接追加strcat(_str, str);_size += len;}
【strcat的追加原理】
- 最始:strcat会找到\0,从\0的位置起开始进行覆盖追加;
- 最后:追加串的\0也会追加过去。
当前存在的一个问题:使用strcat要谨慎,它会从当前字符开始自己去找\0,效率较低。
使用strcpy效率更高,不再让函数去慢慢找\0的位置,而是我们来找\0的位置并直接给到strcpy。
size位置就是\0的位置。使用strcpy指定起始位置_str+size。
库里面的append重载了比较多个版本,实现思路都差不多,而用得最多的还是追加一个C字符串,模拟实现时就没必要再实现其他版本了。
当然日常生活中也不怎么用push_back、append,更多的时候还是使用+=重载。
3.2.5.2 +=重载
+=可以重载两个版本:追加字符、追加字符串。(参数不同构成函数重载)
+=是有返回值的,要返回+=之后的结果,可以直接返回对象本身。
传值返回有拷贝构造,传引用返回效率更高,前提出了作用域,返回对象还在。
直接代码复用就行了。
3.2.6 插入、删除
3.2.6.1 insert()-字符
标准库的insert也是实现了很多个版本,这里只需要实现两个常用的就够了:
- 在pos位置插入一个字符;
- 在pos位置插入一个C字符串;
这里需要一个npos。把npos作为静态成员变量,类里面声明,类外面定义。
但是类外面定义不能在.h文件,一般是.h文件放声明,.cpp文件放定义。
类里面的静态成员变量相当于全局变量,有了外部链接属性。
在.h定义的话,.h在多个.cpp文件一展开,生成的多个.o文件一链接,就会报链接错误。
所以一般是.h文件放声明,.cpp文件放定义。
- string.cpp要用npos直接就能用这个定义。
- test.cpp要用npos就只有头文件里面的一个声明,可以使用,在链接的时候再去找(跟函数一样)
那这里能不能这么写,给一个缺省值?
可以这么写——特例:既是声明,也是定义。
(不建议写特例,建议按一般静态成员变量的方式来写。)
之前说 size_t _size = 0; 这种给缺省值可以,成员变量会走初始化列表,缺省值就是给初始化列表,如果没有显式初始化就会调用这个缺省值;
但是静态成员变量给缺省值就不可以,因为静态成员变量的初始化不走初始化列表。
静态成员变量不在对象里面,在静态区,属于整个类。
不支持 static size_t npos = -1; ,报错error C2864:bit::string::npos:带有类内初始化表达式的静态数据成员必须具有不可变的常量整型类型,或必须被指定为“内联”。
但是这里的加上 const 就可以这么写——但是只有整型是这样,浮点型还是不行。
不支持 const static double N = -1; ,报错error C2864: bit::string::N: 带有类内初始化表达式的静态数据成员必须具有不可变的常量整型类型,或必须被指定为“内联”。
为什么只有整型是特例???
原因: const static size_t size 有用于作数组大小的时候,为了方便就保留了这一个特例。
代码测试:
按F5进入调试。
end是size_t类型,这样当执行完end==0的循环,将第一个元素移动到第二个位置,end减到-1直接变成最大的size_t,_str[end]就会越界访问。
pos是0,end小于0循环结束,但是end永远不会小于0。
涉及到size_t的循环、比较时,要注意:
- 条件判断式;
- 迭代式;
改成int类型,还是越界,就是因为条件判断式那里,int和size_t比较,int会提升为size_t。
这个现象是“算术转换”,是一种隐式类型转换。
(整型提升:小于int的提升到int)
pos不太好给成int类型,因为参数类型最好和标准库保持一致,给size_t。
这样就不用assert(pos >= 0)了。
那此时第一种解决办法就是——强制类型转换。
第二种解决办法就是——改变条件判断式:使用>而不是>=。
当pos(无符号)为0的时候,>=肯定坑:大于等于无符号0继续,小于无符号0才结束,但是无符号一定>=0。
那如何去掉“=”呢?可以把end的起始位置变一下。
3.2.6.2 insert()-字符串
插入的位置是_str[pos],pos是下标,插入的字符串实际上是从第pos+1个字符处开始。
pos是下标,允许了插入到第0个位置。
写法2当len==1的时候,就和之前的insert()单个字符的代码一模一样了。
- 最后拷贝字符串的时候,不能使用strcpy(会把\0拷贝过去)。
- 可以使用memcpy、strncpy、for循环这3种方法。
写法2的_str[end]要注意:
代码测试:
这两个insert写好之后,之前的push_back和append就可以复用了。
在_size位置就是尾插。
3.2.6.3 erase()
- 如何控制全删???
① delete——不行,空间是整块申请的,不能部分删除。
② 实际数组在全删pos后面数据的时候,没必要把数据抹除,直接两步操作完事:
- 加\0覆盖pos(塞一个\0);
- 改_size;
- 如何控制删中间???——数据挪动,进行覆盖。
【注意】内存重叠问题
【总结】使用strcpy时,尽量不在内存重叠的情况下使用;如果非要在内存重叠的情况下使用,那后串给前串拷贝,不会出错;要是前串给后串拷贝,就会出错了。
代码测试:
3.2.7 find()
模拟实现find的两个版本:
- 从pos位置开始查找一个字符;
- 从pos位置开始查找一个C字符串;(可以复用strstr)
代码实现。
一般公司的软件开发工程师对算法的要求没有那么高,包括动态规划这些,掌握一些简单的就可以了。
公司也不会让软件开发工程师去控制一些很难的算法,怕你控制不住,你控制住了后面的新人可能控制不住,都会有专门的算法工程师。
但是某些大公司,对软件开发工程师的算法要求会比较高(动态规划、贪心算法)——用算法筛选思维能力和代码能力。
3.2.8 拷贝
x.1 string类的经典问题
程序崩溃:没写拷贝构造,编译器自动生成的拷贝构造完成浅拷贝,引发对同一块空间的连续析构
【说明】
- 上述String类没有显式定义其:拷贝构造函数、赋值重载函数。
- 此时编译器会生成默认的。当用s1构造s2时,编译器会调用默认的拷贝构造。
- 最终导致的问题是,s1、s2共用同一块内存空间,在释放时同一块空间被释放多次而引起程序崩溃。
- 这种拷贝方式,称为浅拷贝。(值拷贝)
浅拷贝会把s1对象的内存空间上的字节(_str、_size、_capacity)照搬到s2对象的内存空间。
另一个问题就是s1的改变,会导致s2跟着改变。
x.2 浅拷贝
【浅拷贝】
- 也称位拷贝,编译器只是将对象中的值拷贝过来。
- 如果对象中管理资源,最后就会导致多个对象共享同一份资源,当一个对象销毁时就会将该资源释放掉,而此时另一些对象不知道该资源已经被释放,以为还有效,所以当继续对资源进项操作时,就会发生发生了访问违规。
就像一个家庭中有两个孩子,但父母只买了一份玩具,两个孩子愿意一块玩,则万事大吉,万一不想分享就你争我夺,玩具损坏。
可以采用深拷贝解决浅拷贝问题,即:每个对象都有一份独立的资源,不要和其他对象共享。
父母给每个孩子都买一份玩具,各自玩各自的就不会有问题了。
x.3 深拷贝
如果一个类中涉及到资源的管理,其拷贝构造函数、赋值运算符重载、析构函数必须要显式给出。
一般情况都是按照深拷贝方式提供。
所以这里string的拷贝构造要显式实现成深拷贝,让s1、s2的内容相同,但各自有各自独立的空间。
- 浅拷贝s1、s2确实是值相同、有各自的独立空间,但是它们管理的是同一块空间。
- 所以对于内置类型,无资源管理的自定义类型使用默认浅拷贝都没有问题。
3.2.8.1 拷贝构造
测试结果。
3.2.8.2 =重载
使用默认生成的=重载,程序崩溃。
赋值的情况分析:
比较粗糙的处理方式:
释放原空间、申请新空间(等大);拷贝数据;改变指向。
//=重载:s1 = s3;s1 = s1
string& string::operator=(const string& s)
{char* tmp = new char[s._capacity + 1]; //开一块跟被拷贝串一样大的空间——实际空间比capacity多1,留给\0strcpy(tmp, s._str); //把数据拷贝过去delete[] _str; //直接释放掉原空间_str = tmp; //成员变量初始化——改变指向_size = s._size; //成员变量初始化_capacity = s._capacity; //成员变量初始化return *this;
}
通过“调试-监视”能看到,自己给自己赋值s1 = s1;,s1的地址会改变。
不作判断的话,上来就“开空间,拷贝数据,释放旧空间”。
之前日期类不判断问题也不大,这里需要判断不是自我赋值,再进行操作,若是自我赋值,就直接返回*this,提高效率。(赋值需要支持连续赋值,所以要返回自己这个对象)
优化效率:
代码测试。
3.2.8.3 swap()
使用库里面的swap——是函数模版,给它传s1,s3,它能够帮助我们完成交换。
但是!!!这个swap代价极大。
- 用a拷贝构造一个c,b赋值给a,c赋值给b。
- 3次深拷贝:开新空间+拷贝数据+释放旧空间+修改指向。
自定义类型(深拷贝的类型)去调用库里面的swap要谨慎!!!
实际上借助一个中间变量,交换s1和s3的成员变量们就好了。
由于通用算法<algorithm>里面提供的swap对于自定义类型的交换效率不高,所以string实现了自己的swap函数,而且有两个版本:成员函数、全局函数。
成员函数版本:
全局函数版本:
相当于是封装了成员函数版swap()。
代码实现(成员函数版):
这里是自己调自己,参数不匹配,不构成递归,需要指定命名空间。
代码测试:
C++考虑得很周全,为避免一不小心调用到swap(s1,s3)而产生较大的消耗,在string类里面,有一个全局的swap,它是通用算法swap的重载。
当我们直接调用std::swap(s1, s3)时,根据模版的匹配原则:
- 有参数类型直接对应的重载,优先调用这个重载。(有现成,吃现成)
所以std::swap(s1, s3)也不会调用到通用算法swap,而是会去调用string类的全局重载swap。
所以平时交换string的时候,也不用担心写成swap(s1,s3)而不是s1.swap(s3),效率差不多。
越容易掉坑,说明这块库写得越差;越不容易掉坑,说明这个库考虑得比较周全。
3.2.9 substr
从pos位置开始,取len个字符构造子串:
- len大于剩余长度,或没给len,全取;
- len小于剩余长度,取len。
返回值:返回这个子串——这个子串是在函数体内创建的临时对象,只能传值返回。
代码测试:
3.2.10 关系运算符重载
标准库string的关系运算符重载:
- 6个关系运算符重载,都各自有3个版本,因为希望实现C字符串和string的比较。
- 重载为全局函数,因为希望C字符串和string无论在运算符哪边都能正常调用。
这里直接重载为成员函数就可以了,每个操作符只需要写一个版本,char*可以隐式类型转换成string,但是用注意调用时string需要再运算符的左边。
《Effective C++》里面说要减少“转型动作”,因为转型都是会付出代价的,中间会生成临时对象。特别是有些特别大的对象转型,代价就比较大。实践当中,《Effective C++》也是几十年前写的,虽然更新两版,有些理论还是有些过时的,尽信书不若无书,结合实践来理解。对现在的计算机而言,转换构造一个string对象的消耗微乎其微。
代码实现。
复用strcmp:
能复用strcmp,那其余四个就不一定非要复用<和==了,它们也可以直接复用strcmp。
3.2.11 流运算符重载、clear()
流插入和流提取,不适合写成成员函数,因为按照使用习惯,第一个位置必须是ostream/istream的对象。
之前日期类写成友元,方便访问私有,这里没必要。
可以不访问私有_str,而是访问公有【】,一个字符一个字符地访问。
通过【】重载,可以获取string的元素。
- 流插入:返回ostream对象。
- 流提取:返回istream对象。
3.2.11.1 <<重载
先来看到流插入的重载。
3.2.11.2 >>重载、clear()
再来看流提取的重载。
scanf和cin拿不到换行或空格——默认规定是多个值之间的分割。
但是实际上scanf能拿到空格。
试试scanf:
但是这里不能用scanf。c++虽然兼容 c,但是scanf和cin的输入缓冲区不同, 在cin >> str的重载里面使用scanf就会出问题。
c++是损失了效率和一些东西,强行去兼容的。
这里要使用C++的流来提取,使用istream的成员函数get(),功能是提取一个char。
不能用getc(),这是C语言的流。
一般而言,流提取的效果是将输入的内容替换掉s1的原内容,流提取是针对之前的内容进行覆盖。
还需要一个clear函数,清掉当前的内容。
同样的,这里也是不直接访问私有的_str,而是访问公有的+=,一个字符一个字符地提取。
弊端:当输入的字符串过长,一次+=一个字符,就会有很多次扩容。
3.2.11.3 >>重载优化
提前reserve,开小了还是要多次扩容,开大了浪费空间。
可以先把输入的ch放到一个char数组里面,数组满了再插入string。这是一个局部临时对象,出了作用域就销毁了,并且是在栈上操作,很快。
观察容量变化:
前面就开了11的空间,存储"hello world",被数据清除后,capacity不变。
一开始是空串,就是要多少开多少,借助先存入buff,然后一次性开好。
3.2.12 拷贝优化
之前实现的拷贝:拷贝构造、=重载,是传统版写法的String类。
- 传统写法(实在人):什么事都自己干。
- 例如:要吃饭、吃鸡:买小鸡—>养,买水稻—>种。
- 现代写法:事让别人干,自己摘成果。
- 例如:要吃饭、吃鸡:挣钱—>买现成)让别人干活,我去跟它交换
现在介绍一下现代版写法的String类。——学会让系统帮我们干活
3.2.12.1 拷贝构造·优化
//拷贝构造·优化版
string::string(const string& s)
{//1. 调用构造干活:用一个值去构造一个string对象string tmp(s._str); //2. 获取成果,*this直接取构造出来的对象作为自己的本体——夺舍//std::swap(tmp._str, _str);//std::swap(tmp._size, _size);//std::swap(tmp._capacity, _capacity);//s2指向tmp现在指向的东西,tmp去指向s2之前指向的东西//2的这部分可复用我们自己写的swapswap(tmp);
}
//最绝的是this夺舍tmp,tmp指向this,临时对象出作用域销毁,帮我们把this原空间析构了
不能让s2(*this)去调构造,只能是未创建好的对象才能调用构造。
构造不能显式调用:*this.string(s._str);
这个代码有个问题: s2没初始化是随机值, tmp交换到随机值后出函数被析构, 看起来很美好。但是某些编译器下,析构随机值,程序可能会崩溃。
建议:给成员变量一个声明缺省值,在初始化列表对s2进行初始化。
特点就是写法简洁,但是并没有比之前性能更好。
【实例】string s2(s1);拷贝构造
【传统写法】
- 自己开空间,自己拷贝数据,自己完成成员变量的初始化。
【现代写法】
- 用s1的核心数据,去构造一个临时对象。(调用构造干活)
- 交换s2和这个临时对象。(调用swap干活)
【意义】
- 对于现在的string、vector意义没那么大,因为还能自己动手逐步写拷贝构造,和让系统做,工作量差不多,没占多大便宜。
- 但是以后的链表、树的拷贝,得一个节点一个节点地拷,还得使用递归,自己操作就很麻烦,代码复用就能大大减低工作量——性能上没什么差别。
即吃饭、吃鸡:这个水稻总得有人种,这个鸡总得有人养,这个活总得有人干,效率上都要耗费这些时间,只是这个活是你自己干还是让别人干,工作量的问题。
3.2.12.2 =重载·优化
//赋值重载:s1 = s3;
string& string::operator=(const string& s)
{if (this != &s){//调用拷贝构造、构造都可以//string tmp(s); //调用拷贝构造干活string tmp(s._str); //调用构造干活swap(tmp);}return *this;
}
//如果是自我拷贝,之前日期类不判断问题也不大,这里需要判断是自我拷贝,则直接返回*this,提高效率
交换之后,局部变量tmp的销毁,变相地完成了s1的销毁。
tmp帮s1完成拷贝的同时,还顺便帮s1完成了销毁(局部对象出了作用域销毁)
3.2.12.3 =重载·最简优化
//string.h//=重载
//string& operator=(const string& s);
string& operator=(string tmp);//string.cpp
//5.16-最简写法——注意改一下声明
string& string::operator=(string tmp)
{swap(tmp);return *this;
}
一行完成=重载函数的实现,相比于传统的实现方式,代码量大大减少了。
- 之前s1 = s3,s3传参的时候,是引用传参。
- 现在s1 = s3,s3传参的时候,是传值传参,拷贝构造tmp,省了一行拷贝构造的代码。
【注意】
- 现代写法相比于传统写法,效率没有提升,只是编码工作量大大减小了。
- 以后深拷贝都可以像这样直接代码复用,让系统干活。
3.2.13 写时拷贝(了解)
string的拷贝这个部分,包括它的设计部分,还有一种技术方案——引用计数的写时拷贝。
- 在构造时,将资源的计数给成1;
- 每增加一个对象使用该资源,就给计数增加1;
- 当某个对象被销毁时,先给该计数减1,然后再检查是否需要释放资源;
- 如果计数为1,说明该对象是资源的最后一个使用者,将该资源释放;
- 否则就不能释放,因为还有其他对象在使用该资源。
有人觉得说,这个地方这样写拷贝构造,做深拷贝的时候,效率太低了, 比如说有的场景我去进行拷贝:传值传参,传值返回。拷贝了以后,直接又销毁了,消耗有点大还没什么用,能不能就直接浅拷贝——有没有一种技术方案能支持浅拷贝。
【浅拷贝的两大问题】
- 析构两次;
- 修改一个对象,另一个对象也被修改;
但是对于深拷贝,如果我拷贝了以后,也不做什么事,比如直接传值返回,那和做的事比起来,做这件事的代价——深拷贝的代价,见显得会比较大。
于是就有人想出了两种方案,来应对这两个问题:
- 引用计数:用来记录资源使用者的个数——记录有多少个对象,指向这块空间。
- 创建的时候,s2指向s1的同一块空间,空间计数值变成2。
- 析构的时候,不是一上来就释放,而是先自减引用计数。减完之后,引用计数减为0的话,就说明没有对象指向这块空间了,这块空间就可以真正析构了。
——最后一个析构的对象,才释放空间,就不存在析构多次的问题了- 但是一定是每个对象生命周期结束,都会自动调用析构,但是不是每个自动调用的析构函数里面,都进行资源的释放。
- 写时拷贝:写时拷贝就是一种拖延症,是在浅拷贝的基础之上增加了引用计数的方式来实现的,也就是说需要拷贝构造的时候,先只做“浅拷贝+引用计数”,而不做深拷贝;等到被修改的时候再进行深拷贝。(当引用计数不是1,修改的时候谁写谁拷贝)
- s1[0] = 'a',如果引用计数为1,没有对象跟s1共用这块空间,这块空间是s1独享的,就可以直接修改。
- s1[0] = 'a',如果引用计数不是1,就需要再开一块空间,拷贝出来出来给s1,引用计数值减1,这时候再去修改s1,其他共享这块空间的对象继续共用这一块空间。
这个时候,涉及到字符串修改的函数:插入、删除、析构、拷贝构造,[ ]重载……这些函数,都要改写,string类要重新设计。这些函数里面要增加一个函数copy_on_write()——写时拷贝。
void copy_on_write()
{//检测引用计数,不为1,再做深拷贝
}
写时拷贝是一种拖延症、侥幸心理,该拷贝还得拷贝。(该拷贝指涉及数据修改)
多增加这么多工作量,想得到的意义:
- 延迟拷贝的意义:只要拷贝了不修改,就能减少深拷贝,不修改就是赚。
写时拷贝
写时拷贝在读取时的缺陷
VS下的string不是这么设计, linux(g++)下的string就是这么设计。
因为这种方案只有拷贝了不修改,确实就能减少拷贝、析构。
在linux下的进程(子进程和父进程)的拷贝也会使用写时拷贝、引用计数的概念——这是一种通用的技术。其次就是之后讲智能指针,还会用到引用计数的概念。这里不作深入探讨。
但是C++11出来了以后,这种方案就没什么意义了。
因为C++11增加了右值引用的移动语/右值引用的移动构造。
3.3 完整代码
#include"string.h"namespace bit
{const size_t string::npos = -1;string::iterator string::begin(){return _str;}string::iterator string::end(){return _str + _size;}//5.12string::const_iterator string::begin() const{return _str;}//5.12string::const_iterator string::end() const{return _str + _size;}// 21:10string::string(const char* str):_size(strlen(str)){_str = new char[_size + 1];_capacity = _size;strcpy(_str, str);}//5.12-传统写法(要吃饭、吃鸡:买小鸡—>养,买水稻—>种)/*string::string(const string& s){_str = new char[s._capacity + 1]; //开跟s一样大的独立空间strcpy(_str, s._str); //成员变量初始化——拷贝数据_size = s._size; //成员变量初始化_capacity = s._capacity; //成员变量初始化}*/// s2(s1)//5.16-现代写法(要吃饭、吃鸡:挣钱—>买现成)//让别人干活,我去跟它交换string::string(const string& s){string tmp(s._str); //调用构造干活:用一个值去构造//std::swap(tmp._str, _str);//std::swap(tmp._size, _size);//std::swap(tmp._capacity, _capacity); //s2指向tmp现在指向的东西,tmp去指向s2之前指向的东西//这部分可复用我们自己写的swapswap(tmp);}//5.12//粗糙一点的写法——直接释放原空间(自己干活)/*string& string::operator=(const string& s){if (this != &s){char* tmp = new char[s._capacity + 1]; //开一块跟被拷贝串一样大的空间,比capacity多一个——留给\0strcpy(tmp, s._str); //把数据拷贝过去delete[] _str; //直接释放掉原空间_str = tmp; //成员变量初始化_size = s._size; //成员变量初始化_capacity = s._capacity; //成员变量初始化}return *this; //如果是自我拷贝,之前日期类不判断问题也不大,这里需要判断&直接返回*this,提高效率 //如果不是自我拷贝,也会返回*this}*/// s1 = s3// s1 = s1//5.16——改进:系统干活/*string& string::operator=(const string& s){if (this != &s){//调用拷贝构造、构造都可以//string tmp(s); //调用拷贝构造干活string tmp(s._str); //调用构造干活swap(tmp);}return *this; //如果是自我拷贝,之前日期类不判断问题也不大,这里需要判断&直接返回*this,提高效率 }*///5.16-最简写法——注意改一下声明string& string::operator=(string tmp){swap(tmp);return *this;}string::~string(){delete[] _str;_str = nullptr;_size = _capacity = 0;}const char* string::c_str() const{return _str;}size_t string::size() const{//直接返回return _size;}char& string::operator[](size_t pos){//断言检查是否越界assert(pos < _size);//直接返回return _str[pos];}/// ///////////////////////////////////////////////////////////////////////////5.12const char& string::operator[](size_t pos) const{assert(pos < _size);return _str[pos];}//请求保留空间——一般不会缩,比capacity大可以扩void string::reserve(size_t n){if (n > _capacity){char* tmp = new char[n + 1]; //手动扩容——多开一个,留给\0,\0不算在capacity中strcpy(tmp, _str); //拷贝数据delete[] _str; //释放旧空间_str = tmp; //指向新空间_capacity = n;}}//插入一个字符void string::push_back(char ch){//扩容//if (_size == _capacity)//扩容的前提条件:满了//{// size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;// reserve(newcapacity);//直接使用reserve来扩容//}////插入——size:最后一个字符的下一个位置——\0的位置//_str[_size] = ch; //插入到size位置//_str[_size + 1] = '\0'; //补\0//++_size; //size迭代//代码复用insert(_size, ch);}// 插入一个字符串——插入一个字符可以2倍扩容+插入,插入一个字符串就不行,因为可能插入后的字符串长度大于2倍// "hello" "xxxxxxxxxxxxx"//这时候就不能从扩容的角度去写,就需要增加一个函数——reservevoid string::append(const char* str){/*size_t len = strlen(str);if (_size + len > _capacity)//扩容前提条件:长度和大于容量{reserve(_size + len);}//直接追加//strcat(_str, str);//_size += len;//strcat要谨慎使用:// ——它会从当前字符开始自己去找\0//效率较低//使用strcpy更好——自己指定起始位置:size位置——\0位置strcpy(_str+_size, str);_size += len;*///代码复用insert(_size, str);}//+=一个字符:底层很简单,直接调用push_back//有返回值:返回对象本身(传引用返回效率高,前提除了作用域返回对象还在)string& string::operator+=(char ch){push_back(ch);return *this;}//+=一个字符串:底层很简单,直接调用append//有返回值:返回对象本身string& string::operator+=(const char* str){append(str);return *this;}//在pos位置插入一个字符——谨慎使用(要挪动数据:效率低)void string::insert(size_t pos, char ch){//断言给的位置格式是否正确//给pos以size_t的类型就不用考虑小于0的问题了,但是要考虑越界的问题assert(pos <= _size);//如果不够,就扩容if (_size == _capacity){size_t newcapacity = _capacity == 0 ? 4 : _capacity * 2;reserve(newcapacity);}//定义迭代变量end//int end = _size;//while (end >= (int)pos)//{// _str[end + 1] = _str[end];// --end;//}//写法1:int end & pos强转成int——pos的形参类型建议写成size_t而不是int,改不了形参类型就只能在这强转类型//写法2:使用>而不是>=(当pos(无符号)为0的时候,>=肯定坑:大于等于无符号0中止——但是无符号一定>=0)//如何去掉=???——把end的起始位置变一下size_t end = _size + 1;while (end > pos){_str[end] = _str[end - 1];--end;}//大于pos的时候继续,等于pos的时候就终止了//挪走以后直接放_str[pos] = ch;++_size;}//在pos位置插入一个字符串——谨慎使用(要挪动数据:效率低)void string::insert(size_t pos, const char* str){//检查越界assert(pos <= _size);//获取插入字符串的长度size_t len = strlen(str);//先开空间if (_size + len > _capacity){reserve(_size + len);}//再挪动数据,最后拷贝数据//写法1:>= 强转//int end = _size;//while (end >= (int)pos)//{// _str[end + len] = _str[end]; //数据挪动:pos到end(\0)之内的数据全部往后挪动len个长度// --end;//}//写法2:> size_t end = _size + len;//while (end >= pos + len) //理论上len至少是1,这样的话这里写>=也是可以的while (end > pos + len - 1) //直到pos位被挪到pos+len就结束,结束条件>没有等于,结束条件就要写成pos+len-1{_str[end] = _str[end - len];--end;}//拷贝字符串——不使用strcpy(会把\0拷贝过去)//可以使用memcpy、strncpy、for循环这3种方法memcpy(_str + pos, str, len);_size += len;}//以上插入到_str[pos],那么插入的字符串实际上是从第pos+1个字符处开始//因为允许插入到第0个位置的原因,这个pos是数组下标//从pos位置删除len个字符void string::erase(size_t pos, size_t len){//检查越界assert(pos < _size);// len大于前面字符个数时,有多少删多少//if (len == npos || pos + len >= _size)if (len >= _size - pos)//不需要那个或者{//如何控制删???//1.delete——不行,空间是整块申请的,不能部分删除//2.实际数组在删除数据的时候,没必要把数据抹除,直接加\0覆盖pos(塞一个\0),改_size_str[pos] = '\0';_size = pos;//一般情况下erase是不缩容的,即不改变capacity}// len小于前面字符个数时,老老实实自己删,挪动进行数据的覆盖else{strcpy(_str + pos, _str + pos + len);//把源数组str+pos+len拷贝到目的地数组str+pos,直到拷贝完源的\0_size -= len;}}//指定从pos位置开始查找一个字符size_t string::find(char ch, size_t pos){//直接挨着一个一个遍历就好了for (size_t i = pos; i < _size; i++){if (_str[i] == ch){return i;//找到返回它的位置}}return npos;//返回-1——>ffffffff,返回整型的最大值——认为一个串不会有这么长,如果返回值是它,默认没找到}//查找子串——直接使用strstrsize_t string::find(const char* sub, size_t pos){char* p = strstr(_str + pos, sub);return p - _str;//指针转下标:目标位置指针-起始位置指针}// s1.swap(s3)void string::swap(string& s){std::swap(_str, s._str);std::swap(_size, s._size);std::swap(_capacity, s._capacity);}//获取从pos位置开始的一个长度为len的子串string string::substr(size_t pos, size_t len){// len大于后面剩余字符,有多少取多少if (len > _size - pos){string sub(_str + pos); //创建一个临时对象接收子串——char*隐式类型转换为string去拷贝构造+构造==直接构造return sub; //临时对象只能传值返回}else //只取len个{string sub; //创建临时对象sub.reserve(len); //开len个空间for (size_t i = 0; i < len; i++){sub += _str[pos + i]; //拷贝数据}return sub; //临时对象只能传值返回}}bool string::operator<(const string& s) const{return strcmp(_str, s._str) < 0; //比较ascii码——复用C语言库}bool string::operator>(const string& s) const{return !(*this <= s);}bool string::operator<=(const string& s) const{return *this < s || *this == s;}bool string::operator>=(const string& s) const{return !(*this < s);}bool string::operator==(const string& s) const{return strcmp(_str, s._str) == 0; //比较ascii码}bool string::operator!=(const string& s) const{return !(*this == s);}//数据清除void string::clear(){_str[0] = '\0';_size = 0;}//流提取——从输入流提取(需要覆盖,而不是追加)istream& operator>> (istream& is, string& str){str.clear();char buff[128];int i = 0;char ch = is.get(); while (ch != ' ' && ch != '\n') {buff[i++] = ch;if (i == 127)//此时0-126下标都放了字符{buff[i] = '\0';str += buff; i = 0; //开始下一次迭代}ch = is.get(); //提取迭代}//出来之后判断是否不满127个字符if (i != 0){buff[i] = '\0';str += buff;}//这是个类似缓冲区的思路return is;}//流插入——插入输入流里面ostream& operator<< (ostream& os, const string& str){for (size_t i = 0; i < str.size(); i++){os << str[i]; }return os;}
}
4. 扩展阅读
面试中string的一种正确写法
STL中的string类怎么了?