Debug.Assert Everyone!
每个开发人员都知道单元测试提高了代码的质量。我们还从静态代码分析中获益,并在我们的构建管道中使用SonarQube等工具。然而,我仍然发现许多开发人员并不知道检查代码有效性的一种更古老的方法:断言。在这篇文章中,我将向您介绍使用断言的好处,以及.NET应用程序的一些配置技巧。我们还将学习.NET和Windows如何支持它们。
什么是断言,什么时候使用它们
断言声明某个谓词(真-假表达式)在程序中的特定时间必须为真。当断言的计算结果为false时,会发生断言失败,这通常会导致程序崩溃。我们通常在调试版本中使用断言,并在调试器或某些特殊日志中处理断言异常(稍后我们将重点讨论配置)。在.NET中,有两种使用断言的方法:Debug.Assert or Trace.Assert.第一个方法的定义如下:
[System.Diagnostics.Conditional("DEBUG")] public static void Assert(bool condition, string message) { TraceInternal.Assert(condition, message); }
如您所见,只有在定义调试编译符号(通常仅用于调试生成)时,它才会出现在生成的IL中。另一方面,Assert使用跟踪编译符号,默认情况下,编译器不会在发布版本中剥离它。我更喜欢使用Debug.Assert方法,并且在推送到生产环境的二进制文件中没有断言。
让我们来看看我们可能使用断言的一些场景。我将使用corecrl存储库中的代码片段。
验证内部方法参数
断言是执行内部/私有方法参数验证的极好方法。我们应该将它们放在方法的开头,这样任何计划使用我们方法的人都会立即看到它的期望值,例如:
private static char GetHexValue(int i) { Debug.Assert(i >= 0 && i < 16, "i is out of range."); if (i < 10) { return (char)(i + '0'); } return (char)(i - 10 + 'A'); }
或者
internal static int MakeHRFromErrorCode(int errorCode) { Debug.Assert((0xFFFF0000 & errorCode) == 0, "This is an HRESULT, not an error code!"); return unchecked(((int)0x80070000) | errorCode); }
通常真假表达式就足够了,但是对于更复杂的场景,我们可以考虑使用Debug.Assert(bool condition,string message)变量(如上面的示例所示),在这里我们可以解释我们的需求。
我们不能使用断言来验证公共API方法参数。首先,断言将在发布版本中消失。其次,我们的API客户机期望某些特定类型的异常。如果仍要在公共API方法中使用断言,则应同时使用异常和断言来验证参数,例如:
public User FindUser(string login) { if (string.IsNullOrEmpty(login)) { Debug.Assert(false, "Login must not be null or empty"); // or equivalent: Debug.Fail("Login must not be null or empty"); throw new ArgumentException("Login must not be null or empty."); } }
验证正在执行上下文
要查看使用断言进行逻辑验证的示例,我们将分析StringBuilder类的Length属性和AssertInvariants方法。注意,断言(突出显示)如何在方法执行的各个阶段验证上下文。它们反映了编写代码的开发人员的假设,同时帮助我们更好地理解代码的逻辑:
/// <summary> /// Gets or sets the length of this builder. /// </summary> public int Length { get { return m_ChunkOffset + m_ChunkLength; } set { //If the new length is less than 0 or greater than our Maximum capacity, bail. if (value < 0) { throw new ArgumentOutOfRangeException(nameof(value), SR.ArgumentOutOfRange_NegativeLength); } if (value > MaxCapacity) { throw new ArgumentOutOfRangeException(nameof(value), SR.ArgumentOutOfRange_SmallCapacity); } int originalCapacity = Capacity; if (value == 0 && m_ChunkPrevious == null) { m_ChunkLength = 0; m_ChunkOffset = 0; Debug.Assert(Capacity >= originalCapacity); return; } int delta = value - Length; if (delta > 0) { // Pad ourselves with null characters. Append('\0', delta); } else { StringBuilder chunk = FindChunkForIndex(value); if (chunk != this) { // We crossed a chunk boundary when reducing the Length. We must replace this middle-chunk with a new larger chunk, // to ensure the original capacity is preserved. int newLen = originalCapacity - chunk.m_ChunkOffset; char[] newArray = new char[newLen]; Debug.Assert(newLen > chunk.m_ChunkChars.Length, "The new chunk should be larger than the one it is replacing."); Array.Copy(chunk.m_ChunkChars, 0, newArray, 0, chunk.m_ChunkLength); m_ChunkChars = newArray; m_ChunkPrevious = chunk.m_ChunkPrevious; m_ChunkOffset = chunk.m_ChunkOffset; } m_ChunkLength = value - chunk.m_ChunkOffset; AssertInvariants(); } Debug.Assert(Capacity >= originalCapacity); } } [System.Diagnostics.Conditional("DEBUG")] private void AssertInvariants() { Debug.Assert(m_ChunkOffset + m_ChunkChars.Length >= m_ChunkOffset, "The length of the string is greater than int.MaxValue."); StringBuilder currentBlock = this; int maxCapacity = this.m_MaxCapacity; for (;;) { // All blocks have the same max capacity. Debug.Assert(currentBlock.m_MaxCapacity == maxCapacity); Debug.Assert(currentBlock.m_ChunkChars != null); Debug.Assert(currentBlock.m_ChunkLength <= currentBlock.m_ChunkChars.Length); Debug.Assert(currentBlock.m_ChunkLength >= 0); Debug.Assert(currentBlock.m_ChunkOffset >= 0); StringBuilder prevBlock = currentBlock.m_ChunkPrevious; if (prevBlock == null) { Debug.Assert(currentBlock.m_ChunkOffset == 0); break; } // There are no gaps in the blocks. Debug.Assert(currentBlock.m_ChunkOffset == prevBlock.m_ChunkOffset + prevBlock.m_ChunkLength); currentBlock = prevBlock; } }
在.NET核心源代码中还有许多其他地方可以找到正在使用的断言。寻找它们并学习.NET开发人员如何使用它们可能是一个有趣的练习,特别是当您对在代码中使用断言有疑问时。
断言实现详细信息
我们已经介绍了一些可以使用断言的示例场景,因此现在应该进一步了解.NET和Windows如何支持断言。在默认配置中,当断言失败时,您将看到这个漂亮的对话框:
显示此消息框的是DefaultTraceListener的失败,或者更确切地说是AssertWrapper的ShowMessageBoxAssert方法。窗口的标题描述了您拥有的选项。如果按Retry,应用程序将调用Debugger.Break方法,该方法将发出一个软件中断(int 0x3),将执行传输到内核中的KiBreakpointTrap方法,然后再传输KiExceptionDispatch。后者也是处理“正常”异常分派的方法,是Windows提供的结构化异常处理(SEH)机制的一部分。因此,您可以将断言失败视为未处理异常的特殊类型。从Vista开始,有另一个特别为断言创建的软件中断(int 0x2c),但是我还没有找到一种不使用pinvoking从.NET调用它的方法。尽管在某种程度上系统处理它们的方式没有多大区别。因此,当您单击“重试”时,Windows将检查注册表中的AeDebug键中是否配置了任何默认调试器。如果存在,它将启动并附加到您的应用程序,并在发生断言失败的地方停止。如果AeDebug密钥中没有调试器,Windows错误报告将尝试解决该问题,这可能会导致向Microsoft发送新的报告。
在.NET应用程序中处理断言输出
如您所料,对于失败的断言,MessageBox显示并不总是所需的行为。对于在会话0中运行的进程(例如,在IIS上托管的Windows服务或ASP.NET web应用程序),这样的消息框将完全阻止应用程序,而不提供交互选项(会话0中没有桌面)。另一个例子是自动测试,它也可能无限地挂起。为了解决这些问题,我们在应用程序配置文件中有一个标志,用于禁用assert UI并将日志重定向到某个文件:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <system.diagnostics> <assert assertuienabled="false" logfilename="C:\logs\assert.log" /> </system.diagnostics> </configura
如果不设置logfilename属性,断言消息将只出现在调试输出上。禁用断言消息框的另一种方法是从侦听器集合中移除DefaultTraceListener(通常应为发布版本执行此操作):
<?xml version="1.0" encoding="utf-8"?> <configuration> <system.diagnostics> <trace> <listeners> <remove name="Default" /> </listeners> </trace> </system.diagnostics> </configuration>
不幸的副作用是不会报告断言失败。因此,如果要删除DefaultTraceListener,请添加Fail方法的自定义实现。然后您可以按自己的方式记录错误或调用调试器。立即中断。
在处理失败的断言时,我非常喜欢在应用程序中发生未处理的异常时创建转储。我通常安装procdump作为默认的系统异常处理程序。您也可以使用Visual Studio或WinDbg。请记住,这对于会话0中运行的进程不起作用。作为procdump的替代,特别是在某些服务器计算机上,可以考虑配置Windows错误报告。