有时简单的事情看起来非常简单,但是它们却是非常微妙的。例如,你有一个Widget类,你想知道在运行时一共创建了多少个Widget对象。一个简单可行的方法是在Widget内部创建一个static count变量,当对象创建时增加count的值,当对象销毁时减少count的值。同时,也要创建一个static类型的成员函数howMany(),用来返回当前有多少个Widget对象存在。如果Widget类只有简单的计数功能,Widget类的实现代码大致如下:
class Widget {
public:
Widget() { ++count; }
Widget(const Widget&) { ++count; }
~Widget() { --count; }
static size_t howMany()
{ return count; }
private:
static size_t count;
};
// obligatory definition of count. This
// goes in an implementation file
size_t Widget::count = 0;
上面的代码没毛病,唯一需要注意的一小点是要记得实现拷贝构造函数,因为编译器自动生成的拷贝构造函数不会自动增加计数的count的值。
如果你只需要为Widget类增加计数功能,恭喜你,你已经完成任务了,但如果你还要为好几个类增加这个功能,那么显然重复这些事情是非常枯燥的,并且有时还可能出错。为了减少这种枯燥的重复,最好能够将上面计数的代码封装起来,这样就可以很方便的在任何需要的地方重用,理想的封装应该做到:
静下来想想,你会怎样实现一个满足上面要求的可重用的功能封装呢?可能比你预期的要难一些。如果做起来就像看起来那么简单,那么你就不会读到这本杂志上的这篇文章了。
也许你已经在思考怎样来解决这个问题了,请允许我切换到一个看起来不相干的话题。这个话题是当构造函数抛出异常时,new和delete之间的关系。当在C++中动态创建一个对象时,你会像下面这样使用new操作符:
class ABCD { ... }; // ABCD = "A Big Complex Datatype"
ABCD *p = new ABCD; // a new expression
new表达式是C++语言内置的,你不能改变它的行为。它主要做两件事:首先,它会调用一个申请内存的函数operator new,operator new的作用是申请足够的内存来容纳一个ABCD对象。如果operator new调用成功,new表达式将会通过调用ABCD的构造函数在申请到的内存上创建一个ABCD对象。
但是如果new表达式抛出了一个std::bad_alloc的异常呢?这个类型的异常通常意味着动态内存申请失败。在上面的new表达式中,有两个函数可能抛出这个异常。第一个地方是operator new动态申请内存时,第二个是在内存上创建ABCD对象时。
如果异常来自调用operator new,这个时候没有申请到任何内存。然而,如果operator new调用成功,异常来自接下来在内存上创建ABCD对象的过程,那么使用operator new申请的内存必须被释放,如果没有,程序中就会产生内存泄露。编程时,如果有异常抛出,我们其实不能区分这个异常是在那一阶段产生的。
多年来,这都是C++标准草案中的一个坑,但是在1995年3月,C++标准委员会决定来填这个坑,委员会采纳了下面这条规则:调用new表达式时,如果调用operator new成功了但是在调用构造函数时失败了,系统必须能够自动释放operator new申请到的内存。释放内存是通过operator delete来实现的,它和operator new的作用相反。(具体的,可以参阅文末的 “A Note About Placement new and Placement delete” )
new 表达式和operator delete的这种依赖关系会影响到我们在探索对象计数的方法,这也是我为什么要切到这个话题的原因。好吧,让我们继续:
不管怎样,解决对象计数这个问题的方案可能都涉及到写一个对象计数的类。这个类或许十分像我上面写的那个Widget类:
// see below for a discussion of why
// this isn't quite right
class Counter {
public:
Counter() { ++count; }
Counter(const Counter&) { ++count; }
~Counter() { --count; }
static size_t howMany()
{ return count; }
private:
static size_t count;
};
// This still goes in an
// implementation file
size_t Counter::count = 0;
核心思想是需要计数的类直接用Counter这个类来计数。显然,有两种实现方式:一种是将Counter对象作为类的一个数据成员,像下面这样:
// embed a Counter to count objects
class Widget {
public:
..... // all the usual public
// Widget stuff
static size_t howMany()
{ return Counter::howMany(); }
private:
..... // all the usual private
// Widget stuff
Counter c;
};
另外一种是将Counter作为基类,像这样:
// inherit from Counter to count objects
class Widget: public Counter {
..... // all the usual public
// Widget stuff
private:
..... // all the usual private
// Widget stuff
};
两种方法各有优缺点。在仔细检查他们之前,我们发现这两种方法目前的实现其实都不能满足计数的基本功能。这主要是因为Counter类内部的静态数据成员count。我们知道,内存中只有一个count,但是我们需要对不同类使用Counter单独计数。比如,如果我们需要分别对Widget和ABCD类单独计数,我们需要两个静态的static size_t变量,而不是目前的一个。将Counter::count改为非静态的也不会解决这个问题,因为我们是需要对某个类型的class产生的对象个数计数,而不是每个对象一个Counter.
我们可以通过引入C++中最出名的同时也是名字最奇怪的技巧:将Counter变成一个类模板,每个使用Counter的类用该类自己作为模板参数来初始化Counter类模板,如下:
template
class Counter {
public:
Counter() { ++count; }
Counter(const Counter&) { ++count; }
~Counter() { --count; }
static size_t howMany()
{ return count; }
private:
static size_t count;
};
template
size_t
Counter::count = 0; // this now can go in header
那么上面的两种实现方式中,采用成员变量的方式将变为如下:
// embed a Counter to count objects
class Widget {
public:
.....
static size_t howMany()
{return Counter::howMany();}
private:
.....
Counter c;
};
采用继承的方式将变为如下:
// inherit from Counter to count objects
class Widget: public Counter {
.....
};
注意到我们将最初用Counter的地方都替换为了Counter
这种类通过传递自身作为模板参数来实例化一个类模板的方法最初是由 Jim Coplien [1]提出的。他发现这种方法不仅在C++,在多种编程语言中都使用过,因而描述这种方法为"a curiously recurring template pattern" (CRTP)。我不认为他有意为这种方法命名,但是他的描述最终变成了这种方法的名字。这其实不是很好,因为模式的名字很重要,但目前这个名字不能传递出这种模式做了什么或者该怎么用的信息。
为模式命名是一门艺术,我不是非常擅长,但是我宁愿叫这种模式为"Do It For Me"。基本上,每个从Counter实例化出来的类为请求实例化Counter的类提供一种服务(提供计算这个类有多少个对象的服务),具体的,Counter
既然Counter是一个类模板,并且组合或者继承的两种方式都是可行的,那么我们现在要做的就是评估它们二者的优缺点。我们设计标准中的一项是使用简单,通过上面的代码我们显然可以发现采用继承的方法比采用组合的方法简单:这是因为采用继承的方案只需要做个继承就可以了,而采用组合的方案需要定义一个Counter数据成员并且重新实现howMany()方法[2],尽管这也不需要多大的工作量,但是只做一件事情显然比不得不做两件事情容易。那么,让我们首先把注意力转向使用继承的方式。
采用继承的方式可行是因为C++保证每次子类对象构造或析构时,它的基类部分会被首先构造或最后析构。将Counter作为基类因此可以保证每一个继承自Counter的子类对象在构造或析构时,Counter的构造函数或析构函数都会被调用到。
每次谈到基类的话题时,必然会谈到虚析构函数的话题。那么Counter是不是应该有个虚的析构函数呢?现有C++面向对象设计原则决定必须是虚析构函数。如果没有虚析构函数,那么通过指向子类对象的基类指针删除子类对象时将会产生未定义行为:
class Widget: public Counter
{ ... };
Counter *pw =
new Widget; // get base class ptr to derived class object
......
delete pw; // yields undefined result if the base class lacks
// a virtual destructor
这样的行为会违反我们前面提到的设计标准中的第三项:不易用错(foolproof),因为上面的使用代码看上去非常合理。我们因此有非常充足的理由将Counter的析构函数变为虚析构函数。
然而,如果这样做,就会违反第二条设计原则:高效(不因为引入了计数功能而导致速度变慢或者空间占用变大)。这就麻烦了,因为在Counter基类中添加了虚析构函数后(或者其它任何虚函数)意味着Counter类型或其子类的对象都将会包含一个隐藏的虚指针,如果子类中原本没用到虚函数,这将增加子类对象的大小[3]。换句话说,如果Widget自身不包含虚函数,Widget类型的对象如果继承了Counter
那么唯一的方式就是找到某种方法,阻止通过基类指针来删除其指向的子类对象这样的事情发生。将Counter对象中的delete操作符声明为private的貌似是一个合理的方法:
template
class Counter {
public:
.....
private:
void operator delete(void*);
.....
};
现在,下面这样调用delete的地方将不能编译通过:
class Widget: public Counter { ... };
Counter *pw = new Widget; ......
delete pw; // Error. Can't call private
// operator delete
不幸但却非常有意思的的是,我们发现,像下面这样的new表达式也不能编译通过:
Counter *pw = new Widget; // this should not compile because
// operator delete is private
回忆一下之前关于new,delete,和 exception相关的讨论,如果对象构造失败,C++运行时系统需要负责释放operator new申请的内存。而释放内存需要调用operator delete,但是我们在Counter中将operator delete声明为private了,这样就导致了我们不能通过new表达式在堆上创建对象。
是的,上面的说法也许是反直觉的,如果你的编译器目前还不支持上面的规则,不要惊讶,但我描述的这种行为是正确的。另外,也没有更直接的方法来阻止通过基类指针删除子类对象,同时,通过在基类中将析构函数变为虚析构函数的方法我们也是不认可的。那么,我们将舍弃使用继承的方式而尝试改用Counter作为一个数据成员的组合的方式。
我们之前已经发现组合的方式有一个缺点:用户代码必须定义一个Counter对象作为数据成员,另外还必须重写howMany()函数,这个howMany()函数需要调用Counter的howMany()函数。这比我们想强加给用户的工作量稍微多了一点,但似乎也没有更好的办法了。同时,还有一个缺点,添加数据成员必然会导致使用Counter类的对象size变大。
乍一看,这好像并不是什么新发现。毕竟,给一个类添加一个数据成员会让该类的对象size变大,这没什么好惊讶的。但是,再看一下,如果我们像下面这样定义Counter:
template
class Counter {
public:
Counter();
Counter(const Counter&);
~Counter();
static size_t howMany();
private:
static size_t count;
};
注意到,它并没有nonstatic的数据成员,也就是说每个Counter类对象不包含任何东西。那么,我们会期望Counter类对象的size为0吗?也许会,但是这对我们没有帮助。C++标准在这点上非常清楚,所有的对象都必须至少含有一个byte, 即使对象不包含任何nonstatic的数据成员。对于通过实例化Counter类模板得到的类,sizeof也会返回一个正数。所以,客户代码中包含Counter对象的类和不包含Counter对象的类相比,将会包含更多的数据信息。(有意思的是,这并不意味着对包含Counter对象的类进行sizeof的结果大于对不包含Counter对象的类进行sizeof的结果。这主要是因为有内存对齐的存在。比如,如果一个对象Widget只有两个字节,但是是按4字节对齐的,那么,编译器会在其后添加两个字节,sizeof(Widget)会得到4。如果编译器像大多数情况下那样,通过插入一个字节到 Counter
我写这篇文章的时候正是圣诞季的开始,(其实是感恩节,你可以从这里发现我是怎样庆祝重要节日的),现在我的心情非常糟糕,我要做的只是给对象计数呀,我不想拖泥带水的带来额外的负担。肯定有别的办法的。
再看看基于继承的,并且需要引入虚析构函数的代码:
class Widget: public Counter
{ ... };
Counter *pw = new Widget;
......
delete pw; // yields undefined results if Counter lacks a virtual destructor
之前我们尝试通过不让delete表达式编译通过的方法来保证不会出现通过基类指针删除指向的子类对象的情形,但是我们发现这样做会导致一般的new表达式不能编译通过。也许我们还可以阻止其它地方:我们可以禁止子类指针Widget* 隐式转换为基类指针Counter
class Widget: private Counter
{ ... };
Counter *pw = new Widget; // error! no implicit conversion from
// Widget* to Counter*
更进一步的,我们会发现采用Counter作为基类的Widget类和单独的Widget类相比,大小是相同的。是的,就是这样的!我知道我刚刚说过没有size为0的类,是的,但是可能还不够准确,我说的是没有size为0的对象。C++标准说得十分清楚,子类的基类部分的size可以为0。事实上,许多编译器都实现了这一点,这就是所谓的空基类优化(empty base optimization) [4]。
因此,如果一个Widget类包含一个Counter,Widget类的大小必然会变大。因为Counter作为数据成员是一个单独的对象,它必须是占用一定的空间。但是如果Widget继承自Counter,编译器允许Widget的大小和不继承时的大小一样。这也给出了一个有趣的经验法则:当空间很紧张时,如果涉及到空类,当私有继承和组合的方式都可以实现功能时,采用私有继承。
最后的一个方案近乎完美了。如果你的编译器实现了空基类优化,它满足设计准则二:高效,因为从Counter继承不会给子类带来额外的空间开销,并且所有的Counter的成员函数都是inline的。它也满足涉及准则三:不易用错,因为所有的操作都是通过Counter的成员函数实现的,这些函数在计数时会被自动调用,另外,采用private继承也禁止了子类指针转换为基类指针,这同时禁止了通过基类指针操作子类对象。(当然,也不是完全不会出错的,如果Widget的作者错误的用其它类而不是Widget类来实例化Counter类模板,比如Widget继承自Counter
这样的设计对客户代码来说,使用起来当然也是非常方便的,但是有的人可能会抱怨说应该更简单一点才好。因为采用私有继承意味着howMany()在子类中是私有的,所以这些子类都必须包含一个using声明来使howMany()在用户代码中是可访问的:
class Widget: private Counter {
public:
// make howMany public
using Counter::howMany;
..... // rest of Widget is unchanged
};
class ABCD: private Counter {
public:
// make howMany public
using Counter::howMany;
..... // rest of ABCD is unchanged
};
对于不支持namespace的编译器,我们可以采用旧的访问控制声明方法来替代using声明,如下:
class Widget: private Counter {
public:
// make howMany public
Counter::howMany;
..... // rest of Widget is unchanged
};
因此,想要给对象计数并且需要将计数接口公开的客户代码必须做两件事:声明Counter为基类,在子类中使howMany()方法变为可访问的。
然而,采用继承的方式有两点必须要注意。第一点是避免歧义(ambiguity),比如我们想要给Widgets类型计数,并且我们想要扩展为给所有不同类型的Widgets计数。就像上面的代码,我们用Widget继承自Counter
class SpecialWidget: public Widget,
private Counter {
public:
.....
static size_t howMany()
{ return Counter::howMany(); }
.....
};
对于采用继承的方式来计数,第二点需要注意的是从Widget::howMany返回的值不仅包含了Widget对象的数目,并且包含了继承自Widget的其它类的对象的数目。如果只有一个SpecialWidget类继承自Widget,有5个Widget对象,有3个SpecialWidget对象,那么Widget::howMany返回将是8。毕竟,构造SpecialWidget对象时也会构造基类的Widget部分。
你只需要记住下面的几个知识点就可以了:
[1] James O. Coplien. “The Column Without a Name: A Curiously Recurring Template Pattern,” C++ Report, February 1995.
[2] An alternative is to omit Widget::howMany and make clients call Counter::howMany directly. For the purposes of this article, however, we’ll assume we want howMany to be part of the Widget interface.
[3] Scott Meyers. More Effective C++ (Addison-Wesley, 1996), pp. 113-122.
[4] Nathan Myers. “The Empty Member C++ Optimization,” Dr. Dobb’s Journal, August 1997. Also available at http://www.cantrip.org/emptyopt.html.
[5] Simple variations on this design make it possible for Widget to use Counter to count objects without making the count available to Widget clients, not even by calling Counter::howMany directly. Exercise for the reader with too much free time: come up with one or more such variations.
了解更多关于new和delete的细节,可以读Dan Saks的专栏(CUJ January - July 1997),或者我的书《 More Effective C++》中的item8。对于更深入的对象计数问题,比如怎样限制一个对象产生的对象数目,可以参考《 More Effective C++》中的item26.
原文链接: http://www.drdobbs.com/cpp/counting-objects-in-c/184403484