标签:轻量 字符串函数 字符串连接 ret 容器类 优势 转换 一个 动态
C++的std::string类模板是C++标准库中使用最广泛的特性之一。只要操作字符串的代码会被频繁地执行,那么就有优化的用武之地。
字符串在概念上很简单,但是想要实现高效的字符串却非常微妙。由于std::string中特性的特定组合的交互方式,使得实现高效的字符串几乎不可能。
字符串的某些行为会增加使用它们的开销,这一点与实现方式无关。字符串是动态分配的,它们在表达式中的行为与值相似,而且实现它们需要大量的复制操作。
字符串之所以使用起来很方便,是因为它们会为了保存内容而自动增长。相比之下,C的库函数工作于固定长度的字符数组上。为了实现这猴子那个灵活性,字符串被设计为动态分配的。相比于C++的大多数其他特性,动态分配内存耗时耗力。因此无论如何,字符串都是性能优化热点。当一个字符串变量超过了其定义范围或是被赋予了一个新的值后,动态分配的存储空间会被自动释放。尽管如此,但字符串内部的字符缓冲区的大小仍然是固定的。任何会使字符串变长的操作,都可能会使字符串的长度超出它内部的缓存区的大小。当发生这种情况时,操作会从内存管理器中获取一块新的缓冲区,并将字符串复制到新的缓冲区中。
为了能让字符串增长时重新分配内存的开销“分期付款”,std::string使用了一个小技巧。字符串向内存管理器申请的字符缓冲区的大小并非与字符串所需存储的字符数完全一致,而是比该数值更大。下一次某个操作需要增长字符串时,现有的缓冲区足够存储新的内容,可以避免申请新的缓冲区。这个小技巧带来的好处是随着字符串变得更长,在字符串后面再添加字符或是字符串的开销近似于一个常量;而其代价则是字符串携带了一些未使用的内存空间。
在赋值语句和表达式中,字符串的行为与值是一样的。将一个字符串赋值给另一个字符串的工作方式是一样的,就仿佛每个字符串变量都拥有一份它们所保存的内容的私有副本一样:这会导致多次调用内存管理器。
由于字符串的行为与值相似,因此修改一个字符串不能改变其他字符串的值。但是字符串也有可以改变其内容的变值操作。正是因为这些变值操作的存在,每个字符串变量必须表现得好像它们拥有一份自己的私有副本一样。实现这种行为的最简单的方式是当创建字符串、赋值或是将其作为参数传递给函数的时候进行一次复制。如果字符串是以这种方式实现的,那么赋值和参数传递的开销将会变得很大,但是变值函数(mutating function)和非常量引用的开销却很小。
有一种被称为“写时复制”(copy on write)的著名的编程惯用法,它可以让对象与值具有同样的表现,但是会使复制的开销变得非常大。在C++文献中,它被简称为COW。在COW的字符串中,动态分配的内存可以在字符串间共享。每个字符串都可以通过引用计数知道它们是否使用了共享内存。当一个字符串被赋值给另一个字符串时,所进行的处理只有复制指针以及增加引用计数。任何会改变字符串值的操作都会首先检查是否是有一个指针指向该字符串的内存。如果多个字符串都指向该内存空间,所有的变值操作都会在改变字符串值之前先分配新的内存空间并复制字符串。
写时复制不符合C++11标准的实现方式,而且问题百出。如果以写时复制方式实现字符串,那么赋值和参数传递操作的开销很小,但是一旦字符串被共享了,非常量引用以及任何变值函数的调用都需要昂贵的分配和复制操作。在并发代码中,写时复制字符串的开销同样很大。每次变值函数和非常量引用都要访问引用计数器。当引用计数器被多个线程访问时,每个线程都必须使用一个特殊的指令从主内存中得到引用计数的副本,以确保没有其他线程改变这个值。
在C++11及以后的版本中,随着“右值引用”和“移动语义”的出现,使用它们可以在某种程度上减轻复制的负担。如果一个函数使用右值引用作为参数,那么当实参是一个右值表达式时,字符串可以进行轻量级的指针复制,从而节省一次复制操作。
假设通过分析一个大型程序揭示出了代码清单4-1中的remove_ctrl()函数的执行时间在程序整体执行时间中所占的比例非常大。这个函数的功能是从一个由ASCII字符组成的字符串中移除控制字符。看起来它似乎很无辜,但是出于多种原因,这种写法的函数确实性能非常糟糕。实际上,这个函数是一个很好的例子,向大家展示了在编码时完全不考虑性能是多么地危险。
代码清单4-1 需要优化的remove_ctrl()
std::string remove_ctrl(std::string s){
std::string result;
for (int i=0; i<s.length(); ++i){
if (s[i] >= 0x20)
result = result + s[i];
}
return result;
}
正如之前所指出的,字符串连接运算符的开销是很大的。它会调用内存管理器去构建一个新的临时字符串对象来保存连接后的字符串。
除了分配临时字符串来保存连接运算的结果外,将字符串连接表达式赋值给result时可能还会分配额外的字符串。当然,这取决于字符串是如何实现的。
每次执行连接运算时还会将之前处理过的所有字符复制到临时字符串中。如果参数字符串有n个字符,那么remove_ctrl()会复制O(\(n^2\))个字符。所有这些内存分配和复制都会导致性能变差。
由于remove_ctrl()是一个小且独立的函数,所以我们可以构建一个测试条件,通过反复地调用该函数来测量通过优化到底能将该函数的性能提升多少。测量结果的数字本身并不重要,重要的是,它是测量性能改善的基准值。
首先通过移除内存分配和复制操作来优化。
代码清单4-2 remove_ctrl_mutating():符合赋值操作符
// remove_ctrl() with operator replaced by mutating assignment
std::string remove_ctrl_mutating(std::string s) {
std::string result;
for (size_t i=0; i<s.length(); ++i) {
if (s[i] >= 0x20)
result += s[i];
}
return result;
}
这个小的改动却带来了很大的性能提升(13倍)。这次改善源于移除了所有为了分配临时字符串对象来保存连接结果而对内存管理器的调用,以及相关的复制和删除临时字符串的操作。赋值时的分配和复制操作也可以被移除,不过这取决于字符串的实现方式。
remove_ctrl_mutating()函数仍然会执行一个导致result变长的操作。这意味着result会被反复地复制到一个更大的内部动态缓冲区中。
假设字符串中绝大多数都是可打印的字符,只有几个是需要移除的控制字符,那么结果字符串的最终长度几乎等于参数字符串的长度。
代码清单4-3 remove_ctrl_reserve():预留存储空间
// remove_ctrl_mutating() with space reserved in result
std::string remove_ctrl_reserve(std::string s) {
std::string result;
result.reserve(s.length());
for (size_t i=0; i<s.length(); ++i) {
if (s[i] >= 0x20)
result += s[i];
}
return result;
}
移除了几处内存分配后,程序性能得到了明显的提升(13%)。使用reserve()不仅移除了字符串缓冲区的重新分配,还改善了函数所读取的数据的缓存局部性(cache locality)。
如果通过值将一个字符串表达式传递给一个函数,那么形参将会通过复制构造函数被初始化。这可能会导致复制操作,当然,这取决于字符串的实现方式。
用常量引用作为参数,省去了另外一次内存分配。由于内存分配是昂贵的,所以哪怕只是一次内存分配,也值得从程序中移除。
代码清单4-4 remove_ctrl_refs():移除实参复制
// remove_ctrl_reserve() with reference arg instead of value arg
std::string remove_ctrl_refs(std::string const& s) {
std::string result;
result.reserve(s.length());
for (size_t i=0; i<s.length(); ++i) {
if (s[i] >= 0x20)
result += s[i];
}
return result;
}
令人大吃一惊,测试结果是性能下降了8%。引用变量是作为指针实现的,指针解引用会带来额外的开销,抵消了节省内存分配带来的性能提升。
解决方法是在字符串上使用迭代器。字符串迭代器是指向字符缓冲区的简单指针。与在循环中不使用迭代器的代码相比,这样可以节省两次解引操作。
代码清单4-5 remove_ctrl_refs_it():使用了迭代器
// remove_ctrl_reserve() with reference arg instead of value arg
std::string remove_ctrl_refs_it(std::string const& s) {
std::string result;
result.reserve(s.length());
for (auto it=s.begin(),end=s.end();it != end; ++it) {
if (*it >= 0x20)
result += *it;
}
return result;
}
通过迭代器消除指针引用后,可以证明使用引用减少字符串复制确实提高了程序性能。
上述函数中还包含另一个优化点,那就是用于控制for循环的s.end()的值在循环初始化时被缓存起来。这样可以节省2n的间接开销,其中n是参数字符串的长度。
remove_ctrl()函数的初始版本是通过值返回处理结果的。C++会调用复制构造函数将处理结果过设置到调用上下文。想要确保不会发生复制,那么有几种选择。其中一种选择是将字符串作为输出参数返回,这种方法适用于所有的C++版本以及字符串的所有实现方式。这也是编译器在省去调用复制构造函数时确实会进行的处理。
代码清单4-6 remove_ctrl_ref_result_it():移除对返回值的复制
// remove_ctrl_ref_ret with iterators
void remove_ctrl_ref_result_it(
std::string& result,
std::string const& s) {
result.clear();
result.reserve(s.length());
for (auto it=s.begin(),end=s.end();it != end; ++it) {
if (*it >= 0x20)
result += *it;
}
}
其优点在于多数情况下它都可以移除所有的内存分配,比修改前的版本快了大约2%。但是它的接口很容易导致调用方误用这个函数。
当程序有及其严格的性能需求时,可以不使用C++标准库,而是利用C风格的字符串函数来手动编写函数。相比std::string,C风格的字符串函数更难以使用,但是它们却能带来显著的性能提升。要想使用C风格的字符串,程序员必须手动分配和释放字符缓冲区,或者使用静态数组并将大小设置为可能发生的最差情况。如果内存的使用量非常严格,那么可能无法声明很多静态数组。不过,在局部存储区(即函数调用栈)中往往有足够的空间可以静态地声明大型临时缓冲区。当函数退出时,这些缓冲区将会被回收,而产生的运行时开销则微不足道。除了一些限制极度严格的嵌入式环境外,在栈上声明最差情况下的缓冲区为1000甚至10000个字符是没有的问题的。
代码清单4-7 remove_ctrl_cstrings():在底层编码
// remove_ctrl_ref_result() done with buffers instead of strings
void remove_ctrl_cstrings(char* destp, char const* sourcep, size_t length) {
for (size_t i=0; i<length; ++i) {
if (sourcep[i] >= 0x20)
*destp++ = sourcep[i];
}
*destp = 0;
}
测试结果是比上一个版本快了6倍,比最初的版本更是快了170倍、获得这种改善效果的原因之一是移除了若干函数调用以及改善了缓存局部性。
不过,优秀的缓存局部性可能会误导性能测量。通常,在两次调用remove_ctrl_cstrings之间的其他操作会刷新缓存。但是当在一个循环中频繁地调用该函数时,指令和数据可能会驻留在缓存中。
另一个影响的因素是它的接口与初始函数相比发生了太多改变。如果有许多地方都调用了初始版本函数,那么将那些代码修改为调用现在的这个函数需要花费很多人力和时间,而且修改后代码也可能需要优化。尽管如此,这个例子仍然说明,只要开发人员愿意完全重写函数和改变它的接口,他们可以获得很大的性能提升。
这些优化手段都遵循一个简单的规则:移除内存分配和相关的复制操作。
正式版本的性能提升看起来更具有戏剧性。这可能是受到了阿达姆法则的影响。在调试版本中,函数的内联展开被关闭了,这增加了每个函数调用的开销,也导致内存分配的执行时间所占的比重降低了。
一种优化选择是尝试改进算法。通过将整个子祖父穿移动至结果字符串中改善了函数性能。这个改动可以减少内存分配和复制操作的次数。另外一个优化选择是缓存参数字符串的长度,以减少外出for循环中结束条件语句的性能开销。
代码清单4-8 remove_ctrl_block():一种更快的算法
// copy whole substrings to reduce allocations
std::string remove_ctrl_block(std::string s) {
std::string result;
for (size_t b=0,i=b,e=s.length(); b < e; b = i+1) {
for (i=b; i<e; ++i) {
if (s[i] < 0x20) break;
}
result = result + s.substr(b,i-b);
}
return result;
}
测量结果是比初始版本快了7倍。
这个函数与以前一样,可以通过使用复合赋值运算符替换字符串连接运算符来改善性能,但是substr()仍然生成临时字符串。由于这个函数将字符串添加到了result的末尾,开发人员可以通过重载std::string的append()成员函数来复制子字符串,且无需创建临时字符串。
代码清单4-9 remove_ctrl_block_append():一种更快的算法
std::string remove_ctrl_block_append(std::string s) {
std::string result;
result.reserve(s.length());
for (size_t b=0,i=b; b < s.length(); b = i+1) {
for (i=b; i<s.length(); ++i) {
if (s[i] < 0x20) break;
}
result.append(s, b, i-b);
}
return result;
}
比初始版本快了36倍。这个简单的例子向我们展示了选择一种更好的算法是一种多么强大的优化手段。
这个结果还可以通过预留存储空间和移除参数复制以及移除返回值的复制来改善。但使用迭代器重写对性能没有改善,至少在最开始没有。不过,在将参数和返回值都变为引用类型后,使用迭代器会改善性能。
另外一种改善性能的方法是,通过使用erase()成员函数移除控制字符来改变字符串。
代码清单4-10 remove_ctrl_erase():不创建新的字符串,而是修改参数字符串的值作为结果返回
// cleverly reduce the size of a string so it doesn‘t have to be reallocated
std::string remove_ctrl_erase(std::string s) {
for (size_t i = 0; i < s.length(); )
if (s[i] < 0x20)
s.erase(i,1);
else ++i;
return s;
}
这种算法的优势在于,由于s在不断地变短,除了返回值时会发生内存分配外,其他情况下都不会再发生内存分配。比初始版本快了30倍。
我使用了VS2013运行了相同的测试。VS2013实现了移动语义,这应该会让一些函数更快。不过,结果却有点让人看不懂。在调试模式下的运行结果是VS2013比VS2010快,不过从命令行运行的结果是VS2013比VS2010慢。我也试过VS2015,结果更慢。这可能与容器类的改变有关。一个新版本的编译器可能会改善性能,不过这需要开发人员通过测试去验证,而不是想当然。
std::string的定义曾经非常模糊,这让开发人员在实现字符串时有更广泛的选择。后来,对性能和可预测性的需求最终迫使C++标准明确了它的定义,导致很多新奇的实现方式不再适用。定义std::string的行为是一种妥协,它是经过很长一段事件以后从各种设计思想中演变出来的。
希望std::string与C风格的字符数组一样搞笑,这个需求推动者字符串的实现朝着在紧邻的内存中表现字符串的方向前进。C++标准要求迭代器能够随机访问,而且禁止写时复制语义。这样更容易定义std::string,而且更容易推论出哪些操作会使在std::string中使用迭代器无效,但它同时也限制了更聪明的实现方式的范围。
采用更丰富的std::string库
有时候,使用更好的库也表示使用额外的字符串函数。许多库都可以与std::string共同工作,下面列举了其中一部分:
使用std::stringstream避免值语义
C++已经有几种字符串实现方式了:模板化的、支持迭代器访问的、可变长度的std::string字符串;简单的、基于迭代器的std::vector
尽管很难用好C风格的字符串,但我们之前已经通过实验看到了,在适当的条件下,使用C风格的字符数组替换C++的std::string后可以极大程度地改善程序的性能。这两种实现方式都很难完美地适用于所有情况。
C++中还有另外一种字符串:std::stringstream。以一种不同的方式封装了一块动态大小的缓冲区,数据可以被添加至这个实体中。
std::stringstream s;
for (int i=0; i<10; ++i){
s.clear();
s << i*i << std::endl;
log(s.str());
}
这个很长的插入表达式不会创建任何临时字符串,因此不会发生内存分配和复制操作。将s声明在循环外,这样s内部的缓存将会被复用。
如果std::stringstream是用std::string实现的,那么它在性能上永远不能胜过std::string。它的优点在于可以防止某些降低程序性能的编程实践。
采用一种新奇的字符串实现方式
开发人员可能会发现字符串缺乏抽象性。C++最重要的特性之一是没有内置字符串等抽象性,却以模板或者函数库的形式提供了这种抽象性。std::string等可选的实现方式称为了这门编程语言的特性。
每个std::string的内部都是一个动态分配的字符数组。std::string看上去像是下面这样的通用模板的一种特化:
namespace std{
template <class charT,
class traits = char_traits<charT>,
class Alloc = allocator<charT>
>class basic_string;
typedef basic_string<char> string;
...
};
第三个模板参数Alloc定义了一个分配器——一个访问C++内存管理器的专用接口。默认情况下,Alloc是std::allocator,它会调用::operator new()和::operator delete()——两个全局的C++内存分配器函数。它们需要为大大小小的对象以及单线程和多线程程序工作。为了实现良好的通用性,它们在设计上做出了一些妥协。有时,选择一种更加特化的分配器可能会更好。因此,我们可以指定默认分配器以外的为std::string定制的分配器作为Alloc。
我编写了一个及其简单的分配器来展示可以获得怎样的性能提升。这个分配器可以管理几个固定大小的内存块。
代码清单4-12 使用简单的、管理固定大小内存块的分配器的原始版本的remove_ctrl()
typedef std::basic_string<
char,
std::char_traits<char>,
StatelessStringAllocator<char>> fixed_block_string;
fixed_block_string remove_ctrl_fixed_block(std::string s) {
fixed_block_string result;
for (size_t i = 0; i<s.length(); ++i) {
if (s[i] >= 0x20)
result = result + s[i];
}
return result;
}
测试结果大约比初始版本快了7.7倍。
修改分配器并不适用于怯懦的开发人员。你无法将基于不同分配器的字符串赋值给另外一个字符串。修改后的示例代码之所以能够工作,仅仅是因为s[i]是一个字符,而不是一个只有一个字符的std::string。你可以通过将字符串转换为C风格的字符串,将一个字符串的内容复制到另一个字符串中。
将代码中所有的std::string都修改为fixed_block_string将会带来很大的影响。因此,如果一个团队认为需要对他们使用的字符串做些改变,那么最好在设计阶段就定义全工程范围的typedef。
之后,当要进行设计大量代码修改的实验时,只需要修改这一处代码即可。仅在新的字符串与要替换的字符串有相同的成员函数时,这种方法才奏效。不同 分配器分配的std::basic_string具有这种特性。
通常,字符串函数只适用于对相同类型的字符串进行比较、赋值或是作为运算对象和参数,因此,程序员必须将一种类型的字符串转换为另外一种类型。任何时候,设计复制字符和动态分配内存的转换都是优化性能的机会。转换函数库自身也可以被优化。更重要的是,大型程序的良好设计是可以限制这种转换的。
从以空字符结尾的字符串到std::string的无谓转换,是浪费计算机CPU周期的原因之一。例如:
std::string MyClass::Name() const {
return ""MyClass;
}
这个函数必须将字符串常量MyClass转换为一个std::string,分配内存和复制字符到std::string中。C++会自动地进行这次转换,因为在std::string中有一个参数为char*的构造函数。因此当Name()的返回值被赋值给一个字符串或是作为参数传递给另外一个函数时,会自动进行转换。上面的函数可以简单地写为:
char const* MyClass::Name() const {
return ""MyClass;
}
这会将返回值的转换推迟至它真正被使用的时候。当它被使用时,通常不需要转换:
char const* p = myInstance->Name(); // 没有转换
std::string s = myInstance->Name(); // 转换为std::string
std::cout << myInstance->Name(); // 没有转换
现代C++程序需要将C的字面字符串(ASCII,有符号字节)与来自Web浏览器的UTF-8(无符号,每个字符都是可变长字节)字符串进行比较,或是将由生成UTF-16的字流(待或者不带端字节)的XML解析器输出的字符串转换为UTF-8。转换组合的数量令人生畏。
移除转换的最佳方法是为所有的字符串选择一种固定的格式,并将所有字符串都存储为这这种格式。你可能希望提供一个特殊的比较函数,用于比较你所选择的格式和C风格的以空字符结尾的字符串,这样就无需进行字符串转换。
在时间紧迫的情况下编写的大型程序中,你可能会发现在将一个字符串从软件中的一层传递给另一层时,先将它从原来的格式转换为一种新的格式,然后再将它转换为原来的格式的代码。可以通过重写类接口中的成员函数,让它们接收相同的字符串类型来解决这个问题。不幸的是,这项任务就像是在C++程序中加入常量正确性 (const-correctness)。这种修改设计程序中的许多地方,难以控制其范围。
标签:轻量 字符串函数 字符串连接 ret 容器类 优势 转换 一个 动态
原文地址:https://www.cnblogs.com/fr-ruiyang/p/12690743.html