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

Assert 是你的朋友

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.76/5 (49投票s)

2004年3月12日

CPOL

9分钟阅读

viewsIcon

253962

如何使用 assert 来查找程序中的错误。

引言

在 C++ 消息板上看到有人寻求编程帮助,问题却被描述为不想要的 assert 错误,这种情况并不少见。通常,这是寻求摆脱 assert 错误帮助的请求,但几乎总是将 assert 本身描绘成邪恶的东西。

我非常理解 assert 错误对新手程序员造成的沮丧。你的程序大部分按照你的意愿运行,然后砰!一个 assert 出现了。

因此,让我们来审视一下 assert,了解它们为什么存在以及我们可以从中学到什么。我应该强调,本文讨论的是 MFC 如何处理 assert。

语言学

通过谷歌搜索(“定义 assert”)并点击网页定义。
The verb "assert" has 4 senses in WordNet.

1. assert, asseverate, maintain -- (state categorically)
2. affirm, verify, assert, avow, aver, swan, swear -- (to declare or affirm solemnly 
                                                       and formally as true; "Before 
                                                       God I swear I am innocent")
3. assert, put forward -- (insist on having one's opinions and rights recognized; 
                           "Women should assert themselves more!")
4. insist, assert -- (assert to be true; "The letter asserts a free society")
以上所有含义在此实例中都适用,但在 assert 的上下文中,第 4 条含义最为字面准确。assert 表明正在断言的条件确实为真。如果它不为真,程序就陷入了严重的麻烦,而你应该被警告这一点。

代码中的 assert 含义

当程序员编写 assert 语句时,他/她是在说,“这个条件必须为真,否则就是错误”。假设你正在编写一个函数,它期望接收一个字符串指针。
void CMyClass::MyFunc(LPCTSTR szStringPtr)
{
    if (*szStringPtr == '7')
        DoSomething();
}
该函数读取指针指向的内存,所以最好它指向的是有效内存。否则,你就会崩溃!调用该函数时可能发生的许多错误之一是,如果你传递了一个 NULL 指针。现在,如果该指针实际上是 NULL,而你的程序崩溃了,你将无法获得太多信息。只有一个带代码地址的消息框,并告知你尝试读取地址为 0x00000000 的内存。将代码地址与你代码中的实际行相关联并非易事,尤其是如果你是新手。

所以,让我们稍微重写一下这个函数。

void CMyClass::MyFunc(LPCTSTR szStringPtr)
{
    ASSERT(szStringPtr);

    if (*szStringPtr == '7')
        DoSomething();
}
这会测试 szStringPtr。如果它是 NULL,它会立即崩溃。嗯……它崩溃了?是的,没错。但如果你正在运行程序的调试版本,它会以一种可控的方式崩溃。MFC 有一些内置的机制,可以进行可控的崩溃,并将其连接到调试器的副本。如果你正在运行此程序的调试版本,并且 assert 失败,你将看到一个类似于此的消息框。

Sample Image - imagepreviewdialog.jpg

这将向你显示触发断言的文件和行号。你可以中止,停止程序;或者你可以尝试忽略错误,有时这会奏效;或者你可以重试,这将调用调试器,并将你直接带到失败的行。即使你独立运行程序,只要计算机上安装了调试器,这种方法也有效。

发布版本和调试版本之间的区别

我在前面的段落中多次使用了“调试版本”这个短语。理解 assert 是一个调试辅助工具非常重要。如果你正在构建程序的调试版本,编译器会包含 ASSERT(...) 中的所有代码。如果你正在构建发布版本,ASSERT 本身以及括号内的所有代码都会消失。假设你已经测试了调试版本并捕获了所有可能的错误。如果你不幸错过了错误并发布了一个有 bug 的版本,希望它即使在失败了 assert 会捕获的测试时也能勉强运行。有时乐观是件好事!

在调试版本中,你可能希望断言某事为真,而你所断言的事情是必须在程序中编译的代码,无论它是调试版本还是发布版本。这时 VERIFY(...) 宏就可以派上用场了。在调试版本中,VERIFY 会包含 MFC 提供的额外机制,让你在条件不满足时跳转到调试器。在发布版本中,额外的机制会从你的程序中省略,但是 VERIFY(...) 语句中的代码仍然包含在你的可执行文件中。例如。

VERIFY(MoveFile(szOriginalFilename, szNewFileName));
如果由于任何原因 MoveFile() 函数在调试版本中失败,将触发一个调试断言。无论构建类型如何,MoveFile() 调用都会包含在你的程序中。但在发布版本中,该调用的失败将被简单地忽略。与此对比的是
ASSERT(MoveFile(szOriginalFilename, szNewFileName));
在调试版本中,MoveFile() 将被编译和执行。在发布版本中,这一行将完全消失,不会尝试移动文件。这可能会导致一些困惑。

在 MFC 中跟踪 assert

现在,如果你阅读本文是为了理解 assert 错误,那么你正在调试的 assert 很可能不是来自你的代码。如果你幸运的话,它来自 MFC,因为你有它的源代码。

首先要记住的是,它不太可能是由 MFC 中的 bug 引起的。我并不否认 MFC 中存在 bug,但在我使用 MFC 的十几年里,我从未遇到过因 MFC bug 导致的 ASSERT。

第二要记住的是,ASSERT 的存在是有原因的。你需要检查触发 assert 的代码行,并理解它在测试什么。

例如,MFC 中的许多类都是 Windows 控件的包装器。在许多情况下,包装器函数通过将 SendMessage 调用转换为看起来像函数的东西来简化你的代码。例如,CTreeCtrl::SortChildren() 函数接受一个树形项的句柄,并对控件中该句柄的子项进行排序。在你的代码中,它可能看起来像这样。

m_myTreeCtrl.SortChildren(hMyNode);
这就是你写的内容。在内部,类实际上向树控件发送了一个消息。你可以调用一个漂亮的、简单的基于函数的接口,MFC 会负责以消息所需的方式移动参数。下面是从 MFC 源代码中重新格式化的函数。
_AFXCMN_INLINE BOOL CTreeCtrl::SortChildren(HTREEITEM hItem)
{
    ASSERT(::IsWindow(m_hWnd));
    return (BOOL)::SendMessage(m_hWnd, TVM_SORTCHILDREN, 0, (LPARAM)hItem);
}
它首先断言你的 CTreeCtrl 对象中的底层窗口句柄是一个有效的窗口!我现在真的不知道如果你尝试向一个不存在的窗口发送 TVM_SORTCHILDREN 消息,你的系统会发生什么坏事。我所知道的是,我想在尝试这样做时得到通知!这里的 assert 会在我尝试做一件不可能成功的事情时立即提醒我。

所以,如果你调用这样的函数并遇到 assert 错误,你会查看失败的行,看到它断言窗口句柄是一个存在的窗口。这是它唯一断言的内容。如果失败了,唯一可能错误的就是具有该句柄的窗口不存在。这是你跟踪 bug 的线索。从那里,你可以查看调用此函数的函数,并尝试确定为什么你认为存在的窗口不再存在。

因此,这是关于 MFC 如何使用 assert 以及如何确定 MFC 在你的项目中进行断言的原因的非常非常简短的概述。现在,让我们看看如何在自己的代码中使用 assert。

void CMyClass::MyFunc(LPCTSTR szStringPtr) 再访

前面我提到过一个简单的检查,它只是断言传递给函数的指针不是 NULL。实际上,我们可以做得更好。MFC 和 Windows 本身都为我们提供了一系列函数,我们可以用它们来确定一个指针是否指向有效的内存。为了让你回忆起来,这是原始函数,以及我们改进它的第一个草稿。
void CMyClass::MyFunc(LPCTSTR szStringPtr)
{
    if (*szStringPtr == '7')
        DoSomething();
}
以及改进的第一个草稿……
void CMyClass::MyFunc(LPCTSTR szStringPtr)
{
    ASSERT(szStringPtr);

    if (*szStringPtr == '7')
        DoSomething();
}
这只会提醒你一种错误:传递了 NULL 指针。如果我们也能测试指针指向有效内存,而不仅仅是一个非 NULL 的垃圾值,那就太好了。我们可以这样做。
void CMyClass::MyFunc(LPCTSTR szStringPtr)
{
    ASSERT(szStringPtr);
    ASSERT(AfxIsValidString(szStringPtr));

    if (*szStringPtr == '7')
        DoSomething();
}
这增加了对 szStringPtr 指向有效内存的检查。该测试会检查你对内存是否有读取权限,并且内存是否包含字符串终止符。一个相关的函数是 AfxIsValidAddress,它允许你检查对指定大小的内存块是否有访问权限。你还可以检查你对该块是否有读取权限,或者是否有写入权限。

其他类型的 ASSERT 检查

除了使用 Windows 系统调用(如 IsWindow())和内存验证检查之外,还可以断言传递给函数的对象是特定类型的。如果你编写一个同时处理 CEmployee 对象和 CProduct 对象的程序,那么这些对象不太可能是可互换的。因此,验证处理 CEmployee 对象的函数只接收这些类型的对象是有意义的。在 MFC 中,你可以这样做。
void CMyClass::AnotherFunc(CEmployee *pObj)
{
    ASSERT(pObj);    //  Our old friend, it can't be a NULL
    ASSERT_KINDOF(CEmployee, pObj);
}
与之前一样,我们首先断言指针不是 NULL。然后我们断言它是一个 CEmployee 类型的对象指针。你只能对派生自 CObject 的类执行此操作,并且需要添加一些运行时支持。幸运的是,运行时支持非常简单。

你必须将该对象声明为至少是动态的。让我解释一下。在 MFC 中,你可以声明一个类包含运行时类信息。你可以通过在类声明中包含 DECLARE_DYNAMIC(ClassName) 宏,并在实现某处包含 IMPLEMENT_DYNAMIC(ClassName, BaseClassName) 来实现。。

类定义。

class CMyReallyTrivialClass : public CObject
{
    DECLARE_DYNAMIC(CMyReallyTrivialClass)
public:

    //  Various class members and functions...
};
以及实现文件
IMPLEMENT_DYNAMIC(CMyReallyTrivialClass, CObject);

.
.
.
// Other class functions...
如果你只想使用 ASSERT_KINDOF 宏,这两行就足够了。现在,当你编写程序时,你可以在任何传递对象指针的地方使用 ASSERT_KINDOF 宏,它将对其进行测试,以查看它是否确实指向该类型的对象。如果不指向,你的程序将以之前提到的受控方式崩溃,并弹出调试器指向失败的断言。从那里……嗯,我们已经讲过这部分了。

如果你的对象已经包含 DECLARE_DYNCREATE 宏或 DECLARE_SERIAL 宏,你就不需要使用 DECLARE_DYNAMIC,因为这两个宏都包含 ASSERT_KINDOF 所需的运行时类信息。

结论

我试图说明 assert 如何用于捕获运行时错误,并将你引导到导致 assert 的代码行。我们研究了如何从 assert 回溯来确定 assert 失败的原因。在此过程中,我们简要研究了如何在自己的软件中测试内存指针的有效性,以及如何检查你是否收到了指向你的代码所期望的对象类型的指针。

近年来,我一直在使用(也许是过度使用)assert 作为我代码中的运行时检查。将指针有效且指向我代码期望的对象类型断言自动地散布在我的代码中已成为一种习惯。我发现这样做是值得的。如今,我很少需要处理由 NULL 指针或指向错误类型对象的指针引起的崩溃。

历史

2004 年 3 月 12 日 - 初始版本

© . All rights reserved.