析构函数
析构函数有什么作用?
析构函数为对象执行最后的“仪式”。
析构函数用于释放对象分配的任何资源。例如,class
Lock
可能会锁定一个信号量,析构函数将释放该信号量。最常见的例子是构造函数使用 new
,而析构函数使用 delete
。
析构函数是一个“准备销毁”的成员函数。它们通常缩写为“dtor”。
局部对象的析构顺序是什么?
与构造顺序相反:首先构造,最后析构。
在以下示例中,b
的析构函数将首先执行,然后是 a
的析构函数。
void userCode()
{
Fred a;
Fred b;
// ...
}
数组中对象的析构顺序是什么?
与构造顺序相反:首先构造,最后析构。
在以下示例中,析构函数的顺序将是 a[9]
, a[8]
, ..., a[1]
, a[0]
。
void userCode()
{
Fred a[10];
// ...
}
对象的子对象的析构顺序是什么?
与构造顺序相反:首先构造,最后析构。
对象的析构函数体首先执行,接着是对象数据成员的析构函数(按照它们在类定义中出现的逆序),最后是对象基类的析构函数(按照它们在类定义中出现的逆序)。
在以下示例中,当 d
超出作用域时,析构函数调用的顺序将是 ~local1()
, ~local0()
, ~member1()
, ~member0()
, ~base1()
, ~base0()
。
struct base0 { ~base0(); };
struct base1 { ~base1(); };
struct member0 { ~member0(); };
struct member1 { ~member1(); };
struct local0 { ~local0(); };
struct local1 { ~local1(); };
struct derived: base0, base1
{
member0 m0_;
member1 m1_;
~derived()
{
local0 l0;
local1 l1;
}
}
void userCode()
{
derived d;
}
我可以重载类的析构函数吗?
不是。
对于一个类 Fred
,你只能有一个析构函数。它总是被称为 Fred::~Fred()
。它从不接受任何参数,也从不返回任何东西。
你无论如何也无法向析构函数传递参数,因为你从不显式调用析构函数(好吧,几乎从不)。
我应该显式调用局部变量的析构函数吗?
不!
析构函数将在创建局部变量的块的结束 }
处
如果我想让一个局部变量在其创建作用域的结束 }
之前“销毁”怎么办?如果我真的 想,我可以对局部变量调用析构函数吗?
不行![为上下文,请阅读前一个常见问题]。
假设销毁一个局部 File
对象的(期望的)副作用是关闭该 File
。现在假设你有一个 File
类的对象 f
,并且你想让 File f
在对象 f
的作用域结束(即 }
)之前关闭。
void someCode()
{
File f;
// ...code that should execute when f is still open...
← We want the side-effect of f's destructor here!
// ...code that should execute after f is closed...
}
这个问题有一个简单的解决方案。但在此期间,请记住:不要显式调用析构函数!
好吧,好吧,我已经知道了;我不会显式调用局部变量的析构函数;但是我如何处理上一个常见问题中的情况呢?
[为上下文,请阅读前一个常见问题]。
只需将局部变量的生命周期范围包裹在一个人工块 {...}
中。
void someCode()
{
{
File f;
// ...code that should execute when f is still open...
}
↑ // f's destructor will automagically be called here!
// ...code that should execute after f is closed...
}
如果我不能将局部变量包裹在一个人工块中怎么办?
大多数情况下,你可以通过将局部变量包裹在一个人工块 ({...}
) 中来限制其生命周期。但如果由于某种原因你无法做到这一点,可以添加一个具有与析构函数类似效果的成员函数。但是
例如,对于 class
File
,你可以添加一个 close()
方法。通常,析构函数只会简单地调用这个 close()
方法。请注意,close()
方法需要标记 File
对象,以便后续调用不会重新关闭已关闭的 File
。例如,它可能会将 fileHandle_
数据成员设置为某个无意义的值,如 -1,并且在开始时检查 fileHandle_
是否已等于 -1。
class File {
public:
void close();
~File();
// ...
private:
int fileHandle_; // fileHandle_ >= 0 if/only-if it's open
};
File::~File()
{
close();
}
void File::close()
{
if (fileHandle_ >= 0) {
// ...code that calls the OS to close the file...
fileHandle_ = -1;
}
}
请注意,其他 File
方法可能也需要检查 fileHandle_
是否为 -1(即检查 File
是否已关闭)。
另请注意,任何实际不打开文件的构造函数都应将 fileHandle_
设置为 -1。
但是如果我用 new
分配了对象,我可以显式调用析构函数吗?
可能不行。
除非你使用了定位 new
,否则你应该直接 delete
对象,而不是显式调用析构函数。例如,假设你通过典型的 new
表达式分配了对象。
Fred* p = new Fred();
那么当你通过 delete
销毁它时,析构函数 Fred::~Fred()
会自动被调用。
delete p; // Automagically calls p->~Fred()
你Fred
对象本身分配的内存。请记住:delete p
会做两件事:它调用析构函数并释放内存。
什么是“定位 new
”以及我为什么要使用它?
定位 new
有很多用途。最简单的用法是在内存中的特定位置放置一个对象。这是通过将位置作为指针参数提供给 new
表达式的 new
部分来完成的。
#include <new> // Must #include this to use "placement new"
#include "Fred.h" // Declaration of class Fred
void someCode()
{
char memory[sizeof(Fred)]; // Line #1
void* place = memory; // Line #2
Fred* f = new(place) Fred(); // Line #3 (see "DANGER" below)
// The pointers f and place will be equal
// ...
}
第 1 行创建了一个 sizeof(Fred)
字节的内存数组,它足以容纳一个 Fred
对象。第 2 行创建了一个指向该内存第一个字节的指针 place
(有经验的 C 程序员会注意到这一步是不必要的;它只是为了使代码更清晰)。第 3 行实质上只是调用了构造函数 Fred::Fred()
。Fred
构造函数中的 this
指针将等于 place
。因此,返回的指针 f
将等于 place
。
建议: 除非必要,否则不要使用这种“定位 new
”语法。只有当你真的关心对象被放置在内存中的特定位置时才使用它。例如,当你的硬件有一个内存映射的 I/O 定时器设备,并且你想在该内存位置放置一个 Clock
对象时。
危险: 你将new
” operator
的指针指向的内存区域足够大,并且为你要创建的对象类型正确对齐。编译器和运行时系统都不会尝试检查你是否正确执行了此操作。如果你的 Fred
类需要对齐到 4 字节边界,但你提供了一个未正确对齐的位置,你可能会遇到严重的灾难(如果你不知道“对齐”是什么意思,new
语法)。你已被警告。
你还全权负责销毁定位对象。这可以通过显式调用析构函数来完成。
void someCode()
{
char memory[sizeof(Fred)];
void* p = memory;
Fred* f = new(p) Fred();
// ...
f->~Fred(); // Explicitly call the destructor for the placed object
}
这是你唯一一次显式调用析构函数。
注意:有一种更清晰但更复杂的方法来处理销毁/删除的情况。
有没有定位 delete
?
没有,但如果需要,你可以自己编写。
考虑使用定位 new
将对象放置在一组内存池中。
class Arena {
public:
void* allocate(size_t);
void deallocate(void*);
// ...
};
void* operator new(size_t sz, Arena& a)
{
return a.allocate(sz);
}
Arena a1(some arguments);
Arena a2(some arguments);
鉴于此,我们可以编写:
X* p1 = new(a1) X;
Y* p2 = new(a1) Y;
Z* p3 = new(a2) Z;
// ...
但是我们以后如何正确删除这些对象呢?没有内置的“定位 delete
”来匹配定位 new
的原因是没有通用的方法来确保它会被正确使用。C++ 类型系统中没有任何东西能让我们推断出 p1
指向分配在 Arena a1
中的对象。任何分配在任何地方的 X
的指针都可以分配给 p1
。
然而,有时程序员确实知道,并且有一种方法。
template<class T> void destroy(T* p, Arena& a)
{
if (p) {
p->~T(); // explicit destructor call
a.deallocate(p);
}
}
现在,我们可以编写:
destroy(p1,a1);
destroy(p2,a2);
destroy(p3,a3);
如果一个 Arena
记录它持有的对象,你甚至可以编写 destroy()
来防止错误。
也可以为一个类层次结构定义匹配的 operator new()
和 operator delete()
对 TC++PL(SE) 15.6。另请参阅 D&E 10.4 和 TC++PL(SE) 19.4.5。
当我编写析构函数时,我需要显式调用我的成员对象的析构函数吗?
不需要。你永远不需要显式调用析构函数(除了使用定位 new
)。
一个类的析构函数(无论你是否显式定义)都会
class Member {
public:
~Member();
// ...
};
class Fred {
public:
~Fred();
// ...
private:
Member x_;
Member y_;
Member z_;
};
Fred::~Fred()
{
// Compiler automagically calls z_.~Member()
// Compiler automagically calls y_.~Member()
// Compiler automagically calls x_.~Member()
}
当我编写派生类的析构函数时,我需要显式调用我的基类的析构函数吗?
不需要。你永远不需要显式调用析构函数(除了使用定位 new
)。
派生类的析构函数(无论你是否显式定义)都会
class Member {
public:
~Member();
// ...
};
class Base {
public:
virtual ~Base(); // A virtual destructor
// ...
};
class Derived : public Base {
public:
~Derived();
// ...
private:
Member x_;
};
Derived::~Derived()
{
// Compiler automagically calls x_.~Member()
// Compiler automagically calls Base::~Base()
}
注意:带 virtual
继承的顺序依赖关系更复杂。如果你依赖于 virtual
继承层次结构中的顺序依赖关系,你需要比本 FAQ 中更多的信息。
当我的析构函数检测到问题时,它应该抛出异常吗?
小心!!!详情请参阅此常见问题。
有没有办法强制 new
从特定的内存区域分配内存?
是的。好消息是这些“内存池”在许多情况下都很有用。坏消息是,在讨论所有用途之前,我必须先带你了解它的工作原理。但是,如果你不了解内存池,那么认真阅读这个常见问题可能会很有价值——你可能会学到一些有用的东西!
首先,请记住,内存分配器只是返回未初始化的内存块;它不应该生成“对象”。特别是,内存分配器不应该设置虚指针或对象的任何其他部分,因为那是构造函数的工作,它在内存分配器之后运行。从一个简单的内存分配函数 allocate()
开始,你将使用定位 new
来在该内存中构造一个对象。换句话说,以下内容在道德上等同于 new Foo()
。
void* raw = allocate(sizeof(Foo)); // line 1
Foo* p = new(raw) Foo(); // line 2
假设你已经使用了定位 new
并成功通过了上面的两行代码,下一步是将你的内存分配器变成一个对象。这种对象被称为“内存池”或“内存竞技场”。这让你的用户可以拥有不止一个“池”或“竞技场”来分配内存。每个内存池对象都将使用特定的系统调用(例如,共享内存、持久内存、栈内存等;见下文)分配一大块内存,并根据需要将其分成小块。你的内存池类可能看起来像这样:
class Pool {
public:
void* alloc(size_t nbytes);
void dealloc(void* p);
private:
// ...data members used in your pool object...
};
void* Pool::alloc(size_t nbytes)
{
// ...your algorithm goes here...
}
void Pool::dealloc(void* p)
{
// ...your algorithm goes here...
}
现在你的一个用户可能有一个名为 pool
的 Pool
对象,他们可以通过它分配对象,像这样:
Pool pool;
// ...
void* raw = pool.alloc(sizeof(Foo));
Foo* p = new(raw) Foo();
或者简单地:
Foo* p = new(pool.alloc(sizeof(Foo))) Foo();
将 Pool
转换为一个类的原因是,它允许用户创建 N 个不同的内存池,而不是所有用户共享一个巨大的池。这使得用户可以做很多有趣的事情。例如,如果他们系统中有某个部分疯狂地分配内存然后消失,他们可以从一个 Pool
中分配所有内存,然后甚至无需对小块内存进行任何 delete
操作:只需一次性释放整个池。或者他们可以设置一个“共享内存”区域(操作系统专门提供在多个进程之间共享的内存),并让池分配共享内存块而不是进程本地内存。另一个角度:许多系统支持一个非标准函数,通常称为 alloca()
,它从栈而不是堆分配内存块。自然,当函数返回时,这个内存块会自动消失,从而消除了对显式 delete
的需求。有人可以使用 alloca()
为 Pool
提供一大块内存,然后从该 Pool
分配的所有小块内存都像局部变量一样:当函数返回时它们会自动消失。当然,在某些情况下析构函数不会被调用,如果析构函数做了非平凡的事情,你将无法使用这些技术,但在析构函数仅仅是释放内存的情况下,这些技术可能很有用。
假设你已经成功地将你的分配函数封装成 Pool
类的一个方法(这需要 6 到 8 行代码),下一步是改变分配对象的语法。目标是将相当笨拙的语法 new(pool.alloc(sizeof(Foo))) Foo()
更改为更简单的语法 new(pool) Foo()
。为了实现这一点,你需要在你的 Pool
类定义下方添加以下两行代码:
inline void* operator new(size_t nbytes, Pool& pool)
{
return pool.alloc(nbytes);
}
现在当编译器看到 new(pool) Foo()
时,它会调用上面的 operator new
并将 sizeof(Foo)
和 pool
作为参数传递,最终只有你自己的 operator new
会使用那个奇特的 pool.alloc(nbytes)
方法。
现在来谈谈如何析构/释放 Foo
对象的问题。回想一下,定位 new
有时会使用的蛮力方法是显式调用析构函数,然后显式释放内存:
void sample(Pool& pool)
{
Foo* p = new(pool) Foo();
// ...
p->~Foo(); // explicitly call dtor
pool.dealloc(p); // explicitly release the memory
}
这有几个问题,所有问题都可以修复:
- 如果
Foo::Foo()
抛出异常,内存会泄漏。 - 销毁/释放语法与大多数程序员习惯的不同,因此他们可能会搞砸。
- 用户必须以某种方式记住哪个池与哪个对象关联。由于分配代码通常与释放代码在不同的函数中,程序员将不得不传递两个指针(一个
Foo*
和一个Pool*
),这很快就会变得难看(例如,如果他们有一个Foo
数组,其中每个Foo
可能来自不同的Pool
;哎呀)。
我们将按照上述顺序修复它们。
问题 #1:堵塞内存泄漏。 当你使用“正常”的 new
操作符,例如 Foo* p = new Foo()
,编译器会生成一些特殊代码来处理构造函数抛出异常的情况。编译器实际生成的代码在功能上与此类似:
// This is functionally what happens with Foo* p = new Foo()
Foo* p;
// don't catch exceptions thrown by the allocator itself
void* raw = operator new(sizeof(Foo));
// catch any exceptions thrown by the ctor
try {
p = new(raw) Foo(); // call the ctor with raw as this
}
catch (...) {
// oops, ctor threw an exception
operator delete(raw);
throw; // rethrow the ctor's exception
}
关键是,如果构造函数抛出异常,编译器会释放内存。但是在使用“带参数的 new
”语法(通常称为“定位 new
”)的情况下,如果发生异常,编译器不知道该怎么做,因此默认情况下它什么也不做。
// This is functionally what happens with Foo* p = new(pool) Foo():
void* raw = operator new(sizeof(Foo), pool);
// the above function simply returns "pool.alloc(sizeof(Foo))"
Foo* p = new(raw) Foo();
// if the above line "throws", pool.dealloc(raw) is NOT called
所以目标是强制编译器执行类似于它对全局 new
运算符所做的事情。幸运的是这很简单:当编译器看到 new(pool) Foo()
时,它会查找相应的 operator delete
。如果找到了,它会执行类似于将构造函数调用包装在 try
块中的操作,如上所示。所以我们只需提供一个具有以下签名的 operator delete
(注意要确保正确;如果第二个参数的类型与 operator new(size_t, Pool&)
的第二个参数不同,编译器不会抱怨;它只会绕过 try
块,当你的用户说 new(pool) Foo()
时):
void operator delete(void* p, Pool& pool)
{
pool.dealloc(p);
}
在此之后,编译器将自动用 try
块包装你的 new
表达式的构造函数调用。
// This is functionally what happens with Foo* p = new(pool) Foo()
Foo* p;
// don't catch exceptions thrown by the allocator itself
void* raw = operator new(sizeof(Foo), pool);
// the above simply returns "pool.alloc(sizeof(Foo))"
// catch any exceptions thrown by the ctor
try {
p = new(raw) Foo(); // call the ctor with raw as this
}
catch (...) {
// oops, ctor threw an exception
operator delete(raw, pool); // that's the magical line!!
throw; // rethrow the ctor's exception
}
换句话说,单行函数 operator delete(void* p, Pool& pool)
会让编译器自动堵塞内存泄漏。当然,这个函数可以是,但不一定是 inline
。
问题 #2(“丑陋因此容易出错”)和 #3(“用户必须手动将池指针与分配它们的对象关联,这容易出错”)通过在一个地方增加 10-20 行代码同时解决。换句话说,我们在Pool
头文件)增加 10-20 行代码,并简化了任意数量的其他地方(每个Pool
类的代码)。
其思想是隐式地将一个 Pool*
与Pool*
将是 NULL
,但在概念上至少可以说Pool*
。然后你替换全局 operator delete
,使其查找关联的 Pool*
,如果非 NULL
,则调用该 Pool
的解分配函数。例如,如果 (!) 正常的解分配器使用 free()
,那么全局 operator delete
的替换将类似于这样:
void operator delete(void* p)
{
if (p != NULL) {
Pool* pool = /* somehow get the associated 'Pool*' */;
if (pool == NULL)
free(p);
else
pool->dealloc(p);
}
}
如果你不确定正常的释放器是否是 free()
,最简单的方法是也用使用 malloc()
的东西替换全局 operator new
。全局 operator new
的替换看起来像这样(注意:此定义忽略了一些细节,例如 new_handler
循环和内存不足时发生的 throw std::bad_alloc()
):
void* operator new(size_t nbytes)
{
if (nbytes == 0)
nbytes = 1; // so all alloc's get a distinct address
void* raw = malloc(nbytes);
// ...somehow associate the NULL 'Pool*' with 'raw'...
return raw;
}
唯一剩下的问题是如何将 Pool*
与分配关联起来。一种方法,至少在一种商业产品中使用过,是使用 std::map<void*,Pool*>
。换句话说,构建一个查找表,其键是分配指针,值是关联的 Pool*
。出于我稍后将描述的原因,你operator new(size_t,Pool&)
中将键/值对插入到映射中。特别是,你不能从全局 operator new
插入键/值对(例如,你不能在全局 operator new
中说 poolMap[p] = NULL
)。原因:这样做会产生一个讨厌的先有鸡还是先有蛋的问题——因为 std::map
可能使用全局 operator new
,它最终会在每次插入新条目时插入一个新条目,导致无限递归——砰,你完蛋了。
尽管这种技术每次释放都需要进行 std::map
查找,但其性能似乎可以接受,至少在许多情况下是这样。
另一种更快但可能占用更多内存且稍微复杂的方法是在所有分配之前预先添加一个 Pool*
。例如,如果 nbytes
是 24,这意味着调用者要求分配 24 字节,我们将分配 28(或者如果你认为机器需要 8 字节对齐,例如 double
和/或 long long
,则为 32),将 Pool*
填充到前 4 个字节中,并返回一个指向你分配的内存起始处向右偏移 4(或 8)个字节的指针。然后你的全局 operator delete
会向左偏移 4(或 8)个字节,找到 Pool*
,如果为 NULL
,则使用 free()
,否则调用 pool->dealloc()
。传递给 free()
和 pool->dealloc()
的参数将是指向原始参数 p
左侧 4(或 8)个字节的指针。如果你 (!) 决定 4 字节对齐,你的代码将看起来像这样(尽管如前所述,以下 operator new
代码省略了通常的内存不足处理程序):
void* operator new(size_t nbytes)
{
if (nbytes == 0)
nbytes = 1; // so all alloc's get a distinct address
void* ans = malloc(nbytes + 4); // overallocate by 4 bytes
*(Pool**)ans = NULL; // use NULL in the global new
return (char*)ans + 4; // don't let users see the Pool*
}
void* operator new(size_t nbytes, Pool& pool)
{
if (nbytes == 0)
nbytes = 1; // so all alloc's get a distinct address
void* ans = pool.alloc(nbytes + 4); // overallocate by 4 bytes
*(Pool**)ans = &pool; // put the Pool* here
return (char*)ans + 4; // don't let users see the Pool*
}
void operator delete(void* p)
{
if (p != NULL) {
p = (char*)p - 4; // back off to the Pool*
Pool* pool = *(Pool**)p;
if (pool == NULL)
free(p); // note: 4 bytes left of the original p
else
pool->dealloc(p); // note: 4 bytes left of the original p
}
}
当然,本常见问题的最后几段只在允许你更改全局 operator new
和 operator delete
时才可行。如果不允许你更改这些全局函数,本常见问题的前四分之三仍然适用。