任何对象/资源类型的Guard类
一种优雅的方式来包装所有需要清理的对象,以最大限度地减少内存/资源泄漏的可能性。
引言
在程序运行时,会创建/分配各种对象/资源。其中大多数都需要某种形式的清理。例如,如果你通过 new
操作符分配内存/对象,那么之后你必须 delete
它;通过 CreateFile
打开文件需要你 CloseHandle
;套接字需要 closesocket
,GDI 对象需要 DeleteObject
,等等。高级语言会自动处理这些,而 C/C++ 程序员必须自己处理,这是灵活性的代价。
一旦某个函数返回,你就得到了一个指向某个有效对象的指针/句柄 - 你必须稍后以适当的方式销毁它。也就是说,你必须跟踪该对象的 **生命周期**。任何一个错误 - 哎呀,我们就有了内存/资源泄漏。有时,你需要创建一个短暂的对象,比如为了某个函数。然后,你需要在函数返回之前释放它。当一个函数有多个 return
语句时 - 代码就会变得丑陋:要么在每个 return
前面都放置清理语句,要么重写函数使其只有一个 return
语句。你也可以使用 SEH 机制:将所有清理工作放在 __finally
块中。事实上,如果你处理异常,那么这就是你 **必须** 做的事情:异常发生时,你将无法到达 return
语句。
如果你只使用纯 C 语言,那么似乎没有选择,但在 C++ 中有更优雅的方式。C++ 最有价值和最重要的特性之一是析构函数。也就是说,编译器负责跟踪 C++ 对象的生命周期,并在需要时调用它们的析构函数。如果你在作用域/函数中声明了一个对象,那么它的生命周期仅限于该作用域/函数,C++ 保证在离开它时会调用析构函数。你所要做的就是将指针/句柄放在你声明在作用域内的某个对象中,并在析构函数中释放它。让编译器为你工作。
好吧,我当然不是第一个想到这个的人;这是一种众所周知的方法。我长期以来一直在使用它并且很喜欢它。但最近,我发现了一种更优雅的使用方式。
假设我们要创建一个包装 BSTR
的类。清理函数是 SysFreeString
。所以,我们声明一个类,比如 Bstr_G
(G 后缀表示 'guarded'),用于它。现在,我们希望这个类拥有什么?
- 空的构造函数,将封装的
BSTR
设置为NULL
。 - 接收
BSTR
并保存它的构造函数。 - 析构函数(最重要),如果
BSTR
非NULL
,则释放它。 - 转换为
BSTR
类型。 - 显式的
Destroy
。如果BSTR
非NULL
- 释放它并设置为NULL
。 Attach
。销毁先前持有的BSTR
(如果有效)并保存新的。Detach
。返回BSTR
并将其设置为NULL
而不销毁它。- 赋值运算符,类似于
Attach
。 private
/protected
复制构造函数和赋值运算符。不应该有多个对象包装同一个BSTR
,因此最好禁用它们。
不是很复杂,只是一些常规工作。假设现在你需要一个包装 HICON
的类。你声明一个类,比如 HIcon_G
,它具有类似的功能,然后重新编写所有内容,只是将 HICON
替换为 BSTR
,并将 DestroyIcon
替换为 SysFreeString
。这样,你需要做很多常规工作来为不同类型的对象准备这样的类。
多次重写同一内容不是好习惯。这非常令人烦恼,重写时可能会出错。此外,如果你最终发现了一个 bug(或者想添加一些额外功能),你必须在所有类中修复它。因此,我决定使用一种更优雅的方式:模板类。这正是它们的目的。
也就是说,我们不需要为不同类型编写 Guard 类,而是编写一个单一的模板类,该模板类接受以下参数:
- 我们封装的类型。
- 该类型的“无效”值(通常是
NULL
)。 - 清理函数。
然后,只需为所有你想要的类型实例化专门的模板版本即可。
实现
无需深入解释代码。它并不复杂,你可以自己弄清楚。我只想指出一些重要的事情:
有一个基类 GBase_T
,不应单独使用。相反,有两个类继承自它:GObj_T
和 GRef_T
。GObj_T
类封装了简单的类型,就像我们示例中的一样,而 GRef_T
封装了支持引用的类型。特别是,当你保存某个值时,它要求调用一个额外的函数;对于这些类型,可以启用复制构造函数,赋值和附加的含义有所不同,等等。它可以用于实现智能指针或类似的东西。
接下来,你可以将类型和值作为模板参数传递,但不能传递函数(这是清理所需要的)。因此,为了使用这些模板类,你应该传递另一个类,该类声明了所需的函数,这就是它的实现方式。你传递的类必须提供以下内容:
- 一个
typedef
语句,定义GuardType
。 - 值本身保存在
m_Value
成员中。 - 该类型的无效值,通过静态函数
GetNullValue
。 - 可选地,一个引用方法,如果你将其与
GRef_T
一起使用时会调用它。
在这种情况下,提供这样的类通常也可以简化。为此有一些不同的模板基类(GBaseH_XXX
)。
使用示例:
让我们举几个关于如何精确使用它的例子。
例如,你想要一个通过 CreateIcon
创建的 HICON
Guard(不要将其用于通过 LoadIcon
加载的图标,它们不需要清理)。
// Declare a helper class that implements the 4 things GObj_T needs struct GBaseH_DestroyIcon : public GBaseH_CoreNull<HICON> { // the class we've inherited implements 3 things: // 1. Defines the HICON to be the 'guard' type // 2. Declares a variable m_Value of type HICON // 3. Defines NULL as invalid value. // The only thing that is left is the cleanup function. Declare it: void Destroy() { VERIFY(DestroyIcon(m_Value)); } }; // Instantiate the specialized version of GObj_T, call it HIcon_G. typedef GObj_T<GBaseH_DestroyIcon> HIcon_G; // In some function HIcon_G hIcon = CreateIcon( ... ); if (hIcon) { // ... return; } // ... if ( ... ) return; // ... return;
文件句柄封装示例如下。请注意:文件句柄的“无效”值不是 NULL
,而是 INVALID_HANDLE_VALUE
。然后,你可以这样声明:
struct GBaseH_FileClose : public GBaseH_Core<HANDLE> { static HANDLE GetNullValue() { return INVALID_HANDLE_VALUE; } void Destroy() { VERIFY(CloseHandle(m_Value)); } }; typedef GObj_T<GBaseH_FileClose> HFile_G; // In some function HFile_G hFile = CreateFile( ... ); if (hFile) { // ... WriteFile(hFile, ... ); // ... } // ...
注意:当你将 hFile
用于期望 HANDLE
的表达式(WriteFile
)时 - 我们的对象会返回包装的句柄,但当你使用 hFile
作为条件语句(if
)时 - 我们的对象会返回它是否不同于 INVALID_HANDLE_VALUE
。对于无效值等于 0/NULL
的类型,这与我们的情况相同,但在我们的情况下,这是不同的。如果你不确定某个表达式并担心混乱 - 你可以显式使用 IsValid
和 GetValue
。
作为可引用的对象示例,我们可以演示智能指针:
struct GBaseH_IMyInterface : public GBaseH_CoreNull<IMyInterface> { void Reference() { m_Value->AddRef(); } void Destroy() { m_Value->Release(); } }; typedef GRef_T<GBaseH_IMyInterface> IMyInterfacePtr; // In some function IMyInterfacePtr pMyObj = ... ; IMyInterfacePtr pMyObj2 = pMyObj; // will add extra ref to the object // ...
补充说明
如果你倾向于使用异常处理(就像我一样) - 你可能会发现这种方法非常方便。因为否则,你必须在每个需要清理的函数中放置一个 __try
- __finally
块。
但是,你应该知道编译器支持两种异常处理模型:所谓的 **同步** 和 **异步**。在第一种模型中,编译器假设除非你放置一个 throw
语句或调用另一个可能使用 throw
的函数,否则不会发生异常;而在第二种模型中,则指出异常可能发生在任何地方。在第一种情况下,你会得到一个稍微小巧且更快的代码(有时编译器会跳过 __try
- __finally
块),而在第二种情况下,即使发生 GPF 或其他情况,也能保证调用析构函数。我个人总是使用异步异常处理,但不幸的是,默认是同步的。要启用异步异常处理,请在编译器设置中查找它,或使用 ** /EHa** 标志。
为了获得免受内存/资源泄漏的保护,**永远** 不要让你的指针/句柄处于未受保护的状态。例如,不要这样写。
PBYTE pPtr = new BYTE[nSize]; // ... // If exception is raised here - the pPtr will be lost // ... delete[] pPtr;
最好这样写:
GPtr_T<BYTE> pPtr = new BYTE[nSize];
如果你希望你的函数返回一个指针,那么与其这样写:
PBYTE AllocMyArr() { // ... PBYTE pPtr = new BYTE[nSize]; // Fill the pPtr with some data // ... // If exception is raised here - the pPtr will be lost // ... return pPtr; }
你最好这样重写:
void AllocMyArr(GPtr_T<BYTE>& pPtr) { // ... pPtr = new BYTE[nSize]; // Fill the pPtr with some data // ... }
也就是说,不要让指针无人看管。
另一个重要说明:有时,重写 GObj_T
/GRef_T
而不仅仅是通过 typedef
实例化它会更好。这允许添加一些基类未设计的额外功能。例如,我们可能希望文件句柄包装器具有 Write
和 Read
成员函数以便于使用。
这里有一个非常严重的陷阱:假设你写了类似这样的东西:
class HFile_G : public GObj_T<GBaseH_FileClose> { public: // constructors, we have to rewrite them in inherited class. HFile_G() {} HFile_G(HANDLE hFile) : GObj_T<GBaseH_FileClose>(hFile) {} // extra members void Write( ... ); DWORD Read( ... ); }; // In some function HFile_G hFile; // ... hFile = CreateFile( ... ); // ... hFile = CreateFile( ... ); // ...
猜猜这会做什么。你绝对不会相信!
你是否期望在第一次 CreateFile
时保存返回的句柄,而在第二次调用时,关闭之前的句柄并保存新的句柄?错了!
在继承类中,我们没有实现接受 HANDLE
的赋值运算符。它存在于我们的基类中,但根据 C++ 规则,它不会自动继承。但这不是最糟糕的事情:编译器在这里不会给你一个错误。它实际上会这样做:
- 根据你传递的句柄创建一个临时的
HFile_G
对象。这是可能的,因为我们有相应的构造函数。 - 将我们的对象赋给这个临时对象。我们没有实现它,这怎么可能?别担心,编译器会自动生成赋值运算符。怎么生成的?仅仅通过复制成员(!!!)。所以我们之前保存的句柄被新的句柄覆盖了。
- 正如我们所说,编译器生成的
HFile_G
对象是临时的,它的生命周期就是赋值语句。因此,赋值完成后,编译器会调用它的析构函数。所以,新的句柄被关闭了。
令人印象深刻,不是吗?我们关闭的不是旧句柄,而是新句柄!而且你没有任何错误/警告。你只是在运行时遇到惊喜。在我看来,自动生成赋值运算符/复制构造函数是一个极其愚蠢的想法,尤其是当你的基类是一个非平凡类的时候。但这就是现实。
为了避免这种情况,你 **必须** 在继承类中实现赋值运算符和复制构造函数。此外,你必须实现(或通过将其声明为 private
来禁用)三个赋值运算符:一个接受 HANDLE
的,一个接受基类常量引用的,还有一个接受你类引用的。构造函数也一样。
我还在代码注释中指出了这个问题。为了使继承类正常工作,你可以这样写:
class HFile_G : public GObj_T<GBaseH_FileClose> { INHERIT_GUARD_OBJ(HFile_G, GObj_T<GBaseH_FileClose>, HANDLE) // extra members void Write( ... ); DWORD Read( ... ); };
这个宏将实现所有有问题的函数。
这就是全部。希望这对你有用。欢迎评论。