9.5 异常与构造函数
围绕异常还有一个稍微高级一点的问题,即当异常在对象的构造函数中产生时会发生什么情况。并非所有 Object Pascal 程序员都知道,在这种情况下,将调用对象的析构函数(如果有的话)。
这一点很重要,因为它意味着一个部分初始化的对象可能会被调用析构函数。如果想当然地认为内部对象存在于析构函数中,因为它们是在构造函数中创建的,那么在出现实际错误时,可能会陷入一些危险的境地(即在第一个异常处理完毕之前引发另一个异常)。
这也意味着,try-finally 的正确顺序应该是在代码块外创建对象,因为编译器会自动对其进行保护。因此,如果构造函数失败,就没有必要释放对象。这就是为什么 Object Pascal 的标准编码方式是通过写入以下内容来保护对象:
AnObject := AClass.Create;
try
// 使用对象...
finally
AnObject.Free;
end;
注解:类似的情况也发生在 TObject 类的两个特殊方法 AfterDestruction 和 BeforeConstruction 上,它们是为兼容 C++ 而引入的伪构造函数和伪析构函数(但在 Object Pascal 中很少使用)。请注意,如果 AfterConstruction 方法引发异常,就会调用 BeforeDestruction 方法(以及常规的析构函数)。
鉴于我经常看到在析构函数中释放对象的错误,让我通过一个实际的演示来进一步说明这个问题以及实际的解决方法。假设你有一个包含字符串列表的类,你编写了以下代码来创建和销毁该类(ConstructorExcept 项目的一部分):
type
TObjectWithList = class
private
FStringList: TStringList;
public
constructor Create(Value: Integer);
destructor Destroy; override;
end;
constructor TObjectWithList.Create(Value: Integer);
begin
if Value < 0 then
raise Exception.Create('Negative value not allowed');
FStringList := TStringList.Create;
FStringList.Add(Value.ToString);
end;
destructor TObjectWithList.Destroy;
begin
FStringList.Clear;
FStringList.Free;
inherited;
end;
乍一看,代码似乎是正确的。构造函数分配子对象,而析构函数恰当地释放子对象。此外,调用代码的编写方式是,如果在构造函数之后出现异常,则调用 Free 方法,但如果异常出现在构造函数中,则什么也不会发生:
var
Obj: TObjectWithList;
begin
Obj := TObjectWithList.Create(-10);
try
// 进行一些操作
finally
Show('Freeing object');
Obj.Free;
end;
end;
这样行得通吗?绝对不行!当运行这段代码时,异常会在构造函数中出现,发生在创建字符串列表之前,系统会立即调用析构函数,试图清除不存在的列表,从而引发访问违规或类似错误。
为什么会出现这种情况呢?同样,如果在构造函数中颠倒顺序(先创建字符串列表,后引发异常),一切都能正常工作,因为析构函数确实需要释放字符串列表。但这并不是真正的解决办法,只是一种变通方法。你始终应该考虑的是保护析构函数的代码,使其永远不会假定构造函数已被完全执行。下面就是一个例子:
destructor TObjectWithList.Destroy;
begin
if Assigned(FStringList) then
begin
FStringList.Clear;
FreeAndNil(FStringList);
end;
inherited;
end;