c++中string的实现讨论

基本使用方式

字符串是编程语言中最基础的结构之一,能否提供灵活简洁的操作方式是语言中很重要的一环。c/c++中最简单的字符串是通过原生数组来实现的,即当成了字符数组,这种结构的好处是可以以O(1)的时间复杂度用下标访问、替换任何字符;但缺点则无法将字符串当成一个整体来处理修改替换等等,同时当字符串长度发生变化时(特别是变长),则需要重新分配内存并复制数据,这样的代码写起来非常繁琐,且极易出错。

​c++11中使用string来实现字符串的基本操作,先来看下基本的使用:

#include <string>
using namespace std;
string str("hello string");
string str2 = "copy constructor";
str2 = "opreator=";
str2.assign(20, 't');
str2[1] = 'c';
str2.replace(3, 5, "213");

从上面不难看出,string类屏蔽了底层的字符raw数组的细节,同时与c++中其他连续容器一样,其实际是一个动态数组,采用预分配的方式使用内存,一般的策略是如果当前溢出则relloc一个当前长度*2的空间来使用,这个策略应该是一个基于统计意义上的方式,可以很好的屏蔽基于数组元素级别的操作,同时在必要时又可以直接操作具体位置的单个字符。

实现方式浅析

​ 关于string的具体实现,现在常用的有3种方式,eager-copy,copy-on-write(COW)和short/small-string-optimization(SSO),eager-copy方式就是原始的深度拷贝的方式,在string类中存放一个char型数组的指针,指向堆上分配的内存,发生赋值时直接申请新的空间并复制数据,显然这样的时间和空间复杂度都是较大的。

earge-copy方式

​ 一个典型的eager-copy型string结构如下:

COW方式

​ COW方式的string是对字符串深度拷贝问题的一个优化,他基本思想是发生赋值拷贝的string对象共享一个内存空间,通过一个引用计数refcount来记录实际共享该内存的string对象数量,当某个string对象发生修改操作时,新分配空间复制数据给该对象并做相应的修改,原字符串中的refcount-1,当refcount减到0时销毁真正的字符数组对象。
一个典型的实现结构如下

其对应的定义如下

class cow_string   // from libstdc++ -v3
{
    struct Rep 
    {
        size_t size;
        size_t capacity;
        size_t refcount;
        char* data[1];   // variable length
    };
    char* start;
};

​ 上面COW方式看起来很美好,多个复制的string对象共享同一份数据,只在必要时才分裂修改,既节省了内存,又避免了大多数情况下深度拷贝带来的时间消耗,但有一个无法回避的问题就是多线程的安全性:显而易见,当有多个包含同一个Rep对象的string对象被多个线程操作时会发生很多意想不到的结果,refcount引用计数的原子性无法得到保证(有人说可以使用原值类型atomic,这样refcount变量本身的线程安全性可以得到保证,当基于该值判断的操作仍然是线程不安全的),甚至于Req其他结构的安全性也无法保证,有的同学可能会说加锁;但这里的问题在于COW机制是一种语言层面的字符串实现封装方式,与操作系统无关,而多线程的安全性是更高层面多线程编程环境带来的问题,就是说锁机制是比语言层面更高级的特性,显然这种hack的方式是不可取的,会带来其他的问题。另外还有一个问题就是通过operator[]和at()访问字符串数组元素时,程序并不知道是否对特定的元素进行了修改(operator[]和at()实际返回的就是底层原生元素的指针),所以只能默认这2个操作时修改语义的,会引发写拷贝问题,而实际上我可能只是读取了以下某个字符,并未修改。

SSO方式

​ SSO方式的string实际上是一种折中的办法,一般情况下,短字符串具有更高的修改几率(我瞎蒙的啊,应该是这么一回事)和更高的出现机会,而比较大的字符串对象则可能读取的次数会更多一些,比如机器学习中的模型数据。所以对short/small string的存储空间直接放在string对象的函数栈上,而不再分配堆空间,而对于大字符串再使用堆内容则是一种时间和空间的折中方式,另外考虑到多线程安全性,则不再使用copy-on-write的策略,而和eager-copy一样采用深度拷贝的方式。而SSO优化的地方在于对小字符串采用函数运行栈空间(本地空间)的方式,避免了堆内存开销,毕竟短字符串的使用场景更频繁。
一个典型的SSO结构如下

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y5ABRPdp-1603764201860)(sso_string-20200908175932096.png)]

上图示对应的实现代码如下,显然short string的边界为15,长度小于15的短字符串将使用栈内存buffer。

class sso_string
{
    char* start;
    size_t size;
    static const int kLocalSize = 15;
    union
    {
        char buffer[kLocalSize+1];
        size_t capacity;
    }data;
};

redis的SDS结构

​ 关于动态字符串的使用,缓存redis的SDS结构也是一种很精妙的C方式的封装,通过一个无符号的char类型flags的位来对不同长度的字符串进行分类,通过len和aollc来记录已使用和总共分配的内存大小,通过封装特定的sds操作函数[malloc free len等等](实际是通过结构体指针的偏移来直接指向实际字符串buffer的首地址)来使字符串使用起来和原生的字符数组一样。有兴趣的可以去看下redis源代码。其本质的思想和新版c++标准库的思想一致,通过预分配内存减少系统malloc/new的次数,从而一定程度上减少内存碎片的产生;通过一个额外的size(或length)来记录字符串的实际使用空间,避免获取字符串长度时带来O(n)的遍历时间复杂度。

【参考资料】

  1. https://www.cnblogs.com/cthon/p/9181979.html
  2. 《linux多线程服务端编程-使用muduo c++网络库》陈硕 12.7节
  3. https://www.zhihu.com/question/63078737
  4. https://blog.csdn.net/u011475134/article/details/72900890
  5. https://www.cnblogs.com/DswCnblog/p/6371071.html
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值