[C++] 在 C++ 中计算对象个数

作者 Shilei Tian 日期 2017-05-24
C++
[C++] 在 C++ 中计算对象个数

本文为阅读 Scott Meyers 在 1998 年 4 月发表在《C++ User’s Journal》上的文章《Objects Counting in C++》读书笔记,旨在更好地理解 C++ 相关的概念。

问题

我们现在想要设计一个 Widget 类,希望它有一个功能,是能够找到程序执行期间究竟存在多少个该对象的实例。

简单且正确的办法

如果你对对象中的 static 变量概念熟悉的话,应该能够立即想到一种方法:

class Widget {
public:
Widget() { ++count; }
Widget(const Count&) { ++count; }
~Widget() { --count; }
static size_t getCount() { return count; }
private:
static size_t count;
};

这种办法相当正确(这里我们不考虑线程安全,下同),且实现简单。需要注意的是,除了默认的构造函数外,还应该实现拷贝构造函数,编译器自动合成的版本可不会自动进行 ++count

如果我们只想实现为 Widget 技术,那么我们已经完成了任务。但是有时候你可能需要为多个 classes 做相同的工作,那么我们就需要考虑,除了重复的对每个 class 进行类似的设计,有没有什么其他的方法能够一次性解决问题?

通过一个单独的计数类

很快,就有人想到,可以设置一个专门的计数类 Count

class Count {
public:
Count() { ++count; }
Count(const Count&) { ++count; }
~Count() { --count; }
static size_t getCount() { return count; }
private:
static size_t count;
};
size_t Count::count = 0;

然后,在设计我们自己的类的时候,有两种做法:继承类 Count 或者为自己的类添加一个 Count 类型的成员变量。我们依次来看这两种做法。

继承

假如我们有一个类 Widget,我们想为它计数,因此我们让它继承类 Count

class Widget : public Count {};

这样做看起来确实可以完成我们的任务。所以,你兴高采烈地又定义了一个类 Window,同样也让它继承 Count 以期能够同 Widget 一样完成计数。因此,你很欢快地写下类似如下的代码:

Widget w1, w2;
cout << Widget::getCount() << endl;
Window l1, l2;
cout << Window::getCount() << endl;

但是,当你运行这个程序的时候,你惊奇地发现,第一个 cout 的结果是 2,但是第二个 cout 的运行结果并不是你想要的 2,而是 4!这是为什么呢?问题就在于 Count 中的 static 变量。这样的静态成员变量只有一个,但是我们却打算为每个使用 Count 的类各准备一个!这种做法看起来失败了。

成员变量

我们的 Widget 类还可以这样设计:

class Widget {
public:
static size_t getCount() { return Count::getCount(); }
private:
Count c;
}

那这种方法有没有上面方法的问题呢?答案是:同样的!除此之外,这种方法还有可能会导致每个 Widget 类都要有一个 Count 成员变量而造成空间上的浪费。但为什么是有可能而不是一定呢?这个问题我们待会回答。现在来看一下正确的方法应该是什么。

正确的通用的做法

在 C++ 中有一个最广为人知但十分诡异的伎俩,我们就可以取得我们想要的行为:我们可以把 Count 放进一个 template 中,然后让每一个想要使用 Count 的类,以自己为 template 参数实现出这个 template,如下所示:

template<typename T>
class Count {
public:
Count() { ++count; }
Count(const Count&) { ++count; }
~Count() { --count; }
static size_t getCount() { return count; }
private:
static size_t count;
};
template<typename T> size_t Count<T>::count = 0;

于是前面的第一种方法(继承)变成这样:

class Widget : public Count<Widget> {};

第二种方法(成员变量)变成这样:

class Widget {
public:
static size_t getCount() { return Count::getCount(); }
private:
Count<Widget> count;
};

现在我们来思考一下这种方式为什么会奏效。我们想要为每个使用 Count 的类准备一个,这个“每个”实际上就相当于不同的类型,template 正好应对这种需求,它可以为每个不同的 T 类型都实现出一个对应的类。由于我们这里限定了成员变量 count 是一个 static 类型,那么刚好对于每一个不同的 T,只会实现出一个 count 变量来,铛铛,完美解决问题。

使用 public 继承

上面我们提到的继承方法之所以能够运作,是因为 C++ 保证,每当一个 drived class 对象被构造(析构)时,其中的基类成份会先被构造(析构)。然而,任何时候只要涉及基类这个主题,就不要忘记 virtual 析构函数。我们的 Count 类要有这个吗?从面向对象的设计规范上来看,显然,是要有这个的。否则当我们对一个 Count 类的指针执行 delete 操作,就会导致未定义的行为。

因为 virtual 函数的出现,最佳效率(也就是不因为 Count 类而增加任何非必要的速度开销和空间开销)就会是一个需要考虑的问题。使用 virtual 析构函数,这就意味着每一个 Count(或其衍生类)的对象都必须内含一个虚函数表。也就是说,即使 Widget 类本身不含有任何的虚函数,Widget 对象也会因为继承了 Count<Widget> 类使大小扩张。这个不是我们想看到的。

所以我们想要的,是屏蔽掉从一个 Count 类型指针 delete 一个 derived class 对象的方法。似乎将 Count 中的 operator delete 声明为 private 是一个合情合理的方法(注:C++ 11 的出现使得这种方式不是一个很好的方法,虽然本身这个方法的思想就是错误的,可以用 operator delete = delete 来代替):

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

如此一来,对 Count 类执行 delete 运算就不会编译成功。不过,这样做会导致 C++ 的运行时系统也无法释放 Count 类型的对象,也就是说,一旦 Count 类型的对象被构造出来后,它的那块儿内存再也无法被释放掉。这样会导致一定程度的内存泄漏。

让我们放弃这个设计吧,将注意力放在使用成员变量这种方法上面。

使用一个成员变量

我们之前已经看过这种方法的实现了,这种方式的缺点就是每定义一个类,就需要添加一个 Count 类型的数据成员并且编写一个 staticinline 成员函数 getCount()。工作量稍微多了一点,但是总不至于难以控制。除此之外的另一个缺点:为每个对象增加一个 Count 类型的数据成员,往往会增加对象的大小。

但是,我们再看一眼 Count 的定义:

template<typename T>
class Count {
public:
Count() { ++count; }
Count(const Count&) { ++count; }
~Count() { --count; }
static size_t getCount() { return count; }
private:
static size_t count;
};
template<typename T> size_t Count<T>::count = 0;

Count 类型除了有一个 static 类型的成员变量 count 外,没有任何其他的数据成员,那这就意味着 Count 的大小为 0?不!在这一点上,C++ 表现得相当清楚:所有的对象都至少有 1 字节的大小,哪怕这些对象没有任何的数据成员。所以,每一个内含 Count 类型数据成员的类,都要比不含的拥有更多的资料

有趣的是,这并不意味着,每一个内含 Count 类型数据成员的类,都要比不含的拥有更多的大小(体积)。这就是内存对齐造成的影响。举个例子,如果每个 Widget 内含两个字节的成员,但系统要求必须以 4 字节来进行对齐,所以每一个 Widget 对象将内含两个字节的空白,而 sizeof(Widget) 的结果为 4。所以,哪怕是 Widget 类型都内含一个 Count 类型的数据成员,该成员的大小为 1,那么 sizeof(Widget) 的结果还是 4,只是空白字节的大小变成 1 了。