C++高效编程的12条建议:性能优化实践指南 #
引言 #
在C++开发中,代码性能往往是关键考虑因素。本文总结了12条实用的性能优化建议,从参数传递方式到算法选择,帮助开发者编写更高效的C++程序。
以下为正文:
参数传递方式:值传递还是引用传递 #
一般情况下使用const的引用参数。对于函数本身会拷贝的参数,最好使用值传递,但只有当参数的类型支持移动语义时才这样。
在某些情况下,值传递并移动实际上是向函数传递参数的最佳方式(注意看后面的tips),例如:
class A {
public:
A(const std::string &str) { str_ = str; }
private:
std::string str_;
};
可以考虑改为这种形式:
class A {
public:
A(std::string str) { str_ = std::move(str); }
private:
std::string str_;
};
因为无论如何都会对它们进行拷贝。
tips: 看有些资料说后者值传递是更好的参数传递方式,貌似有些道理,但是我没找到非常合理的理由,有知道的读者可以在评论区留言。
函数返回方式:按值返回还是按引用返回 #
可以通过从函数中按引用方式返回对象,以避免对象发生不必要的复制。但有时不可能通过引用返回对象,例如编写重载的operator+和其他类似运算符时。
永远都不要返回指向局部对象的引用或指针,局部对象会在函数退出时被销毁。
但是,按值返回对象通常没啥大问题。因为一般情况下他们会触发返回值优化或移动语义,即不会有多余的拷贝动作。
使用移动语义 #
尽量确保对象拥有移动构造函数和移动赋值运算符。对象有了移动语义后,许多操作都会更加高效,特别是与标准库和算法相结合时。
避免创建临时对象 #
没有必要的临时对象能避免就避免。一般来说,应该避免迫使编译器构造临时对象的情况。尽管有时这是不可避免的,但是至少应该意识到这项"特性"的存在,这样才不会为实际性能和分析结果而感到惊讶。编译器还会使用移动语义使临时对象的效率更高。这是要在类中添加移动语义的另一个原因。
《More Effective C++》第19条款中介绍过:所谓的临时对象并不是程序员创建的用于存储临时值的对象,而是指编译器层面上的临时对象:这种临时对象不是由程序员创建,而是由编译器为了实现某些功能(例如函数返回,类型转换等)而创建。
比如下面的代码就会有临时对象的产生:
void Func(const std::string& s);
char arr[] = "hello";
Func(arr); // here
返回值优化 #
通过值返回对象的函数可能导致创建一个临时对象。看下面的代码:
Person createPerson() {
Person newP { "Marc", "Gregoire", 42 };
return newP;
}
假如像这样调用这个函数(假设Person 类已经实现了operator«运算符):
cout << createPerson();
即便这个调用没有将createPerson()的结果保存在任何地方,也必须将结果保存在某个地方,才能传递给operator«。为此编译器创建一个临时变量,来保存createPerson()返回的Person 对象。
即使这个函数的结果没有在任何地方使用,编译器也仍然可能会生成创建临时对象的代码:
createPerson();
编译器可能生成代码来创建一个临时对象来保存返回值,即使这个返回值没有使用也是如此。
不过吧,编译器会在大多数情况下优化掉临时变量,以避免复制和移动。
预分配内存 #
比如标准化容器中的reserve,需要频繁创建内存的地方可以考虑预分配一块内存出来,避免频繁的创建内存。
内联函数 #
短函数可以使用内联消除函数开销。
迭代 vs 递归 #
这里我更倾向于选择迭代方式,而不是递归。递归占用大量栈内存,且可能会产生很多不必要的临时对象构建。
选择效率更高的算法 #
学计算机的估计没有不知道算法的吧,学算法估计没有人不知道如何计算时间复杂度和空间复杂度吧,在平时开发过程中遇到算法问题时我们可尽量选择效率更高的算法,比如O(N)和O(N2)的算法,我们肯定要选择O(N)的呀,这里可以了解下C++的,这里引入了很多高效的算法供我们使用。
尽可能多的使用缓存 #
将某些数据保存下来供下次使用,避免再次获取或重新计算它们。如果任务或计算特别慢,应该保证不执行那些没有必要的任务或者重复计算。
- 网络通信:如果频繁发起相同的网络请求,可考虑将第一次的网络请求结果保存在内存中,或文件中?
- 磁盘访问:如果频繁访问一个文件,可考虑将这个文件的内容保存在内存中。
- 数学计算:某些很耗时很复杂的运算,可考虑只执行这种计算一次,然后共享结果。
- 对象分配:如果需要大量频繁创建和销毁短期对象,可考虑使用对象池。
- 线程创建:如果需要大量频繁创建和销毁线程,可考虑使用线程池。
做客户端开发的朋友应该都听说多级缓存的概念,就是这个原理。
profiling #
性能问题永远离不开profiling工具,多用profiling工具。
other碎碎念 #
- 选择合适的数据结构:
选择合适的STL,想清楚什么时候用栈,什么时候用队列,什么时候用数组,什么时候用链表。
某些if-else可改为switch,效率可能更高。
优先考虑栈内存,而不是堆内存(免得频繁的申请释放内存)。
如何函数不需要返回值,就不要设置返回值。
使用位操作,移位代替乘法除法操作。
构造函数时使用初始化方式,而不是赋值。
A::A() : a_(a) {} // better
A::A() { a_ = a; }
- 明确使用模板带来的益处:
如果使用模板并没有给你的开发带来任何益处,是不是可以考虑不使用它,因为调试起来真的麻烦。
函数参数的个数不要太多。
擅用emplace,有些情况下会省去一次构造的开销。
最后想说一句:
先完成再完美。不要一开始就想着写最完美的代码,很多bug都是过早过度优化导致的。一般情况下,性能较高的代码可读性都不是特别高。提早优化很可能引发很多bug。很多情况下,我们可以先完成代码,确保功能完成且正确之后,再去考虑完善。完成代码后,可以使用profiling工具,找到瓶颈所在,然后做相应优化。另外产品和测试如果没给你提性能需求,那优化它干嘛!