C++中string的实现原理

C++中string的实现原理

背景

当我刚开始学习C++,对C还是有一部分的了解,所以以C的思维去学C++,导致我很长一段时间的学习都处于一个懵逼的状态,C++的各种特性,标准库,模板还有版本的迭代,简直是欲仙欲死。

后来在论坛中就有热心的朋友们出招了:你得放弃C的思维去学C++!!嗯,说得好有道理,这就去试试!!

但是我又发现一个问题,不用C的思维学C++,难道我以撸铁(博主业余喜欢健身)的思维来学C++?又在论坛中一问,原来是要用面向对象的思维来学习。

问题依然没有解决,因为博主压根就不知道面向对象的思维是个啥思维?难道是学C++还要先找个对象?那就没时间学习了!!要知道有对象的程序员处于程序员鄙视链的顶端。

结果问来问去,啥思维都没搞懂,反倒是浪费了时间,回过头来才发现,学习这东西,还是得动手动脑去学,思维转换也不是个拨码开关,看得写得多了,自然会有一些心得,在不断的积累中量变成为质变,而别人给的建议往往只能是过眼云烟,当时觉得很有道理,结果回头就忘.

蛋扯得有点长了,我们来回归正题:

C++中string类的实现原理简述

请注意简述这两个字,因为博主目前依旧处于C++初级学习阶段,对C++的理解仍不够透彻,对于底层的原理尤其是内存实现部分还在进行艰难的研究,不敢妄称详解。但是博主可以保证的是,所有的内容都是经过上机实验的,可能不全面,但是已经是非常努力地保证正确性。

string

定义

string的内容其实就是C中的字符串,在C中是char*类型,而在C++中变成了string类型,定义是这样的:

C:
    char *str="downey";
C++:
    string str="downey";
    string str("downey");

操作

对于C中字符串的操作,其实就是对str指针的操作,增删改查都是在内存的基础上进行操作,非常高的自由度,但是对新手并不友好,因为一旦操作失误,将会引起内存上的问题,而内存上的问题往往是很难进行debug的。

而在C++中,string类提供很多內建方法,可以无害地进行增删改查,基本覆盖用户的所有操作,用户并不需要了解底层实现,拿来直接用,也不会造成内存的问题,对初、中级水平程序员非常友好,而且还有一些亮点(仅列出一些博主认为主要的亮点,欢迎指正与补充):

  • 支持运算符,'+'运算符可以直接拼接字符串,以及一些內建的查找替换方法,使用非常方便
  • 迭代器的发明使得数据与操作分离,对程序的移植、接口扩展和维护都是很有优势的。
  • 支持变长。

关于第一点以及其他string方法,其实就是封装的问题,在C标准库中也能找到相应的函数,但是由于关联性不强,强大的C标准库经常被程序员们忽略。

而第二点中,迭代器其实也可以看成是一种泛型指针,由类本身来实现,但是在C中实现泛型是比较麻烦的,很多程序员对void* 以及其转换简直是恨之入骨,C++在这一点上算是一个突破。

而第三点中的C++变长特性,这个特性相对于C而言是个巨大的优势,在C中如果直接定义一个字符串如:

char *str="downey";

这个字符串默认被编译器识别为const类型,也就无法进行写操作。如果要进行写操作,我们就得将其定义成数组:

char str[]="downey";

但是这个数组的长度是固定的,想删除可以,但是如果想增加一个字符,就会发生数组越界的情况,导致内存问题,好像也很难找到一个好的办法来用C语言来实现这个问题。

C++是如何实现字符串变长的

我们可以继续以沿着C语言的思路来思考怎么样实现变长数组:

V1.0

实现

既然数组是固定的无法扩展,那么我们就用动态申请内存的方式来存字符串,在字符串定义的时候申请一片内存空间,在需要增加字符的时候再申请内存,放置增加的那一部分。

问题

这样有一个弊端,内存动态申请是个十分费时的操作,这样频繁地申请刚好合适的大小在空间上能够达到最优,但是在执行效率上是一个非常大的损失,在目前普遍接受的时间复杂度重要性>空间复杂度重要性的软件环境下,这个是完全不能接受的。


V1.1

实现

在V1.0上做改版:在第一次申请的时候就申请一块大一些的内存,比如初始化的字符串长度为10,那我就申请一块大小为20的内存,如果用完了就又申请一块比目前需要的空间大一些的内存空间,这种情况下,会浪费一部分空间,只要策略得当,浪费的空间是可以接受的。

问题

但是仔细一想,这样又有问题了,经过多次字符串变长之后,一个字符串中的内容很可能对应好几个分段的存储地址,这样会给底层实现带来更大的难度以及更多的操作时间,比如遍历、删除、内存回收等操作。


V1.2

实现

既然上述的版本都存在内存不连续的问题,那我就在V1.1的版本上做相应改进,让它实现内存连续。具体的实现方法为:
还是在第一次申请的时候就申请一块大一些的内存,比如初始化的字符串长度为10,那我就申请一块大小为20的内存,如果这块内存用完了而字符串还需要扩展,那我就去找一块更大的内存,能够同时容纳需要的内存空间,而且还有一些余量,直接将字符串整体迁移到新内存空间中,放弃原来那部分内存空间,这样就实现了字符串的内存连续。

问题

仔细一想,这样又存在一个问题,如果在操作之前未扩展的字符串时,我使用的是指针访问,那在字符串扩展之后,字符串存储地址已经改变了,那这个指针就成了无效指针,再次操作它甚至可能导致严重的内存问题。


V1.3

博主智商余额已经不足,想不出更好的实现方法,欢迎各路大神补充....

STL的string实现

事实上,在STL的实现中,用的就是上述的V1.2版本,在C++ reference 中,明确指出base_string类型是连续存储的,而string继承自base_string类型,实现也是一样的,尽管确实存在指针失效的问题,但是在找不到理想的解决方案时,如果没有比它更好的,那么它就是最好的。


string的实现细节以及示例

注:所有示例代码运行环境为:平台:ubuntu 16.04,编译工具链版本:gcc 5.4.0
上面既然说了string类的内存是连续的,口说无凭,当然是要上代码才有说服力:

char *p=NULL;
string str="(downey)";
p=&str[0];
/*如果直接输出p,cout方法会识别p为指向字符串的指针,将p指向的字符串输出,转换成void*类之后就会输出地址值*/
cout<<"addr of p is "<<(void*)p<<endl;
cout<<"str= ";
for(int i=0;i<str.size();i++)
	cout<<*p++;
cout<<endl;
str+="+(abcdefghijklmnopqrstuvwxyz)";
p=&str[0];
cout<<"addr of p is "<<(void*)p<<endl;
cout<<"str= ";
for(int i=0;i<str.size();i++)
	cout<<*p++;
cout<<endl;

执行结果:

addr of p is 0x7ffd330eeb40
str= (downey)
addr of p is 0x237b030
str= (downey)+(abcdefghijklmnopqrstuvwxyz)

上述示例的内容为,将字符指针p赋值为str的首地址,然后用指针自增的方式遍历整个类内字符串,同时打印出p的地址,这里面还有一次字符串的扩展,从这里我们可以看出两点:

  • 在字符串扩展前后,将str[0]的地址赋值给p,p都能以地址自增的方式访问整个字符串,表明string类的存储为连续的。
  • 在字符串的扩展前后,p的地址出现了变化,表明string类在扩展之后改变了地址,而存储空间是连续的,可以推出整个str都进行了地址迁移。

问题到这里并没有结束,因为我们还需要弄清楚string在的内存分配策略,我们可以用string內建方法 capacity()方法来获取目前string对象申请的内存大小,继续看下列代码:

string str;
cout<<str.capacity()<<endl;
str.append(16,'c');
cout<<str.capacity()<<endl;
str.append(15,'c');
cout<<str.capacity()<<endl;

在程序中,添加字符串,让其一次一次超出原始内存容量,输出结果:

15
30
60
120

看起来像是以 2^n*15的方式增长,我们再来试试:

string str;
cout<<str.capacity()<<endl;
str.append(10,'c');
cout<<str.capacity()<<endl;
str.append(20,'c');
cout<<str.capacity()<<endl;
str.append(40,'c');
cout<<str.capacity()<<endl;

在这次扩展中,并没有每次都超出原始内存容量,结果:

15
15
30
70

跟上述结果不一致,事实证明,string的内存扩展并非遵循某个单一规则,博主的猜测应该是遵循某种扩展算法,根据之前的应用情况采取弹性的策略(博主目前还没搞懂具体分配算法....)。

但是可以确认的是,每个初始化长度不超过15的字符串初始容量是15.

同时我们可以通过reserve()方法来修改内存分配容量的大小,如果我们提前知道需要操作的字符串大小,例如一个几K的文件,我们可以直接这样写:

std::ifstream file ("test.txt",std::ios::in|std::ios::ate);
if (file) {
std::ifstream::streampos filesize = file.tellg();
str.reserve(filesize);

如果不直接指定,一个几K的文件肯定会触发好几次内存重新分配,导致时间和空间上的浪费。

从字符串层面看C和C++比较(仅从字符串层面!)

在这里博主斗胆从C++ string类和C char*字符串层面对比C和C++,如果你看了上面博主的分析,就会觉得不管是从易用性,容错性还是开发效率上C++都要优于C,但是这有个前提,这个前提就是对初、中级程序员而言,因为对初、中级程序员而言,标准化意味着高效,而灵活性反而像是个累赘。

其实在上面的string例子中,不管string实例化的对象中有没有字符串,字符串的长度都是15,这对硬件资源来说无疑是一种浪费,(当然C++有其他策略来弥补,比如写时复制,但是也无法避免浪费),如果能精确地控制每块内存的应用,可以将硬件资源发挥到极致,同时封装本身也将带来资源的浪费,同时C++很多操作中,伴随着一些隐形的临时变量,这些临时变量的构造和析构也是比较费时的。

但是很多盆友就要说了,将硬件资源用到极致必然带来的是开发难度的大幅上升,是的,但是如果用C,我们至少有选择!

在一般的开发过程中,我们一般会从开发效率、执行效率、硬件资源这几个层面来考虑软件的实现,在实际的项目中,很可能会对其中的一项作严格要求,比如硬件资源,比如执行效率,又或者开发效率,C语言至少提供了这么一种可能性,以牺牲其他性能为代价来将一种性能做到极致。

而对于已经标准化的C++而言,便失去了这种柔韧性,虽然说C++是C的超集,但是就如同我在文章开头说的一样,两种语言的设计目的有根本上的区别,或者说思维的不一致注定了C++不会像C那样进行开发。

其实说到这里,C和C++并没有高下之分,语言本身是工具,工具只有合适不合适,没有绝对的谁比谁好,如果有,那么其中一个肯定会被马上淘汰,但是就目前而言,C和C++的存在证明了这两种语言各有各的应用场合。
(这里好像跑题了??)

好了,关于C++标准库string实现的讨论就到此为止啦,如果朋友们对于这个有什么疑问或者发现有文章中有什么错误,欢迎留言

原创博客,转载请注明出处!

祝各位早日实现项目丛中过,bug不沾身.

posted @ 2019-03-04 15:22  牧野星辰  阅读(11115)  评论(3编辑  收藏  举报