本文是对 Counting Objects in C++ 的翻译 |
By Scott Meyers, April 01, 1998
Scott Meyers 展示了正确的对象计数
尽管简单的事情一般很简单,但是它们也会有一些微妙之处。比如,假设你有一个 Widget 类,然后你想在运行时查看有多少个 Widget 对象存在。一种既容易实现又正确的方式是在 Widget 中创建一个 static 计数器,每当调用 Widget 的构造函数的时候就加一,析构函数则减一。你还需要一个静态成员函数 howMany 用来显示当前有多少个存在的对象。如果 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;
上述代码就是正确的实现。唯一需要注意的是需要手动实现拷贝构造函数,因为编译器生成的拷贝构造函数不会将 count 加一
如果你只是要给 Widget 添加这个任务,那么工作已经结束了,但是有时候你可能需要给一些类添加对象计数功能。重复地做这项工作无疑是乏味、枯燥的,还可能会造成错误。要避免这些东西,最好的方式就是以某种方式封装对象计数代码以便其它需要这些功能的类能够重用这些代码。封装代码应该是:
易用的:使用类的用户只需要很少的工作。理想情况下,这不应当比说一句“我想为添加对象计数功能”做更多的工作
有效的:当类依赖于此代码时,不应当带来任何不必要的空间或时间上的成本
鲁棒的:不会产生错误的计数(但是我们不会对对于恶意用户做出保证,在 C++ 中用户总是找到办法搞乱你的计数)
停下来思考一下你如何实现一个满足上述要求的可重用的对象计数代码。这可能很复杂,如果它和你想的一样简单,你就不会在这个杂志上阅读这篇文章了
new, delete, 和 Exceptions
当你思虑如何解决对象计数问题的时候,请允许我先转换到一个看似不相干的主题上:当构造函数中抛出异常时 new 和 delete 之间的关系。当你使用 new 表达式在 C++ 中动态构造一个对象时,比如:
class ABCD { ... }; // ABCD 是一个大而复杂的数据类型
ABCD *p = new ABCD; // new 表达式
new 表达式是语言内建的操作符,而且它的行为你没法改变 —— 做两件事情:首先,它调用一个名为 operator new 的内存分配函数,此函数用来为构造 ABCD 对象分配足够的内存。如果 operator new 调用成功, new 表达式会在 operator new 上分配的内存上调用构造函数
但是设想一下 new 抛出了一个 std::bad_alloc 异常。此异常表明尝试动态分配内存失败了。在上述的 new 表达式中,有两个函数可能会抛出此异常。第一个是调用用来分配内存的 operator new。第二是后续使用此内存区域来构造一个有效 ABCD 对象的构造函数。
如果异常来自 operator new ,就没有任何内存被分配,如果 operator new 成功了,但是调用 ABCD 的构造函数导致了此异常,由 operator new 分配的内存会被回收,否则会有内存泄漏。对于客户端来说,确定到底是那个函数抛出了此异常是不可能的。
很多年来这一直是 C 的一个漏洞,但是 1995 年三月的 C 标准委员会批准了此规则:在 new 表达式中,如果调用 operator new 成功,但是构造函数却失败了,运行时系统必须负责回收 operator new 分配的内存。此回收行为由operator delete 负责,它的动作正好与 operator new 相反。(细节请参阅尾部的 “A Note About Placement new and Placement delete”)
这即使影响我们对象计数功能而 new 表达式和 operator delete 之间的关系
对象计数
很有可能的是,你解决对象计数的方式涉及到了一个对象计数类。你的类很有可能看起来和我先前展示的差不多:
// 请思考为什么这是错误的
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 对象
class Widget {
public:
..... // 其他 Widget 普通成员函数
static size_t howMany()
{ return Counter::howMany(); }
private:
..... // 其他 Widget 私有成员变量
Counter c;
};
另一种方式是将 Counter 作为基类:
// 继承 Counter
class Widget: public Counter {
..... // 其他 Widget 普通成员函数
private:
..... // 其他 Widget 私有成员变量
};
两种方式都有其优点和缺点。但我让我们先对其进行检查,值得注意的是任何一种方式都可以以它当前的形式工作。问题来自 Counter 中的静态成员 count 。我们只有一个 count ,但是所有的 Counter 都要使用它。比如,如果我们同时对 Widget 和 ACBD 的对象进行计数,我们需要两个静态的 size_t 对象,而不是一个。另 Counter::count 非静态并不能解决这个问题,因为我们每一个类只需要一个 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++ ) 中使用了这个技巧,并称其为 奇异递归模板 (Curiously Recurring Template)
模式[1] 。我不知道 Jim 是不是有意的,但是这个名字并不适合此模式。这很糟糕,因为名字是很重要的,而这个名字并没有为此模式的使用方式和效果提供充足信息。
模式的命名与其他任何事情一样是一门艺术,我对此并不擅长,但是我更喜欢叫这个模式为“ Do It For Me ”。本质上,每一个由 Counter 生成的类提供了一个服务(用来计算由多少对象存在)。因此 Counter<Widget> 可以用来为 Widget 计数, Counter<ABCD> 可以用来为 ABCD 计数
注意 Counter 是一个模板,不论是使用组合还是继承的方式都能工作,因此我们可以来评估两者的优点和缺点。我们设计的目标之一是让客户更方便地使用对象计数功能,而上面很明显继承比组合更简单。这是因为前者只需要将 Counter 作为基类,后者需要一个 Counter 数据成员、一个 hoMany 函数。[2] 这并不是很多的工作(客户的 howMany 只需要使用内联函数即可),但是仍然比做一件事更麻烦。因此,让我们首先将注意力集中到继承方式上
使用公有继承
基于继承的代码可以工作,因为 C++ 保证每当一个派生类对象被构造或析构,它的基类一定先于其构造,后于其析构。使用 Counter 作为基类因而能保证每当派生类对象时其构造函数和析构函数能够被正确调用
每当类被提作基类时,虚有析构函数总是会被提及。 Counter 应当有一个虚有析构函数吗?众所周知的 C++ 面向对象设计确认了这一点。如果没有虚有析构函数,通过基类指针删除一个派生类对象就会产生一个未定义(一般也是不良的)结果:
class Widget: public Counter<Widget>
{ ... };
Counter<Widget> *pw =
new Widget; // 获得指向派生类对象的基类指针
----..
delete pw; // 如果基类没有虚有析构函数,产生未定义行为
这样的行为违反了我们所定义原则。对于鲁棒性它十分满足,因为上述代码没有什么不合理之处。这是给 Counter 一个虚有析构函数充分的理由
然而,对于另一个最小需求(不带来任何不必要的速度或空间上的成本)原则就不行了。因为 Counter 中存在虚有析构函数(或任何虚函数)都会导致 Counter 对象(或派生自 Counter 的对象)中包含一个隐含的虚指针,这增加了以前没有虚函数的类的大小[3] 。也就是说,如果 Widget 本身不包含虚函数,那么当它继承 Counter<Widget> 后会尺寸会增长。这是我们不希望看到的。
唯一的方式是阻止客户使用基类指针删除派生类对象。看起来将 operator delete 声明为私有可以解决这个问题:
template<typename T>
class Counter {
public:
.....
private:
void operator delete(void*);
.....
};
现在,这样的 delete 表达式将无法通过编译:
class Widget: public Counter<Widget> { ... };
Counter<Widget> *pw = new Widget; ......
delete pw; // 错误,无法调用私有 operator delete
不幸的是 —— 而且有趣的部分是 —— new 表达式也无法通过编译
Counter<Widget> *pw =
new Widget; // 这无法通过编译,
// 因为 delete 是私有的
就像我们上面在 new, delete, 和 Exceptions 中讨论的那样:C++ 运行时需要负责回收 operator new 成功而构造函数失败的内存。在此过程中 operator delete 负责实行回收动作。但是我们将 Counter 的 operator delete 声明为私有,这导致我们无法使用 new 在堆上创建对象
是的,这有些反直觉,但是如果你的编译器不支持这个规则请不要惊讶,因为我描述的是正确的。此外,没有任何方式来组织使用 Counter* 来删除派生类对象。因此我们放弃了这个方法,并转向将 Counter 作为数据成员
使用组合
我们已经看到使用数据成员设计方式的不足之处:客户必须在定义成员函数的时候写一个内敛版本的 hoMany 函数来调用 Counter 的 howMany 函数。这略微地增加了我们希望客户所做的工作。使用组合还将会增加对象的大小
乍一看,这不是什么新发现。毕竟,向类中添加数据成员导致类变大不是什么稀奇古怪的事。但是请看一 Counter 的定义:
template<typename T>
class Counter {
public:
Counter();
Counter(const Counter&);
~Counter();
static size_t howMany();
private:
static size_t count;
};
请注意它没有任何非静态成员变量。这意味着每个 Counter 的对象都是空的。或许我们期待它的尺寸是零?不,C++ 十分清晰地之处,任何对象都至少有一个字节大小,即使它并没有非静态成员变量。根据定义,sizeof 将会为每个实例化自 Counter 模板的类产生一些正数。因此每个包含 Counter 对象的客户类都将比不包含 Counter 对象的类产生更多的数据
(有趣的是,包含 Counter 的类的尺寸不一定就比不包含的类的尺寸大。这是因为内存对齐的问题。比如,如果一个 Widget 包含两个字节的数据,但是却要求四字节对齐,那么每个 Widget 都会包含两个字节的填充字节,因此 sizeof(Widget) 将会返回 4。一般来说,为了满足非零尺寸对象的要求,编译器将会在 Counter<Widget> 中插入一个 char ,那么包含 Counter<Widget> 的对象的 sizeof(Widget) 可能返回值依然是 4。对象只是简单地拿出一个填充字节用来填充这个区域。然而,这并不是一个非常常见的场景,在封装对象计数功能的时候,我们当然不能以此为准)
我在即将圣诞节时撰写这个文章。(事实上是感恩节,这让你知道我是如何庆祝主要节日的。。。)我现在已经感到很糟糕了。我想要的只是对对象计数,我不想再拖延这个任务了,一定有办法的!
使用私有继承
再一次看看基于继承的代码:这导致了 Counter 对虚有析构函数的需求
class Widget: public Counter<Widget>
{ ... };
Counter<Widget> *pw = new Widget;
----..
delete
pw; // 如果基类没有虚有析构函数,产生未定义行为
起先我们尝试阻止在编译期使用 delete 表达式,但是我们发现这也导致 new 表达式不可用。但是我们可以阻止另外一些事情。我们可以阻止从 Widget\* 到 Counter<Widget>\* 的隐式转换。总之,我们可以阻止基于继承的指针转换,我们需要做的仅仅是使用私有继承替换共有继承:
class Widget: private Counter<Widget>
{ ... };
Counter<Widget> *pw =
new Widget; // 错误!没有从
// Widget* 到
// Counter<Widget>*
// 的隐式转换
此外,我们会发现使用 Counter 作为基类与没有 Counter 作为基类的类相比大小并没有发生变化。好吧,我知道我刚刚说过没有类的大小是零,但是 —— 好吧,实际上我说的并不准确。我想说的是没有任何对象的大小是零。C++ 标准清晰地阐述了允许派生类对象的基类大小为零。事实上很多编译器都会实现所谓的空基类优化[4]
因此,如果使用组合的方式,Widget 的大小必然发生变化,Counter 数据成员是一个独立的对象,因此它的大小必不为零。这为大小敏感而又有很多空类时提供了一个经验法则:两者均可时,私有继承要优于组合。
这个法则接近完美。它满足了有效准则,并让你的编译器有机会执行空基类优化,因为从 Counter 继承并不会增加派生类的大小,而且所有的 Counter 成员函数的内联的。同样也满足了鲁棒性准则,因为 count 由 Counter 成员函数自动维护,这些函数由 C++ 自动调用,而且私有继承还阻止了隐式类型转换以避免使用基类指针操纵派生类对象。(好吧,它并不是完全的具备鲁棒性:Widget 的作者也许会愚蠢地使用不是 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 的编译器,可以使用旧式(没有废弃)的语法替代:
class Widget: private Counter<Widget> {
public:
// 令 howMany 共有
Counter<Widget>::howMany;
..... // Widget 其余部分不变
};
因此,需要对象计数功能的客户和令计数可以为它们客户所见(作为它们接口的一部分)需要做两件事:将 Counter 作为基类和令 howMany 公有。[5]
使用继承可以工作,但是会导致两个可注意的情况。首先是歧义性。假设我们想对 Widget 计数,而且我们想让count 可以被外界访问。就像上面的,我们继承 Counter<Widget> 并令 howMany 公有。现在假设我们有一个继承自 Widget 的类 SpecialWidget 。而且我们想让 SpecialWidget 具有与 Widget 相似的功能。没问题,我们只需要 SpecialWidget 继承 Counter<SpecialWidget>
但这里有一个问题,哪一个 howMany 才是 SpecialWidget 应当使用的。继承自 Counter<SpecialWidget> 的那个还是继承自 Widget 的那个?我们需要的是来自 Counter<SpecialWidget> 的那个,但是这里没有办法让 SpecialWidget::howMany 消失。幸运的是我们可以用一个简单的内联函数:
class SpecialWidget: public Widget,
private Counter<SpecialWidget> {
public:
.....
static size_t howMany()
{ return Counter<SpecialWidget>::howMany(); }
.....
};
另一个问题是我们 Widget::howMany 返回的 count 中不仅包含 Widget 对象,还包括了所有派生自 Widget 的对象。如果 唯一派生自 Widget 的类 SpecialWidget 有三个对象,Widget 有五个对象, Widget::howMany 的返回值就变成了八。毕竟,构造一个 SpecialWidget 对象也包含了他的 Widget 基类部分
总结
以下部分应当记住:
自动化的对象技术并不困难,但是依然有很多微妙之处。使用“Do It For Me”(Coplien 的奇异递归模板)另生成正确数量的 counter 成为可能。使用私有继承则为在不增加对象尺寸的情况下提供对象计数功能成为可能
当客户需要在空类继承或者空类组合做出选择时,继承更好,因为这允许尺寸更小的对象对象
由于 C++ 尽力避免堆对象带来的内存泄漏,因此需要 operator new 的对象也需要 operator delete
Counter 模板类并不关心你是继承还是组合。客户可以自由选择,甚至可能在一个应用或者库中使用不同的方式
注释和引用
建议阅读
要了解更多关于 new 和 delete 的细节,请阅读 Dan Saks 关于这个主题的专栏(CUJ January - July 1997),或我的 More Effective C++ (Addison-Wesley, 1996)中的条款八。关于对象计数问题的更广泛的研究,包括如何限制类实例化的数量,请参阅 More Effective C++ 条款二十六
鸣谢
MarkRodgers, Damien Watkins, Marco Dalla Gasperina, 和 Bobby Schmidt 为这篇文章提供了有用的建议。他们的洞察力和建议改善了这篇文章的一些方面
一点关于 Placement new 和 Placement delete 的笔记
C++ 中 operator new 与 malloc 位于同等地位,operator delete 则对标 free 。与 malloc 和 free 不同的是,operator new 和 operator delete 是可重载函数,因此它们可以使用几种不同的参数类型。这在 operator new 中经常出现,但直到现在,对于重载 operator delete 都不是有效的
operator new “正常”的函数签名是:
void * operator new(size_t) throw (std::bad_alloc);
(为了简化,我会排除 异常规范 (Exception Specifications)
。这些与我想说的不相干)。operator new 的重载形式是受到限制的,因此一个 operator new 的重载形式看起来像:
void * operator new(size_t, void *whereToPutObject)
{ return whereToPutObject; }
这个特殊版本的 operator new —— 额外的这个 void\* 参数指明了此函数应当返回哪个指针 —— 它是如此的有用以至至于在 C++ 标准库中(在头文件 <new> 中)有一个单独的名字:“ 原地 new (Placement new)
”。这个名字指明了它的意图:允许程序员指定的内存位置上创建对象
随着时间的推移,术语 placement new 可以用来代替任何版本的重载形式(这个术语实际上被列入了即将发布的 C++ 标准语法中)。因此,当 C++ 程序员讨论 the placement new 的时候,指的是上面那个:使用额外一个 void\* 参数以指定对象构造位置。当他们谈及 a placemment new 时,他们意指参数不止 size_t 参数的任何版本的 operator new 。不仅仅包括了上述函数,还包含了更多版本的 placement new
总之,当主题是内存分配函数时,placement new 意味着“重载形式中的一个 operator new 版本”。这个词在其他语境中还可以表示其他东西,但我们这里不需要这么做。详情请参阅文章末尾的建议阅读材料
类比于 placement new,术语 placement delete 意味着“重载函数中的一种”。 operator delete 的“正常”函数签名应该是:
void operator delete(void*);
因此,operator delete 重载中的任何需要不止一个 void* 参数的 operator delete 是 placement delete
现在让我们重讨论主文中的问题。在构造堆对象期间抛出异常会发生什么?再次考虑这个简单的例子:
class ABCD { ... };
ABCD *p = new ABCD;
假设创建这个 ABCD 抛出了一个异常。主文指出如果异常来自 ABCD 构造函数,operator delete 将会被自动调用来释放 operator new 分配的内存。但是如果 operator new 被重载了,而且内存是由不同版本的 operator new 分配的(和合理)会怎样?operator delete 这样知道如何正确释放内存?另外,如果 ABCD 对象是由 placement new 构造的呢:
void *objectBuffer = getPointerToStaticBuffer();
ABCD *p = new (objectBuffer) ABCD; // 在 static buffer 创建 ABCD 对象
(The)placement new 实际上并没有分配任何内存。它只是返回了传递给它的指向 static buffer 的指针。因此这里无需释放内存
显然,operator delete 的调用根据 operator new 的不同而不同
为了让程序员能够指出特定版本的 operator new 操作符的操作如何撤消,C++ 标准委员会扩展了 C++,允许重载operator delete 操作符。当从堆对象的构造函数抛出异常时,操作将以一种特殊的方式进行。被调用的 operator delete 版本的额外参数类型对应于被调用的 operator new 版本的额外参数类型
如果没有与 operator new 相比配的 operator delete 。就不会调用 operator delete 。因此,operator new 带来的影响无法撤销。对于 (the)placement 版本的 operator new 这很好,因为它实际上并没有分配内存。总之,如果你创建了一个自定义版本的 operator new ,你也需要创建一个与之相匹配的 operator delete
另外,很多编译器还不支持 placement delete。这样的编译器自动生成的代码必定会在堆对象构造函数抛出异常时导致内存泄漏,因为没有相应的操作能够释放构造函数中分配的内存
Scott Meyers 撰写了畅销书 Effective C,第二版和 More Effective C(均由 Addison Wesley 出版)。想了解更多关于他、他的书、他的服务和他的狗,请登录 http://www.aristeia.com