《More Effective C++》读书笔记(下)
五、技巧
当设计 C++软件时, 总会再三地受到一些问题的困扰。 你如何让构造函数和非成员函数具有虚拟函数的特点? 你如何限制一个类的实例的数量? 你如何防止在堆中建立对象呢?你如何又能确保把对象建立在堆中呢?其它一些类的成员函数无论何时被调用, 你如何能建立一个对象并让它自动地完成一些操作?你如何能让不同的对象共享数据结构, 而让每个使用者以为它们每一个都拥有自己的拷贝?你如何区分 operator[] 的读操作和写操作?你如何建立一个虚函数,其行为特性依赖于不同对象的动态类型?
所有这些问题(还有更多) 都在本章得到解答, 在本章里我叙述的都是 C++ 程序员普遍遇到的问题, 且解决方法也是已被证实了的, 我把这些解决方法叫做技巧。不管你把它称做什么, 在你日复一日地从事软件开发工作时, 下面这些信息都将使你受益。 它会使你相信无论你做什么,总可以用 C++ 来完成它。
条款25:将构造函数和非成员函数虚拟化
假设你编写一个程序,用来进行新闻报道的工作,每一条新闻报道都由文字或图片组成。你可以这样管理它们:
class NLComponent //用于 newsletter components的抽象基类
{
public:
... //包含至少一个纯虚函数
};
class TextBlock: public NLComponent
{
public:
... // 不包含纯虚函数
};
class Graphic: public NLComponent
{
public:
... // 不包含纯虚函数
};
class NewsLetter
{
public:
NewsLetter(istream& str);
...
private:
std::list<NLComponent*> components; // 一个 newsletter 对象由 NLComponent 对象 的链表组成
};
对象 NewLetter
不运行时就会存储在磁盘上。为了能够通过位于磁盘的替代物来建立 Newsletter
对象,让 NewLetter
的构造函数带有 istream
参数是一种很方便的方法。当构造函数需要一些核心的数据结构时,它就从流中读取信息。
此构造函数的伪代码是这样的:
NewsLetter::NewsLetter(istream& str)
{
while (str)
{
// 从 str 读取下一个 component 对象;
// 把对象加入到 newsletter 的 components 对象的链表中去;
}
}
或者,把这种技巧用于另一个独立出来的函数叫做 readComponent
,如下所示:
class NewsLetter
{
public:
...
private:
// 为建立下一个 NLComponent 对象从 str 读取数据,
// 建立 component 并返回一个指针。
static NLComponent * readComponent(istream& str);
...
};
NewsLetter::NewsLetter(istream& str)
{
while (str)
{
// 把 readComponent 返回的指针添加到 components 链表的最后
components.push_back(readComponent(str));
}
}
readComponent
所做的工作:根据所读取的数据建立了一个新对象,或是 TextBlock 或是 Graphic。因为它能建立新对象,它的行为与构造函数相似,而且因为它能建立不同类型的对象,我们称它为虚拟构造函数,虚拟构造函数是指能够根据输入给它的数据的不同而建立不同类型的对象。
虚拟拷贝构造函数
还有一种特殊种类的虚拟构造函数――虚拟拷贝构造函数――也有着广泛的用途。虚拟拷贝构造函数能返回一个指针,指向调用该函数的对象的新拷贝。因为这种行为特性,虚拟拷贝构造函数的名字一般都是 copySelf
,cloneSelf
或者是像下面这样就叫做 clone
。很少会有函数能以这么直接的方式实现:
class NLComponent
{
public:
// declaration of virtual copy constructor
virtual NLComponent * clone() const = 0;
...
};
class TextBlock: public NLComponent
{
public:
virtual TextBlock * clone() const { return new TextBlock(*this); } // virtual copy constructor
...
};
class Graphic: public NLComponent
{
public:
virtual Graphic * clone() const // virtual copy
{ return new Graphic(*this); } // constructor
...
};
正如我们看到的,类的虚拟拷贝构造函数只是调用它们真正的拷贝构造函数。因此“拷贝”的含义与真正的拷贝构造函数相同。如果真正的拷贝构造函数只做了简单的拷贝,那么虚拟拷贝构造函数也做简单的拷贝。
如果真正的拷贝构造函数做了全面的拷贝,那么虚拟拷贝构造函数也做全面的拷贝。如果真正的拷贝构造函数做一些奇特的事情,像引用计数或copy-on-write
,那么虚拟构造函数也这么做。
在 NLComponent
中的虚拟拷贝构造函数能让实现NewLetter
的(正常的)拷贝构造函数变得很容易:
class NewsLetter
{
public:
NewsLetter(const NewsLetter& rhs);
...
private:
list<NLComponent*> components;
};
NewsLetter::NewsLetter(const NewsLetter& rhs)
{
// 遍历整个 rhs 链表,使用每个元素的虚拟拷贝构造函数 把元素拷贝进这个对象的 component 链表。
for (list<NLComponent*>::const_iterator it = rhs.components.begin(); it != rhs.components.end(); ++it)
{
components.push_back((*it)->clone());
}
}
非成员函数
就像构造函数不能真的成为虚拟函数一样,非成员函数也不能成为真正的虚拟函数。
然而,既然一个函数能够构造出不同类型的新对象是可以理解的,那么同样也存在这样的非成员函数,可以根据参数的不同动态类型而其行为特性也不同。例如,假设你想为 TextBlock
和 Graphic
对象实现一个输出操作符。显而易见的方法是虚拟化这个输出操作符。但是输出操作符是 operator<<
,函数把 ostream&
做为它的左参数(即把它放在函数参数列表的左边),这就不可能使该函数成为 TextBlock
或 Graphic
成员函数。
试一下看看会发生什么:
class NLComponent
{
public:
// 对输出操作符的不寻常的声明
virtual ostream& operator<<(ostream& str) const = 0;
...
};
class TextBlock: public NLComponent
{
public:
// 虚拟输出操作符(同样不寻常)
virtual ostream& operator<<(ostream& str) const;
};
class Graphic: public NLComponent
{
public:
// 虚拟输出操作符 (同样不寻常)
virtual ostream& operator<<(ostream& str) const;
};
TextBlock t;
Graphic g;
...
// 不寻常的语法
t << cout; // 通过 virtual operator<< 把 t 打印到 cout 中。
g << cout; //通过 virtual operator<< 把 g 打印到 cout 中。
类的使用者得把 stream
对象放到<<
符号的右边,这与输出操作符一般的用发相反。为了能够回到正常的语法上来,我们必须把 operator<<
移出TextBlock
和 Graphic
类,但是如果我们这样做,就不能再把它声明为虚拟了。
为了解决这个问题,我们可以定义 operator<<
和 print
函数,让前者调用后者:
class NLComponent
{
public:
virtual ostream& print(ostream& s) const = 0;
...
};
class TextBlock: public NLComponent
{
public:
virtual ostream& print(ostream& s) const;
...
};
class Graphic: public NLComponent
{
public:
virtual ostream& print(ostream& s) const;
...
};
inline ostream& operator<<(ostream& s, const NLComponent& c)
{
return c.print(s);
}
具有虚拟行为的非成员函数很简单。你编写一个虚拟函数来完成工作,然后再写一个非虚拟函数,它什么也不做只是调用这个虚拟函数。为了避免这个句法花招引起函数调用开销,你当然可以内联这个非虚拟函数。
条款26:限制某个类所能产生的对象数量
例如你在系统中只有一台打印机,所以你想用某种方式把打印机对象数目限定为一个。或者你仅仅取得 16 个可分发出去的文件描述符,所以应该确保文件描述符对象存在的数目不能超过 16 个。你如何能够做到这些呢?如何去限制对象的数量呢?
每次实例化一个对象时,我们很确切地知道一件事情:“将调用一个构造函数。”事实确实这样,阻止建立某个类的对象,最容易的方法就是把该类的构造函数声明在类的 private 域或者声明为 delete:
class CantBeInstantiated
{
private:
CantBeInstantiated();
CantBeInstantiated(const CantBeInstantiated&);
...
};
或者:
class CantBeInstantiated
{
public:
CantBeInstantiated() = delete;
CantBeInstantiated(const CantBeInstantiated&) = delete;
...
};
如果想为打印机建立类,但是要遵守我们只有一个对象可以用的约束,我们应该把打印机对象封装在一个函数内,以便让每一个人都能够访问打印机,但是只有一个打印机对象被建立:
class Printer
{
public:
static Printer& thePrinter();
...
private:
Printer();
Printer(const Printer& rhs);
...
};
Printer& Printer::thePrinter()
{
static Printer p;
return p;
}
// 用户使用 printer 时有些繁琐:
Printer::thePrinter().reset();
Printer::thePrinter().submitJob(buffer);
类似于单例模式。
限制对象的数量
只需简单地计算对象的数目,一旦需要太多的对象,就抛出异常。如下所示,这样建立 printer
对象:
class Printer
{
public:
class TooManyObjects{}; // 当需要的对象过多时就使用这个异常类
Printer();
~Printer();
...
private:
static size_t numObjects;
Printer(const Printer& rhs); // 这里只能有一个 printer, 所以不允许拷贝
};
此法的核心思想就是使用 numObjects
跟踪 Pritner
对象存在的数量。当构造类时,它的值就增加,释放类时,它的值就减少。如果试图构造过多的 Printer
对象,就会抛出一个TooManyObjects
类型的异常:
size_t Printer::numObjects = 0;
Printer::Printer()
{
if (numObjects >= 1)
{
throw TooManyObjects();
}
// 继续运行正常的构造函数;
++numObjects;
}
Printer::~Printer()
{
// 进行正常的析构函数处理;
--numObjects;
}
这种限制建立对象数目的方法有两个较吸引人的优点:
- 一个是它是直观的,每个人都能理解它的用途。
- 另一个是很容易推广它的用途,可以允许建立对象最多的数量不是一,而是其它大于一的数字。
条款27:要求或禁止在堆中产生对象
为了执行这种限制,你必须找到一种方法禁止以调用new
以外的其它手段建立对象。这很容易做到。非堆对象在定义它的地方被自动构造,在生存时间结束时自动被释放,所以只要禁止使用隐式的构造函数和析构函数,就可以实现这种限制。
把这些调用变得不合法的一种最直接的方法是把构造函数和析构函数声明为 private
这样做副作用太大。没有理由让这两个函数都是 private
。最好让析构函数成为 private
,让构造函数成为 public
。
例如,如果我们想仅仅在堆中建立代表无限精确度数字的对象,可以这样做:
class UPNumber
{
public:
UPNumber();
UPNumber(int initValue);
UPNumber(double initValue);
UPNumber(const UPNumber& rhs);
void destroy() const { delete this; } // 伪析构函数 (一个 const 成员函数, 因为即使是 const 对象也能被释放。)
...
private:
~UPNumber();
};
然后客户端这样进行程序设计:
UPNumber n; // 错误! (在这里合法, 但是当它的析构函数被隐式地调用时,就不合法了)
UPNumber *p = new UPNumber; //正确
...
delete p; // 错误! 试图调用private 析构函数
p->destroy(); // 正确
另一种方法是把全部的构造函数都声明为 private
。
这种方法的缺点是:一个类经常有许多构造函数,类的作者必须记住把它们都声明为 private
。否则如果这些函数就会由编译器生成,构造函数包括拷贝构造函数,也包括默认构造函数;编译器生成的函数总是 public
。因此仅仅声明析构函数为 private
是很简单的,因为每个类只有一个析构函数。
上面这种方法虽然可行,但是这种方法也禁止了继承和包含。
class UPNumber {
...
}; // 声明析构函数或构造函数为 private
// 继承
class NonNegativeUPNumber: public UPNumber {
...
}; // 错误! 析构函数或构造函数不能编译
// 包含
class Asset
{
private:
UPNumber value;
... // 错误! 析构函数或 构造函数不能编译
};
这些困难也不是不能克服,通过把 UPNumber
的析构函数声明为 protected
就可以解决继承的问题,需要包含 UPNumber
对象的类可以修改为包含指向 UPNumber
的指针。
条款28:灵巧(smart)指针
灵巧指针是一种外观和行为都被设计成与内建指针相类似的对象,不过它能提供更多的功能。它们有许多应用的领域,包括资源管理(参见条款 M9、M10、M25 和 M31)和重复代码任务的自动化(参见条款 M17 和 M29)。
灵巧指针的实现可以参考auto_ptr
和share_ptr
源码。
条款29:引用计数
引用计数是这样一个技巧,它允许多个有相同值的对象共享这个值的实现。这个技巧有两个常用动机。
- 第一个是简化跟踪堆中的对象的过程。一旦一个对象通过调用 new 被分配出来,最要紧的就是记录谁拥有这个对象。引用计数可以免除跟踪对象所有权的担子,因为当使用引用计数后,对象自己拥有自己。当没人再使用它时,它自己自动销毁自己。因此,引用计数是个简单的垃圾回收体系。
- 第二个动机是由于一个简单的常识。如果很多对象有相同的值,将这个值存储多次是很无聊的。更好的办法是让所有的对象共享这个值的实现。这么做不但节省内存,而且可以使得程序运行更快,因为不需要构造和析构这个值的拷贝。
假如要设置一个 string 的引用计数的实现形式,多个相同的 string 值时,让这些 string 对象指向相同的 string 值。我们需要某个空间,用以每个 string 存储引用次数,这个空间不应该放在 string 对象内,我们要记的是字符串值的引用次数,不是字符串对象的引用次数。
class String{
String(const char *initValue = "");
String(const String &rhs);
~String();
String& operator=(const String& rhs);
private:
struct StringValue{
int refCount;
char *data;
StringValue(const char *data);
~StringValue();
}
StringValue *value;
}
String::StringValue::StringValue(const char *data)
{
data = new char[strlen(data) + 1];
strcpy(data, data);
}
String::StringValue::StringValue()
{
delete [] data;
}
String::String(const char *initData):value(new StringValue(initData))
{}
String::String(const String &rhs):value(rhs.value)
{
++value->refCount;
}
String::~String()
{
if(--value->refCount == 0){
delete value;
}
}
String &String::operator=(const String &rhs)
{
if(value == rhs.value){
return *this;
}
if(--value->refCount == 0){
delete value;
}
value = rhs.value;
++value->refCount;
return *this;
}
// Client
String a, b, c, d, e;
a = b = c = d = e = "Hello";
这里,只存储了一个 “Hello” 的拷贝,所有具有此值的 String 对象共享其实现。
也可以参考share_ptr
源码中的引用计数实现。
条款30:代理类
可参考设计模式中的代理模式。
条款31:让函数根据一个以上的对象类型来决定怎么虚拟
假设要编写一个发生在太空的游戏,其中有飞船(spaceship),太空站(space station)和小行星(ssteroid),使它们继承自一个抽象基类GameObject:
class GameObject { ... };
class SpaceShip: public GameObject { ... };
class SpaceStation: public GameObject { ... };
class Asteroid: public GameObject { ... };
不同的对象相撞有不同的规则,处理碰撞的函数声明像这样:
void checkForCollision(GameObject& object1,GameObject& object2);
这就产生了一个问题,要处理 object1 和 object2 的碰撞,必须知道这两个引用的动态类型,但 C++ 支持的虚函数只支持 single-dispatch(单重分派),如果某个函数调用根据两个参数而虚化就称为双重分派,根据多个函数而虚化称为多重分派。C++ 不支持双重分派和多重分派,因此我们必须自己实现。有以下几种方法:
- 虚函数 + RTTI(运行时期类型辨识)
- 只使用虚函数
- 自行仿真虚函数表格(使用成员函数的碰撞处理函数)
这三种方法的具体实现可以参考:
【more effective c++读书笔记】【第5章】技术(7)——让函数根据一个以上的对象类型来决定如何虚化(1)
More Effective C++ 条款31 让函数根据一个以上的对象类型来决定如何虚化
六、杂项
我们现在到了接近结束的部分了,这章讲述的是一些不属于前面任一章节的指导原则。开始两个是关于 C++ 软件开发的,描述的是设计适应变化的系统。面向对象的一个强大之处是支持变化,这两个条款描述具体的步骤来增强你的软件对变化的抵抗能力。
然后,我们分析怎么在同一程序中进行 C 和 C++ 混合编程。这必然导致考虑语言学之外的问题,但 C++ 存在于真实世界中,所以有时我们必须面对这种事情。
条款32:在未来时态下开发程序
要用语言提供的特性来强迫程序符合设计,而不要指望使用者去遵守约定。比如禁止继承,禁止复制,要求类的实例只能创建在堆中等等。处理每个类的赋值和拷贝构造函数,如果这些函数是难以实现的,则声明它们为私有。
尽可能写可移植性的代码,只有在性能极其重要时不可移植的结构才是可取的。
多为未来的需求考虑,尽可能完善类的设计。
条款33:将非尾端类设计为抽象类
只要不是最根本的实体类(不需要进一步被继承的类),都设计成抽象类。
条款34:如何在同一程序中混合使用 C++ 和 C
名变换
名变换,就是C++编译器给程序的每个函数换一个独一无二的名字。由函数名和参数组合生成一个新的名字,这样是为了支持函数重载。在 C 语言中,这样的做法是不必要的,因为它没有重载函数。
这样就存在一个问题:就是当你在 C++ 环境中调用 C 函数库中的函数时,比如一个 drawLine(int x,int y)
。经过 C++ 编译器后在 obj 中的函数名称可能是 drawLine_int_int
,这样当你试图将 obj 文件链接为程序时,将得到一个错误。因为链接程序无法在 C函数库中找到 drawLine_int_int
。
要解决这个问题,你需要一种方法来告诉 C++ 编译器不要在这个函数上进行名变换。extern ‘C’
声明这个函数应该被当做是 C 写的一样,要禁止名称变换。
extern "C"
{
void drawLine(int x1, int y1, int x2, int y2);
void twiddleBits(unsigned char bits);
void simulate(int iterations);
...
}
通过定义一个宏,我们可以轻松的控制是否需要添加 extern "C"
。比如程序放在 C++ 编译器下编译时我们需要添加 extern "C"
,而在 C 编译器下编译时就不需要了。
#ifdef __cplusplus
extern "C" {
#endif
void drawLine(int x1, int y1, int x2, int y2);
void twiddleBits(unsigned char bits);
void simulate(int iterations);
...
#ifdef __cplusplus
}
#endif
总结
如果想在同一程序下混合 C++ 与 C 编程,记住下面的指导原则:
- 确保 C++和 C 编译器产生兼容的 obj 文件。
- 将在两种语言下都使用的函数申明为
extern 'C'
。 - 只要可能,用 C++写 main()。
- 总用 delete 释放 new 分配的内存;总用 free 释放 malloc 分配的内存。
- 将在两种语言间传递的东西限制在用 C 编译的数据结构的范围内;这些结构的 C++ 版本可以包含非虚成员函数。
条款35:让自己习惯使用标准 C++语言
可以参考《C++标准程序库》,另外可以使用最新编译器,尝试 C++11 新特性。
参考:
《More Effective C++》技巧与杂项篇