【C++】详谈STL中的string

🎬 乀艨ic:个人主页

⛺️这段时间大概会写很多内容,各位感兴趣也请点个关注哇!

⭐️来看看这次的博客吧~



在这里插入图片描述

了解 string 类

我们先来简单的认识一下STL中的string类。

C语言的字符串

C 语言中,字符串是以 ‘\0’ 结尾的一些字符的集合,为了操作方便, C 标准库中提供了一些 str 系列的库函数, 但是这些库函数与字符串是分离开的,不太符合 OOP 的思想,而且底层空间需要用户自己管理,稍不留神可能还会越界访问。

STL的string

STL中的string类

1. 字符串是表示字符序列的类
2. 标准的字符串类提供了对此类对象的支持,其接口类似于标准字符容器的接口,但添加了专门用于操作单字节字符字符串的设计特性。
3. string类是使用char(即作为它的字符类型,使用它的默认char_traits和分配器类型(关于模板的更多信息,请参阅basic_string)。
4. string类是basic_string模板类的一个实例,它使用char来实例化basic_string模板类,并用char_traits和allocator作为basic_string的默认参数(根于更多的模板信息请参考basic_string)。
5. 注意,这个类独立于所使用的编码来处理字节:如果用来处理多字节或变长字符(如UTF-8)的序列,这个类的所有成员(如长度或大小)以及它的迭代器,将仍然按照字节(而不是实际编码的字符)来操作。

总结:

1. string是表示字符串的字符串类
2. 该类的接口与常规容器的接口基本相同,再添加了一些专门用来操作string的常规操作。
3. string在底层实际是:basic_string模板类的别名,typedef basic_string<char, char_traits, allocator> string;
4. 不能操作多字节或者变长字符的序列。

注:在使用string类时,必须包含#include头文件以及using namespace std;
像是string的具体接口我这里就不过多讲述了,可以去查文档,string 类有多到奇怪的的接口函数。这也是目前C++ 阵营中一直在声讨的话题。一个小小的 string 类居然有 106 个成员接口函数。这个问题后面也会具体谈到。

string的实现

C++中string的写法经过长时间的变迁,大体下来有两种写法流传较广。(STL从来没有规范过容器底层的具体实现逻辑,所以具体实现方法也从来没有得到过统一)
不过在谈具体实现之前,我们先了解一种技术——写时拷贝(COPY-ON-WRITE)

string的写时拷贝技术

写时拷贝其实是一种拖延症,但其在编程世界中摇身一变,成了一种天才式的设计!正如C++中的可以随处声明变量的特点一样,Scott Meyers推荐我们,在真正需要一个存储空间时才去声明变量(分配内存),这样会得到程序在运行时最小的内存花销。执行到那才会去做分配内存这种比较耗时的工作,这会给我们的程序在运行时有比较好的性能。毕竟,20%的程序运行了80%的时间。
而我们最喜欢的string就是采用了这样一种技术,从逻辑上去看,我们声明两个或者多个内容一样的 string ,如果我们不进行写的操作而是只读,那么这些string对象是完全可以共用一块内存空间的,当然,如果有某个string对象被改写了,那就在开辟一块空间进行拷贝就行了,这也就是写时拷贝的含义。
C++曾在性能问题上被广泛地质疑和指责过,为了提高性能,STL中的许多类都采用了COW技术。这种”偷懒“的行为的确使使用STL的程序有着比较高要性能。
基于这个技术原理,我们就可以探讨下面的几个问题:

1. 写时拷贝的原理是什么?
2. string类在什么情况下才共享内存的?
3. string类在什么情况下触发写时拷贝(Copy-On-Write)?
4. 写时拷贝的具体实现是怎么样的?

写时拷贝的原理是什么?

引用计数! 是的,string的写时拷贝技术就是通过引用计数实现的!
引用计数:用来记录资源使用者的个数。在构造时,将资源的计数给成1,每增加一个对象使用该资源,就给计数增加1,当某个对象被销毁时,先给该计数减1,然后再检查是否需要释放资源,如果计数为1,说明该对象时资源的最后一个使用者,将该资源释放;否则就不能释放,因为还有其他对象在使用该资源。
不过还有个问题,引用计数放在哪?如果存放在string类中,那么每个string的实例都有各自的一套,根本不能共有一个引用计数,如果是声明成全局变量,或是静态成员,那就是所有的string类共享一个了,这也不行,我们需要的是一个”既统一又不统一“的一个解决方法。这该怎么办?我们先继续往下看。

string类在什么情况下才共享内存的?

显然是在一个类要用另一个类的数据时,就可以共享被使用类的内存了。
具体考虑一下,就是两种情况:1)用别的类构造时。2)用别的类赋值时。
其实就是拷贝构造跟赋值重载里面做一下引用计数的控制就好。当然,我们现在还没解决引用计数到底放到什么地方的问题。
在这之前,我们看一种特殊情况:

	char tmp[]=”hello world”;
	string str1 = tmp;
	string str2 = tmp;

试问,上面的 str1 跟 str2 会公用一个引用吗?
当然,从逻辑上看,我们自然愿意两者共用,但是整个过程并没有发生拷贝构造跟赋值,所以其实并没有出现内存共用的情况。实际上C++至今为止也做不到这种情况下实现两者共用内存。
注:不是说看到 “=” 就是赋值,要做好具体区分!
string类在什么情况下触发写时拷贝?
当然是在共享同一块内存的类发生内容改变时,才会发生Copy-On-Write。比如string类的[]、=、+=、+、操作符赋值,还有一些string类中诸如insert、replace、append等成员函数,包括类的析构时。
修改数据才会触发Copy-On-Write,不修改当然就不会改啦。这就是拖延战术的真谛,非到要做的时候才去做。

写时拷贝的具体实现是怎么样的?

	string h1 = “hello”;
	string h2= h1;
	string h3;
	h3 = h2;
 
	string w1 = “world”;
	string w2(“”);
	w2=w1;

从逻辑上看,我们当然是想让h1、h2、h3共享同一块内存,让w1、w2共享同一块内存。因为,在h1、h2、h3中,我们要维护一个引用计数,在w1、w2中我们又要维护一个引用计数。
如何使用一个巧妙的方法产生这两个引用计数呢?我们想到了string类的内存是在堆上动态分配的,既然共享内存的各个类指向的是同一个内存区,我们为什么不在这块区上多分配一点空间来存放这个引用计数呢?这样一来,所有共享一块内存区的类都有同样的一个引用计数,而这个变量的地址既然是在共享区上的,那么所有共享这块内存的类都可以访问到,也就知道这块内存的引用者有多少了。
如下图:
[图片]

如此一来,我们便得到了g++中STL的实现方法!
注意:下述结构是在32位平台下进行验证,32位平台下指针占4个字节

g++下string的结构

G++下,string是通过写时拷贝实现的,string对象总共占4个字节,内部只包含了一个指针,该指
针将来指向一块堆空间,内部包含了如下字段:

  • 空间总大小
  • 字符串有效长度
  • 引用计数
struct _Rep_base 
{ 
     size_type _M_length; 
     size_type _M_capacity; 
     _Atomic_word _M_refcount; 
};
  • 指向堆空间的指针,用来存储字符串。

写时拷贝的问题

很经典的结构,当我们吹完一种技术的强大之后,随之而来的就是他的问题。
COW技术其实还是存在很多诟病,否则我上面不会说是g++的实现方法,而是应该说STL中string的实现方法。
其中一个主要的问题就是线程不安全!
其实当我们看到这种设计之后,自然就能想到这个方法在多线程”环境中极容易发生错误,如果分别在两个线程中的 string 实例共享着同一块内存,很有可能发生潜在的内存问题。
还有如果导入动态库的话,也存在内存被提前释放的问题。
但是这都不是我想说明的,我更想谈谈一个更“诡异”的问题:读时拷贝!
string的 “读时拷贝” 技术
我们先看一段代码:

static long getcurrenttick()
{
    long tick ;
    struct timeval time_val;
    gettimeofday(&time_val , NULL);
    tick = time_val.tv_sec * 1000 + time_val.tv_usec / 1000 ;
    return tick;
}
int main( )
{
    string the_base(1024 * 1024 * 10, 'x');
    long begin =  getcurrenttick();
    for( int i = 0 ;i< 100 ;++i ) {
       string the_copy = the_base ;
    }
    fprintf(stdout,"耗时[%d] \n",getcurrenttick() - begin );
}

简单省一下流:就是一个非常大的字符串,有10M字节的x,并且执行了100此拷贝。编译执行它,非常快,在我的虚拟机甚至不要1个毫秒。
这也证明了写时拷贝,毕竟只是引用计数++,根本不需要很多的消耗,如果对里面的字符串进行修改,那么花费时间直接蹦到4s,可见COW技术的强大!
但是如果这样:

int main(void) {
    string the_base(1024 * 1024 * 10, 'x');
    fprintf(stdout,"the_base's first char is [%c]\n",the_base[0] );
    long begin =  getcurrenttick();
    for (int i = 0; i < 100; i++) {
        string the_copy = the_base;
    }
    fprintf(stdout,"耗时[%d] \n",getcurrenttick() - begin );
}

我在拷贝之前,读了一下 the_base[0] ,居然也是4~5秒!你可能在想,我只是做了一个读,没有写嘛,这到底是怎么回事?难道真的还有读时拷贝的技术???
不错,为了避免了你通过[]操作符获取string内部指针而直接修改字符串的内容,在你使用了the_base[0]后,这个字符串的写时才拷贝技术就失效了。
C++标准的确就是这样的,C++标准认为,当你通过迭代器或[]获取到string的内部地址的时候,string并不知道你将是要读还是要写。这是它无法确定,为此,当你获取到内部引用后,为了避免不能捕获你的写操作,它在此时废止了写时才拷贝技术!
这样看来我们在使用COW的时候,一定要注意,如果你不需要对string的内部进行修改,那你就千万不要使用通过[]操作符和迭代器去获取字符串的内部地址引用,如果你一定要这么做,那么你就必须要付出代价。当然,string还提供了一些使迭代器和引用失效的方法。比如说push_back,等, 你在使用[]之后再使用迭代器之后,引用就有可能失效了。那么你又回到了COW的世界!比如下面的一个例子:

int main( )
{
    struct timeval time_val;
    string the_base(1024 * 1024 * 10, 'x');
    long begin = 0 ;
    fprintf(stdout,"the_base's first char is [%c]\n",the_base[0] );
    the_base.push_back('y');
    begin = getcurrenttick();
    for( int i = 0 ;i< 100 ;++i ) {
        string the_copy = the_base ;
    }
    fprintf(stdout,"耗时[%d] \n",getcurrenttick() - begin );
}

这就恢复到了1ms的水平,哈哈,string真是充满了惊喜。
正是因为上面的种种问题,现在很多STL版本不再支持引用计数,转向下面的逻辑:
注意:下述结构是在32位平台下进行验证,32位平台下指针占4个字节

vs下string的结构

string总共占28个字节,内部结构稍微复杂一点,先是有一个联合体,联合体用来定义string中字符串的存储空间:
当字符串长度小于16时,使用内部固定的字符数组来存放
当字符串长度大于等于16时,从堆上开辟空间

union _Bxty 
{ // storage for small buffer or pointer to larger one 
     value_type _Buf[_BUF_SIZE]; 
     pointer _Ptr; 
     char _Alias[_BUF_SIZE]; // to permit aliasing 
} _Bx;

这种设计也是有一定道理的,大多数情况下字符串的长度都小于16,那string对象创建好之后,内部已经有了16个字符数组的固定空间,不需要通过堆创建,效率高。
其次:还有一个size_t字段保存字符串长度,一个size_t字段保存从堆上开辟空间总的容量
最后:还有一个指针做一些其他事情。
故总共占16+4+4+4=28个字节。

对string的批判!

当然,前面已经对string做了很多批判了,不过我还是选择最后在骂一次()
string 类其实挺好的。它的设计很有技术含量。它有高的效率、运行速度快、容易使用。它有很充足的接口可以满足各式各样的法,使用起来也很灵活。
然而,这个 string 类似乎有点没有与时俱进,它现在的设计还保持着 10 年以前的样子, 10 年来,整个技术环境都出现很多变革,人们也在使用 C++ 的过程中得到了许许多的经验。比如:现在的几乎所有的程序都运行在一个多进 / 线程的环境中,而 10 年前主流还只是单进 / 线程的应用,这是一个巨大的变化。近几年来, C++ 阵营也在实践中取得了很多的经验,特别是模板技术,而我们的 STL 显然没能跟上脚步。
就简单说一下以下几个问题:

  1. 目前的标 string 类有 106 个接口函数(包括构造和析构函数),如果考虑上默认参数,那么就一共有 134 不同的接口。其中有 5 个函数模板还会产生无穷多个各种各样的函数。还有各种各样的性能上的优化。在这么众多的成员函数中,很多都是冗余不必要的。最糟糕的是,众多程序员们并不了解它们,导到要么浪费了它的优势,要么踩中了其中的陷阱。
  2. 很多人认为 string 类提供的功能中,该有的没有,已有的又很冗余。 string 类在同一个功能上实现了多次,而有一些功能却没有实现。如:大小写不区分的比较,宽行字符( w_char )的支持,和字符 char 型数据的接口等等。
  3. 作为一个 STL 的容器, string 类和的设计和其它容器基本一样。这些 STL 都不鼓励被继承。因为 STL 容器的设计者们的几乎都遗忘了虚函数的使用,这样阻止了多态性,也许,这也是一个为了考虑效率和性能的设计。对于 STL 这样的设计,大多数人表示接受。但对于 string 类,很多人认为, string 类是一个特殊的类,考虑到它被使用的频率, string 类应该得到特殊的照顾。
  4. 还有很多人认为标准的 strng 类强行进行动态内存分配( malloc ),那怕是一个很短的字符串。这会导致内存碎片问题(我们知道内存碎片问题会让 malloc 很长时间才能返回,由于降低了整个程序的性能。而关于内存碎片问题,这是一个很严重的问题,目前除了重启应用程序,还没有什么好的方法)。他们认为, string 类的设计加剧了内存碎片问题。他们希望 string 类能够拥有自己的栈上内存来存放一些短字符串,而不需要总是去堆上分配内存。(因为用于 string 类的字符串长度几乎都不会很长)
  5. 很多 string 类的实现,都采用了 Copy-On-Write ( COW )技术。虽然这是一个很有效率的技术。但是因为内存的共享,导致了程序在“多线程”环境中及容易发生错误,如果分别在两个线程中的 string 实例共享着同一块内存,很有可能发生潜在的内存问题。
  6. 标准的 string 类不支持 policy-base 技术的错误处理。 string 遇到错误时,只是简单地抛出异常。虽然这是一个标准,但有一些情况下不会使用异常( GCC –fno-exception )。另外,不可能要求所有的程序都要在使用 string 操作的时候 try catch ,一个比较好的方法是 string 类封装有自己的 error-handling 函数,并且可以让用户来定义需要使用哪一种错误处理机制。

由于 string 类的的种种不如人意,特别是 106 个接口函数让许多人难以接受,有很多人都在写适合自己的 string 类。但总体来说,标准的 string 类是一个好坏难辨的类。无论你是否是一个高级资深的程序员,你都会用到它。它和整个 C++ 一样,都是一把双刃剑,在大多数情况下,它还是值得我们信赖。
在这里插入图片描述

结尾

以后还会更新更多关于C++的相关知识,点一个关注避免错过哦~

感谢观看,喜欢的话可以给一个大大的三连哦~
期待我们的下次相遇,喜欢的请点个关注再走哦!之后会持续更新更多的内容!
个人主页:传送门
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值