More Effective C++ 17:考虑使用懒惰计算法

从效率的观点来看,最佳的计算就是根本不计算。
懒惰计算法广泛适用于各种应用领域,从四个部分讲述

引用计数

class String { ... };
String s1 = "Hello";
String s2 = s1; // 调用 string 复制构造函数

通常 string 拷贝构造函数让 s2 被 s1 初始化后,s1 和 s2 都有自己的”Hello”拷贝。这种拷贝构造函数会引起较大的开销,因为要制作 s1 值的拷贝,并把值赋给 s2,这通常需要用 new 操作符分配堆内存。

需要调用 strcpy 函数拷贝 s1 内的数据到 s2,然而这时的 s2 并不需要这个值的拷贝,因为 s2 没有被使用。

懒惰能就是少工作。不应该赋给 s2 一个 s1 的拷贝,而是让 s2 与 s1 共享一个值。我们只须做一些记录以便知道谁在共享什么,就能够省掉调用 new 和拷贝字符的开销。事实上s1 和 s2 共享一个数据结构,这对于 client 来说是透明的,对于下面的例子来说,这没有什么差别,因为它们只是读数据

cout << s1; // 读 s1 的值 
cout << s1 + s2; // 读 s1 和 s2 的值

仅仅当这个或那个 string 的值被修改时,共享同一个值的方法才会造成差异。仅仅修改一个 string 的值,而不是两个都被修改,这一点是极为重要的。例如这条语句:

s2.convertToUpperCase();

为了这样执行语句,stringconvertToUpperCase 函数应该制作 s2 值的一个拷贝,在修改前把这个私有的值赋给 s2。在 convertToUpperCase 内部,我们不能再懒惰了:必须
为 s2(共享的)值制作拷贝以让 s2 自己使用。另一方面,如果不修改 s2,我们就不用制作它自己值的拷贝。继续保持共享值直到程序退出。如果我们很幸运,s2 不会被修改,这种情况下我们永远也不会为赋给它独立的值耗费精力。

小总结:除非你确实需要,不去为任何东西制作拷贝。我们应该是懒惰的,只要可能就共享使用其它值。在一些应用领域,你经常可以这么做

区别对待读取和写入

考虑这样的代码:

String s = "Homer's Iliad";//假设是一个引用计数string
cout << s[3]; // 调用 operator[] 读取 s[3] 
s[3] = 'x'; // 调用 operator[] 写入 s[3]

首先调用 operator[]用来读取 string 的部分值,但是第二次调用该函数是为了完成写操作。我们应能够区别对待读调用和写调用,因为读取引用计数string是很容易的,而写入这个 string 则需要在写入前对该 string 值制作一个新拷贝.

我们陷入了困难之中。为了能够这样做,需要在operator[]里采取不同的措施.我们如果判断调用operator[]是读取操作还是写入操作呢?残酷的事实是我们不可能判断出来.

通过使用懒惰计算法和条款30中的proxy class可以推迟做出是读操作还是写操作的决定,直到我们能判断出正确的答案

懒惰提取

假设你的程序使用了一些包含许多字段的大型对象。
这些对象的生存期超越了程序运行期,所以它们必须被存储在数据库里。每一个对都有一个唯一的对象标识符,用来从数据库中重新获得对象:

class LargeObject  // 大型持久对象
{
public: 
	LargeObject(ObjectID id); // 从磁盘中恢复对象 
	const string& field1() const; // field 1 的值 
	int field2() const; // field 2 的值 
	double field3() const; // ... 
	const string& field4() const; 
	const string& field5() const;
	...
};

现在考虑一下从磁盘中恢复 LargeObject 的开销:

void restoreAndProcessObject(ObjectID id) 
{ 
	LargeObject object(id); // 恢复对象 
	... 
}

因为 LargeObject 对象实例很大,为这样的对象获取所有的数据,数据库的操作的开销将非常大,特别是如果从远程数据库中获取数据和通过网络发送数据时。而在这种情况下,不需要读去所有数据。比如:

void restoreAndProcessObject(ObjectID id) 
{ 
	LargeObject object(id); 
	if (object.field2() == 0) 
	{ 
		cout << "Object " << id << ": null field2.\n"; 
	} 
}

这里仅仅需要 filed2 的值,所以为获取其它字段而付出的努力都是浪费。

LargeObject 对象被建立时,不从磁盘上读取所有的数据,这样懒惰法解决了这个问题。不过这时建立的仅是一个对象“壳”,当需要某个数据时,这个数据才被从数据库中取回。这种“demand-paged”对象初始化的实现方法是:

class LargeObject 
{ 
public: 
	LargeObject(ObjectID id); 
	const string& field1() const; 
	int field2() const; 
	double field3() const; 
	const string& field4() const; 
	... 
private: 
	ObjectID oid; 
	mutable string *field1Value; //参见下面有关 
	mutable int *field2Value; // "mutable"的讨论 
	mutable double *field3Value; 
	mutable string *field4Value; 
	... 
};
LargeObject::LargeObject(ObjectID id) : oid(id), field1Value(0), field2Value(0), field3Value(0), ... 
{}
const string& LargeObject::field1() const 
{ 
	if (field1Value == 0) 
	{ 
		从数据库中为 filed 1 读取数据,使 
		field1Value 指向这个值; 
	} 
	return *field1Value; 
}

对象中每个字段都用一个指向数据的指针来表示,LargeObject 构造函数把每个指针初始化为空。这些空指针表示字段还没有从数据库中读取数值。每个LargeObject 成员函数在访问字段指针所指向的数据之前必须字段指针检查的状态。如果指针为空,在对数据进行操作之前必须从数据库中读取对应的数据

实现懒惰提取时,你面临着一个问题:在任何成员函数里都有可能需要初始化空指针使其指向真实的数据,包括在const 成员函数里,例如field1。然而当你试图在 const成员函数里修改数据时,编译器会出现问题。最好的方法是声明字段指针为 mutable,这表示在任何函数里它们都能被修改,甚至在 const 成员函数里。

再来看 LargeObject 里的指针,必须把这些指针都初始化为空,然后每次使用它们时都必须进行测试,这是令人厌烦的而且容易导致错误发生。幸运的是使用智能指针可以自动完成这种苦差。

懒惰表达式计算

考虑这样的代码:

template<class T> 
class Matrix { ... }; 
Matrix<int> m1(1000, 1000); // 一个 1000 * 1000 的矩阵 
Matrix<int> m2(1000, 1000);
...
Matrix<int> m3 = m1 + m2; // m1+m2

通常 operator 的它会计算和返回 m1 与m2 的和。这个计算量相当大(1000000 次加法运算),当然系统也会分配内存来存储这些值。

这样做工作太多,所以还是不要去做。而是应该建立一个数据结构来表示 m3 的值是 m1m2 的和,在用一个 enum 表示它们间是加法操作。很明显,建立这个数据结构比 m1m2 相加要快许多,也能够节省大量的内存。
考虑程序后面这部分内容,在使用 m3 之前,代码执行如下:

Matrix<int> m4(1000, 1000);
... // 赋给 m4 一些值 
m3 = m4 * m1;

现在我们可以忘掉 m3 是 m1 与 m2 的和(因此节省了计算的开销),在这里我们应该记住m3 是 m4 与 m1 运算的结果。

虽然好程序员不会进行不需要的计算,但是在维护中程序员修改了程序的路径,使得以前有用的计算变得没有了作用,这种情况是常见的。 通过定义使用前才进行计算的对象可以减少这种情况发生的可能性(参见Effective C++条款32),不过这个问题偶尔仍然会出现。


一个更常见的应用领域是当我们仅仅需要计算结果的一部分时。例如假设我们初始化 m3 的值为 m1m2 的和,然后像这样使用 m3

cout << m3[4]; // 打印 m3 的第四行

很明显,我们不能再懒惰了,应该计算 m3 的第四行值。但是我们也没必要计算 m3 第四行以外的结果;m3 其余的部分仍旧保持未计算的状态直到确实需要它们的值。


公正地讲,懒惰有时也会失败。如果这样使用 m3:

cout << m3; // 打印 m3 所有的值

一切都完了,我们必须计算 m3 的全部数值。同样如果修改 m3 所依赖的任一个矩阵,我们也必须立即计算:

m3 = m1 + m2; // 记住 m3 是 m1 与 m2 的和
m1 = m4; // 现在 m3 是 m2 与 m1 的旧值之和!

这里我们我们必须采取措施确保赋值给 m1 以后不会改变 m3。在 Matrix<int>赋值操作符里,我们能够在改变 m1 之前捕获 m3 的值,或者我们可以给 m1 的旧值制作一个拷贝让 m3依赖于这个拷贝计算,我们必须采取措施确保 m1 被赋值以后 m3 的值保持不变。其它可能会修改矩阵的函数都必须用同样的方式处理。

总结

懒惰计算在各个领域都是有用的:能避免不需要的对象
拷贝,通过使用 operator[]区分出读操作,避免不需要的数据库读取操作,避免不需要的数字操作。

但是它并不总是有用。就好象如果你的父母总是来检查你的房间,那么拖延整理房间将不会减少你的工作量。

实际上,如果你的计算都是重要的,懒惰计算可能会减慢速度并增加内存的使用,因为除了进行所有的计算以外,你还必须维护数据结构让懒惰计算尽可能地在第一时间运行。

猜你喜欢

转载自blog.csdn.net/qq_44800780/article/details/106744312