1. 部分构造及placement delete
讨论在一般情况下的部分构造、动态生成对象时的部分构造,以及用 placement delete来解决部分构造问题。
C++标准要求标准运行库头文件<new>提供几个operator delete的重载形式。在这些重载形式中,Visual C++ 6缺少:
l void operator delete(void *, void *)
而Visual C++ 5缺少:
l void operator delete(void *, void *)
l void operator delete(void *, std::nothrow_t const &)
这些重载形式支持placement delete表达式,并解决了一个特殊问题:释放部分构造的对象。在这次和接下来一次,我将给出一般情况下的部分构造、动态生成对象时的部分构造,以及用 placement delete来解决部分构造问题的例子。
1.1 部分构造
看这个例子:
// Example 1
#include <iostream>
class A
{
public:
A()
{
throw 0;
}
};
int main()
{
try
{
A a;
}
catch(...)
{
std::cout <<"caught exception" << std::endl;
}
return 0;
}
因为A的构造函数抛出了一个异常,a对象没有完全构造。在这个例子中,没有构造函数有可见作用:因为A没有子对象,构造函数实际上没有任何操作。但,考虑这样的变化:
// Example 2
#include <iostream>
class B
{
public:
B()
{
throw 0;
}
};
class A
{
private:
B const b;
};
// ... main same as before ...
现在,A的构造函数不是无行为的,因为它构造了一个B成员对象,而它里面会抛异常。程序对这个异常作出什么反应?
从C++标准中摘下了四条(稍作了简化)原则:
l 一个对象被完全构造,当且仅当它的构造函数已经完全执行,而它的析构函数还没开始执行。
l 如果一个对象包含子对象,包容对象的构造函数只有在所有子对象被完全构造后才开始执行。
l 一个对象被析构,当且仅当它被完全构造。
l 对象按它们被构造的反序进行析构。
因为抛出了一个异常,B::B没有被完全执行。因此,B的对象A::b既没有被完全构造也没有被析构。
要证明这点,跟踪相应的类成员:
// Example 3
#include <iostream>
class B
{
public:
B()
{
std::cout << "B::B enter" << std::endl;
throw 0;
std::cout << "B::B exit" << std::endl;
}
~B()
{
std::cout << "B::~B" << std::endl;
}
};
class A
{
public:
A()
{
std::cout << "A::A" << std::endl;
}
~A()
{
std::cout << "A::~A"<< std::endl;
}
private:
B const b;
};
// ... main same as before ...
当运行时,程序将只输出
B::B enter
caught exception
从而显示出对象a和b既没有完全构造也没有析构。
1.2 多对象
使例子变得更有趣和更有说明力,把它改得允许部分(不是全部)对象被完全构造:
// Example 4
#include <iostream>
class B
{
public:
B(int const ID) : ID_(ID)
{
std::cout << ID_ << " B::B enter" <<std::endl;
if (ID_ > 2)
throw 0;
std::cout << ID_ << " B::B exit" <<std::endl;
}
~B()
{
std::cout << ID_ << " B::~B" <<std::endl;
}
private:
int const ID_;
};
class A
{
public:
A() : b1(1), b2(2), b3(3)
{
std::cout <<"A::A" << std::endl;
}
~A()
{
std::cout <<"A::~A" << std::endl;
}
private:
B const b1;
B const b2;
B const b3;
};
// ... main same asbefore ...
注意B的构造函数现在接受一个对象ID值的参数。用它作B的对象的唯一标记并决定对象是否完全构造。大部分跟踪信息以这些ID开头,显示为:
1 B::B enter
1 B::B exit
2 B::B enter
2 B::B exit
3 B::B enter
2 B::~B
1 B::~B
caught exception
b1和b2完全构造而b3没有。所以,b1和b2被析构而b3没有。此外,b1和b2的析构按其构造的反序进行。最后,因为一个子对象(b3)没有完全构造,包容对象a也没有完全构造和析构。
1.3 动态分配对象
将类A改为其成员变量是动态生成的:
// Example 5
#include <iostream>
// ... class B same as before ...
class A
{
public:
A() : b1(new B(1)), b2(new B(2)), b3(new B(3))
{
std::cout <<"A::A" << std::endl;
}
~A()
{
delete b1;
delete b2;
delete b3;
std::cout <<"A::~A" << std::endl;
}
private:
B * const b1;
B * const b2;
B * const b3;
};
// ... main same as before ...
这个形式符合C++习惯用法:在包容对象的构造函数里分配成员变量,并对其填充数据,然后在包容对象的析构函数里释放它们。
编译并运行例5。输出是:
1 B::B enter
1 B::B exit
2 B::B enter
2 B::B exit
3 B::B enter
caught exception
其结果与例4相似,但有一个巨大的不同:因为~A没有被执行,其中的delete语句也就没有执行,被成功分配的*b1和*b2的析构函数也没有调用。例四中的不妙状况(三个对象析构了两个)现在更差了(三个对象一个都没有析构)。
实际上,没有比这更坏的了。记住,delete b1语句有两个作用:
l 调用*b1的析构函数~b。
l 调用operator delete释放*b1所占有的内存。
所以我们不光是遇到~B没有被调用所导致的问题,还有每个B对象造成的内存泄漏问题。这不是件好事。
B对象是A私有的,它们是实现细节,对程序的其它部分是不可见的。用动态生成B的子对象来代替自动生成B的子对象不该改变程序的外在行为,这表明了我们的例子在设计上的缺陷。
1.4 析构动态生成的对象
为了最接近例4的行为,我们需要在任何情况强迫delete语句的执行。将它们放入~A明显不起作用。我们需要找个能起作用的地方,我们知道它能被执行的地方。跳入脑海的解决方法中,最优雅的方法来自于C++标准运行库:
// Example 6
#include <iostream>
#include <memory>
// ... class B same as before ...
class A
{
public:
A() : b1(new B(1)), b2(new B(2)), b3(new B(3))
{
std::cout << "A::A" << std::endl;
}
~A()
{
std::cout << "A::~A" << std::endl;
}
private:
std::auto_ptr<B> const b1;
std::auto_ptr<B> const b2;
std::auto_ptr<B> const b3;
};
// ... main same as before ...
auot_ptr读作“auto-pointer”。如名所示,auoto-pointer表现为通常的指针和自动对象的混合体。
std::auto_ptr是在<memory>中申明的类模板。一个std::auto_ptr<B>类型的对象的表现非常象一个通常的B*类型对象,关键的不同是:auto_ptr是一个实实在在的类对象,它有析构函数,而这个析构函数将在B*所指对象上调用delete。最终结果是:动态生成的B对象如同是个自动B对象一样被析构。
可以把一个auto_ptr<B>对象当作对动态生成的B对象的简单包装。在包装消失(析构)时,它也将被包装对象带走了。要实际看这个魔术戏法,编译并运行例6。结果是:
1 B::B enter
1 B::B exit
2 B::B enter
2 B::B exit
3 B::B enter
2 B::~B
1 B::~B
caught exception
Bingo!输出和例4相同。
你可能会奇怪为什么没有为b3调用~B。这表明了auto_ptr包装上的失败?根本不是。我们所读过的规则还在起作用。对b3进行的构造函数的调用接受了new B(3)传过来的参数。于是发生了一个异常终止了b3的构造。因为b3没有完全构造,它同样不会析构。
藏在atuo-pointer后面的想法没有新的地方;string对象实际上就是char数组的auto-pointer型包装。虽然如此,我仍然期望有一天我能更详细的讨论auto_ptr及其家族,目前只要把auto_ptr当作一个保证发生异常时能析构动态生成的对象的简单方法。
1.5 预告
既然b3的析构函数没有被调用,也就没有为其内存调用delete。如前面所见,被包装的B对象受到两个影响:
l 析构函数~B没有被调用。这是意料中的甚至是期望中的,因为B对象在先前没有完全构造。
l 内存没有被通过operator delete释放。不管是不是意料中的,它绝不是期望中的,因为B对象所占用的内存被分配了,即使B对象没有在此内存中完全构造。
我需要operator delete被调用,即使~B没有被调用。要实现这点,编译器必须在脱离delete语句的情况下调用operator delete。因为我知道b3是我的例子中的讨厌对象,我可以显式地为b3的内存调用operator delete;但要知道这只是教学程序,通常情况下我们不能预知哪个构造函数将失败。
不,我们所需要的是编译器检测到动态生成对象时的构造函数失败时隐含调用operator delete来释放对象占用的内存。这有些效仿编译器在自动对象构造失败时的行为:对象的内存如同程序体中的无用单元一样,是可回收的。
幸好,它有个大喜结局。要看这个结局,需到下回。在下回结束时,我将揭示C++语言如何提供了这个完美特性,为什么标准运行库申明了placement operator delete,以及为什么你可能想在自己的库或类中做同样的事。