软件约定称为代码约定,通过这一约定可以表示代码正常工作所需的正式条件。 如果方法未按预期收到数据或生成的数据不符合预期的后置条件,代码约定将导致代码引发异常。 有关前置条件和后置条件的概述,您可能需要查看我上个月发表的文章 (msdn.microsoft.com/magazine/gg983479)。
代码约定是 .NET Framework 4 的一部分,但同样依赖于 Visual Studio 2010 中的一些功能,例如运行时工具、与 MSBuild 集成以及“项目属性”框中的属性页。 值得注意的是,仅编写前置条件和后置条件是不够的。 您还需要为每个项目启用运行时检查功能才能使用软件约定。 您可以通过 Visual Studio 2010 中的“代码约定”项目属性页来完成上述操作。
在本文中,我将讨论您可以查看或选择的各个选项的预定用途,并深入讨论使用代码约定中的参数验证可以执行的最常见操作的重写程序工具和实践。
代码约定属性页
应在所有版本中还是仅在调试版本中实施代码约定前置条件和后置条件? 实际上,这取决于您对软件约定概念的理解。 它是设计工作的一部分吗? 或者,它仅是一种调试措施?
如果它是设计功能,则没理由剥离发行版中的约定。 如果它仅是一种调试技术,当在发布模式中进行编译时,您不希望显示它。
在 .NET Framework 中,代码约定仅是此框架的一部分并且未融入任何语言。 这样将更容易在项目中按版本配置它们。 因此,通过软件约定的 .NET Framework 实现,您可以决定实现约定的合适时间和地点。
图 1 显示 Visual Studio 2010 中的属性页,通过此页可以设置软件约定为应用程序工作的方式。 请注意,此类设置基于项目应用,因此可以根据需要进行调整。
图 1 Visual Studio 2010 中代码约定的属性页
您可以选择选项配置(调试、发布等)并仅对该配置应用设置。 这样,您可以启用代码约定用于调试但不用于发布,而且更重要的是,您可以随时改变决策。
运行时检查
若要启用代码约定,必须选中“执行运行时约定检查”选项。 如果未选中此选项,则在源代码中显示的任何约定说明将可能不会产生任何效果(定义了 DEBUG 符号的任何版本中的 Contract.Assert 和 Contract.Assume 例外,但这不是很重要)。 复选框控制是否在每个编译步骤结束时触发重写程序工具。 重写程序是一个外部工具,用于对软件约定进行后处理并修改 MSIL 代码,以及在合适的位置执行前置条件、后置条件和固定条件检查。
但是,请注意,如果您具有类似下面这样的前置条件,则在关闭重写程序时会得到运行时断言失败:
Contract.Requires<TException>(condition)
图 2 显示了您得到的消息框。
图 2 代码需要运行时约定检查
若要详细查看运行时检查的工作方式,请考虑以下代码:
Int32 Sum(Int32 x, Int32 y) {
ValidateOperands(x, y);
ValidateResult();
(x == y)
* x;
x + y;
}
约定详细信息使用 ContractAbbreviator 存储在 ValidateXxx 方法中,如上个月的专栏所讨论。 以下是 ValidateXxx 方法的源代码:
[ContractAbbreviator]
ValidateOperands(Int32 x, Int32 y) {
Contract.Requires(x >= && y >= );
}
[ContractAbbreviator]
ValidateResult() {
Contract.Ensures(Contract.Result<Int32>() >= );
}
如果您使用 Contract.Requires 而不是 Contract.Requires<TException>,则在某个版本中关闭重写程序时不会出现图 2 所示的失败。 图 2 中的消息框是由 Contract.Requires 的内部实现所致,如下所示:
[Conditional()]
Requires( condition, userMessage) {
AssertMustUseRewriter(
ContractFailureKind.Precondition, );
}
Requires<TException>( condition)
TException: Exception {
AssertMustUseRewriter(
ContractFailureKind.Precondition, );
}
条件属性向编译器指示除非定义了 CONTRACTS_FULL 符号,否则应忽略此类方法调用。 仅当您启用“执行运行时约定检查”选项时,才定义此符号。 由于 Contract.Requires<TException> 不是根据条件定义的且缺少该属性,因此将执行重写程序检查,如果禁用运行时约定检查,则会导致失败的断言。
接下来我们将考虑对上述代码使用重写程序的效果。 您可以方便地亲自验证我所说的,方法是仅使用断点并按 Ctrl+F11 在 Visual Studio 2010 中打开反汇编视图。 图 3 显示了在未启用运行时约定检查的情况下,逐步查看编译的 Sum 方法时反汇编视图的内容。 正如您所看到的,源代码与您在类中编写的代码相同。
图 3 不执行运行时约定检查时的反汇编视图
如果启用运行时检查,重写程序工具将通过编译器传递,返回并编辑 MSIL 代码。 如果您在启用代码约定的情况下逐步执行相同代码,将看到类似图 4 的内容。
图 4 Return 语句后执行的后置条件检查
明显的区别是在退出 Sum 方法之前且在 return 语句之后调用 ValidateResult。 您不必是 MSIL 专家就能了解图 4 中所示代码的状况。 在方法开始接受最上面位置的前置条件之前,将对操作数进行验证。 包含后置条件的代码将移动到方法的底部,最后一个 return 语句的 MSIL 代码将也是如此。 更有意思的是,第一个 return 语句(Sum 方法中实现快捷方式的语句)现在只跳到 ValidateResult 开始的地址:
...
return 2 * x00000054 mov eax,dword ptr
00000057 add eax,eax
00000059 mov dword ptr ,eax
0000005c nop
0000005d jmp 0000006B
...
ValidateResult()0000006b push dword ptr ds:
...
回到图 1,请注意“执行运行时约定检查”复选框旁边的下拉列表。 您可以通过该列表指示要启用的软件约定数目。 存在多个级别:“完全”、“前置和后置”、“前置条件”、“发行版要求”和“无”。
“完全”表示支持所有类型的软件约定,“无”表示不考虑任何软件约定。 “前置和后置”排除固定条件。 “前置条件”还排除 Ensure 语句。
“发行版要求”不支持 Contract.Requires 方法,仅允许使用 Requires<TException> 或旧的 If-Then-Throw 格式指定前置条件。
通过项目属性页可按项目启用或禁用运行时检查,但是如果您只想对代码的一些部分禁用运行时检查该怎么办? 在这种情况下,只需使用 ContractRuntimeIgnored 属性以编程方式禁用运行时检查。 但是,最新发行版 (1.4.40307.0) 中增加了新的“跳过限定符”选项,该选项也不允许您执行任何包含对 Contract.ForAll 或 Contract.Exists 的引用的约定。
可以对在 Contract 表达式中使用的成员应用属性。 如果成员已使用此属性加以修饰,则显示该成员的整个 Contract 语句将不会进行运行时检查。 属性不会在 Assert 和 Assume 等 Contract 方法中识别。
程序集模式
代码约定属性还可用于为约定配置“程序集模式”设置。 此设置是指您打算执行参数验证的方式。 有两个可能的选项:“标准约定要求”和“约定引用程序集”。 程序集模式设置可帮助重写程序等工具在必要时优化代码并给出合适的警告。 假设您使用程序集模式来声明您使用代码约定进行参数验证的意图。 程序集模式设置引入了一些必须符合的简单规则,否则您将收到编译错误。
如果您使用 Requires 和 Requires<T> 方法验证参数,程序集模式必须设置为“标准约定要求”。 如果您使用任何 If-Then-Throw 语句作为前置条件,则应使用“自定义参数验证”。 如果您不使用“自定义参数验证”,该语句将被视为 Requires<T>。 自定义参数验证的组合以及任何形式的 Requires 语句的显式使用将引发编译器错误。
使用 Requires 和使用 If-Then-Throw 语句之间有何差别? If-Then-Throw 语句在验证失败时始终引发您指示的异常。 在这一点上,它与 Requires 不同,但与 Requires<T> 相似。 纯 If-Then-Throw 语句也不会被约定工具(重写程序和静态检查程序)发现,除非您在该语句后调用 EndContractBlock。 使用 EndContractBlock 时,它必须是您在方法中调用的最后一个代码约定方法。 其后不能执行任何其他代码约定调用:
(y == )
ArgumentException();
Contract.EndContractBlock();
此外,Requires 语句是自动继承的。 除非您也使用 EndContractBlock,否则不会继承 If-Then-Throw 语句。 在旧模式中,不会继承 If-Then-Throw 约定。 实际上,您必须手动执行约定继承。 如果这些工具未检测到前置条件在重写和接口实现中重复,将尝试发出警告。
最后,请注意,不允许 ContractAbbreviator 包含任何 If-Then-Throw 语句,但您可以对该属性使用约定验证程序。 缩写方法只能包含常规 Contract 语句进行参数验证。
其他设置
在代码约定属性页中,如果选中“执行运行时约定检查”选项,则将启用其他一些有用选项。
如果启用“约定失败时断言”选项,则当违反约定时,将导致描述失败上下文的断言。 您将看到类似于图 2 中所示内容的消息框,并且可以选择一些选项。 例如,您可以再次尝试附加调试器,中止应用程序或者直接忽略失败并继续。
您可能希望仅将此选项用于调试版本,因为显示的信息对于一般最终用户来说可能没有意义。 代码约定 API 提供了一个集中式异常处理程序用来捕获任何冲突,并由您判断错误的真正根源。 您收到的信息将区分是前置条件、后置条件还是固定条件失败,但仅使用布尔表达式并可能使用配置的错误消息来描述错误特征。 换句话说,从集中式异常处理程序轻松恢复有点难度:
Contract.ContractFailed += CentralizedErrorHandler;
下面是说明处理程序的一些代码:
CentralizedErrorHandler(
Object sender, ContractFailedEventArgs e) {
Console.WriteLine(, e.
FailureKind, e.Condition, e.Message);
e.SetHandled();
}
如果要在运行时引发特定异常,则可以使用 Requires<TException>。 如果您打算限制调试版本约定的使用或者如果您不关心异常的实际类型是什么,则可以使用 Requires 和集中式处理程序。 通常这足够指明发生了错误。 例如,许多应用程序在顶层都具有可捕获各种类型异常并指出如何重新启动的全能功能。
“仅公共接口约定”选项是指您希望实施代码约定的位置:每个方法或仅公共方法。 如果选中该选项,重写程序将忽略代码约定语句的私有和受保护成员,并仅处理公共成员的约定。
如果您将代码约定融入您的整体设计从而在任何地方使用代码约定,选中此选项很有意义。 但是,一旦应用程序做好交付准备,作为一种优化形式,您可以不必检查内部成员参数,因为没有外部代码直接调用这些成员。
将代码约定限制为程序集的公共接口的想法是否好用,还取决于您编写代码的方式。 如果您可以保证公共接口对较低级别发出的任何调用都是正确的,则这是一种优化形式。 如果不能保证,禁用内部方法的约定可导致出现令人厌烦的错误。
“调用网站需要检查”选项提供了另一种优化方案。 假设您正在编写要由其他程序集中的模块使用的库。 出于性能考虑,您禁止对约定进行运行时检查。 但是,只要您创建了约定引用程序集,就可以使调用方检查所调用的每个方法的约定。
包含使用代码约定的类的每个程序集中都可能存在约定引用程序集。 其中包含具有 Contract 语句但没有其他代码的父程序集的公共可见接口。 可以从代码约定属性页对程序集的创建进行排序和控制。
旨在调用库的任何代码都可能引用约定引用程序集,并且可以通过仅启用“调用网站需要检查”选项自动导入约定。 处理调用方代码时,重写程序将为随约定引用程序集一起提供的外部程序集上调用的每个方法导入约定。 这种情况下,将在调用站点(位于调用方侧)检查约定,并保留可对不直接控制的代码启用和禁用的可选行为。
总结
代码约定是 .NET Framework 的一个方面,值得进行更多研究。 我这里只对配置选项进行了简要的讨论,甚至未涉及静态检查程序的使用。 代码约定可帮助您更清楚地设计应用程序以及编写更简洁的代码。
若要了解有关代码约定的详细信息,请参见 Melitta Andersen 2009 年 6 月的“CLR 全面透彻解析”专栏 (msdn.microsoft.com/magazine/ee236408) 和 DevLabs Code Contracts 网站 (msdn.microsoft.com/devlabs/dd491992)。 您还可以在 Microsoft Research 的代码约定网站 (research.microsoft.com/projects/contracts) 上找到有关代码约定开发的有趣背景信息。