创作不易,感谢三连!!
在C语言中,我们想要存储字符串的话必须要用字符数组
char str[]="hello world"
这其实是将在常量区的常量字符串拷贝到数组中,我们会在数组的结尾多开一个空间存储\0,这样我们如果想在访问的时候,比如打印,我们总是认为这个字符串是会读取到\0结束的
但是过于依赖\0也会有一系列的问题:
1、如果我是一个很长的字符串,但是中间有几个/0,那么我很难直接打印出来全部的字符串,因为访问到\0就会卡住
2、如果我们想通过键盘输入hello world,我们把它当成一个字符串,但是cin和scanf会默认访问到第一个空格或者是换行符就结束。
也就是说,我们的字符串如果有空格,那么还得分批次打印……
3、如果我想减少、增加、修改……这个字符串中的某些字符,也十分麻烦……如果是静态字符数组,会面临空间不够的问题,如果是动态字符数组,那么我们还要时刻注意空间的管理,稍不留神就会越界访问。
4、虽然C语言中提供了一系列的str类的库函数,但是这些库函数都是以字符串分离开的,没有把该字符串作为一个整体,并且也容易受到\0的影响。这并不符合C++面向对象的思想。
基于此,我们的祖师爷在想,能不能把string封装成一个类,把它像顺序表一样管理起来,给他增设一些常用的比如增删查改的函数接口?针对扩容进行检查?利用构造函数和析构函数帮助我们管理内存呢??因而我们的string类就诞生了!!
一、标准库中的string类
想要学习strling类,就要去通过他的文档去了解
诶!!我们发现string类竟然是一个叫做basic_strling的类模版生成的?难道string类还能有其他版本??没错!!string类有很多版本
为什么string要有这么多版本呢??原因是这样的:
C语言最早都是由老美发明的,他们研究出了ascii码表
为什么要发明这个东西呢??因为我们的语言和计算机的语言是不一样的,计算机只能看得懂二进制的语言,所以我们为了能让计算机看懂我们的语言,我们必须要给一个映射表, 比如我们想打印apple,那么计算机通过这个ASCII码表来匹配,比如a对应的是97 p对应的是112 l对应的是108 e对应的是101.我们通过调试转出10进制是可以验证这个结果的。
一个字节是8个比特位,所以最多其实可以有256个表示的方法。而美国的英文字母才26个,哪怕算上大写,算上数字,算上一些符号,128个,也就是7bit就足够了!!所以ASCII码在使用英文的国家是非常友好的,每个字节都可以存储一个字符,这样就都可以表示出来。
但是老美也想把技术推广到其他国家啊!!比如中国汉字特别多,如果全部像ASCII码表一样都按照一个字节的方式,那汉字远远不止256个啊!
随着计算机的发展,不同国家也出现了很多字符编码,但是由于字符编码不同,计算机在不同国家之间的交流变得很困难,经常会出现乱码的问题,比如:对于同一个二进制数据,不同的编码会解析出不同的字符
当互联网迅猛发展,地域限制打破之后,人们迫切的希望有一种统一的规则, 对所有国家和地区的字符进行编码,于是 Unicode 就出现了
unicode又有三个版本:
1、UTF-8
UTF-8: 是一种变长字符编码,被定义为将码点编码为 1 至 4 个字节,具体取决于码点数值中有效二进制位的数量
UTF-8 的编码规则:
- 对于单字节的符号,字节的第一位设为 0,后面 7 位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的, 所以 UTF-8 能兼容 ASCII 编码,这也是互联网普遍采用 UTF-8 的原因之一
- 对于 n 字节的符号( n > 1),第一个字节的前 n 位都设为 1,第 n + 1 位设为 0,后面字节的前两位一律设为 10 。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码
2、UTF-16
UTF-16 也是一种变长字符编码, 这种编码方式比较特殊, 它将字符编码成 2 字节 或者 4 字节
具体的编码规则如下:
- 对于 Unicode 码小于 0x10000 的字符, 使用 2 个字节存储,并且是直接存储 Unicode 码,不用进行编码转换
- 对于 Unicode 码在 0x10000 和 0x10FFFF 之间的字符,使用 4 个字节存储,这 4 个字节分成前后两部分,每个部分各两个字节,其中,前面两个字节的前 6 位二进制固定为 110110,后面两个字节的前 6 位二进制固定为 110111, 前后部分各剩余 10 位二进制表示符号的 Unicode 码 减去 0x10000 的结果
- 大于 0x10FFFF 的 Unicode 码无法用 UTF-16 编码
3、UTF-32
UTF-32 是固定长度的编码,始终占用 4 个字节,足以容纳所有的 Unicode 字符,所以直接存储 Unicode 码即可,不需要任何编码转换。虽然浪费了空间,但提高了效率。
UTF-8、UTF-16、UTF-32 是 Unicode 码表示成不同的二进制格式的编码规则,同样,通过这三种编码的二进制表示,也能获得对应的 Unicode 码,有了字符的 Unicode 码,按照上面介绍的 UTF-8、UTF-16、UTF-32 的编码方法 就能转换成任一种编码了
总结:由于UTF-8兼容ASCII,这是他的最大优势,并且在我们日常编程中很多情况只需要一个字节就可以,虽然字节存储的时候需要用一些比特位来区分该字符是几个字节的,但是从效率上来说,他可以根据具体的情况去决定用几个字节去存储,所以互联网大多采用UTF-8,而UTF-16也是变长,他的起点就是两个字节,两个字节其实可以表示完大部分国家的字符了,只有极少数古老的文字可能会需要用到4个字节。UTF-32就很粗暴,无论什么都是用4个字节,所以足够容纳所有的Unicode字符,虽然浪费了空间,但是不需要任何的编码转换,效率会比较高。但是使用得很少,在C11的时候引入了u32string。
简单介绍GBK:
但是微软使用的主要还是GBK,Windows支持GBK的时候UTF-8还没有普及,而微软是一家及其看重存量客户和兼容性的公司,形成了路径依赖不能轻易改变。
GBK是大部分用两个字节表示,一些比较生僻的用三个字节表示
并且一般都是把读音类似的编在一起
以上只是浅浅了解为什么string有这么多个版本,下面开始研究string类!!
二、string类的接口说明
2.1 string对象常见构造(constructor)
我们研究几个比较重要的
1、string()
构造空的string对象,即空字符串
2、string(const char* s)
用C-string来构造string类对象
其实相当于将常量字符串拷贝到str中
3、string(const string&s)
拷贝构造函数
拷贝构造其实是深拷贝,因为str1和str2指向的是不同的空间
4、string(size_t n, char c)
意思是用几个相同的字符去构造
5、string (const string& str, size_t pos, size_t len = npos);
意思是,给一个string类,读取从他的第pos个位置开始往后的len个字符。
如果len给大了,就全部打印完
我们还注意到len如果不给,会给一个缺省值nops,nops是什么呢?
我们可以看到npos是string类里面的一个静态成员变量,他的值是-1,但是由于是size_t类型,所以转化成无符号整型是一个特别大的数
所以如果我们不传的话,也是直接拷贝完。跟传太大是一样的
6、string (const char* s, size_t n);
给一个常量字符串,从第一个位置拷贝到第n个位置
总结: 5和6易混淆,第一个传str类,然后从第pos个位置开始拷贝len个字符,第二个是传常量字符串,从第一个位置拷贝到n位置。
2.2 string类对象的容量操作(Capacity)
1、size和length
size和length其实是一样的, 都代表字符串的长度,但是早期STL还没出现的时候,strling类用的是length,但是后来STL出来后,里面大部分都是用的size,所以为了保持一致性又造了一个size出来,平时用哪个都可以的。
2、capacity
表示string当前的容量,一般来说是默认不算上/0
这边其实是16,但是capacity不会吧/0算进去。相当于capacity是实际容量-1
那string类究竟是如何扩容的呢?我们来用一段代码观察一下:
不同编译器存在差异,因为用的STL版本可能不一样,我们看看g++
对比后我们可以看到区别:
vs用的是PJ版STL,字符串会先被存在_Bx数组中,超过之后才会去动态开辟空间,第一次扩容是从32开始进行1.5倍扩容。而g++是SGI版STL,直接就是从0开始,然后根据情况直接开始扩容,从0开始进行2倍扩容
3、reverse
作用是提前去增加容量,扩容的效率是很低的,所以如果我们知道这个string类最多需要空间,我们一次性开好,可以减少拷贝。如果是减少的话,不会缩容。
他根据1.5倍的扩容规则,至少会扩容到超过你的要求。如果是g++的话,就是刚好到你要求的
4、resize
会去改变size,减少的话就是变少(不会改变容量),如果增多的话就可能会扩容顺便帮助我们初始化,第一个版本的话初始化补\0,第二个版本的话就是补自己想要初始化的内容
5、empty
字符串为空返回1,不为空返回0
6、clear
清楚字符串,变成空字符串(不会缩容)
7、shrink_to_fit
意思是缩容到刚好够容纳他的有效字符个数
如上图一开始的空间是32,然后我们将size变成5,然后进行缩容到刚好够容纳他的大小
所以前面的resize和reverse如果减少的话,都是不会改变容量大小的!!clear也不会
7、小总结
1. size()与length()方法底层实现原理完全相同,引入size()的原因是为了与其他容器的接口保持一
致,一般情况下基本都是用size()。
2. clear()只是将string中有效字符清空,不改变底层空间大小。
3. resize(size_t n) 与 resize(size_t n, char c)都是将字符串中有效字符个数改变到n个,不同的是当字符个数增多时:resize(n)用\0来填充多出的元素空间,resize(size_t n, char c)用字符c来填充多出的元素空间。注意:resize在改变元素个数时,如果是将元素个数增多,可能会改变底层容量的大小,如果是将元素个数减少,底层空间总大小不变。
4. reserve(size_t res_arg=0):为string预留空间,不改变有效元素个数,当reserve的参数小于
string的底层空间总大小时,reserver不会改变容量大小。
5.缩容的shrink_to_fit尽量不要用,意义不大,而且任何一个接口都不会帮我们缩容
2.3 string类对象的访问及遍历操作(Iterators)
1、operator[ ] / at
访问下标为pos的字符,at和他是一样的功能
2、begin+end
获取一个字符的迭代器+获取最后一个字符的迭代器,要用到iterator(迭代器)
可以把迭代器理解成指针,begin是开始的指针,end是指向\0的指针,注意是左闭右开!!
如果是用const类型的迭代器,就不能修改
至此我们知道了三种可以访问的字符串的方法: 1、下标遍历 2、迭代器遍历 3、范围for
3、rbegin+rend
跟上面差不多,只不过是从尾开始访问
有没有感觉string::reverse_iterator有点难写,那用auto是不是更香
2.4 string类对象的修改操作(Modifiers)
1、push back
尾插一个字符
2、 string::operator+=
3.append
(1) string& append (const string& str);
直接尾插一个str对象
(2)string& append (const string& str, size_t subpos, size_t sublen);
从这个字符串的subpos位置往后的sublen个字符尾插
(3)string& append (const char* s)
尾插一个字符串
(4)string& append (const char* s, size_t n);
尾插字符串中的前n个字符
(5)string& append (size_t n, char c);
尾插n个字符
一般来说我们更喜欢用+=,除非是一些特殊情况才会去用append
4,insert
(1) string& insert (size_t pos, const string& str)和string& insert (size_t pos, const char* s);
从第pos个位置开始插入字符
(2)string& insert (size_t pos, const string& str, size_t subpos, size_t sublen)
是从被插入字符串中的subpos位置往后选len个字符插入
(3) string& insert (size_t pos, const char* s, size_t n);
是从被插入字符串中选前n个字符插入
(4)string& insert (size_t pos, size_t n, char c);
从第pos个位置开始插入n个相同字符
5.erase
从pos位置开始往后删除len个字符,不穿nops默认就pos后面全删
一般来说insert和erase都可能设计到大量数据的移动,所以不建议使用!!
6,pop_back
尾删一个字符
7,replace
(1)string& replace (size_t pos, size_t len, const string& str)和string& replace (size_t pos, size_t len, const char* s);
将pos位置开始后面的len个字符替换成别的字符串
(2)string& replace (size_t pos, size_t len, const string& str, size_t subpos, size_t sublen);
是从被替换字符串中的subpos位置往后选sublen个字符插入
(3)string& replace (size_t pos, size_t len, const char* s, size_t n);
是从被替换字符串中选前n个字符插入
(4)string& replace (size_t pos, size_t len, size_t n, char c);
替换n个相同字符
8.swap
交换两个字符串
思考:
明明全局swap也可以达到交换的效果,那string里面也实现一个swap的成员函数有必要吗??
综上,要尽量使用成员函数的swap
2.5 string类对象的操作(operations)
1、c_str(重点)
返回一个指向C类型的字符串指针,下面介绍他的用处:
我们可以观察到,s1.c_str()返回的其实是一个char*指针,但是为什么打印出来的不是地址呢??因为cout可以自动识别类型,对于char*类型的指针他会把它当成是字符串去处理,只要指针不是char*类型的,都会当成打印地址。
我们会发现,当我们尾插‘\0’后再插入一些字符,打印出来的结果就不一样了,因为对于c语言来说,字符串默认是读取到\0停止,但是对于string来说,读取多少是取决于他的成员变size!!
如果string类我们想用C语言的方法处理文件,就可以用c_str
2、find
找一个字符里的子串是否存在,如果存在,返回对应的第一个字符的下标,如果不存在,就会返回string::npos。
(1)(3)(4)版本差不多,区别是一个是找string类,一个是找常量字符串,一个是找字符。pos的缺省值0,不传的话就是从头开始遍历往后找,我们也可以通过pos来缩小查找的范围。
(2)版本就是 找常量字符串从pos位置开始的n个字符
3、refind
和find的区别就是默认是pos开始从后往前找
4.substr
从pos位置开始截取len个字符返回,不传len就是默认全部返回(经常和find以及rfind配合使用)
比如我们想打印出.com,我们可以先用find去定位'.'然后再打印
如果我们不知道几个字符,s1.size()-pos刚好是剩下所有的字符,或者就干脆不传,这时候相当于就是把后面的全部打印完
有些时候要从后往前找(rfind)
如果我们想打印中间怎么办??比如"http://www.cplusplus.com/reference/string/string/find/"我想打印出www.cplusplus.com
5.find_first_of
找到第一个匹配的子串中任何一个字符的下标
练习:将Please, replace the vowels in this sentence by asterisks中的abcdef全部换成*
6,find_last_of
找到最后一个与子字符串任意一个字符匹配的下标。其实可以理解从后往前找第一个
7,find_first_not_of / find_last_not_of
跟前两个类似,区别就是找第一个不匹配的和找最后一个不匹配的
2.6 string类对象的非成员函数
1,operator+ (string)
尽量少用,因为传值返回导致深拷贝效率很低
2,relational operators (string)
我们可以通过这些重载的操作符来比较字符串!!
3,operator>>(string)和operator<< (string)
值得注意的是,从c的字符串数组到c++的string类,原先读取字符串是默认读取到\0,但是封装乘string类后他有了自己的size,所以会根据size去打印,因此是可以打印出\0的,但是>>还是跟之前的scanf一样,默认以换行或者是空格作为标识,如果我们想打印出有空格的字符串,是行不通的!!
因此我们想要流插入有空格的字符串,就得用getline
4.getline
注意要包含string的头文件
三、写时拷贝
3.1 g++下string 的结构
G++下,string是通过写时拷贝实现的,string对象总共占4个字节,内部只包含了一个指针,该指针将来指向一块堆空间,内部包含了如下字段:
1、空间总大小
2、字符串有效长度
3、引用计数
3.2 什么是写时拷贝
写时拷贝就是一种拖延症,是在浅拷贝的基础之上增加了引用计数的方式来实现的。
引用计数:用来记录资源使用者的个数。在构造时,将资源的计数给成1,每增加一个对象使用该资源,就给计数增加1,当某个对象被销毁时,先给该计数减1,然后再检查是否需要释放资源,如果计数为1,说明该对象时资源的最后一个使用者,将该资源释放;否则就不能释放,因为还有其他对象在使用该资源。
3.3 深入理解写时拷贝的存在价值
写时拷贝(Copy-on-write,简称COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。
简单举个例子就是,比如说我现在写了一个开源的程序,业内的无论是大佬还是菜鸟都可以看,而对于菜鸟来说,他的能力有限,一般是以一个学习的心态去学习你的东西,一般只会读而不会去写,其实就没有必要进行深拷贝。而大佬就不一样了,他会以一个更高的角度去审视你的文章,这个时候有可能会发现你的一些不足,如果他需要尝试去修改的话,这个时候就需要进行深拷贝然后尝试优化,不会影响到原来的你开源的程序。也就是说,写时拷贝对于有能力修改的人,进行深拷贝(可读可写),对于没有能力修改的人,只进行了浅拷贝(可读不可写)。效率上会有很多的提升。
换个角度理解,我们可以当成是一种赌博,比如快开学了,但是存在一种可能性是老师有可能忘记收寒假作业,这个时候你赌他不会检查,如果他真的不检查,你就赚大了!!如果在开学前一天说会检查,这个时候你再去疯赶……因为寒假作业从理论上来说就是要完成了,我们心里已经接受了最坏的预期,你想的是,我赌他不检查,如果真的不检查我就赚大了,就算真的要检查,大不了我熬几个夜。
高速缓存命中其实也是这个原理,我们读取一个数据的时候,可能会把后面的数据也跟着带入缓存区,我们不会马上去清理他们,因为有可能马上就有机会用得到
关于写时拷贝的文章:
C++ STL string的Copy-On-Write技术 | 酷 壳 - CoolShell
C++的std::string的“读时也拷贝”技术! | 酷 壳 - CoolShell