C++ 性能优化篇四《优化字符串的使用:案例研究》

只有少数人才能触摸到魔法琴弦(string), 可是聒噪的名声却企图击败他们; 悲哀于那些从来都不歌唱的人们, 死亡时却要带着他们的音乐陪葬!

​ ——奥利弗 • 温德尔 • 霍姆斯 1 ,“无声”(1858)

C++ 的 std::string 类模板是 C++ 标 准 库 中 使 用 最 广 泛 的 特 性 之 一。 例 如, 谷 歌 Chromium2 开 发 者 论 坛(https://groups.google.com/a/chromium.org/forum/#!msg/chromiumdev/EUqoIz2iFU4/kPZ5ZK0K3gEJ)上的一篇帖子中提到,“在 Chromium 中,std::string 对内存管理器的调用次数占到了内存管理器被调用的总次数的一半”。只要操作字符串的 代码会被频繁地执行,那么那里就有优化的用武之地。本章将会通过讨论“优化字符串处 理”来阐释优化中反复出现的主题。

4.1 为什么字符串很麻烦

字符串在概念上很简单,但是想要实现高效的字符串却非常微妙。由于 std::string 中特性的特定组合的交互方式,使得实现高效的字符串几乎不可能。的确,在编写本书时,几 种流行的编译器曾经实现的 std::string 在许多方面都不符合标准。

而且,为了能够跟上 C++ 标准的变化,std::string 的行为也在不断地变化。这意味着, 在 C++98 编译器中实现的符合标准的 std::string 的行为可能与在 C++11 之后实现的 std::string 的行为是不同的。

字符串的某些行为会增加使用它们的开销,这一点与实现方式无关。字符串是动态分配 的,它们在表达式中的行为与值相似,而且实现它们需要大量的复制操作。

4.1.1 字符串是动态分配的

字符串之所以使用起来很方便,是因为它们会为了保存内容而自动增长。相比之下,C 的 库函数(strcat()、strcpy() 等)工作于固定长度的字符数组上。为了实现这种灵活性, 字符串被设计为动态分配的。相比于 C++ 的大多数其他特性,动态分配内存耗时耗力。因 此无论如何,字符串都是性能优化热点。当一个字符串变量超出了其定义范围或是被赋予 了一个新的值后,动态分配的存储空间会被自动释放。与下面这段代码展示的需要为动态 分配的 C 风格的字符数组手动释放内存相比,这样无疑方便了许多。

char* p = (char*) malloc(7);
strcpy(p, "string");
 ...
free(p);

尽管如此,但字符串内部的字符缓冲区的大小仍然是固定的。任何会使字符串变长的操 作,如在字符串后面再添加一个字符或是字符串,都可能会使字符串的长度超出它内部的 缓冲区的大小。当发生这种情况时,操作会从内存管理器中获取一块新的缓冲区,并将字 符串复制到新的缓冲区中。

为了能让字符串增长时重新分配内存的开销“分期付款”,std::string 使用了一个小技巧。字符串向内存管理器申请的字符缓冲区的大小并非与字符串所需存储的字符数完全一 致,而是比该数值更大。例如,有些字符串的实现方式所申请的字符缓冲区的大小是需要 存储的字符数的两倍。这样,在下一次申请新的字符缓冲区之前,字符串的容量足够允许它增长一倍。下一次某个操作需要增长字符串时,现有的缓冲区足够存储新的内容,可以 避免申请新的缓冲区。这个小技巧带来的好处是随着字符串变得更长,在字符串后面再添 加字符或是字符串的开销近似于一个常量;而其代价则是字符串携带了一些未使用的内存 空间。如果字符串的实现策略是字符串缓冲区增大为原来的两倍,那么在该字符串的存储 空间中,有一半都是未使用的。

4.1.2 字符串就是值

在赋值语句和表达式中,字符串的行为与值是一样的(请参见 6.1.3 节)。2 和 3.14159 这 样的数值常量是值。可以将一个新值赋予给一个变量,但是改变这个变量并不会改变这个 值。例如:

int i,j;
i = 3; // i的值是3
j = i; // j的值也是3
i = 5; // i的值现在是5,但是j的值仍然是3

将一个字符串赋值给另一个字符串的工作方式是一样的,就仿佛每个字符串变量都拥有一 份它们所保存的内容的私有副本一样:

std::string s1, s2;
s1 = "hot"; // s1是"hot"
s2 = s1; // s2是"hot"
s1[0] = 'n'; // s2仍然是"hot",但s1变为了"not"

由于字符串就是值,因此字符串表达式的结果也是值。如果你使用 s1 = s2 + s3 + s4; 这 条语句连接字符串,那么 s2 + s3 的结果会被保存在一个新分配的临时字符串中。连接 s4 后的结果则会被保存在另一个临时字符串中。这个值将会取代 s1 之前的值。接着,为第一 个临时字符串和 s1 之前的值动态分配的内存将会被释放。这会导致多次调用内存管理器。

4.1.3 字符串会进行大量复制

由于字符串的行为与值相似,因此修改一个字符串不能改变其他字符串的值。但是字符串 也有可以改变其内容的变值操作。正是因为这些变值操作的存在,每个字符串变量必须表 现得好像它们拥有一份自己的私有副本一样。实现这种行为的最简单的方式是当创建字符 串、赋值或是将其作为参数传递给函数的时候进行一次复制。如果字符串是以这种方式实 现的,那么赋值和参数传递的开销将会变得很大,但是变值函数(mutating function)和非常量引用的开销却很小。

有一种被称为“写时复制”(copy on write)的著名的编程惯用法,它可以让对象与值具有 同样的表现,但是会使复制的开销变得非常大。在 C++ 文献中,它被简称为 COW(详见 6.5.5 节)。在 COW 的字符串中,动态分配的内存可以在字符串间共享。每个字符串都可 以通过引用计数知道它们是否使用了共享内存。当一个字符串被赋值给另一个字符串时, 所进行的处理只有复制指针以及增加引用计数。任何会改变字符串值的操作都会首先检查 是否只有一个指针指向该字符串的内存。如果多个字符串都指向该内存空间,所有的变值 操作(任何可能会改变字符串值的操作)都会在改变字符串值之前先分配新的内存空间并 复制字符串:

COWstring s1, s2;
s1 = "hot"; // s1是"hot"
s2 = s1; // s2是"hot"(s1和s2指向相同的内存)
s1[0] = 'n';// s1会在改变它的内容之前将当前内存空间中的内容复制一份
 // s2仍然是"hot",但s1变为了"not"

写时复制这项技术太有名了,以至于开发人员可能会想当然地以为 std::string 就是以 这种方式实现的。但是实际上,写时复制甚至是不符合 C++11 标准的实现方式,而且问题百出。

如果以写时复制方式实现字符串,那么赋值和参数传递操作的开销很小,但是一旦字符串 被共享了,非常量引用以及任何变值函数的调用都需要昂贵的分配和复制操作。在并发代 码中,写时复制字符串的开销同样很大。每次变值函数和非常量引用都要访问引用计数 器。当引用计数器被多个线程访问时,每个线程都必须使用一个特殊的指令从主内存中得 到引用计数的副本,以确保没有其他线程改变这个值(详见 12.2.7 节)。

在 C++11 及之后的版本中,随着“右值引用”和“移动语义”(详见 6.6 节)的出现,使用 它们可以在某种程度上减轻复制的负担。如果一个函数使用“右值引用”作为参数,那么 当实参是一个右值表达式时,字符串可以进行轻量级的指针复制,从而节省一次复制操作。

4.2 第一次尝试优化字符串

假设通过分析一个大型程序揭示出了代码清单 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;
}

remove_ctrl() 在循环中对通过参数接收到的字符串 s 的每个字符进行处理。循环中的代码 就是导致这个函数成为热点的原因。if 条件语句从字符串中得到一个字符,然后与一个字面常量进行比较。这里没有什么问题。但是第 5 行的语句就不一样了。

正如之前所指出的,字符串连接运算符的开销是很大的。它会调用内存管理器去构建一个 新的临时字符串对象来保存连接后的字符串。如果传递给 remove_ctrl() 的参数是一个由可打印的字符组成的字符串,那么 remove_ctrl() 几乎会为 s 中的每个字符都构建一个临时字符串对象。对于一个由 100 个字符组成的字符串而言,这会调用 100 次内存管理器来 为临时字符串分配内存,调用 100 次内存管理器来释放内存。

除了分配临时字符串来保存连接运算的结果外,将字符串连接表达式赋值给 result 时可能 还会分配额外的字符串。当然,这取决于字符串是如何实现的。

  • 如果字符串是以写时复制惯用法实现的,那么赋值运算符将会执行一次高效的指针复制 并增加引用计数。
  • 如果字符串是以非共享缓冲区的方式实现的,那么赋值运算符必须复制临时字符串的内 容。如果实现是原生的,或者 result 的缓冲区没有足够的容量,那么赋值运算符还必 须分配一块新的缓冲区用于复制连接结果。这会导致 100 次复制操作和 100 次额外的内 存分配。
  • 如果编译器实现了 C++11 风格的右值引用和移动语义,那么连接表达式的结果是一个 右值,这表示编译器可以调用 result 的移动构造函数,而无需调用复制构造函数。因此, 程序将会执行一次高效的指针复制。

每次执行连接运算时还会将之前处理过的所有字符复制到临时字符串中。如果参数字符串 有 n 个字符,那么 remove_ctrl() 会复制O(n2 ) 个字符。所有这些内存分配和复制都会导致 性能变差。

因为 remove_ctrl() 是一个小且独立的函数,所以我们可以构建一个测试套件

  • 5
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值