字符串很麻烦
- c++不同标准的编译器在std::string 的实现上有区别,未必符合标准
- 字符串是动态分配的
- 字符串在表达式中,会涉及到复制操作
字符串的动态分配
- C的字符数组是定长的,c++字符串是动态分配的,耗时耗力。
- c++字符串内部缓冲区大小固定,当某个操作使其长度变长超出缓冲区大小时,会从内存分配一块新的缓冲区,并将字符串复制到该缓冲区。
- 为了避免多次申请缓冲区,std::string 每次申请的缓冲区大小是所需大小的两倍。代价是多余的空间占用。
字符串是值
- 在赋值语句和表达式中,字符串的行为和值一样
- 将一个值赋值给字符串,或者将一个字符串赋值给另一个字符串,改变的都是字符串变量,而不是字符串原来的值。(字符串拷贝是值拷贝不是引用拷贝)
- 字符串表达式结果也是值,如s1 = s2 + s3 + s4,s2 + s3 结果存在一个临时字符串s23,连接s4又会产生一个临时字符串,当结果保存给s1后,临时字符串会销毁,这里多次调用内存管理器。
字符串的复制
- 字符串的修改不会影响到其他字符串,最简单的实现就是每个字符串变量拥有自己的值的副本,而这样在赋值和传参时的开销会很大(需要复制)
- 写时复制(copy on write,COW)
- 多个字符串变量可以共享一块内存
- 用引用计数判断共享范围,赋值时增加引用计数即可
- 任何写操作都会让不想受影响的变量做一次复制(这时开销大)
- 在并发当中,每次访问引用计数器都得用锁控制
- c++ 11 后,右值引用和移动语义减轻了复制的负担
字符串的优化一
remove_ctrl() 函数,将ASCII 字符组成的字符串中删除控制字符
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;
}
问题出在第5行,若每个字符都可打印(>=0x20),则对于100个字符的string,就会调用100此内存管理器来分配内存,然后又调用100次来释放内存。
根据字符串的实现,赋值给result 可能还有额外的开销:
- 若是写时复制,那就是一次指针复制,引用计数自增
- 若是非共享内存,则还需复制内容。若缓冲区空间不足,还需内存分配,那就额外多了100次复制和100次内存分配。
- 若编译器实现了移动语义和右值引用,则可直接调用result 的移动构造函数,而不是复制构造函数,只需一次指针复制。
每次字符串连接时,会复制之前的所有字符串,对于n长度的string而言,就是O(n^2) 个字符复制。
复合赋值避免临时字符串
将赋值表达式替换为 +=
remove_ctrl_mutating():
std::string remove_ctrl_mutating()(std::string s) {
std::string result;
for (int i=0; i<s.length(); ++i) {
if(s[i] >= 0x20)
result += s[i];
}
return result;
}
因为+=就是在result的末尾加,所以相对原代码,省掉了临时字符串的内存分配,和从result 到临时字符串的复制操作。
赋值时的分配与复制开销取决于string的实现。
预留空间减少内存重分配
remove_ctrl_mutating() 会导致result 变长,缓冲区溢出,若string 实现为2倍扩容,则100个字符的string 扩容次数达到8次。
假设字符串中大多数字符都保留,则可以用string 的reserve 预先分配足够的空间。
reserve好处:
- 减少缓冲区的重新分配
- 改善了函数读取数据的局部缓存性(cache locality)—— 经常分配新内存释放旧内存,由于不是同一块内存,加载速度慢
remove_ctrl_reserve
std::string remove_ctrl(std::string s) {
std::string result;
result.reserve(s.length());
for (int i=0; i<s.length(); ++i) {
if(s[i] >= 0x20)
result += s[i];
}
return result;
}
消除参数字符串的复制
将一个字符串表达式的值传递给函数,则形参s会调用复制构造函数,同样这里操作取决于string的实现:
- 写时复制,则是一次指针复制加引用计数加一
- 非共享缓冲区,则复制构造函数需分配空间并复制
- 若编译器实现了移动语义和右值引用
- 若实参是一个表达式,是右值,则调用移动构造函数,比较高效
- 若实参是一个变量,则需分配空间和复制
remove_ctrl_ref_args 将参数定义为const 引用,则避免了复制:
std::string remove_ctrl_ref_args(std::string const& s) {
std::string result;
result.reserve(s.length());
for (int i=0; i<s.length(); ++i) {
if(s[i] >= 0x20)
result += s[i];
}
return result;
}
这么改也不是完全高效,引用的实现是指针,每次对s的访问都需要解引用,也是一点开销。
用迭代器消除指针解引用
迭代器是指向字符串缓冲区的简单指针,相比无迭代器代码,节省了两次解引用操作(s的解引用,s[i]的解引用?)
remove_ctrl_ref_args_it
std::string remove_ctrl_ref_args_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;
}
这里还有一个优化,字符串末尾end 在for 循环开始就缓存了,减少了2n的间接开销(2是什么?)
消除对返回字符串的复制
以上都是通过值返回结果,返回后一般会有复制构造函数的调用。若确保这里不会复制,一种方法是将返回值加入到入参中。
remove_ctrl_ref_result_it
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;
}
}
当函数返回时,调用方直接使用result 的实参,无需复制。
但这个函数的问题在于引用,容易让调用方误用,如下代码:
std::string foo("this is a string");
remove_ctrl_ref_result_it(foo, foo);
调用方希望的是返回foo,但是因为函数入口已经clear了,所以两个形参都为空,返回也为空串。
字符数组代替字符串
对于程序有严格性能要求,可用C风格字符串,需要:
- 手动分配、释放字符缓冲区
- 静态数组,大小初始化为最坏情况
- 在局部存储区(函数调用栈)可以声明大量临时缓冲区,函数退出时自动回收。
remove_ctrl_cstrings
void remove_ctrl_cstrings(char* destp, char const* srcp, size_t size) {
for (size_t i=0; i<size; ++i) {
if (srcp[i] >= 0x20)
*destp++ = srcp[i];
}
*destp = 0;
}
注意:
- 缓存局部性(代码和数据)可能会影响性能测试结果
- 进行性能优化时,要注意权衡简单性、安全性与所获得的性能提升效果。
借用原书的话:
我无法告诉你什么时候优化过度了,因为这取决于性能改善有多重要。但是开发人员应当注意性能的转变,然后停下来多多思考。
C++ 为开发人员提供了很多选择,从编写简单、安全但效率低下的代码,到编写高效但必须谨慎使用的代码。其他编程语言的提倡者可能会认为这是一个缺点,但是就优化而言,这是 C++ 最强有力的武器之一
转自:
《c++ 性能优化指南》