概述

GCC 8.2提供了两个版本的std::string:一个是基于Copy On Write的,另一个直接字符串拷贝的。前者针对C++11以前的,那时候没有移动构造,一切以效率为先,需要使用COW这种奇技淫巧。后者针对C++11,也就是_GLIBCXX_USE_CXX11_ABI宏被设置为非零时会被用到,并没有COW的功能,更简单,用户可以在必要时使用移动构造。如果不使用移动构造,字符串的频繁拷贝将会是异常灾难,这一点,在我的项目中已经踩过坑。

本文主要研究Modern C++中的string实现,因为使用C++11以后的标准都使用它。

数据组织:16字节的栈上存储,如果超出16字节则在堆上分配

数据的组织很简单,如果字符串长度不超过15字节(加上后缀0一共16字节),则在栈上保存,否则会在堆上分配内存。


// Use empty-base optimization: http://www.cantrip.org/emptyopt.html
struct _Alloc_hider : allocator_type // TODO check __is_final
{
#if __cplusplus < 201103L
_Alloc_hider(pointer __dat, const _Alloc& __a = _Alloc())
: allocator_type(__a), _M_p(__dat) { }
#else
_Alloc_hider(pointer __dat, const _Alloc& __a)
: allocator_type(__a), _M_p(__dat) { } _Alloc_hider(pointer __dat, _Alloc&& __a = _Alloc())
: allocator_type(std::move(__a)), _M_p(__dat) { }
#endif pointer _M_p; // The actual data.
}; _Alloc_hider _M_dataplus; ////数据指针,指向本地或者堆内存
size_type _M_string_length; ////实际长度,如果大于_S_local_capacity则是堆内存,否则,是栈内存 enum { _S_local_capacity = 15 / sizeof(_CharT) }; ////注意这是Union,16字节的栈内存和整个堆的容量(Capacity)共用一块存储
union
{
_CharT _M_local_buf[_S_local_capacity + 1];
size_type _M_allocated_capacity;
};

里面那个union挺有意思,如果_M_string_length>=_S_local_capacity,那么_M_allocated_capacity保存了整块缓冲区的大小(注意不是字符串的大小);如果_M_string_length<_S_local_capacity则_M_dataplus._M_p指针指向_M_local_buf这块栈上内存区。union的特性是全部元素共用一块空间,根据程序的语义只使用其中一个变量,在这里_M_local_buf有效的时候_M_allocated_capacity无效,反之亦然,巧妙地运用空间,减少内存浪费。

对象的构造:第一组,不带参数的构造函数(默认构造函数)

  • 始化长度为0,数据指针指向栈内存_M_local_buf,包括以下函数:

    • basic_string()
    • basic_string(const _Alloc& __a)

对象的构造:第二组,根据字符串构造

  • 调用_M_construct()构造对象,也就是把字符串拷贝一个(而不是增加引用计数);关于_M_construct()的细节,在下面会分析。包括以下函数:
  • 2.1 基于basic_string构建字符串拷贝
    • basic_string(const basic_string& __str):拷贝__str的完整字符串;
    • basic_string(const basic_string& __str, const _Alloc& __a):同上,指定构造器;
    • basic_string(const basic_string& __str, size_type __pos, const _Alloc& __a = _Alloc()):拷贝__str从__pos位置到末尾的所有字符;
    • basic_string(const basic_string& __str, size_type __pos, size_type __n):拷贝__str从__pos位置开始的N个字符(或者到末尾);
    • basic_string(const basic_string& __str, size_type __pos, size_type __n, const _Alloc& __a):同上,指定构造器;
  • 2.2 基于指针构建字符串拷贝,指针可以是字符串指针,也可以是其他指针,只要编译器不抱怨
    • basic_string(const _CharT* __s, const _Alloc& __a = _Alloc()):拷贝__s的全部字符串,构造时会计算__s的长度,使用char_traits<char>::length()内联函数;
    • basic_string(const _CharT* __s, size_type __n, const _Alloc& __a = _Alloc()):拷贝__s字符串的前__n个字符,不检测越界问题,留给调用方保证
    • basic_string(const _Tp& __t, size_type __pos, size_type __n, const _Alloc& __a = _Alloc()):这个比上面两个构造函数更加宽泛,可以是任意数据指针,编译器会对数据能否正确转换进行检测(warnning或者error);
  • 2.3 其他
    • basic_string(initializer_list<_CharT> __l, const _Alloc& __a = _Alloc()):使用初始化列表构造;
    • basic_string(size_type __n, _CharT __c, const _Alloc& __a = _Alloc()):以n个相同的字符填充缓冲区:调用_M_construct()初始化缓冲区并填充字符。

对象的构造:第三组:移动构造函数

  • 对于栈上保存的字符串,会拷贝到目标字符串(最多16Bytes);如果是堆上分配的字符串,则把指针转到目标字符串。完成这些拷贝和转移后,右值字符串回归到默认构造的状态(长度为0,字符串指针指向栈缓冲区的起始位置);

    • basic_string(basic_string&& __str)
    • basic_string(basic_string&& __str, const _Alloc& __a)

对象的构造:第四组:基于迭代器构造

  • 调用_M_construct(),根据迭代器的类型进行分类处理:

    • basic_string(_InputIterator __beg, _InputIterator __end, const _Alloc& __a = _Alloc()):把迭代器区间内的数据(允许不是char/wchar_t,只要编译器不抱怨);

对象的构造:第五组:根据std::string_view构造

  • 可以轻量级使用std::string_view构造一个std::string,同样是使用字符串拷贝

    • basic_string(const _Tp& __t, const _Alloc& __a = _Alloc()):需要__t是std::string_view类型,取整个std::string_view字符串构建std::string;
    • basic_string(const _Tp& __t, size_type __pos, size_type __n, const _Alloc& __a = _Alloc()):需要__t是std::string_view类型,并且取它的字符串一部分构建新的字符串,调用上面的构造函数完成构造。
    • basic_string(__sv_wrapper __svw, const _Alloc& __a):允许某个类型,如果可以转换成std::string_view,则使用此构造函数,类似于basic_string(sv.data(), sv.size(), alloc)

对象的构造:第六组:赋值

  • 调用assign()函数,进而调用_M_assign()系列函数,这部分下面会详细分析

    • operator=(const basic_string& __str) :代码相对比较复杂,其实就是字符串拷贝,没有COW。
    • operator=(const _CharT* __s):同上
    • operator=(_CharT __c):只插入一个字符

先谈谈构造时的调用流程

上面大部分版本的构造函数都是给定一个起始区间,调用_M_construct(_InIterator __beg, _InIterator __end)函数。下面分析这个函数的来龙去脉。

  • _M_construct(_InIterator __beg, _InIterator __end):根据std::__is_integer<_InIterator>::__type分析这个“迭代器”是否数值类型;

    • 如果是数值类型,则表示调用方是basic_string(size_type __n, _CharT __c)这一组的函数,那么就调用_M_construct_aux(_Integer __beg, _Integer __end, std::__true_type)去生成,最终调用_M_construct(size_type __req, _CharT __c)函数;
    • 如果不是数值类型,则表示调用方是上面第二组的调用方式,则调用_M_construct_aux(_Integer __beg, _Integer __end, std::__true_type),继而调用_M_construct(_InIterator __beg, _InIterator __end)函数;

现在回到_M_construct()函数

_M_construct()函数执行字符串的实际构造操作。它按照迭代器/参数的类型,有两种实现:

  • _M_construct(size_type __req, _CharT __c):实现的算法很简单,申请一段__req的内存空间(如果小于15字节则直接写在栈上),并拷贝__req个__c字符;
  • _M_construct(_InIterator __beg, _InIterator __end, std::forward_iterator_tag):只有当迭代器类型是forward_iterator或者比它更宽的迭代器时使用,它会根据迭代器之间的长度来确定字符串的长度,并逐字符拷贝。关于迭代器的“更宽”,可以参考迭代器类型
  • _M_construct(_InIterator __beg, _InIterator __end, std::input_iterator_tag):用于input_iterator迭代器,如输入迭代器等。这个构造比较低效,每次输入一个字符,会检测是否达到缓冲区的末尾,如果是,则把缓冲区的大小加1,重新分配空间并拷贝字符串。
  • 讲完了

字符串赋值专用的_M_assign()函数

  • 赋值类的函数都会在预分配足够内存后,调用_M_assign()函数,该函数的逻辑很简单,只是单纯的字符串拷贝,拷贝到需要的位置。

结论:std::string在程序在使用的注意事项(划重点)

  • 两个字符串连接:根据std::string的实现代码,字符串的连接一定会引起内存的重新分配和字符串拷贝。很遗憾,C++没有使用C的realloc()函数。如果使用realloc()函数,有可能在扩大内存的同时,不需要拷贝字符串(也就是原来分配的内存区域末尾还有足够的字符串)。C++很单纯地分配一块新内存,再把就内存拷贝过去。这一点在效率上会比较低下。个人认为解决办法是,使用std::deque代替std::string。在字符串不断变动的时候,字符数组可以写入std::deque,因为它的插入、追加操作比std::string高效。等字符串稳定后,再使用basic_string(_InputIterator __beg, _InputIterator __end)构造函数生成std::string

    • google的abseil提供了一个可用的办法:也就是先计算多个字符串的长度,预分配一大块内存来存放,然后分别拷贝字符串,这样避免反复分配内存和拷贝字符串,请参考abseil中的string库。但是这种方法只适用于已知固定个数的字符串连接。
    • 有人测试出C++拼接字符串的比较中,std::string::append和operator+的效率是最高的,同样的代码,我用C++98和C++11、C++17分别测试过,O3级别优化,最终结果类似。代码和测试结果见这里
  • 字符串的赋值:C++11下,因为有移动构造,所以std::string的复杂性比之前的版本有所降低,但是如果直接使用赋值,而不使用移动赋值,效率反而较“史前”版本慢,因为C++11以前,使用基于引用计数的COW,拷贝更加轻量级,而C++11出现以后,移动赋值成为提升效率几乎唯一的途径了,因此C++11以后的程序尽可能使用移动赋值
  • 另外,字符串的中间插入,也是需要重新分配内存,并完整拷贝整个字符串,这一点需要在使用中注意。
  • 如果只是需要字符串的只读操作,可以使用std::string_view代替。
  • 最后说一个地方,虽然比较少用,就是基于std::input_iterator的构造,它每次重新分配内存只会分配“刚刚适合需要的大小”,而不会如std::vector那样分配2倍,所以将会有频繁分配内存和拷贝的操作。解决的办法还是用std::deque,等字符足够之后再写入std::string
  • 总结上述几条:任何对已有字符串的插入、追加操作,都很可能造成内存的重新分配和整个字符串拷贝,这种重新分配和拷贝,并不是realloc(),而是new+memcpy,这是必须注意的。解决的办法,一是使用reserver()函数申请足够的内存,二是使用std::deque临时代替。

C++17剖析:string在Modern C++中的实现的更多相关文章

  1. 计算机程序的思维逻辑 (29) - 剖析String

    上节介绍了单个字符的封装类Character,本节介绍字符串类.字符串操作大概是计算机程序中最常见的操作了,Java中表示字符串的类是String,本节就来详细介绍String. 字符串的基本使用是比 ...

  2. 深入源码剖析String,StringBuilder,StringBuffer

    [String,StringBuffer,StringBulider] 深入源码剖析String,StringBuilder,StringBuffer [作者:高瑞林] [博客地址]http://ww ...

  3. C++17剖析:string_view的实现,以及性能

    主要内容 C++17标准发布,string_view是标准新增的内容.这篇文章主要分析string_view的适用范围.注意事项,并分析string_view带来的性能提升,最后从gcc 8.2的li ...

  4. Java编程的逻辑 (29) - 剖析String

    ​本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http: ...

  5. C字符串和C++中string的区别 &&&&C++中int型与string型互相转换

    在C++中则把字符串封装成了一种数据类型string,可以直接声明变量并进行赋值等字符串操作.以下是C字符串和C++中string的区别:   C字符串 string对象(C++) 所需的头文件名称 ...

  6. WCF技术剖析之八:ClientBase<T>中对ChannelFactory<T>的缓存机制

    原文:WCF技术剖析之八:ClientBase<T>中对ChannelFactory<T>的缓存机制 和传统的分布式远程调用一样,WCF的服务调用借助于服务代理(Service ...

  7. 剖析Asp.Net Web API中HttpController的激活

    在Asp.Net Web API中,请求的目标是定义在某个HttpController中的某个Action方法.当请求经过Asp.Net Web API消息处理管道到达管道"龙尾" ...

  8. [LeetCode] 186. Reverse Words in a String II 翻转字符串中的单词 II

    Given an input string, reverse the string word by word. A word is defined as a sequence of non-space ...

  9. [LeetCode] 557. Reverse Words in a String III 翻转字符串中的单词 III

    Given a string, you need to reverse the order of characters in each word within a sentence while sti ...

随机推荐

  1. 注解式controller开发,action找不到controller???

    Spring这一列列的 ,  配置是真的多,学完我都忘啦  那个配置是干什么的了. 这里我遇到的问题是  我前台 使用action请求controller中的方法时,却找不到 报错404. 首先你路径 ...

  2. 20行以内python代码画出各种减压图

    一.太阳花 看到一个很有意思的代码,你若安好,便是晴天!太阳花向你开~ 绘画效果如下: 代码如下: from turtle import * color('red', 'yellow') begin_ ...

  3. Java连接数据库之MySQL

    工具: eclipse MySQL Navicat for MySQL MySQL 连接驱动:mysql-connector-java-5.0.4-bin.jar SQL 代码 CREATE TABL ...

  4. eclipse项目有红叉的解决办法

    eclipse项目上有红叉,说明这个项目存在一些的问题,对于这种情况需要具体来看. 1 新导入项目的红叉 如果是新导入的项目,一般红叉就只在项目名称上面有红叉,项目下的分项上面没有,这一般是由于当初项 ...

  5. JVM基础系列第2讲:Java 虚拟机的历史

    说起 Java 虚拟机,许多人就会将其与 HotSpot 虚拟机等同看待.但实际上 Java 虚拟机除了 HotSpot 之外,还有 Sun Classic VM.Exact VM.BEA JRock ...

  6. .NET Core中的验证组件FluentValidation的实战分享

    今天有人问我能不能出一篇FluentValidation的教程,刚好今天在实现我们的.NET Core实战项目之CMS的修改密码部分的功能中有用到FluentValidation,所以就以修改用户密码 ...

  7. 正则表达式与H5表单

     RegExp 对象    exec 检查字符中是正则表达中的区域    text  检查内容  String 对象的方法    match    search    replace    splic ...

  8. redis 系列25 哨兵Sentinel (高可用演示 下)

    一. Sentinel 高可用环境准备 1.1 Sentinel 集群环境 环境 说明 操作系统版本 CentOS  7.4.1708  IP地址 172.168.18.200 网关Gateway 1 ...

  9. JS 中 原生方法 (四) --- Object

    Javascript 中 str. arr.date.obj 等常见的原生方法总结 本文也说主要阐释了 Javascript 中的基础类型和 引用类型的自带方法,那么熟悉的同学又可以绕道了 总是绕道, ...

  10. 【ASP.NET Core快速入门】(十)Cookie-based认证实现

    准备工作 新建MVC项目,然后用VSCode打开 dotnet new mvc --name MvcCookieAuthSample 在Controllers文件夹下新建AdminController ...