《More Effective C++》读书笔记(中)

四、效率

本章的内容从两个角度阐述效率的问题。第一是从语言独立的角度,关注那些你能在任何语言里都能使用的东西。C++ 为它们提供了特别吸引人的实现途径,因为它对封装的支持非常好,从而能够用更好的算法与数据结构来替代低效的类似实现,同时接口可以保持不变。

第二是关注 C++ 语言本身。高性能的算法与数据结构虽然非常好,但如果实际编程中代码实现得很粗糙,效率也会降低得相当多。潜在危害性最大的错误是既容易犯而又不容易察觉的错误,频繁地构造和释放大量的对象就是一种这样的错误。过多的对象构造和对象释放对于你的程序性能来说就象是在大出血,在每次建立和释放不需要的对象的过程中,宝贵的时间就这么流走了。这个问题在 C++程序中很普遍,我将用四个条款来说明这些对象从哪里来的,在不影响程序代码正确性的基础上又如何消除它们。

建立大量的对象不会使程序变大而只会使其运行速度变慢。还有其它一些影响性能提高的因素,包括程序库的选择和语言特性的实现(implementations of language features)。在下面的条款中我也将涉及。

在学习了本章内容以后,你将熟悉能够提高程序性能的几个原则,这些原则可以适用于你所写的任何程序。你将知道如何准确地防止在你的软件里出现不需要的对象,并且对编译器生成可执行代码的行为有着敏锐的感觉。


条款16:牢记 80-20 准则(80-20 rule)

80-20 准则说的是大约 20% 的代码使用了 80% 的程序资源:

  • 大约 20% 的代码耗用了大约 80% 的运行时间;
  • 大约 20% 的代码使用了 80% 的内存;
  • 大约 20% 的代码执行 80% 的磁盘访问;
  • 大约 80% 的维护投入在 20% 的代码上。

那怎么找出这 20% 的代码来进行优化呢?可以通过 Profiler(翻译为分析器) 来分析内存、占用 CPU、启动时间、网络情况、功耗等各个指标,而不要凭感觉或经验来判断。


条款17:考虑使用 lazy evaluation(懒惰计算法)

  • 拖延战术的思想
  • 引用计数:
class String { ... }; 

String s1 = "Hello";
String s2 = s1; // 调用 string 拷贝构造函数

拷贝构造函数会引起较大的开销,因为要制作 s1 值的拷贝,并把值赋给 s2,这通常需要用 new 操作符分配堆内存,需要调用 strcpy 函数拷贝 s1 内的数据到 s2,这是一个 eager evaluation(热情计算)。然而这时的 s2 并不需要这个值的拷贝,因为 s2 没有被使用。

懒惰就是少工作。不应该赋给 s2 一个 s1 的拷贝,而是让 s2 与 s1 共享一个值。我们只须做一些记录以便知道谁在共享什么,就能够省掉调用 new 和拷贝字符的开销。事实上s1 和 s2 共享一个数据结构,这对于 client 来说是透明的。如果不修改 s2,我们就不用制作它自己值的拷贝。继续保持共享值直到程序退出。

  • 区别对待读取和写入
  • Lazy Fetching(懒惰提取):
    • 只产生对象的一个外壳,当对象内的某个字段被需要了,程序才从数据库中取回对应的数据
    • mutable关键字,允许对 const 对象进行修改, 由编译器支持
  • Lazy Expression Evaluation(懒惰表达式计算)

几种语言例如著名的 APL、dialects of Lisp(事实上所有的数据流语言)都把这种思想做为语言的一个基本部分。然而主流程序设计语言采用的是 eager evaluation,C++ 是主流语言。不过 C++ 特别适合用户实现 lazy evaluation,因为它对封装的支持使得能在类里加入 lazy evaluation,而根本不用让类的使用者知道。


条款18:分期摊还期望的计算

这个条款的核心是over-eager evaluation(过度热情计算法):在要求你做某些事情以前就完成它们。
考虑下面的类,用来表示放有大量数字型数据的一个集合:

template<class NumericalType> 
class DataCollection 
{ 
public: 
	NumericalType min() const; 
	NumericalType max() const; 
	NumericalType avg() const; 
	... 
};

假设 min,max 和 avg 函数分别返回现在这个集合的最小值,最大值和平均值。可以使用 over-eager evaluation(过度热情计算法),我们随时跟踪目前集合的最小值,最大值和平均值,这样当 min,max 或 avg 被调用时,我们可以不用计算就立刻返回正确的数值。

如果频繁调用 min,max 和 avg,我们把跟踪集合最小值、最大值和平均值的开销分摊到所有这些函数的调用上,每次函数调用所分摊的开销比正常计算和懒惰计算要小。

这个方法的思想是:如果你认为一个计算需要频繁进行,你就可以设计一个数据结构高效地处理这些计算需求,这样可以降低每次计算需求时的开销。

采用过度热情最简单的方法就是缓存那些已经被计算出来而以后还有可能需要的值。

比如:你编写了一个程序,用来提供有关雇员的信息,这些信息中的经常被需要的部分是雇员的办公隔间号码。而假设雇员信息存储在数据库里,但是对于大多数应用程序来说,雇员隔间号都是不相关的,所以数据库不对它们进行优化。为了避免你的程序给数据库造成沉重的负担,可以编写一个函数findCubicleNumber,用来缓存查找到的数据。以后需要已经被获取的隔间号时,可以在cache里找到,而不用向数据库查询:

// 查找隔间号
int findCubicleNumber(const string& employeeName)
{
	using CubicleMap = map<string,int>;
	static CubicleMap cubes;
	CubicleMap::iterator it = cubes.find(employeeName);
	if(it == cubes.end())
	{
		int cubicle = the result of looking up employeeName's cubicle number in the database;
		cubes[employeeName] = cubicle ;
		return cubicle;
	}
	else
	{
		return (*it).second;
	}
}

这个方法是使用 local cache,用开销相对不大的内存中查询来替代开销较大的数据库查询。假如隔间号被不止一次地频繁需要,在findCubicleNumber 内使用缓存会减少返回隔间号的平均开销。

总结

当你必须支持某些操作而不总需要其结果时,懒惰计算是在这种时候用以提高程序效率的技术,当你必须支持某些操作而其结果几乎总是被需要或被不止一次地需要时,over-eager 是在这种时候使用的用以提高程序效率的一种技术。它们所产生的巨大的性能提高证明在这方面花些精力是值得的。


条款19:理解临时对象的来源

当程序员之间进行交谈时,他们经常把仅仅需要一小段时间的变量称为临时变量。例如在下面这段 swap 示例里:

template<class T>
void swap(T& object1, T& object2)
{
	T temp = object1;
	object1 = object2;
    object2 = temp;
}

通常把 temp 叫做临时变量。不过就 C++ 而言,temp 根本不是临时变量,它只是一个函数的局部对象。

C++ 中真正的临时对象是看不见的,它们不出现在你的源代码中。建立一个没有命名的非堆对象会产生临时对象。这种未命名的对象通常在两种条件下产生:

  • 为了使函数成功调用而进行隐式类型转换
  • 函数返回对象时

首先考虑为使函数成功调用而建立临时对象这种情况:当传送给函数的对象类型与参数类型不匹配时会产生这种情况。

比如一个函数,它用来计算一个字符在字符串中出现的次数:

size_t countChar(const string& str, char ch);

// client
char buffer[MAX_STRING_LEN];
char c;
cin >> c >> setw(MAX_STRING_LEN) >> buffer;
cout << "There are " << countChar(buffer, c)<< " occurrences of the character " << c << " in " << buffer << endl;

看一下 countChar 的调用。第一个被传送的参数是字符数组,但是对应函数的正被绑定的参数的类型是const string&。仅当消除类型不匹配后,才能成功进行这个调用,你的编译器很乐意替你消除它,方法是建立一个 string 类型的临时对象。通过以 buffer 做为参数调用 string 的构造函数来初始化这个临时对象。countChar 的参数 str 被绑定在这个临时的 string 对象上。当 countChar 返回时,临时对象自动释放。

这种类型转换很方便,但是从效率的观点来看,临时string对象的构造和释放是不必要的开销。通常有两个方法可以消除它:

  • 重新设计你的代码,不让发生这种类型转换。
  • 通过修改软件而不再需要类型转换。

仅当通过传值式传递对象或传递常量引用参数时,才会发生这些类型转换。当传递一个非常量引用参数对象,就不会发生。考虑下面这个函数:

void uppercasify(string& str); // 非常量引用参数

char subtleBookPlug[] = "Effective C++";
uppercasify(subtleBookPlug); // 错误!

在这里插入图片描述


没有为使调用成功而建立临时对象,为什么呢?

为了避免对非常量引用进行隐式类型转换却修改临时对象。这就是为什么 C++ 语言禁止为非常量引用产生临时对象。而常量引用参数就不会遇到这种问题。

建立临时对象的第二种环境是函数返回对象时。比如 operator+必须返回一个对象,以表示它的两个操作数的和。

const Number operator+(const Number& lhs, const Number& rhs);

这个函数的返回值是临时的,因为它没有被命名;它只是函数的返回值。你必须为每次调用 operator+构造和释放这个对象而付出代价。

通常你不想付出这样的开销。对于这种函数,你可以切换到 operator=,而避免开销。

不过对于大多数返回对象的函数来说,无法切换到不同的函数,从而没有办法避免构造和释放返回值。至少在概念上没有办法避免它。然而概念和现实之间有一个黑暗地带,叫做优化,有时你能以某种方法编写返回对象的函数,以允许你的编译器优化临时对象。这些优化中,最常见和最有效的是返回值优化,这是条款 20的内容。


条款20:协助完成返回值优化

一个返回对象的函数很难有较高的效率,因为传值返回会导致调用对象内的构造和析构函数,这种调用是不能避免的。问题很简单:一个函数要么为了保证正确的行为而返回对象要么就不这么做。如果它返回了对象,就没有办法摆脱被返回的对象。

考虑以下类:

class Rational 
{ 
public: 
	Rational(int numerator = 0, int denominator = 1); 
	... 
	int numerator() const; 
	int denominator() const; 
};

const Rational operator*(const Rational& lhs, const Rational& rhs);

不用看operator*的代码,我们就知道它肯定要返回一个对象,因为它返回的是两个任意数字的计算结果。这些结果是任意的数字。operator*如何能避免建立新对象来容纳它们的计算结果呢?这是不可能的,所以它必须得建立新对象并返回它。

从效率的观点来看,你不应该关心函数返回的对象,你仅仅应该关心对象的开销。你所应该关心的是把你的努力引导到寻找减少返回对象的开销上来,而不是去消除对象本身。

以某种方法返回对象,能让编译器消除临时对象的开销,这样编写函数通常是很普遍的。这种技巧是返回constructor argument而不是直接返回对象,你可以这样做:

// 一种高效和正确的方法,用来实现 
// 返回对象的函数 
const Rational operator*(const Rational& lhs, const Rational& rhs) 
{ 
	return Rational(lhs.numerator() * rhs.numerator(), 
	lhs.denominator() * rhs.denominator()); 
}

仔细观察被返回的表达式,它正在调用 Rational 的构造函数,你通过这个表达式建立一个临时的 Rational 对象,并且这是一个临时对象,函数把它拷贝给函数的返回值。返回 constructor argument 而不出现局部对象,这种方法还会给你带来很多开销,因为你仍旧必须为在函数内临时对象的构造和释放而付出代价,你仍旧必须为函数返回对象的构造和释放而付出代价。但是你已经获得了好处。

C++ 规则允许编译器优化不出现临时对象。因此如果你在如下的环境里调用 operator*

Rational a = 10; 
Rational b(1, 2); 
Rational c = a * b;

编译器就会被允许消除在operator*内的临时变量和operator*返回的临时变量。
能在为目标 c 分配的内存里构造 return 表达式定义的对象。如果你的编译器这样去做,调用operator*的临时对象的开销就是零:没有建立临时对象。你的代价就是调用一个构造函数――建立 c 时调用的构造函数。而且你不能比这做得更好了,因为 c 是命名对象,命名对象不能被消除。

inline const Rational operator*(const Rational& lhs, 
 const Rational& rhs) 
{ 
	return Rational(lhs.numerator() * rhs.numerator(), 
	lhs.denominator() * rhs.denominator()); 
}

这种特殊的优化――通过使用函数的 return 位置(或者在函数被调用位置用一个对象来替代)来消除局部临时对象――是众所周知的和被普遍实现的。它甚至还有一个名字:返回值优化

但注意,这种优化对普通的赋值运算无效,编译器不能够用拷贝构造函数取代赋值运算动作。


条款21:通过重载避免隐式类型转换

考虑以下类:

class UPInt
{
	public: 
	UPInt(); 
	UPInt(int value); 
	 ... 
};

const UPInt operator+(const UPInt& lhs, const UPInt& rhs);
UPInt upi1, upi2;
...
UPInt upi3 = upi1 + upi2;

现在考虑下面这些语句:

upi3 = upi1 + 10; 
upi3 = 10 + upi2;

这些语句也能够成功运行。方法是通过建立临时对象把整形数 10 转换为 UPInts。让编译器完成这种类型转换是确实是很方便,但是建立临时对象进行类型转换工作是有开销的,而我们不想承担这种开销。

大多数 C++ 程序员希望进行没有临时对象开销的隐式类型转换,我们如何能这么做呢?

仔细想想,我们的目的不是真的要进行类型转换,而是用 UPint 和 int做为参数调用 operator+。隐式类型转换只是用来达到目的的手段,但是我们不要混淆手段与目的。

如果我们想要把 UPInt 和 int 对象相加,通过声明如下几个函数达到这个目的,每一个函数有不同的参数类型集:

const UPInt operator+(const UPInt& lhs, const UPInt& rhs); 
const UPInt operator+(const UPInt& lhs, int rhs);  
const UPInt operator+(int lhs, const UPInt& rhs);
UPInt upi1, upi2;
...
UPInt upi3 = upi1 + upi2;// 正确,没有由 upi1 或 upi2生成的临时对象
upi3 = upi1 + 10; // 正确, 没有由 upi1 or 10生成的临时对象 
upi3 = 10 + upi2; // 正确, 没有由 10 or upi2 生成的临时对象。

但是注意,一旦你开始用函数重载来消除类型转换,你就有可能这样声明函数,把自己陷入危险之中:

const UPInt operator+(int lhs, int rhs); // 错误!

这个想法是合情合理的。对于 UPInt 和 int 类型,我们想要用所有可能的组合来重载 operator 函数。上面只给出了三种重载函数,唯一漏掉的是带有两个 int 参数的 operator,所以我们想把它加上。

在 C++ 中有一条规则是每一个重载的 operator 必须带有一个用户定义类型的参数。int 不是用户定义类型,所以我们不能重载 operator 成为仅带有此 [int] 类型参数的函数。

总结
利用重载避免临时对象的方法不只是用在 operator 函数上。比如在大多数程序中,你想允许在所有能使用 string 对象的地方,也一样可以使用 char*,反之亦然。任何带有 string、char*、complex 参数的函数可以采用重载方式来消除类型转换。

不过,必须谨记 80-20 规则。没有必要实现大量的重载函数,除非你有理由确信程序使用重载函数以后其整体效率会有显著的提高。

条款22:考虑用运算符的赋值形式取代其单独形式

大多数程序员认为如果他们能这样写代码:

x = x + y; x = x - y;

那他们也能这样写:

x += y; x -= y;

但是,如果 x 和 y 是用户定义的类型,就不能确保这样。

operator+operator=operator+=之间没有任何关系,因此如果你想让这三个 operator 同时存在并具有你所期望的关系,就必须自己实现它们。同理,operator -, *, /, 等等也一样。

确保 operator 的赋值形式(比如operator+=)与一个 operator 的单独形式(比如operator+)之间存在正常的关系,一种好方法是后者根据前者来实现。比如:

class Rational 
{ 
public: 
	... 
	Rational& operator+=(const Rational& rhs); 
	Rational& operator-=(const Rational& rhs); 
};

const Rational operator+(const Rational& lhs, const Rational& rhs)
{
	return Rational(lhs) += rhs;
}

const Rational operator-(const Rational& lhs, const Rational& rhs) 
{ 
	return Rational(lhs) -= rhs; 
}

在这个例子里,从零开始实现operator+=-=,而operator+operator-则是通过调用前述的函数来提供自己的功能。使用这种设计方法,只用维护 operator 的赋值形式就行了。

如果你不介意把所有的 operator 的单独形式放在全局域里,那就可以使用模板来替代单独形式的函数的编写:

template<class T> 
const T operator+(const T& lhs, const T& rhs) 
{ 
	return T(lhs) += rhs; 
} 

template<class T> 
const T operator-(const T& lhs, const T& rhs) 
{ 
	return T(lhs) -= rhs; 
} 
...

这样编写确实不错,但是到目前为止,我们还没有考虑效率问题,在这里值得指出的是三个效率方面的问题。

(1)总的来说 operator 的赋值形式比其单独形式效率更高,因为单独形式要返回一个新对象,从而在临时对象的构造和释放上有一些开销。operator 的赋值形式把结果写到左边的参数里,因此不需要生成临时对象来容纳 operator 的返回值。

(2)提供 operator 的赋值形式的同时也要提供其标准形式,允许类的客户端在便利与效率上做出折中选择。也就是说,客户端可以决定是这样编写:

Rational a, b, c, d, result;
result = a + b + c + d; // 可能用了 3 个临时对象

还是这样编写:

result = a; // 不用临时对象 
result += b; // 不用临时对象 
result += c; // 不用临时对象 
result += d; // 不用临时对象

前者比较容易编写、debug 和维护,并且在 80% 的时间里它的性能是可以被接受的。后者具有更高的效率,估计这对于汇编语言程序员来说会更直观一些。通过提供两种方案,你可以让客户端开发人员用更容易阅读的单独形式的 operator 来开发和 debug 代码,同时保留用效率更高的 operator 赋值形式替代单独形式的权力。

(3)涉及到 operator 单独形式的实现。再看看operator+的实现:

template<class T> 
const T operator+(const T& lhs, const T& rhs) 
{ 
	return T(lhs) += rhs; 
}

表达式T(lhs)调用了 T 的拷贝构造函数。它建立一个临时对象,其值与 lhs 一样。这个临时对象用来与 rhs 一起调用operator+=,操作的结果被从 operator+返回。这个代码好像不用写得这么隐密。这样写不是更好么?

template<class T> 
const T operator+(const T& lhs, const T& rhs) 
{ 
	T result(lhs); // 拷贝 lhs 到 result 中 
	return result += rhs; // rhs 与它相加并返回结果 
}

这个模板几乎与前面的程序相同,但是它们之间还是存在重要的差别。第二个模板包含一个命名对象,result。这个命名对象意味着不能在operator+里使用返回值优化。第一种实现方法可以使用返回值优化,所以编译器为其生成优化代码的可能就会更大。

return T(lhs) += rhs;

上面函数实现也有这样的临时对象开销,就象你为使用命名对象 result 而耗费的开销一样。然而未命名的对象在历史上比命名对象更容易清除,因此当我们面对在命名对象和临时对象间进行选择时,用临时对象更好一些。它使你耗费的开销不会比命名的对象还多,特别是使用老编译器时,它的耗费会更少。

总结

  • operator 的赋值形式(operator+=)比单独形式(operator+)效率更高。
  • 作为一个库程序设计者,应该两者都提供,做为一个应用程序的开发者,在优先考虑性能时你应该考虑考虑用 operator 赋值形式代替单独形式。

条款23:考虑变更程序库

程序库的设计就是一个折中的过程。理想的程序库应该是短小的、快速的、强大的、灵活的、可扩展的、直观的、普遍适用的、具有良好的支持、没有使用约束、没有错误的。这也是不存在的。为尺寸和速度而进行优化的程序库一般不能被移植。具有大量功能的的程序库不会具有直观性。没有错误的程序库在使用范围上会有限制。真实的世界里,你不能拥有每一件东西,总得有付出。

不同的设计者给这些条件赋予了不同的优先级。他们从而在设计中牺牲了不同的东西。因此一般两个提供相同功能的程序库却有着完全不同的性能特征。

例如,考虑 iostream 和 stdio 程序库,对于 C++程序员来说两者都是可以使用的。iostream 程序库与 C 中的 stdio 相比有几个优点(参见 Effective C++)。例如它是类型安全的(type-safe),它是可扩展的。然而在效率方面,iostream 程序库总是不如 stdio,因为 stdio 产生的执行文件与 iostream 产生的执行文件相比尺寸小而且执行速度快。

首先考虑执行速度的问题。让我们测试一个简单的 benchmark 程序,只测试最基本的 I/O 功能。这个程序从标准输入读取 30000 个浮点数,然后把它们以固定的格式写到标准输出里。编译时预处理符号 STDIO决定是使用 stdio 还是 iostream。 如果定义了这个符号, 就是用 stdio, 否则就使用 iostream程序库。

#ifdef STDIO
	#include <stdio.h>
#else
    #include <iostream>
    #include <iomanip>
    using namespace std;
#endif
	const int VALUES = 30000; // # of values to read/write
int main()
{
	double d;
	for (int n = 1; n <= VALUES; ++n) {
#ifdef STDIO
        scanf("%lf", &d);
        printf("%10.5f", d);
#else
        cin >> d;
        cout << setw(10) // 设定 field 宽度
        << setprecision(5) // 设置小数位置
        << setiosflags(ios::showpoint) // keep trailing 0s
        << setiosflags(ios::fixed) // 使用这些设置
        << d;
#endif
        if (n % 5 == 0) {
#ifdef STDIO
            printf("\n");
#else
            cout << '\n';
#endif
        }
	}
	
	return 0;
}

当把正整数的自然对数传给这个程序,它会这样输出:

0.00000 0.69315 1.09861 1.38629 1.60944
1.79176 1.94591 2.07944 2.19722 2.30259
2.39790 2.48491 2.56495 2.63906 2.70805
2.77259 2.83321 2.89037 2.94444 2.99573
3.04452 3.09104 3.13549 3.17805 3.21888

我做了几种计算机、操作系统和编译器的不同组合,在其上运行这个程序,在每一种情况下都是使用 stdio 的程序运行得较快。优势它仅仅快一些(大约 20%), 有时则快很多(接近 200%),但是我从来没有遇到过一种 iostream 的实现和与其相对应的 stdio 的实现运行速度一样快。另外,使用 stdio 的程序的尺寸比与相应的使用 iostream 的程序要小(有时是小得多)。 (对于程序现实中的尺寸,这点差异就微不足道了)。

iostream 和 stdio 之间性能的对比不过是一个例子,这并不重要,重要的是具有
相同功能的不同的程序库在性能上采取不同的权衡措施,所以一旦你找到软件的瓶颈(通过进行 profile 参见条款 M16),你应该知道是否可能通过替换程序库来消除瓶颈。
比如如果你的程序有 I/O 瓶颈,你可以考虑用 stdio 替代 iostream,如果程序在动态分配和释放内存上使用了大量时间,你可以想想是否有其他的 operator new 和 operator delete 的实现可用(参见条款 M8 和 Effective C++条款 10)。因为不同的程序库在效率、可扩展性、移植性、类型安全和其他一些领域上蕴含着不同的设计理念,通过更改使用性能更高的程序库,你有时可以大幅度地提高软件的效率。


条款24:理解虚函数、多继承、虚基类和 RTTI 所需的代价

虚函数

当调用一个虚拟函数时,被执行的代码必须与调用函数的对象的动态类型相一致

编译器如何能够高效地提供这种行为呢?大多数编译器是使用 virtual tablevirtual table pointers,通常被分别地称为 vtblvptr

一个 vtbl 通常是一个函数指针数组。在程序中的每个类只要声明了虚函数或继承了虚函数,它就有自己的 vtbl,并且类中 vtbl 的项目是指向虚函数实现体的指针。例如,如下这个类定义:

class C1 
{ 
public: 
	C1(); 
	virtual ~C1(); 
	virtual void f1(); 
	virtual int f2(char c) const; 
	virtual void f3(const string& s); 
	void f4() const; 
	... 
};

C1 的 virtual table 数组看起来如下图所示:

在这里插入图片描述

如果有一个 C2 类继承自 C1,重新定义了它继承的一些虚函数,并加入了它自己的一些虚函数,

class C2: public C1 
{ 
public: 
	C2(); // 非虚函数 
	virtual ~C2(); // 重定义函数 
	virtual void f1();
	virtual void f5(char *str); // 新的虚函数 
	... 
};

它的 virtual table 项目指向与对象相适合的函数。这些项目包括指向没有被 C2 重定义的 C1 虚函数的指针:

在这里插入图片描述

你必须为每个包含虚函数的类的 virtual talbe 留出空间。类的 vtbl 的大小与类中声明的虚函数的数量成正比。每个类应该只有一个 virtual table,所以 virtual table 所需的空间不会太大,但是如果你有大量的类或者在每个类中有大量的虚函数,你会发现 vtbl 会占用大量的地址空间。

Virtual table只实现了虚拟函数的一半机制,如果只有这些是没有用的。只有用某种方法指出每个对象对应的vtbl时,它们才能使用。这是virtual table pointer的工作,它来建立这种联系。

每个声明了虚函数的对象都带有它,它是一个看不见的数据成员,指向对应类的virtual table。这个看不见的数据成员也称为vptr,被编译器加在对象里,位置只有才编译器知道。从理论上讲,我们可以认为包含有虚函数的对象的布局是这样的:

在这里插入图片描述

不同的编译器放置vptr的位置不同。存在继承的情况下,一个对象的 vptr 经常被数据成员所包围。如果存在多继承,这幅图片会变得更复杂。虚函数所需的第二个代价是:在每个包含虚函数的类的对象里,你必须为额外的指针付出代价。

如果对象很小,这是一个很大的代价。比如如果你的对象平均只有 4 比特的成员数据,那么额外的 vptr 会使成员数据大小增加一倍(假设 vptr 大小为 4 比特)。在内存受到限制的系统里,这意味着你必须减少建立对象的数量。即使在内存没有限制的系统里,你也会发现这会降低软件的性能,因为较大的对象有可能不适合放在缓存或虚拟内存页中,这就可能使得系统换页操作增多。

在这里插入图片描述

考虑下面代码:

void makeACall(C1 *pC1) 
{ 
	pC1->f1(); 
}

通过指针 pC1 调用虚拟函数 f1。为了确保无论 pC1 指向什么对象,函数的调用必须正确。编译器生成的代码会做如下这些事情:

1.通过对象的 vptr 找到类的 vtbl。这是一个简单的操作,因为编译器知道在对象内哪里能找到 vptr。这个代价只是一个偏移调整(以得到vptr)和一个指针的间接寻址(以得到 vtbl)。

2.找到对应 vtbl 内的指向被调用函数的指针(在上例中是 f1)。

3.调用第二步找到的的指针所指向的函数。

如果我们假设每个对象有一个隐藏的数据叫做 vptr,而且 f1vtbl 中的索引为 i,此语句:

pC1->f1();

生成的代码就是这样的:

(*pC1->vptr[i])(pC1); // 调用被vtbl中第i个单元指向的函数,而 pC1->vptr 指向的是 vtbl;pC1 被做为this指针传递给函数。

这几乎与调用非虚函数效率一样。在大多数计算机上它多执行了很少的一些指令。调用虚函数所需的代价基本上与通过函数指针调用函数一样。虚函数本身通常不是性能的瓶颈。

在实际运行中,虚函数所需的代价与内联函数有关。实际上虚函数不能是内联的。这是因为“内联”是指“在编译期间用被调用的函数体本身来代替函数调用的指令,”但是虚函数的“虚”是指“直到运行时才能知道要调用的是哪一个函数”。如果编译器在某个函数的调用点不知道具体是哪个函数被调用,你就能知道为什么它不会内联该函数的调用

这是虚函数所需的第三个代价:你实际上放弃了使用内联函数。

多继承

多继承里,在对象里为寻找 vptr 而进行的偏移量计算会变得更复杂。在单个对象里有多个 vptr(每一个基类对应一个);除了我们已经讨论过的单独的自己的 vtbl 以外,还得为基类生成特殊的 vtbl因此增加了每个类和每个对象中的虚函数额外占用的空间,而且运行时调用所需的代价也增加了一些。例如考虑下面这幅图:

在这里插入图片描述

这里 A 是一个虚基类。因为 B 和 C 虚拟继承了它。使用一些编译器(特别是比较老的编译器),D 对象会产生这样布局:

在这里插入图片描述

如果我们把这幅图与前面展示如何把 virtual table pointer 加入到对象里的图片合并起来,我们就会认识到如果在上述继承体系里的基类 A 有任何虚函数,对象 D 的内存布局就是这样的:

在这里插入图片描述

值得注意的是:虽然存在四个类,但是上述图表只有三个 vptr。只要编译器喜欢,当然可以生成四个 vptr,但是三个已经足够了(它发现 BD 能够共享一个 vptr),大多数编译器会利用这个机会来减少编译器生成的额外负担。

运行时类型识别

RTTI 能让我们在运行时找到对象和类的有关信息,所以肯定有某个地方存储了这些信息让我们查询。这些信息被存储在类型为 type_info 的对象里,你能通过使用 typeid 操作符访问一个类的 type_info 对象。

在每个类中仅仅需要一个 RTTI 的拷贝。

例如,vtbl 数组的索引 0 处可以包含一个 type_info 对象的指针,这个对象属于该 vtbl相对应的类。上述 C1 类的 vtbl 看上去像这样:

在这里插入图片描述

使用这种实现方法,RTTI耗费的空间是在每个类的 vtbl 中的占用的额外单元再加上存储 type_info 对象的空间。就像在多数程序里 virtual table 所占的内存空间并不值得注意一样,你也不太可能因为 type_info 对象大小而遇到问题。

总结

下面这个表各是对虚函数、多继承、虚基类以及 RTTI 所需主要代价的总结:

在这里插入图片描述

理解虚函数、多继承、虚基类、RTTI 所需的代价是重要的,但是如果你需要这些功能,不管采取什么样的方法你都得为此付出代价。有时你确实有一些合理的原因要绕过编译器生成的服务。例如隐藏的 vptr 和指向虚基类的指针会使得在数据库中存储 C++对象或跨进程移动它们变得困难,所以你可能希望用某种方法模拟这些特性,能更加容易地完成这些任务。不过从效率的观点来看,你自己编写代码不可能做得比编译器生成的代码更好。


参考:

《More Effective C++》效率篇

More Effective C++


posted @ 2021-04-21 19:03  fengMisaka  阅读(590)  评论(1编辑  收藏  举报