性能优化学习笔记(2)-更好地使用字符串

字符串是每个编程语言中使用频率较高的一个功能特性,不知道是什么原因,包括C++在内的编程语言都把字符串的类命名为string,string的直接意义是绳子,也可以译为琴弦。相信对于字符串使用较多的工程师们会发现,当你不能很好的使用字符串的时候,它就是一条难以控制的绳子,有时你甚至不能使用它完成你想做的事,但当你可以很好的使用它,你甚至可以改造它,把它变成可以奏出优美旋律的琴弦,那么如何把不听话的"绳子"变成"琴弦"呢?

字符串与字符数组-C++与C的不解之缘

"C++并不是一个带有一组守则的一体语言;它是从四个次语言组成的联邦政府"-《Effective C++》

在一开始,C++只是C加上面向对象的特性,因此C++语言中有很多和C语言分不开的特性,比如字符串和字符数组;一般来讲,对于一个字符串,有两个信息是必要的,一个是字符串内容,一个是字符串长度,后面的讨论中,也将围绕如何存储和修改这两个元素来展开。C语言中并没有提供字符串标准库,字符串是用字符数组表示的,但是字符数组有一个问题,就是无法存储字符串的另外一个信息-长度,C语言的字符串使用空字符('\0')来终止字符数组,另外C还提供了一些str*函数提供一些诸如字符串比较、字符串复制功能。

C++的标准库提供了string类,因此它提供了两种字符串的表现形式,即string类和C风格字符串(C字符数组);需要注意的是,这两个特性并不是独立的,在一些实现里,string类型的内部封装的就是C风格字符串,因此也可以理解为,string类型只是对于C风格字符串的封装。

那么什么时候使用string,什么时候使用C风格字符串呢?坦白讲,C风格的字符串更加难以使用,因为你需要手动分配和释放内存;但是使用C风格字符串一方面可以保证程序不使用C++标准库,如果你在开发一个第三方库,这样可以增强你开发的库的可用性和可移植性,比如著名的rapidjson在它们的特性介绍里就表明它们不使用标准库;另外一方面,也是更重要的就是在一些场景下,使用C风格字符串可以带来更好的性能,比如在函数传递参数的时候:

1.字符串传参数写法1:`string example_1(string a)`

这样的函数头,在传递参数的时候的性能消耗取决于字符串的实现方式,这点在本文后面会详细介绍,但是无论怎么实现,一个拷贝构造函数的调用开销是不可避免的,另外如果函数里有修改字符串的操作,也会有内存重新分配的开销。

2.字符串传参数写法2:`string example_2(string const& a)`

使用这样的写法,第一是用形参取代了实参,移除了复制开销,另外使用常量,避免了复制开销,对于性能优化来说,内存分配的开销总是昂贵的,所以我们的原则是能少一次就少一次。 3.字符串传参数写法3:`string example_2(char const* a)`

这里虽然还是实参的写法,但是传指针的实参和形参并无区别,这里就移除了构造函数的调用,降低了内存开销。

关于C++和C字符串的不解之缘,还有一个话题就是二者的转换,string类提供c_str函数直接返回C风格字符串的数组指针,但是C风格字符串要转换成C++标准库的string就要显式或者隐式的调用一次构造函数,还要进行内存分配和复制字符的操作,所以在一个项目中,string还是C字符串----这个问题最好保持统一,避免不必要的类型转换的开销,尤其是C风格字符串到string的转换。

string的实现-cow?sso?eager-copy?

前提到了一些场景下使用string的内存开销取决于实现方式,之所以有不同的实现方式,其实就是在string的基础上使用不同策略进行的优化,在《Effective STL》中提到了string类的实现方式可以归结为三类:

- 写时拷贝(copy on write,以下简称cow):顾名思义,最多被使用的方式,也是争议最多的一种方式,g++采用这种方式

- 短字符串优化(small string optimization,以下简称sso):对于短小的字符串,采用本地缓存进行存储的一种优化方式,Visual C++采用这种方式

- 直接拷贝(eager-copy):使用vector进行存储,对于拷贝没有特殊的优化处理,比较有代表性的使用的库是SGI STL。

这三类拷贝优化方式在不同操作系统上string对象的大小如表所示:

|------------|-----------------------|---------------------|------------|
| 库· | 32位 | 64位 | 优化策略 |
| g++ | 4 | 8 | cow |
| clang | 12 | 24 | sso |
| visual C++ | 28(debug) 32(release) | 40(debug) 48(debug) | sso |
| SGI | 12 | 24 | eager-copy |

​ 对于string类型来说,无论用哪种方式实现,有三个数据都是要存储的:字符串内容信息,长度,容量,这三个本文统称字符串三要素。

cow(写时拷贝):

"cow已死,eager-copy万岁"-Andrei Alexandrescu(《Modern C++ Design》)

这是最常被使用的优化策略,即在对一个字符串进行写入操作的时候再进行深拷贝,也是一种懒处理的思想,除了必要的三个元素,写时拷贝还有一个引用计数的变量,一个新的字符串初始化的时候,该变量为1,每次这个字符串被赋值给其他变量的时候,引用计数加1,在字符串作修改的时候,先进行深度拷贝,再把原来的字符串引用计数减1,当引用计数减为0的时候,释放最初的字符串占用的内存。

这种方式有如下优点:

1.减少了一部分内存分配,复制的开销,节约了时间。

2.减少了不必要的内存分配,节约了空间。

看上去很美?首先,它无法避免C风格字符串到string的复制和拷贝开销,这很好理解,类的构造函数还是会被调用,对于string类来说,它就是一个新的字符串,然后,为了处理线程安全问题,引用计数是原子操作,但是效率并不高,另外需要修改的时候,是先进行内存分配和复制,然后再引用计数减1,这虽然可以保证线程安全,但是在有些场景下,会进行更多的内存分配操作。

因此cow并不是最优的解决方案,同时它也不在C++ 11标准之中。

sso(短字符串优化):

从上面的表中可以发现,sso所占的内存空间比cow的大,原因是它将字符串的文本内容存在本地缓冲区,也就是栈上,它的主要思想是,如果字符串低于阈值(15字节),那么它直接存放在本地缓冲区中,如果超过阈值,那么它会在堆上分配空间,本地只记录三要素。这样的优化对于短字符串确实有提升性能的作用,因为对于短字符串来说,它避免了在堆上申请内存空间的开销,但是也带来了一个副作用,就是在两种模式下,会有一些变量是多余的,这些变量占据了内存空间。

这样的策略确实会在短字符串上带来llvm和clang采用了更优的实现策略,本地缓存的指针和大小的指针可以重合,那么在两种情况下,只有一个变量会起作用,它用一个bit来区分字符串是否高于阈值,然后用位操作和掩码来取重叠部分的数据。

eager-copy(直接拷贝):

在string中存储着字符数组的起始位置,以及内存的边界位置,后两种可以使用指针类型作为变量,也可以使用整型,但是无论使用哪个类型,因为size_t和char *是同样的大小,然后字符串的具体内容使用vector数组存储,eager-copy没有做任何的优化策略,却在存储大小和运行效率上实现了平衡。

对于这三个类型的string实现来说,并没有一个最优解,它们三个都是在某个场景下具有更好的表现,当然,如果没有作用,那么这个实现方式早就被淘汰了。Andrei Alexandrescu的建议是,对于短字符串来说,使用sso,对于中等长度的字符串,使用eager更为合适,对于长字符串,采用懒处理的思想会获得更高的运行效率,那么,可不可以实现一个字符串类,同时包含这三种优化策略呢?folly的fbstring做到了。

一个更优的字符串类-fbstring:folly框架中的字符串,就是按照小中大区分字符串,并根据字符串的大小来使用不同的优化策略:

  • Category::isSmall (0-22字节): 放在栈上,避免了内存分配的开销,需要预留1个字节的大小来存储字符串结尾标志('\0')。

  • Category::isMedium(22-255字节):使用malloc进行内存分配,采用eager-copy的方式。

  • Category::isLarge (>255字节): 使用cow的方式进行实现。

fbstring的优化包括整体策略的优化,内存分配的优化以及一些算法的优化:

  • fbstring推荐使用jemalloc,它在多线程并发时有更好的性能表现。

  • fbstring结尾'\0'懒惰处理,平时预留空间,在调用data和c_str时才在结尾添加。

  • find函数使用简化了的Boyer-Moore算法,在长字符串的搜索上,它可以有更高效的表现。

string类的高效使用

fbstring是一个更好的字符串策略,但是在有些场景下,还是会直接使用普通的字符串,对于在C++中使用字符串,也可以使用一些技巧来让程序更高效。

  • 提前预留大小,STL中string和vector类似,都有size和capibity两个属性,容器的内容都是有一个大小的,为了更好的运行效率,容器会提前预留空间,在存入一个元素时,会首先检查是否还有剩余的空间,只有不够的时候才会重新的申请内容,所以如果我们对于一个字符串未来的大小有预期的话,可以提前预留更多的空间。

  • 编码统一,字符串有很多不同种类的编码,比如UTF8,UTF16等,在同一个程序中,要保持同一个编码,这样可以避免不同编码之间的比较。

  • string和字符数组的统一,之前已经提到过了,字符数组转换成string会造成额外的内存开销,隐式的调用构造函数等等,在同一个项目框架中,使用STL string,还是字符数组最好前后一致。

  • 当一个函数要返回字符串信息时,可以传入一个形参来返回信息,避免return时构造函数,拷贝构造函数和内存分配的调用开销。

  • C风格字符串有更多的工具函数可以被使用,可以更多的考虑使用C风格的字符串,或者像fbstring一样,开发一个专属的工具类。

相关推荐
白云偷星子1 小时前
云原生笔记1
笔记·云原生
●VON1 小时前
HarmonyOS应用开发实战(基础篇)Day11 -《组件复用》
学习·安全·华为·harmonyos·von
桂花很香,旭很美1 小时前
Anthropic Agent 工程实战笔记(五)评测与 Eval
笔记·架构·agent
liliangcsdn2 小时前
探索和学习信任区域策略优化算法-TRPO
学习·算法
宇木灵11 小时前
考研数学-高中数学-反三角函数与特殊函数day3
笔记·考研·数学·函数
学编程的闹钟13 小时前
E语言EXE开发全流程指南
学习
(❁´◡`❁)Jimmy(❁´◡`❁)14 小时前
【算法】二分图
学习
yunhuibin15 小时前
NIN网络学习
人工智能·python·深度学习·神经网络·学习
LateFrames16 小时前
WinForms + OpenTK (OpenGL 3.3) 粒子动画实测:100 万粒子,流畅无压力
ui·性能优化