使用 checked/unchecked 进行算术溢出检查






4.65/5 (18投票s)
如何有效地使用溢出检查并避免一些陷阱。
引言
在本文中,我将深入探讨用于控制溢出检查的极其有用的 checked
和 unchecked
关键字。本文偏向于熟悉 C 和/或 C++ 的读者。
能够控制溢出检查是 C# 优于 C/C++(和其他语言)的众多方式之一。理论上,C/C++ 编译器可以对有符号整数算术进行溢出检查,但根据 C 标准,其行为是“实现定义的”。我所知的所有编译器都采取了简单的做法,忽略溢出条件——只要它们记录了行为,就被认为是符合标准的。(然而,如果可能发生溢出,您应该始终在 C/C++ 中使用无符号整数类型,这些类型被明确定义为在溢出时具有未检查的行为。)
大多数时候,溢出不会发生,但您有时希望检查溢出,以防您的代码存在错误。然而,C# 真正有用之处在于您预期可能发生溢出。我将在接下来的章节中讨论所有情况。
溢出不期望发生
通常溢出并不重要,因为它不会发生。在大多数代码中,整数变量只取很小范围的值。我猜这就是为什么大多数 C/C++ 编译器忽略溢出的可能性。
当然,即使您不期望发生溢出条件,它仍可能由于错误而发生。例如,在 C 语言中,尝试将无符号整数递减到零以下很容易创建无限循环。在 C# 中,如果开启了溢出检查,这将生成一个溢出异常。
既然如此,为什么在使用 C# 时不总是开启溢出检查呢?嗯,溢出检查会带来性能损失。如果您确定永远不会发生溢出,您可能不想通过溢出检查来降低代码速度。
我的建议是,如果您不期望溢出条件,则不要使用 checked
或 unchecked
。在发布版本中,关闭溢出检查。在调试版本中开启它,以方便在测试期间发现和隔离错误。
请注意,Visual Studio 在发布版本和调试版本中都不会默认开启溢出检查。您需要为项目在生成属性中开启检查算术溢出设置——但请确保在执行此操作时选择了调试配置。
溢出预期
有两种情况预期会发生溢出:
- 在某些算法中,溢出需要被忽略。
- 溢出是一种需要明确处理的错误情况(但不是 Bug)。
我们现在将探讨第一种情况,下一节将探讨第二种情况。
许多算法都依赖于溢出被忽略。示例包括校验和,以及许多随机数和加密算法。换句话说,它们依赖于模 2^32(对于 32 位整数)进行算术运算,其中任何生成的高位都被简单地丢弃。
我曾将一个 PRNG(伪随机数生成器)从 C 语言翻译成 Pascal 语言。不幸的是,Pascal 总是进行溢出检查。(我无法找到使用我正在使用的编译器关闭它的方法。)在 C 语言中大约三行代码扩展到 Pascal 语言中大约两页代码。额外的代码只是为了避免引起溢出。(即使如此,我也不确定 Pascal 代码能否处理所有条件,但我知道只需看一眼那三行 C 代码就知道它们会正常工作。)
许多 C/C++ 程序员一开始不会意识到 unchecked
关键字有多么有用,因为他们习惯于表达式从不被检查,通常甚至没有意识到这一点。例如,我见过许多 GetHashCode
的实现会导致溢出。这是一个正确关闭哈希码计算中溢出检查的示例。
public override int GetHashCode()
{
return unchecked(name.GetHashCode() + address.GetHashCode());
}
溢出捕获
上面提到的第二种情况是您希望检测(而不是忽略)溢出。它通常发生在所使用的整数值是输入或以某种方式依赖于用户输入的情况下。例如,当我在我的十六进制编辑器(参见 http://www.hexedit.com)中添加计算器时,我遇到了这种情况。
计算器需要告知用户它执行的操作是否导致了溢出。不幸的是,代码是用 C++ 编写的,编译器不提供溢出检查的支持。为了检测溢出,代码必须检查每个操作的操作数,并尝试确定是否会发生溢出。执行此操作的代码并非微不足道,并且增加了引入新错误的几率。事实上,HexEdit 曾经发生过的唯一已知错误是由于除以零(仅当用户尝试乘以零时)引起的,而这段代码试图检测计算器中的乘法何时会导致溢出!
这就是 C# checked
关键字的妙用之处。只需将所有计算放在 checked
块中以开启溢出检查,然后将整个代码块包装在捕获 OverflowException
的 try
块中即可。
粗心者的陷阱
到目前为止,您可能已经意识到我喜欢 C# 对溢出检查的控制。但是,您应该注意一些模糊之处。
首先,它只适用于整数算术。许多人可能期望它也适用于浮点数、decimal 等,但它不适用。事实上,浮点数(float
和 double
)的溢出永远不会抛出异常,而只会返回 +/- Infinity 的特殊值。另一方面,对于 decimal
类型,checked
/unchecked
关键字也会被忽略,但溢出*总是*抛出 OverflowException
。
另一件需要注意的是,即使是整数,当尝试除以零时,checked
/unchecked
关键字也无效——总是抛出 DivideByZeroException
。这可能会让一些人感到惊讶,因为他们可能认为除以零只是溢出的一个特例。然而,在这种情况下,结果没有逻辑值——即使在 C 语言中,将整数(有符号或无符号)除以零也是一个错误,通常会导致程序终止。
另外,对于整数来说,0/0(零除以零)不会引起错误(至少在我的机器上是这样),而是结果为零。根据 C# 文档,它应该抛出 DivideByZeroException
。更有趣的是,decimal
类型确实会对 0/0 抛出 DivideByZeroException
。(float
和 double
的 0/0 会生成一个称为 NaN 的特殊值。)
在数值类型之间进行转换时,如果待转换的值溢出目标类型,也可以使用 checked
/unchecked
,但前提是源类型为浮点或整数类型,并且结果为整数类型。如果源类型或目标类型是 decimal
类型,则 checked
/unchecked
关键字会被忽略,并且总是执行溢出检查。所有其他转换都不会引起溢出(尽管可能会有精度损失),除了将 double
转换为 float
会生成 NaN(而为了保持一致性,可能会期望 +/- Infinity)。
另一个不一致之处在于,溢出检查适用于简单的算术运算(加法、减法和乘法),但不对左移操作执行溢出检查。
例如
uint a = uint.MaxValue;
uint b = checked(a << 1);
uint c = checked(a * 2);
由于移位实际上是乘以 2 的幂,您可能期望上述操作(<<
和 *
)表现相同。然而,只有乘法会引发异常,而移位只是给出与表达式在 unchecked
块中相同的结果。我发现,这又是一个不一致之处。
警告
一个更大的陷阱是,checked
和 unchecked
语句只适用于其所包含的语句块或表达式中的代码,而不适用于任何嵌套的函数调用。重要的是要认识到这些关键字只改变 C# 编译器生成的代码;它们不会在运行时设置或清除任何溢出“标志”。诱惑在于假设它们与其他语句类似地工作——例如,try
块将捕获任何嵌套函数调用中生成的异常。具有 C/C++ 背景的程序员会觉得这种语法具有误导性——在 C/C++ 中,通常会使用 #pragma
来处理这种情况。
请注意,unchecked
和 checked
块很像 C# 的 unsafe
块,因为它们控制的是代码生成,而不是运行时行为。与 unsafe
块的区别在于,如果您尝试在 unsafe
块中调用“安全”函数,编译器会生成错误。但如果您尝试在 checked
块中调用包含 unchecked
表达式的函数,反之亦然,C# 编译器甚至不会发出警告。
例如
int square(int i)
{
return i * i;
}
void f()
{
checked
{
int i = square(1000000);
}
}
当从 f()
调用时,square()
中的代码将不会被检查(除非全局编译器检查标志已开启)。没有运行时“标志”来控制溢出检查,因此函数(如上面的 square()
)无法根据其被调用的位置改变其运行时行为。因此,上述代码不会像预期那样抛出溢出异常。
请注意,您可以使用 ILDASM 查看 IL 代码来检查已编译代码如何变化。相关的命令有两种变体,有溢出检查和无溢出检查。例如,乘法命令称为 mul
和 mul.ovf
。(实际上,有两种带溢出检查的乘法命令:mul.ovf
和 mul.ovf.un
,分别用于有符号和无符号整数。)
摘要
总之,C# 对溢出处理的控制在可能发生溢出时非常有用。
我对其使用经验法则如下:
- 除非可能发生溢出条件,否则不要使用
unchecked
/checked
关键字。 - 当您预期溢出但想忽略它时,使用
unchecked
。 - 当溢出是您希望捕获的可能错误条件时,使用
checked
。 - 在调试版本中全局开启溢出检查以检测错误。
- 在发布版本中全局关闭溢出检查以提高效率。
请记住,checked
/unchecked
只对包含的语句有效,不影响嵌套函数调用。它们也只适用于简单的整数算术(而不是移位),并且只适用于从实数/整数类型到较小整数类型的转换。对于 decimal
类型,无论是否使用 unchecked
关键字,始终进行溢出检查,而纯浮点运算则从不进行检查。