65.9K
CodeProject 正在变化。 阅读更多。
Home

任何对象/资源类型的Guard类

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.76/5 (7投票s)

2007 年 4 月 15 日

CPOL

9分钟阅读

viewsIcon

39398

downloadIcon

142

一种优雅的方式来包装所有需要清理的对象,以最大限度地减少内存/资源泄漏的可能性。

引言

在程序运行时,会创建/分配各种对象/资源。其中大多数都需要某种形式的清理。例如,如果你通过 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'),用于它。现在,我们希望这个类拥有什么?

  1. 空的构造函数,将封装的 BSTR 设置为 NULL
  2. 接收 BSTR 并保存它的构造函数。
  3. 析构函数(最重要),如果 BSTRNULL,则释放它。
  4. 转换为 BSTR 类型。
  5. 显式的 Destroy。如果 BSTRNULL - 释放它并设置为 NULL
  6. Attach。销毁先前持有的 BSTR(如果有效)并保存新的。
  7. Detach。返回 BSTR 并将其设置为 NULL 而不销毁它。
  8. 赋值运算符,类似于 Attach
  9. private/protected 复制构造函数和赋值运算符。不应该有多个对象包装同一个 BSTR,因此最好禁用它们。

不是很复杂,只是一些常规工作。假设现在你需要一个包装 HICON 的类。你声明一个类,比如 HIcon_G,它具有类似的功能,然后重新编写所有内容,只是将 HICON 替换为 BSTR,并将 DestroyIcon 替换为 SysFreeString。这样,你需要做很多常规工作来为不同类型的对象准备这样的类。

多次重写同一内容不是好习惯。这非常令人烦恼,重写时可能会出错。此外,如果你最终发现了一个 bug(或者想添加一些额外功能),你必须在所有类中修复它。因此,我决定使用一种更优雅的方式:模板类。这正是它们的目的。

也就是说,我们不需要为不同类型编写 Guard 类,而是编写一个单一的模板类,该模板类接受以下参数:

  1. 我们封装的类型。
  2. 该类型的“无效”值(通常是 NULL)。
  3. 清理函数。

然后,只需为所有你想要的类型实例化专门的模板版本即可。

实现

无需深入解释代码。它并不复杂,你可以自己弄清楚。我只想指出一些重要的事情:

有一个基类 GBase_T,不应单独使用。相反,有两个类继承自它:GObj_TGRef_TGObj_T 类封装了简单的类型,就像我们示例中的一样,而 GRef_T 封装了支持引用的类型。特别是,当你保存某个值时,它要求调用一个额外的函数;对于这些类型,可以启用复制构造函数,赋值和附加的含义有所不同,等等。它可以用于实现智能指针或类似的东西。

接下来,你可以将类型和值作为模板参数传递,但不能传递函数(这是清理所需要的)。因此,为了使用这些模板类,你应该传递另一个类,该类声明了所需的函数,这就是它的实现方式。你传递的类必须提供以下内容:

  1. 一个 typedef 语句,定义 GuardType
  2. 值本身保存在 m_Value 成员中。
  3. 该类型的无效值,通过静态函数 GetNullValue
  4. 可选地,一个引用方法,如果你将其与 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 的类型,这与我们的情况相同,但在我们的情况下,这是不同的。如果你不确定某个表达式并担心混乱 - 你可以显式使用 IsValidGetValue

作为可引用的对象示例,我们可以演示智能指针:

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 实例化它会更好。这允许添加一些基类未设计的额外功能。例如,我们可能希望文件句柄包装器具有 WriteRead 成员函数以便于使用。

这里有一个非常严重的陷阱:假设你写了类似这样的东西:

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++ 规则,它不会自动继承。但这不是最糟糕的事情:编译器在这里不会给你一个错误。它实际上会这样做:

  1. 根据你传递的句柄创建一个临时的 HFile_G 对象。这是可能的,因为我们有相应的构造函数。
  2. 将我们的对象赋给这个临时对象。我们没有实现它,这怎么可能?别担心,编译器会自动生成赋值运算符。怎么生成的?仅仅通过复制成员(!!!)。所以我们之前保存的句柄被新的句柄覆盖了。
  3. 正如我们所说,编译器生成的 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( ... );
};

这个宏将实现所有有问题的函数。

这就是全部。希望这对你有用。欢迎评论。

© . All rights reserved.