递归断言会用一个假断言模糊真实问题
断言是 C/C++ 代码非常有效的调试工具。但是,断言存在一个非常微妙的问题,可能会导致您花费大量调试时间去追查错误的问题。
引言
断言是 C/C++ 代码非常有效的调试工具。但是,断言存在一个非常微妙的问题,可能会导致您花费大量调试时间去追查错误的问题。背景
在 Windows 应用程序中,有两种主要的断言方式:assert()
宏(定义在 assert.h 中)和 _ASSERT
宏(定义在 crtdbg.h 中)。主要区别在于 assert()
宏调用 _assert()
API,而 _ASSERT()
调用 _CrtDbgReport()
API。
大多数 Windows 开发人员使用 _ASSERT
宏。它们提供更多功能、更大的灵活性,并对下面描述的递归问题提供一定的保护。请查看 crtdbg.h 中所有可用的选项。除非您因为跨平台或其他有效原因必须使用 assert()
,否则您应该使用 _ASSERT()
宏。
断言的问题——意外递归
断言通常会弹出一个对话框,该对话框是用 Windows API MessageBox()
创建的。MessageBox()
有自己的 Windows 消息循环,在对话框显示期间运行。因此,断言的线程实际上并没有停止。它仍在运行,处理 Windows 消息,最重要的是,调用应用程序的消息处理程序。无论 MessageBox 是应用程序模态、系统模态还是任务模态,都没有区别。
当您在消息处理程序中添加断言时,问题就出现了。 第一个断言可能导致第二个断言,完全隐藏了第一个断言。
这种确切的情况在我工作的单位发生过几次,涉及多个不同的开发人员/测试人员。开发人员非常认真地在 WM_PAINT
处理程序中放置断言,以捕获在我们的庞大应用程序完全初始化(数据库打开、文件打开、对象创建等)之前发生的任何绘制尝试。与大多数断言一样,我们从未期望这些断言会被触发。它们只是为了捕获诸如有人尝试在初始化应用程序之前绘制之类的主要编程错误。通常,如果在启动过程中出现故障,我们会通过常规错误处理向用户报告。
当这些断言被触发时,我们对如何在应用程序的大部分尚未初始化的情况下,且没有初始化代码报告任何错误的情况下,能够进入 WM_PAINT
处理程序感到非常困惑。
直到我向上滚动堆栈很多层,我才注意到我们实际上处于第二个断言中,而真正的问题(第一个断言)已经完全被隐藏了。
事件顺序如下:1) 应用程序启动。当您单步调试时,您现在处于 WM_PAINT 消息处理程序中的第二个断言,而不是导致问题的原始断言。
2) 一个WM_PAINT
消息被发布到消息队列。
3) 应用程序初始化代码因某种原因断言。
4) 断言调用MessageBox()
,后者将立即处理WM_PAINT
消息并调用应用程序的处理程序(MFC 中的OnDraw()
)。
5) 由于应用程序尚未完成初始化,WM_PAINT
处理程序会发出另一个断言。
6) 第一个断言被这个新的断言完全隐藏了。
重现问题
如果您想亲眼看看这个问题,它很容易重现。1) 在 Visual Studio 中,创建一个标准的 MFC 应用程序。
2) 在应用程序类的InitInstance()
中,在m_pMainWnd->ShowWindow(SW_SHOW)
行之后,添加_ASSERT(0)
。
3) 在视图类的OnDraw()
中,在第一行添加_ASSERT(0)
。
4) 运行应用程序。
微软的解决方案
微软在 _VCrtDbgReportA()
(由 _ASSERT
宏调用)中在某种程度上解决了递归问题。但他们的解决方案仍有许多不足之处。
注意:CRT _assert()
API 根本没有进行递归检查——这是避免使用它的另一个原因。
微软解决方案的一个问题是,如果您在调试器之外运行,第二个断言不会显示标准的断言对话框,而是显示通用且晦涩的“... 应用程序遇到问题,需要关闭”对话框。如果您按“调试”按钮,您将看到即时调试器对话框,它错误地声称发生了未处理的 win32 异常。
现在您已进入调试器,如果您拥有 CRT 源代码和符号,调试器将定位在 _CrtDbgBreak()
(在 dbgrptt.c 中)——这更加令人困惑!如果没有 CRT 符号,至少调试器会将您定位在第二个 _ASSERT()
(在 WM_PAINT
处理程序中)。
还有一个“第二次机会断言”消息发送到输出窗口。但是,在这种情况下很容易错过,因为那里有太多其他消息。
更好的解决方案
由于 Windows 消息循环只处理当前线程的消息,因此解决这些问题的最简单方法是将断言对话框放在它们自己的线程上,并冻结调用线程直到对话框关闭。
为此,我编写了一个静态类 CMessageBoxThread
(MessageBoxThread.h)。由于所有方法都是内联的,因此您只需要这一个头文件。您可以将其包含在 stdafx.h 中,以替换整个项目中的所有 _ASSERT
和 _RPTx
宏。基本上就是这样。您无需更改任何代码。
我还提供了一个通用的、基于线程的 MessageBox 替换项,当您不希望调用线程继续处理消息时,可以使用它来显示标准的 MessageBox。
示例CMessageBoxThread::MessageBox(_T("This is the msg box text"), _T("Message box caption"));