c++ 性能优化之字符串(上)

字符串很麻烦

  • 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++ 性能优化指南》

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值