MoreEffectiveC++笔记 4效率
1 牢记80-20准则
胡乱提高一部分程序效率难有很大帮助;使用profiler程序识别其中20%的部分;用尽可能多的数据进行profiler测试。
2 lazy evaluation
延迟计算工作到系统需要这些计算的结果,如果不需要那么将不进行计算。
引用计数
例如一个变量s1赋值给s2,在s1s2未发生变化之前,我们可以让二者都使用同一个存储空间。直到更改的时候再为s2开辟新的空间。所以我们尽量延迟拷贝的操作。
区别对待读取和写入
承接上面的计数问题,如何判断目前是读操作还是写操作呢?比如operator[]返回的是一个引用,a[i]被读还是写在函数中是无法感知的。但是后续会提出proxy class(M30)这一概念,通过它我们可以推迟两种操作的决定。
Lazy Fetching(懒惰提取)
假设用一个对象管理大存储资源,只有在需要数据的时候才去提取数据,否则在相应位置仍然是一个对象“壳”。
懒惰表达式计算
考虑矩阵计算场景,执行一个矩阵运算时,我们可以用一个对象去保存计算的对象和计算的操作,然而并不进行计算过程。直到后续代码需要提取答案中的某个元素(比如其中一行或者其中一列)的时候,才根据存储的对象执行相应部分的计算操作。
3分期摊还期望计算
例1,一个容器类,频繁计算最大值最小值,我们可以在容器内容发生更改后就立刻更新一次最值;这时提供的getMaxgetMin函数的复杂度就分摊到每一次更新操作中。
例2,建立缓存cache。
例3,预提取,就是磁盘扇区读取数据的部分周围很可能就是接下来需要读取的数据,所以一般就算只需要读一部分,但是还是把磁盘中一个扇区的数据都读出来了。
例4,vector扩容二倍。
本节与上一节并不矛盾,如果某些操作很少需要结果,那就是和lazy策略,如果某个操作的结果几乎总是需要,那就是和over-eager。
4理解临时对象的来源
常见的临时对象可以来自于隐式转型,比如向形参为string的函数传入一个c风格的字符数组,编译器会用字符数组进行构造来生成临时的string对象,而非常量引用作为形参是不会产生临时对象的。
此外返回值是作为一个临时变量存在的,对这种情况的优化在后续会提到。
5协助完成返回值优化
返回对象的函数难有较高的效率,因为传值返回会导致对象内的构造和析构。而传值有时不能被改成指针或者引用,因为可能局部对象的释放会带来错误。
**返回值优化:**在return语句写一个构造函数并立即返回,编译器会跳过临时对象的生成。(经过在g++测试,目前不需要这种return的写法也可以完成临时变量的优化)
#include <iostream>
#include <vector>
#include <string>
#include <iostream>
using namespace std;
class Rational
{
public:
Rational(int numerator = 0, int denominator = 1) : n(numerator), d(denominator)
{
cout << "Constructor Called..." << endl;
}
~Rational()
{
cout << "Destructor Called..." << endl;
}
Rational(const Rational &rhs)
{
this->d = rhs.d;
this->n = rhs.n;
cout << "Copy Constructor Called..." << endl;
}
int numerator() const { return n; }
int denominator() const { return d; }
private:
int n, d;
};
const Rational operator*(const Rational &lhs, const Rational &rhs)
{
Rational tmp(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
return tmp;
}
// const Rational operator*(const Rational& lhs,const Rational& rhs)
// {
// return Rational(lhs.numerator() * rhs.numerator(),
// lhs.denominator() * rhs.denominator());
// }
int main(int argc, char **argv)
{
Rational x(1, 5), y(2, 9);
Rational z = x * y;
cout << "calc result: " << z.numerator()
<< "/" << z.denominator() << endl;
return 0;
}
无论乘法重载是否有tmp变量,执行的过程只有3次构造3次析构,说明现在的版本存在内联优化。自己又试着写了一段:
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class Widget
{
public:
int i;
Widget()
{
cout << "construct" << endl;
}
const Widget &operator=(const Widget &w)
{
cout << "= op" << endl;
}
Widget(const Widget &w)
{
cout << "copy construct" << endl;
}
~Widget()
{
cout << "delete" << endl;
}
};
const Widget foo(int j)
{
Widget ret;
ret.i = 123;
// if (j == 1)
// {
return ret;
// }
// return foo(j - 1);
}
int main()
{
Widget w = foo(1);
return 0;
}
发现没有注释递归操作的时候,只有一次构造一次析构,把递归打开(当然输入参数就是1,那么函数只执行一次实际没有递归),输出结果就变成了一次构造、一次拷贝、两次析构,说明有递归调用的代码就可以骗过编译器,让它用最基础的策略执行代码。
6通过重载函数避免隐式类型转换
就像题目说的,多重载几个版本,没必要都隐转调用同一个函数体。但是不要重载形参全部都是内置类型的函数,这会更改内置类型原本普通的计算。
7考虑用op=取代单独形式的操作符
一般我们可以把op+=操作符定义为一个类的成员函数,然后重载op+操作符定义成模板友元函数,在op+内调用成员+=操作完成加法对应的操作。
但是+操作势必会返回一个新的临时对象,+=直接把结果放在第一个操作数内不需要生成新对象。所以允许+=的地方优先使用+=。
8考虑变更程序库
给出了iostream和stdio作为例子,iostream具有使用方便、类型安全的有点,stdio效率更高,大约快20%。
9理解虚函数多继承虚基类和rtti带来的代价
虚函数需要有虚表存储指向它们入口的指针才能调用,程序里每个类只需要存储一个虚表。虚表的存储位置有两种,一种是每个需要虚表的object文件都添加一个虚表拷贝,连接期去重,最后的可执行文件每种虚表只保存一个实例;另一种是启发式的算法,如果一个obj包含一个类的非内联非纯虚函数定义,那么在这个obj包含该类的虚表。基于启发式实现的虚函数如果内联那么就会启发失败导致每个使用它的obj都会生成一个虚表;因此编译器一般忽略虚函数的内联要求(内联展开是静态的、虚函数是动态的,这也要求内联必须忽略)。
接下来每个对象存储一个虚指针指向各自的虚表,这个指针的位置只有每个编译器自己清楚。所以存在虚函数的类实例化的对象会多一部分4bits空间存储虚指针。
通过上述机制运行期编译器动态的用指针去寻找对象应该调用的虚函数的地址,这个寻找的过程开销是可以忽略的,通常不是瓶颈。
多继承发生时会导致虚指针的寻找过程变得复杂。每个对象会包含多个虚指针(每个基类会对应一个)。
虚基类用于避免菱形继承带来的多个基类部分拷贝,每个虚继承都以为着会有一个指针从该子类指向父类部分(菱形继承就需要两个虚继承指针)。
最后讨论rtti,运行时的信息被存储在type_info类型对象里面。这个信息就存储在虚函数表的开始位置。
虽然多态带来了上述开销,但是明显的可以减少代码量;在数据库存储对象或者跨进程移动对象的时候可能出现问题,需要自己模拟这种特性。
本文介绍了C++程序性能优化的多种策略,包括遵循80-20准则、懒惰求值、分期摊还期望计算等,并探讨了临时对象管理、返回值优化、虚函数开销等问题。
4196

被折叠的 条评论
为什么被折叠?



