现在的位置: 首页 > 综合 > 正文

对象计数

2013年04月19日 ⁄ 综合 ⁄ 共 10720字 ⁄ 字号 评论关闭

       在C++中,除非你要为其它事情分神,否则对某个特定类的所有已创建对象计数并不是一件很难的事。
有时,简单就是简单,不过某些简单也往往很微妙。例如,假设你有一个类Widget,并且你想实现一种方法,以便在运行期获知到底有多少个Widget对象存在。一个既简单又正确的方法是,在Widget中创建一个static计数器,每次调用Widget构造函数时就将计数器加1,而每次调用Widget析构函数时就将计数器减1。同时,你还需要一个static成员函数howMany来报告当前有多少个Widget对象存在。如果Widget除了记录本类型对象个数以外什么也不做,那么它或多或少看上去应该是这样的:

class Widget {
public:
    Widget() { ++count; }
    Widget(const Widget&) { ++count; }
    ~Widget() { --count; }

    static size_t howMany()
    { return count; }

private:
    static size_t count;
};

// count的定义是必须的
// 以下是在一个实现文件中的定义
size_t Widget::count = 0;

这段代码会工作的很好。唯一需要小心一点的是,别忘了实现拷贝构造函数,因为编译器为Widget生成的拷贝构造函数并不知道要对count加1。
如果你只是想对Widget做计数工作,那么现在你的工作就完成了,不过你可能想为多个类实现对象计数功能。反反复复做一件事是很乏味的,而乏味往往导致错误的出现。为了避免这种乏味,最好是将上面的实现对象计数功能的代码打包,这样就可以在任何需要对象计数功能的类中重用这些代码。理想的包(package)应该是:
·容易使用的——类的作者只需要做极少的工作就可以重用这些代码。理想情况是,他们不需要做更多的事,除了说“我想要为这个类添加对象计数功能”以外。
·高效的——使用这个代码包的客户类不需要负担多余的空间和时间。
·安全可靠的——基本不会产生错误的count。(我们不需要考虑那些恶毒的客户,他们总是故意让count混乱。在C++中,这些客户总是能找到完成他们卑鄙勾当的方法。)
请静下心来仔细想一想,到底如何才能实现一个满足上述要求的、可重用的对象计数包。它也许不想你想象中的那么简单。当然,如果真的很简单的话,你也不会在这本杂志(指C++ Users Journal)里读到这篇文章了。

new,delete,和异常
你或许还在沉思如何解决对象计数的问题,不过请允许我打断你一下,我要首先讨论一个看似与之无关的话题。这个话题的内容是,当构造函数抛出一个异常时,new和delete之间的关联。当你需要C++动态分配一个对象时,你会使用类似下面的new表达式:

class ABCD { ... }; // ABCD = "A Big Complex Datatype"(一个大而复杂的数据类型)
ABCD *p = new ABCD; //一个new表达式

New表达式——它的含义内建于语言中,而且你无法改变它的行为——会做两件事。首先,它调用一个内存分配函数,这个函数称为operator new。这个函数负责寻找足够大的内存来保存一个ABCD对象。如果operator new的调用成功了,new表达式就会在operator new找到的内存空间上调用一个ABCD的构造函数。
现在假设operator new抛出了一个std::bad_alloc异常。这个类型的异常表示试图动态分配内存的行为失败了。在上文中的new表达式中,有两个函数可能会引发这个异常。第一个是调用operator new,它被认定可以找到足够的内存保存一个ABCD对象。第二个是接下来调用的ABCD构造函数,它被认定将原始内存(raw memory)转化为一个有效的ABCD对象。
如果异常是调用operator new时引发的,那么没有内存被分配。但是,如果调用operator new成功了,而调用ABCD构造函数时引发了异常,那么一个很重要的问题就是要把operator new分配的内存释放掉。如果没有很好的解决这个问题,那么程序中就隐藏着内存泄漏的问题。对于客户(client)——即请求创建ABCD对象的代码——来说,确定是哪一个函数引发异常是不可能的。
多年以来,这都是C++语言规范草案中的一个漏洞,不过在1995年3月,C++标准委员会修订了相关规则,它规定,在一个new表达式中,如果operator new调用成功而其后的构造函数抛出一个异常,那么运行期(runtime)系统必须自动释放由operator new分配的内存。释放内存的工作由operator delete(对应于operator new的释放内存的函数)执行。(相关细节,可以参考sidebar一文中关于new占位符和delete占位符的部分,http://www.cuj.com/articles/1998/9804/9804a/sidebar.htm。)
new表达式和operator delete之间这种微妙的关系影响了我们的对象实例计数的自动化操作。

对象计数
对于对象计数,你的解决方法十有八九是设计一个对象计数类。你的类很可能和我前面提到的Widget类极为相似,甚至可能是完全一样的:

// 下面的内容讲述了为什么
// 这种设计不完全正确

class Counter {
public:
    Counter() { ++count; }
    Counter(const Counter&) { ++count; }
    ~Counter() { --count; }
    static size_t howMany()
        { return count; }

private:
    static size_t count;
};
// 下面的代码仍然放置在
// 一个实现文件中
size_t Counter::count = 0;

这个设计的主要思想是:需要对存在的对象进行计数的类的作者只需要简单的使用Counter来完成“记帐”。有两个显而易见的方法可以做到这一点。一个方法是定义一个Counter对象作为一个类的数据成员,比如:

// 嵌入一个Counter对象来实现对象计数功能
class Widget {
public:
    .....  // 原有的公共(public)
        // Widget成员
    static size_t howMany()
    { return Counter::howMany(); }
private:
    .....  // 原有的私有(private)
        // Widget成员
    Counter c;
};

另一个方法是将Counter作为基类,例如:

// 从Counter中派生,以实现对象计数功能
class Widget: public Counter {
    .....  // 原有的公共
        // Widget成员
private:
    .....  // 原有的私有
        // Widget成员
};

这两种方法各有各的优点和缺点。不过在深入的考察他们之前,我们首先了解到这两种方法的当前版本都不能正常工作。问题在于Counter中的静态(static)对象count。这个count对象只有一个,而我们需要的是对应于每个使用Counter的对象都有一个count。例如,如果我们想要对Widget和ABCD两个类实现计数功能,我们就需要两个静态(static)size_t对象,而不是一个。而将Counter::count改为非静态(nonstatic)对象并不能解决这个问题,因为我们需要的是每个类有一个count,而不是每个对象。
通过使用C++中的一个著名但名字古怪的技巧,我们可以得到想要的东西:我们把Counter转化为一个模板,而每个使用Counter的类则以自己为模板参数实例化这个模板。
让我们再来看看。现在Counter变成了一个模板:

template<typename T>
class Counter {
public:
    Counter() { ++count; }
    Counter(const Counter&) { ++count; }
    ~Counter() { --count; }

    static size_t howMany()
    { return count; }

private:
    static size_t count;
};

template<typename T>
size_t
Counter<T>::count = 0; // 现在这行语句放在头文件中了

现在第一个Widget的实现看上去是这个样子的:

// 嵌入一个Counter对象来实现对象计数功能
class Widget {
public:
    .....
    static size_t howMany()
    {return Counter<Widget>::howMany();}
private:
    .....
    Counter<Widget> c;
};

而第二个实现则变成了这般模样:

//从Counter中派生,以实现对象计数功能
class Widget: public Counter<Widget> {   
    .....
};

注意,在这两种情况下我们是如何用Counter<Widget>代替Counter的。正如我前面所说,每个使用Counter的类都以其自身为参数实例化这个模板。一个类以其自身为模板参数实例化一个模板,并将这个模板实例以为己用,这个策略最早由Jim Coplien提出。他指出这个策略在许多语言中都有应用(不仅仅在C++中),并且他将其称为“a curiously recurring template pattern(一个奇特的递归模板模式)”[1]。我不认为Jim是故意如此,不过他为这个模式起的名字更像是对它的描述。这太糟糕了,因为模式的名字是十分重要的,而这个名字则太失败了,既没有表达出它是做什么的,也没有说出应该如何使用它。
模式的命名和其它许多事情一样大有学问,我也并不十分擅长此道,不过我或许会把这个模式称为“Do It For Me(为我做)”,或者其它类似的名字。至少,每个由Counter衍生出来的类都为将Counter实例化的类提供了一种服务(它记录有多少个对象存在)。于是类Counter<Widget>对Widget对象们计数,而类Counter<ABCD>则对ABCD对象们计数。
现在Counter是一个模板了,无论嵌入的设计还是继承的设计都能够正常工作,是我们把它们的优缺点比较一番的时候了。我们的一个设计准则是客户必须能够很容易的实现对象计数功能,而且前面的代码已经表述的很明白,基于继承的设计要比基于嵌入的设计容易。因为前者只要求将Counter作为一个基类,而后者则要求定义一个Counter数据成员,并且在客户代码中重新实现howMany以调用Counter的howMany[2]。这并不是什么很复杂的工作(客户代码中的howMany只是简单的inline函数),但是只用做一件事比必须做两件事来得简单。因此,我们先来考察基于继承的设计方案。

使用公共继承
基于继承的设计之所以能够工作,是因为C++保证每当一个派生类对象被创建或销毁时,它的基类部分会被首先创建,并且被最后销毁。因此每当以Counter为基类的派生类的一个对象被创建和销毁时Counter的构造函数和析构函数就会被分别调用。
每次涉及到基类,我们必须考虑关于虚拟析构函数的问题。Counter应该具有析构函数吗?现有的C++面向对象设计法则显示的确应该如此。如果Counter不具有虚拟的析构函数,那么通过一个基类指针delete一个派生类对象将导致未定义(通常也是不希望的)的结果:

class Widget: public Counter<idget>
{ ... };
Counter<Widget> *pw =
    new Widget;  // 使一个基类指针指向
                // 一个派生类对象
......
delete pw; // 导致未定义的结果
         // 如果基类没有
         // 虚拟析构函数的话

上面的行为可能会产生无法预料的后果,这违反了“我们的对象计数的设计必须是安全可靠的”这一设计准则。让Counter具有一个虚拟析构函数也就有了足够的理由。
但是,另一个设计准则是效率最大化(完成对象计数不会耗费任何不必要的时间和不会占用任何不必要空间),现在麻烦来了。这是因为Counter中存在着一个虚拟析构函数(存在其它虚拟函数也一样),也就是说每个Counter类(或其派生类)的对象都要具有一个(隐藏的)虚拟指针,而如果本来的对象并不支持虚拟函数,这个虚拟指针的存在就会使对象的体积变大[3]。这意味着,如果Widget本身不包含虚拟函数,那么从Counter<Widget>中派生出(直接或间接)Widget,就会导致Widget类的对象体积膨胀。这可不是我们想要的。
解决这个问题的唯一途径是避免客户代码通过基类指针销毁派生类对象。看起来将Counter的operator delete声明为私有函数可以达到这个目的:

template<typename T>
class Counter {
public:
    .....
private:
    void operator delete(void*);
    .....
};

现在delete表达式将无法通过编译:

class Widget: public Counter<Widget> { ... };
Counter<idget> *pw = new Widget;  ......
delete pw; // Error,不能调用私有的
// operator delete

不幸的是——这才是最有意思的地方——new表达式也不能编译了!

Counter<Widget> *pw =
    new Widget;  // 这条语句也无法
                // 通过编译,因为
                // operator delete是
                // 私有的

还记得我们前面关于new,delete和异常的讨论吗?如果后续的构造函数调用失败,C++运行期(runtime)系统必须将operator new分配的内存空间释放掉。还有,系统会调用operator delete函数完成释放内存的工作。但是我们把Counter类的operator delete声明为私有函数了,这导致了无法通过new在堆(heap)中创建对象!
是的,这的确有悖常理,而且如果你的编译器还不支持这条规则也不用吃惊,但是我所描述的行为才是正确的。此外,没有其它简单易行的方法可以防止通过Counter*指针销毁派生类对象,并且我们已经否定了在Counter中提供虚拟虚构函数的想法。所以我说我们还是放弃这个设计,试试使用一个Counter数据成员吧。

使用一个数据成员
我们已经见到基于Counter数据成员的设计有一个缺点:客户代码不但要定义一个Counter数据成员,还要编写一个inline版的howMany函数来调用Counter的howMany函数。这只比我们希望强加于客户代码上的工作多不了多少,但是这使它非常难以管理。此外还存在着另一个缺陷。通常在一个类中添加一个Counter数据成员将会使该类的对象体积增加。
初看起来这似乎有些不可思议。增加一个数据成员怎么会使对象的体积增加?别着急,再仔细想想。考虑Counter的定义:

template<typename T>
class Counter {
public:
    Counter();
    Counter(const Counter&);
    ~Counter();

    static size_t howMany();
private:
    static size_t count;
};

注意,它的所有数据成员都是静态(static)的。这就是说Counter类的每个对象都是什么也没有。或许我们应该认为Counter类对象的大小是0?或许,但这样认为对我们一点好处也没有。C++在这一点上是很明确的。任何对象的大小至少是一个字节,即使是没有非静态数据成员的对象也是如此。由此规则可知,sizeof Counter模板的每个实例类都会产生一个正整数。因此每个包含Counter类对象的客户类都比其不包含Counter类对象时具有更多的数据。
(有趣的是,这并不表示一个包含Counter类对象的类的大小一定比其不包含Counter类对象时大。这是因为边界对齐在捣乱。例如,如果Widget是一个包含两个字节数据的类,而系统进行4字节的对齐,那么每个Widget类对象都会包含两个字节的占位符,而sizeof(Widget)将返回4。如果,通常情况下,编译器为了保证每个对象的大小都大于0而在Counter<Widget>中插入了一个char,那么即使Widget包含了一个Counter<Widget>对象,sizeof(Widget)也很可能仍然返回4。这个Counter<Widget>对象只是简单的占用了Widget中占位符所占用的一个字节。这种情况并不是经常出现,因此我们在设计对象计数功能包时不考虑这种情况。)
我写这些东西的时候是在圣诞节假期的开始(事实上是感恩节,由此你也可以知道我是如何庆祝这些重大节日的……)。而现在我的心情变坏了。我所要做的只是对象计数,我不想为此背上沉重的包袱。该寻找一个真正的解决途径了。

使用私有继承
回头在看看基于继承的设计代码,就是那个需要在Counter中考虑虚拟析构函数的设计:

class Widget: public Counter<Widget>
{ ... };
Counter<Widget> *pw = new Widget;
......
delete
pw;  // 导致未定义的后果
     // 如果Counter类缺少一个虚拟
     // 析构函数

早先我们试图通过禁止编译delete表达式来防止以上这一系列操作,不过我们发现这样做也导致new表达式不能被编译。但是我们可以禁止其它一些操作。比如我们可以禁止由Widget*指针到Counter<Widget>*指针的隐式转换。换句话说,我们可以禁止派生类指针到基类指针的转换。我们所要做的只是将公共继承替换为私有继承:

class Widget: private Counter<Widget>
{ ... };
Counter<Widget> *pw =
    new Widget;  // 错误! 不能进行
                // 由Widget*Counter<Widget>*
                // 的隐式转换

此外,我们很可能会发现将Counter作为基类不会增加Widget的体积。是的,我记得我刚刚说过0大小的类是不存在的,但是——好吧,这并不是我的本意。我所要说的是0大小的对象是不存在的。C++标准中明确的指出,派生类(直接或间接)的基类部分的大小可以是0。事实上,许多编译器通过空基类优化来达到这个要求[4]。
因此,如果一个Widget包含一个Counter,那么Widget的大小肯定会增加。Counter数据成员本身就是一个对象,所以它的大小不可能是0。但是如果Widget派生自Counter,编译器就可以保持Widget的大小不变。这导出了一个有趣的设计经验,这条经验适用于内存空间紧张并且需要考虑空类(大小为0的类)的情况:如果私有继承和包含都能很好的工作,那么私有继承由于包含。
最后的这个设计已经近乎完美了。由于自Counter派生不会为派生类增加任何每个对象都要具有的数据,并且所有Counter的成员函数都是inline的,这就允许你的编译器实现空类优化,满足了效率要求。由于计数操作由Counter的成员函数自动完成,而这些函数则由C++自动调用,并且使用了虚拟继承来防止隐式转化(隐式转换允许像操作基类对象一样操作派生类对象),因此也满足了安全性的要求(OK,并不是绝对的安全:Widget的作者可能会愚蠢的使用另一个类型实例化Counter,比如Widget可能会派生自Counter<Gidget>。我决定忽略这种情况)。
这个设计当然也很容易被客户代码使用,不过有些人也许会嘀嘀咕咕,说还可以再容易一些。使用虚拟继承意味着再派生类中howMany将称为私有成员,所以派生类必须必须包含一个using声明来让howMany称为公共成员:

class Widget: private Counter<Widget> {
public:
    // 使howMany成为公共成员
    using Counter<Widget>::howMany;

    ..... // Widget的其它部分没有改变
};

class ABCD: private Counter<ABCD> {
public:
    // 使howMany成为公共成员
    using Counter<ABCD>::howMany;

    ..... // ABCD的其它部分没有改变
};

对于那些不支持名字空间(namespace)的编译器,可以使用老式的访问声明(已废弃)代替using声明:

class Widget: private Counter<Widget> {
public:
    // 使howMany成为公共成员
    Counter<Widget>::howMany;

    .....  // Widget的其它部分没有改变
};

现在,那些想要实现对象计数功能,并且想把计数值开放(作为类的接控)给它们的客户的客户代码必须要做两件事:将Counter作为基类,使howMany可访问[5]。
不过使用继承要注意两种情况。第一个是二义性。假设我们像对Widget对象们计数,并且我们想把计数值开放。根据前面的讨论,我们从Counter<Widget>中派生Widget,然后在Widget中将howMany声明为公共成员。现在假设我们有一个公共继承自Widget类的SpecialWidget类,我们也想为SpecialWidet的客户提供Widget所提供的功能。没问题,我们只需从Counter<SpecialWidget>中派生出SpecialWidget即可。
但是这样就导致了二义性问题。SpecialWidget应该开放哪一个howMany?是继承自Widget的那一个,还是继承自Counter<Widget>的那一个?自然,我们希望的是继承自Counter<SpecialWidget>的那一个howMany,但是除了写出SpecialWidget::howMany以外,没有其它任何方法可以做到这一点。幸运的是,SpecialWidget::howMany只是个简单的inline函数:

class SpecialWidget: public Widget,
    private Counter<SpecialWidget> {
public:
    .....
    static size_t howMany()
    { return Counter<SpecialWidget>::howMany(); }
    .....
};

第二个要注意的是,如果我们使用继承的方法实现对象计数,那么我们由Widget::howMany得到的计数值不仅包括了Widget对象的数目,也包括所有派生自Widget类的子类对象的数目。如果SpecialWidget是唯一一个派生自Widget的类,并且存在着五个独立的Widget对象和三个独立的SpecialWidget对象,那么Widget::howMany将返回8。毕竟,每个SpecialWidget对象的构造函数都要调用基类Widget的构造函数。

小结
以下几点是你需要记住的:
·对象自动计数并不难实现,但也不是轻而易举的事。使用“Do It For me”设计模式(Coplien的“curiously recurring template”模式)使正确的生成计数值成为可能。使用私有继承则使“提供对象计数功能的同时不增加对象的大小”成为可能。
·当客户代码可以选择“从一个空类继承”或“包含一个空类对象作为数据成员”时,优先选用继承,因为它允许对象的结构更紧凑。
·在调用堆对象的构造函数时,C++会尽量避免内存泄漏问题,因此需要访问operator new的程序通常也需要访问相应的operator delete。
·Counter类模板并不在意你是将其作为基类,还是将其对象作为数据成员。它对着两种情况一视同仁。因此,客户代码可以自由的选择是派生还是包含,甚至可以在同一个程序的不同部分使用不同的设计策略。

注释和参考
[1]James O. Coplien。“The Column Without a Name: A Curiously Recurring Template Pattern”,C++ Report,1995年2月。
[2]另一个选择是忽略Widget::howMany,并让客户代码直接调用Counter<Widget>::howMany。不过考虑到本文的主题,我们假设需要将howMany作为Widget接口的一部分。
[3]Scott Meyers。More Effective C++ (Addison-Wesley,1996),113-122页。
[4]Nathan Myers。“The Empty Member C++ Optimization”, Dr. Dobb's Journal,1997年8月。你也可以在http://www.cantrip.org/emptyopt.html找到它。
[5]简单的修改一下这个设计就可以使Widget使用Counter<Widget>来进行对象计数,并且使Widget的客户无法访问计数值,即使直接调用Counter<Widget>::howMany也不行。有时间的读者可以以此为练习:试着实现一个或者更多的修改方案。

进一步阅读
要获得更多关于new和delete的细节,请阅读Dan Saks的专栏(CUJ 1997年1月-7月),或者阅读我的More Effective C++(Addison-Wesley,1996年)一书的条款8。关于对象计数的更广泛性的讨论,包括如何限制一个类的实例的个数的内容,请参考More Effective C++一书的条款26。

抱歉!评论已关闭.