条款18:分期摊还期望的计算
在条款17中,我极力称赞懒惰的优点,尽可能地拖延时间,并且我解释说懒惰如何提高程序的运行效率。在这个条款里我将采用一种不同的态度。这里将不存在懒惰。我鼓励你让程序做的事情比被要求的还要多,通过这种方式来提高软件的性能。这个条款的核心就是over-eager evaluation(过度热情计算法):在要求你做某些事情以前就完成它们。例如下面这个模板类,用来表示放有大量数字型数据的一个集合:
template<class NumericalType>
class DataCollection {
public:
NumericalType min() const;
NumericalType max() const;
NumericalType avg() const;
...
};
假设min,max和avg函数分别返回现在这个集合的最小值,最大值和平均值,有三种方法实现这三种函数。使用eager evaluation(热情计算法),当min,max和avg函数被调用时,我们检测集合内所有的数值,然后返回一个合适的值。使用lazy evaluation(懒惰计算法),只有确实需要函数的返回值时我们才要求函数返回能用来确定准确数值的数据结构。使用 over-eager evaluation(过度热情计算法),我们随时跟踪目前集合的最小值,最大值和平均值,这样当min,max或avg被调用时,我们可以不用计算就立刻返回正确的数值。如果频繁调用min,max和avg,我们把跟踪集合最小值、最大值和平均值的开销分摊到所有这些函数的调用上,每次函数调用所分摊的开销比eager evaluation或lazy evaluation要小。
隐藏在over-eager evaluation后面的思想是如果你认为一个计算需要频繁进行。你就可以设计一个数据结构高效地处理这些计算需求,这样可以降低每次计算需求的开销。
采用over-eager最简单的方法就是caching(缓存)那些已经被计算出来而以后还有可能需要的值。例如你编写了一个程序,用来提供有关雇员的信息,这些信息中的经常被需要的部分是雇员的办公隔间号码。而假设雇员信息存储在数据库里,但是对于大多数应用程序来说,雇员隔间号都是不相关的,所以数据库不对查抄它们进行优化。为了避免你的程序给数据库造成沉重的负担,可以编写一个函数findCubicleNumber,用来cache查找的数据。以后需要已经被获取的隔间号时,可以在cache里找到,而不用向数据库查询。
以下是实现findCubicleNumber的一种方法:它使用了标准模板库(STL)里的map对象(有关STL参见条款35)。
int findCubicleNumber(const string& employeeName)
{
// 定义静态map,存储 (employee name, cubicle number)
// pairs. 这个 map 是local cache。
typedef map<string, int> CubicleMap;
static CubicleMap cubes;
// try to find an entry for employeeName in the cache;
// the STL iterator "it" will then point to the found
// entry, if there is one (see Item 35 for details)
CubicleMap::iterator it = cubes.find(employeeName);
// "it"'s value will be cubes.end() if no entry was
// found (this is standard STL behavior). If this is
// the case, consult the database for the cubicle
// number, then add it to the cache
if (it == cubes.end()) {
int cubicle =
the result of looking up employeeName's cubicle
number in the database;
cubes[employeeName] = cubicle; // add the pair
// (employeeName, cubicle)
// to the cache
return cubicle;
}
else {
// "it" points to the correct cache entry, which is a
// (employee name, cubicle number) pair. We want only
// the second component of this pair, and the member
// "second" will give it to us
return (*it).second;
}
}
不要陷入STL代码的实现细节里(你读完条款35以后,你会比较清楚)。应该把注意力放在这个函数蕴含的方法上。这个方法是使用local cache,用开销相对不大的内存中查询来替代开销较大的数据库查询。假如隔间号被不止一次地频繁需要,在findCubicleNumber内使用cache会减少返回隔间号的平均开销。
(上述代码里有一个细节需要解释一下,最后一个语句返回的是(*it).second,而不是常用的it->second。为什么?答案是这是为了遵守STL的规则。简单地说,iterator是一个对象,不是指针,所以不能保证”->”被正确应用到它上面。不过STL要求”.”和”*”在iterator上是合法的,所以(*it).second在语法上虽然比较繁琐,但是保证能运行。)
catching是一种分摊期望的计算开销的方法。Prefetching(预提取)是另一种方法。你可以把prefech想象成购买大批商品而获得的折扣。例如磁盘控制器从磁盘读取数据时,它们会读取一整块或整个扇区的数据,即使程序仅需要一小块数据。这是因为一次读取一大块数据比在不同时间读取两个或三个小块数据要快。而且经验显示如果需要一个地方的数据,则很可能也需要它旁边的数据。这是位置相关现象,正因为这种现象,系统设计者才有理由为指令和数据使用磁盘cache和内存cache,还有使用指令prefetch。
你说你不关心象磁盘控制器或CPU cache这样低级的东西。没有问题。prefetch在高端应用里也有优点。例如你为dynamic数组实现一个模板,dynamic就是开始时具有一定的尺寸,以后可以自动扩展的数组,所以所有非负的索引都是合法的:
template<class T> // dynamic数组
class DynArray { ... }; // 模板
DynArray<double> a; // 在这时, 只有 a[0]
// 是合法的数组元素
a[22] = 3.5; // a 自动扩展
//: 现在索引0-22
// 是合法的
a[32] = 0; // 有自行扩展;
// 现在 a[0]-a[32]是合法的
一个DynArray对象如何在需要时自行扩展呢?一种直接的方法是分配所需的额外的内存。就象这样:
template<class T>
T& DynArray<T>::operator[](int index)
{
if (index < 0) {
throw an exception; // 负数索引仍不
} // 合法
if (index >当前最大的索引值) {
调用new分配足够的额外内存,以使得
索引合法;
}
返回index位置上的数组元素;
}
每次需要增加数组长度时,这种方法都要调用new,但是调用new会触发operator new(参见条款8),operator new (和operator delete)的调用通常开销很大。因为它们将导致底层操作系统的调用,系统调用的速度一般比进程内函数调用的速度慢。因此我们应该尽量少使用系统调用。
使用Over-eager evaluation方法,其原因我们现在必须增加数组的尺寸以容纳索引i,那么根据位置相关性原则我们可能还会增加数组尺寸以在未来容纳比i 大的其它索引。为了避免为扩展而进行第二次(预料中的)内存分配,我们现在增加DynArray的尺寸比能使i 合法的尺寸要大,我们希望未来的扩展将被包含在我们提供的范围内。例如我们可以这样编写DynArray::operator[]:
template<class T>
T& DynArray<T>::operator[](int index)
{
if (index < 0) throw an exception;
if (index > 当前最大的索引值) {
int diff = index – 当前最大的索引值;
调用new分配足够的额外内存,使得
index+diff合法;
}
返回index位置上的数组元素;
}
这个函数每次分配的内存是数组扩展所需内存的两倍。如果我们再来看一下前面遇到的那种情况,就会注意到DynArray只分配了一次额外内存,即使它的逻辑尺寸被扩展了两次:
DynArray<double> a; // 仅仅a[0]是合法的
a[22] = 3.5; // 调用new扩展
// a的存储空间到索引44
// a的逻辑尺寸
// 变为23
a[32] = 0; // a的逻辑尺寸
// 被改变,允许使用a[32],
// 但是没有调用new
如果再次需要扩展a,只要提供的新索引不大于44,扩展的开销就不大。
贯穿本条款的是一个常见的主题,更快的速度经常会消耗更多的内存。跟踪运行时的最小值、最大值和平均值,这需要额外的空间,但是能节省时间。Cache运算结果需要更多的内存,但是一旦需要被cache的结果时就能减少需要重新生成的时间。Prefetch需要空间放置被prefetch的东西,但是它减少了访问它们所需的时间。自从有了计算机就有这样的描述:你能以空间换时间。(然而不总是这样,使用大型对象意味着不适合虚拟内存或cache 页。在一些罕见的情况下,建立大对象会降低软件的性能,因为分页操作的增加(详见操作系统中内存管理 译者注),cache命中率降低,或者两者都同时发生。如何发现你正遭遇这样的问题呢?你必须profile, profile, profile(参见条款16)。
在本条款中我提出的建议,即通过over-eager方法分摊预期计算的开销,例如caching和prefething,这并不与我在条款17中提出的有关lazy evaluation的建议相矛盾。当你必须支持某些操作而不总需要其结果时,可以使用lazy evaluation用以提高程序运行效率。当你必须支持某些操作而其结果几乎总是被需要或被不止一次地需要时,可以使用over-eager用以提高程序运行效率。它们对性能的巨大提高证明在这方面花些精力是值得的。
本文地址:http://com.8s8s.com/it/it29933.htm