主要内容

C++17标准发布,string_view是标准新增的内容。这篇文章主要分析string_view的适用范围、注意事项,并分析string_view带来的性能提升,最后从gcc 8.2的libstdc++库源码级别分析性能提升的原因。

背景知识:静态字符串的处理

所谓静态字符串,就是编译时已经固定的字符串,他们存储在二进制文件的静态存储区,而且程序只能读取,不能改动。

一个例子:


//指针指向静态字符串
const char* str_ptr = "this is a static string"; //字符串数组
char str_array[] = "this is a static string"; //std::string
std::string str = "this is a static string"; //std::string_view
std::string_view sv = "this is a static string";

反汇编:


g++ -O0 -o static_str static_str.cc -std=c++17 -g && objdump -S -t -D static_str > static_str.s

汇编代码如下:


int main()
{
4013b8: 55 push %rbp
4013b9: 48 89 e5 mov %rsp,%rbp
4013bc: 53 push %rbx
4013bd: 48 83 ec 68 sub $0x68,%rsp
//指针指向静态字符串
const char* str_ptr = "this is a static string!";
##直接设置字符串指针
4013c1: 48 c7 45 e8 30 1e 40 movq $0x401e30,-0x18(%rbp)
4013c8: 00 //字符串数组
char str_array[] = "this is a static string!";
##这里使用一个很取巧的办法,不使用循环,而是使用多个mov语句把字符串设置到堆栈
4013c9: 48 b8 74 68 69 73 20 mov $0x2073692073696874,%rax
4013d0: 69 73 20
4013d3: 48 ba 61 20 73 74 61 mov $0x6369746174732061,%rdx
4013da: 74 69 63
4013dd: 48 89 45 c0 mov %rax,-0x40(%rbp)
4013e1: 48 89 55 c8 mov %rdx,-0x38(%rbp)
4013e5: 48 b8 20 73 74 72 69 mov $0x21676e6972747320,%rax
4013ec: 6e 67 21
4013ef: 48 89 45 d0 mov %rax,-0x30(%rbp)
4013f3: c6 45 d8 00 movb $0x0,-0x28(%rbp) //std::string
std::string str = "this is a static string!";
#esi保存了字符串开始地址$0x401e30,调用std::string的构造函数
4013f7: 48 8d 45 e7 lea -0x19(%rbp),%rax
4013fb: 48 89 c7 mov %rax,%rdi
4013fe: e8 15 fe ff ff callq 401218 <_ZNSaIcEC1Ev@plt>
401403: 48 8d 55 e7 lea -0x19(%rbp),%rdx
401407: 48 8d 45 a0 lea -0x60(%rbp),%rax
40140b: be 30 1e 40 00 mov $0x401e30,%esi
401410: 48 89 c7 mov %rax,%rdi
401413: e8 fe 01 00 00 callq 401616 <_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEC1IS3_EEPKcRKS3_>
401418: 48 8d 45 e7 lea -0x19(%rbp),%rax
40141c: 48 89 c7 mov %rax,%rdi
40141f: e8 c4 fd ff ff callq 4011e8 <_ZNSaIcED1Ev@plt> //std::string_view
std::string_view sv = "this is a static string!";
#直接设置字符串的长度0x18,也就是24Bytes,还有字符串的起始指针$0x401e30,没有堆内存分配
401424: 48 c7 45 90 18 00 00 movq $0x18,-0x70(%rbp)
40142b: 00
40142c: 48 c7 45 98 30 1e 40 movq $0x401e30,-0x68(%rbp)
401433: 00 return 0;
401434: bb 00 00 00 00 mov $0x0,%ebx //字符串数组
## 对象析构:字符串数组分配在栈上,无需析构
char str_array[] = "this is a static string!"; //std::string
## 对象析构:调用析构函数
std::string str = "this is a static string!";
401439: 48 8d 45 a0 lea -0x60(%rbp),%rax
40143d: 48 89 c7 mov %rax,%rdi
401440: e8 a9 01 00 00 callq 4015ee <_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEED1Ev>
401445: 89 d8 mov %ebx,%eax
401447: eb 1a jmp 401463 <main+0xab>
401449: 48 89 c3 mov %rax,%rbx
40144c: 48 8d 45 e7 lea -0x19(%rbp),%rax
401450: 48 89 c7 mov %rax,%rdi
401453: e8 90 fd ff ff callq 4011e8 <_ZNSaIcED1Ev@plt>
401458: 48 89 d8 mov %rbx,%rax
40145b: 48 89 c7 mov %rax,%rdi
40145e: e8 e5 fd ff ff callq 401248 <_Unwind_Resume@plt> //std::string_view
## 对象析构:std::string_view分配在栈上,无需析构
std::string_view sv = "this is a static string!"; return 0;
}
  • 静态字符串:会把指针指向静态存储区,字符串只读。如果尝试修改,会导致段错误(segment fault)。
  • 字符串数组:在栈上分配一块空间,长度等于字符串的长度+1(因为还需要包括末尾的'\0'字符),然后把字符串拷贝到缓冲区。上述代码,我之前一直以为会使用循环(类似memmove),但是一直找不到循环的语句,却找到一堆莫名其妙的数字($0x2073692073696874,$0x6369746174732061)仔细观察发现,原来编译器把一个长字符串分开为几个64bit的长整数,逐次mov到栈缓冲区中, 那几个长长的整数其实是: 0x2073692073696874=[ si siht],$0x6369746174732061=[citats a],0x21676e6972747320=[!gnirts],刚好就是字符串的反序,编译器是用这种方式来提高运行效率的。我觉得其实末尾的0是可以和字符串一起写在同一个mov指令中的,这样执行的指令就可以少一个了,不知道为什么不这样做。
  • std::string:只在寄存器设置了字符串的起始指针,调用了basic_string( const CharT* s,const Allocator& alloc = Allocator() )构造函数,中间涉及各种检测和字符串拷贝,后面会在另一篇讲述std::string原理的文章中详细分析,总之动态内存分配与字符串拷贝是肯定会发生的事情。值得一提的是,如果在构造函数里面至少会有如下操作:确定字符串长度(如strlen,遍历一遍字符串),按字符串长度(或者预留更多的长度)新建一块内存空间,拷贝字符串到新建的内存空间(第二次遍历字符串)。
  • std::string_view:上面的汇编代码很简单,只是单纯设置静态字符串的起始指针和长度,没有其他调用,连内存分配都是栈上的!跟std::string相比,在创建std::string_view对象的时候,没有任何动态内存分配,没有对字符串多余的遍历。一直以来,对于C字符串而言,如果需要获取它的长度,至少需要strlen之类的函数。但是我们似乎忽略了一点,那就是,如果是静态字符串,编译器其实是知道它的长度的,也就是,静态字符串的长度可以在编译期间确定,那就可以减少了很多问题。
  • 题外话:编译期确定字符串长度、对象大小,这种并不是什么奇技淫巧,因为早在operator new运算符重载的时候,就有一个size_t参数,这个就是编译器传入的对象大小,而std::string_view,则是在编译期间传入字符串的指针和长度,构建对象。但是,std::string和std::string_view这两个类同时提供了只带字符串指针同时带字符串指针和字符串长度两个版本的构造函数,默认的情况下,std::string str = "this is a static string!"会调用basic_string( const CharT* s,const Allocator& alloc = Allocator() )构造,但是std::string_view sv = "this is a static string!"会调用带长度的basic_string_view(const _CharT* __str, size_type __len) noexcept版本,这一点我一直没弄明白(TODO)。但是,标准库提供了一个方法,可以让编译器选择带长度的std::string构造函数,下一小节讲述。

std::string_view的实现(GCC 8.2)

std::string_view类的成员变量只包含两个:字符串指针和字符串长度。字符串指针可能是某个连续字符串的其中一段的指针,而字符串长度也不一定是整个字符串长度,也有可能是某个字符串的一部分长度。std::string_view所实现的接口中,完全包含了std::string的所有只读的接口,所以在很多场合可以轻易使用std::string_view代替std::string。一个通常的用法是,生成一个std::string后,如果后续的操作不再对其进行修改,那么可以考虑把std::string转换成为std::string_view,后续操作全部使用std::string_view来进行,这样字符串的传递变得轻量级。虽然在很多实现上,std::string都使用引用计数进行COW方式管理,但是引用计数也会涉及锁和原子计数器,而std::string_view的拷贝只是单纯拷贝两个数值类型变量(字符串指针及其长度),效率上前者是远远无法相比的。std::string_view高效的地方在于,它不管理内存,只保存指针和长度,所以对于只读字符串而言,查找和拷贝是相当简单的。下面主要以笔记的形式,了解std::string_view的实现。

  • 只读操作:没有std::string的c_str()函数。因为std::string_view管理的字符串可能只是一串长字符串中的一段,而c_str()函数的语义在于返回一个C风格的字符串,这会引起二义性,可能这就是设计者不提供这个接口的原因。但是与std::string一样提供了data()接口。对于std::string而言,data()与c_str()接口是一样的。std::string_view提供的data()接口只返回它所保存的数据指针,语义上是正确的。在使用std::string_view的data()接口的时候,需要注意长度限制,例如cout<<sv.data();cout<<sv;的输出结果很可能是不一样的,前者会多输出一部分字符。

  • std::string_view的前身,google的abseil::string_view的文档中有如下描述(https://abseil.io/tips/1):

  • std::string_view与std::string的生成:C++17新增了operator""sv(const char* __str, size_t __len)operator""s(const char* __str, size_t __len)操作符重载,因此,生成字符串的方法可以使用这两个操作符。令人惊奇的是,使用这种方法,生成std::string调用的是basic_string_view(const _CharT* __str, size_type __len) noexcept版本的构造函数,这就意味着免去了构造时再一次获取字符串长度的开销(实际上是编译器在帮忙)


//std::string
std::string str = "this is a static string!"s; //std::string_view
std::string_view sv = "this is a static string!"sv;

反汇编如下(其实读者可以使用gdb调试,查看实际调用的构造函数):


//std::string
std::string str = "this is a static string!"s;
## esi存放字符串起始地址,edx存放字符串长度,0x18就是字符串长度24字节
4014b7: 48 8d 45 a0 lea -0x60(%rbp),%rax
4014bb: ba 18 00 00 00 mov $0x18,%edx
4014c0: be 50 1e 40 00 mov $0x401e50,%esi
4014c5: 48 89 c7 mov %rax,%rdi
4014c8: e8 da 00 00 00 callq 4015a7 <_ZNSt8literals15string_literalsli1sB5cxx11EPKcm>
  • 修改操作:如前所述,std::string_view并不提供修改接口,因为它保存的数据指针是const _CharT*类型的,无法运行时修改。
  • 字符串截取substr():这部分特别提出。因为使用std::string::substr()函数,会对所截取的部分生成一个新的字符串返回(中间又涉及内存动态分配以及拷贝),而std::string_view::substr(),也是返回一个std::string_view,但是依旧不涉及内存的动态分配。只是简单地用改变后的指针和长度生成一个新的std::string_view对象,O(1)操作。代码如下:

constexpr basic_string_view
substr(size_type __pos, size_type __n = npos) const noexcept(false)
{
__pos = _M_check(__pos, "basic_string_view::substr");
const size_type __rlen = std::min(__n, _M_len - __pos);
return basic_string_view{_M_str + __pos, __rlen};
}
  • 关于字符串截取,引用一下其他人的测试结果,性能提高不是一星半点。(来自这里

使用注意事项

std::string_view/std::string用于项目中,我认为有下面几点需要注意的:

  • C++17中,std::string与std::string_view的转换仍然需要手动调用构造函数,没有to_string/to_string_view之类的函数可以调用。
  • 以std::string为key的map,如果需要使用std::string_view检索,则需要把less函数设置为通用的,也就是map<std::string, int, std::less<>>,使用全局的比较函数代替std::string的专属std::lessstd::string。C++14后可以使用不同类型的key检索map,使用默认的std::less<>作为比较函数,可以调用通用的比较函数,而std::lessstd::string只能是两个std::string比较。

std::map<std::string, int> map1;
//插入数据到map1,略
std::string k1 = "123";
int v1 = map1[k1]; //OK
auto it_1 = map1.find(k1); //OK std::string_view k2 = "456";
int v2 = map1[k1]; //ERR,因为默认std::less<std::string>只能两个std::string比较
auto it_2 = map1.find(k1); //ERR,同上 std::map<std::string, int, std::less<>> map2;
//插入数据到map2,略
std::string k3 = "123";
int v3 = map1[k3]; //OK
auto it_3 = map1.find(k3); //OK std::string_view k4 = "456";
int v4= map1[k4]; //ERR,因为operator []的类型只能与key一样的类型,也就是只能是std::string
auto it_4 = map1.find(k4); //OK,因为C++14可以提供不同类型的key模板
  • std::string_view管理的只是指针,试用期间应该注意指针所指向的内存是可访问的;
  • 如果使用静态字符串初始化std::string,建议使用operator s()重载,但是使用这个运算符重载需要使用std::literals,反正我经常会忘记。
  • 如果在项目中需要使用下面这种方式生成字符串的:

int num = 100;
//process @num
std::string err_message = "Invalid Number: " + std::to_string(num);

在c++11有可能会报错,因为 "Invalid Number: " 是一个const char*,无法使用operator +(const std::string&),或者改为

  std::string err_message = std::string("Invalid Number: ") + std::to_string(num);

在C++17中,可以使用如下方法:


using namespace std::literals;
std::string err_message = "Invalid Number: "s + std::to_string(num);

这样,可以让编译器在构造时调用带长度的构造函数,免去一次使用strlen获取长度的开销。

上古时代的std::string_view及其类似实现

所谓“上古时代”,指的是C++11之前的C++98时代,当时标准库还没有这么充实,开发时需要用到的一些库需要自己实现。那时候一些注重效率的程序就提供了这类的库作为附属实现。如:

我的项目中用到的std::string_view的类似实现:针对libhiredis

在上古时代,我的项目中也用到类似std::string_view这种“轻量级字符串”的功能,下面晒晒代码,说说使用这种设计的初衷。

在项目中,我需要用到redis库hiredis,经常需要从库里面取得字符串。比如这样的操作:从redis中scan出一堆key,然后从redis中取出这些key,这些key-value有可能用于输出,有可能用于返回。hiredis是一个C库,快速而简单,然而我不希望在我的应用层库中处理太多细节(诸如分析返回数据的类型,然后又进行错误处理,等等),因为那样会造成大量重复代码(对返回数据的处理),而且会让应用层代码变得很臃肿。于是我自己写了一个简单的adaptor,实现了使用C++的string、vector等类作为参数对hiredis的调用。那么redis返回的字符串,如果封装成std::string,字符串的拷贝会成为瓶颈(因为项目中的value部分是一些稍长的字符串),而且这些来自redis的value返回到应用层只会做一些json解析、protobuf解析之类的操作就被释放掉,所以这就考虑到字符串的拷贝和释放完全是重复劳动,于是自己设计了一个基于RedisReply的Slice实现。

下面只贴出头文件,实现部分就不多贴出来占地方了(代码其实是使用C++11开发的,但是类似的实现可以在C++98中轻易做到,在这里作为一个例子并不过分=_=):

    //字符串
//创建这个类,是因为在性能调优的时候发现,生成字符串太多,影响性能
class Slice
{
public:
Slice() = default;
~Slice() = default; Slice(const char* str, size_t len,
const std::shared_ptr<const redisReply>& reply): str_(str), len_(len), reply_(reply) {}
Slice(const char* str, size_t len):str_(str), len_(len) {} //下面几个接口,兼容std::string
const char* c_str() const {return str_;}
const char* data() const {return str_;}
size_t length() const {return len_;}
bool empty() const {return str_ == NULL || len_ == 0;} bool begin_with(const std::string& str) const;
std::string to_string() const;
bool operator==(const char* right) const;
bool operator==(const Slice& right) const;
bool operator!=(const char* right) const;
bool operator!=(const Slice& right) const; private: //字符串
const char* str_{NULL};
size_t len_{0}; //字符串所属的Redis返回报文
std::shared_ptr<const redisReply> reply_;
};

之所以不重用LevelDB的Slice,是因为这些字符串都是struct redisReply中分配的,所以使用shared_ptr管理struct redisReply对象,这样就可以不需要担心struct redisReply的释放问题了。

为了这个类的使用方式兼容std::stringSlice,我使用模板实现,下面是我的Redis适配层的实现(局部):


/**********头文件************/
class CustomizedRedisClient
{
public:
//GET
template<class StringType>
std::pair<Status, Slice> get(const StringType& key)
{
return this->get_impl(key.data(), key.length());
} //....
}; /***********这部分在代码部分实现***********/ //GET实现
//CustomizedRedisClient::Status是另外实现的一个状态码,不在这里讲述
std::pair<CustomizedRedisClient::Status, CustomizedRedisClient::Slice>
CustomizedRedisClient::get_impl(const char* key, size_t key_len)
{
constexpr size_t command_item_count = 2;
const char* command_str[command_item_count];
size_t command_len[command_item_count]; command_str[0] = "GET";
command_len[0] = 3; command_str[1] = key;
command_len[1] = key_len; //reply
//get_reply()函数使用redisAppendCommandArgv()和redisGetReply()函数实现,参考libhiredis文档,这样做是为了兼顾key/value中可能有二进制字符
const auto& reply_status = this->get_reply(command_str, command_len, command_item_count);
const redisReply* reply = reply_status.first.get();
if(reply == NULL)
{
return std::make_pair(reply_status.second,
CustomizedRedisClient::Slice());
}
else if(reply->type == REDIS_REPLY_STATUS
|| reply->type == REDIS_REPLY_ERROR)
{
return std::make_pair(CustomizedRedisClient::Status(std::string(reply->str, reply->len)),
CustomizedRedisClient::Slice());
}
else if(reply->type == REDIS_REPLY_NIL)
{
return std::make_pair(CustomizedRedisClient::Status(STATUS_NOT_FOUND),
CustomizedRedisClient::Slice());
}
else if(reply->type != REDIS_REPLY_STRING)
{
return std::make_pair(CustomizedRedisClient::Status(STATUS_INVALID_MESSAGE),
CustomizedRedisClient::Slice());
} return std::make_pair(CustomizedRedisClient::Status(),
CustomizedRedisClient::Slice(reply->str, reply->len, reply_status.first));
}

后记

追本溯源,是一个极客的优秀素质。

作为C++17文章的第一篇,略显啰嗦,希望以后有恒心把自己的研究成果一直进行下去。

C++17剖析:string_view的实现,以及性能的更多相关文章

  1. C++17剖析:string在Modern C++中的实现

    概述 GCC 8.2提供了两个版本的std::string:一个是基于Copy On Write的,另一个直接字符串拷贝的.前者针对C++11以前的,那时候没有移动构造,一切以效率为先,需要使用COW ...

  2. 2018/09/17《涂抹MySQL》【性能优化及诊断】学习笔记(七)

    读 第十三章<MySQL的性能优化与诊断> 总结 一说性能优化,整个人都像被打了鸡血一样

  3. MySQL性能剖析工具(pt-query-digest)【转】

    这个工具同样来自percona-toolkit 该工具集合的其他工具 MySQL Slave异常关机的处理 (pt-slave-restart)  验证MySQL主从一致性(pt-table-chec ...

  4. 20个Linux服务器性能调优技巧

    Linux是一种开源操作系统,它支持各种硬件平台,Linux服务器全球知名,它和Windows之间最主要的差异在于,Linux服务器默认情况下一般不提供GUI(图形用户界面),而是命令行界面,它的主要 ...

  5. 高性能MySQL第2,3章性能相关 回顾笔记

    1.  基准测试(benchmark)   不管是新手还是专家都要熟悉基准测试,benchmark测试是对系统的一种压力测试,目标是为了掌握在特定压力下系统的行为.也有其他原因:如重现系统状态,或者是 ...

  6. Linux服务器性能查看分析调优

    一 linux服务器性能查看 1.1 cpu性能查看 1.查看物理cpu个数: cat /proc/cpuinfo |grep "physical id"|sort|uniq|wc ...

  7. 【转】linux服务器性能查看

    转载自https://blog.csdn.net/achenyuan/article/details/78974729 1.1 cpu性能查看 1.查看物理cpu个数: cat /proc/cpuin ...

  8. Linux服务器性能分析与调优

    一 linux服务器性能查看 1.1 cpu性能查看 1.查看物理cpu个数: cat /proc/cpuinfo |grep "physical id"|sort|uniq|wc ...

  9. linux服务器性能查看

    1.1 cpu性能查看 1.查看物理cpu个数: cat /proc/cpuinfo |grep "physical id"|sort|uniq|wc -l 2.查看每个物理cpu ...

随机推荐

  1. [Swift]LeetCode731. 我的日程安排表 II | My Calendar II

    Implement a MyCalendarTwoclass to store your events. A new event can be added if adding the event wi ...

  2. [Swift]LeetCode993. 二叉树的堂兄弟节点 | Cousins in Binary Tree

    In a binary tree, the root node is at depth 0, and children of each depth k node are at depth k+1. T ...

  3. python获取当前运行程序的名字

    import os filename = os.path.abspath(__file__) print filename 打印结果: E:\bluedon\test.py

  4. Flume篇---Flume安装配置与相关使用

    一.前述 Copy过来一段介绍Apache Flume 是一个从可以收集例如日志,事件等数据资源,并将这些数量庞大的数据从各项数据资源中集中起来存储的工具/服务,或者数集中机制.flume具有高可用, ...

  5. 【纯·技术干货】更 App 化的小程序开发

    2018 年 10 月13 日,由又拍云和知晓云联合主办的 Open Talk 丨2018 小程序开发者沙龙系列活动广州站拉开帷幕,糗事百科前端负责人宋航在沙龙上做了<更App化的小程序开发&g ...

  6. python中的None

    python中的None python中的None就相较于Java中的Null.python中就没有所谓的NULL.网络上很多的时候说的"python的Null"这个说法本身就是不 ...

  7. 死磕 java集合之ArrayList源码分析

    欢迎关注我的公众号"彤哥读源码",查看更多源码系列文章, 与彤哥一起畅游源码的海洋. 简介 ArrayList是一种以数组实现的List,与数组相比,它具有动态扩展的能力,因此也可 ...

  8. 【jQuery】(1)---初次接触Jquery

    1.浅理解Jquery:jQuery是一个快速的,简洁的javaScript库,使用户能更方便地处理HTML documents.events.实现动画效果,并且方便地为网站提供AJAX交互. 2.D ...

  9. ASP.NET Core SignalR中的流式传输

    什么是流式传输? 流式传输是这一种以稳定持续流的形式传输数据的技术. 流式传输的使用场景 有些场景中,服务器返回的数据量较大,等待时间较长,客户端不得不等待服务器返回所有数据后,再进行相应的操作.这时 ...

  10. asp.net core 系列 17 通用主机 IHostBuilder

    一.概述 ASP.NET Core 通用主机 (HostBuilder),该主机对于托管不处理 HTTP 请求的应用非常有用.通用主机的目标是将 HTTP 管道从 Web 主机 API 中分离出来,从 ...