条款4:使用Conditional特性代替#if条件编译
#if/#endif条件编译常用来由同一份源代码生成不同的结果文件,最常见的有debug版和release版。但是,这些工具在具体应用中并不是非常得心应手,因为它们太容易被滥用了,使用它们创建的代码通常都比较难理解,且难以调试。C#语言的设计者们对这种问题的解决方案是创建更好的工具,以达到为不同环境创建不同机器码的目的。C#为此添加了一个Conditional特性,该特性可以标示出某种环境设置下某个方法是否应该被调用。使用这种方式来描述条件编译要比#if/#endif更加清晰。由于编译器理解Conditional特性,所以它可以在Conditional特性被应用时对代码做更好的验证。Conditional特性应用在方法这一层次上,因此它要求我们将条件代码以方法为单位来表达。当需要创建条件代码块时,我们应该使用Conditional特性来代替传统的#if/#endif。
大多数程序老手都使用过条件编译来检查对象的前置条件和后置条件。例如,编写一个私有方法来检查所有类与对象的不变式(invariant)[8],然后将这样的方法进行条件编译,从而让其只出现在debug版本的程序中。
private void CheckState( )
{
// 老式的做法:
#if DEBUG
Trace.WriteLine( "Entering CheckState for Person" );
// 获取正在被调用函数的名称:
string methodName =
new StackTrace( ).GetFrame( 1 ).GetMethod( ).Name;
Debug.Assert( _lastName != null,
methodName,
"Last Name cannot be null" );
Debug.Assert( _lastName.Length > 0,
methodName,
"Last Name cannot be blank" );
Debug.Assert( _firstName != null,
methodName,
"First Name cannot be null" );
Debug.Assert( _firstName.Length > 0,
methodName,
"First Name cannot be blank" );
Trace.WriteLine( "Exiting CheckState for Person" );
#endif
}
条件编译#if和#endif使得最终release版本中的CheckState()成为一个空方法,但它在release版和debug版中都将得到调用。虽然在release版中,CheckState()什么也不做,但是我们必须为方法的加载、JIT编译和调用付出成本。
就正确性而言,这种做法一般没什么问题,但有时候还是可能会在release版本中导致一些诡异的bug。下面的代码展示了使用#if和#endif条件编译时可能常犯的错误:
public void Func( )
{
string msg = null;
#if DEBUG
msg = GetDiagnostics( );
#endif
Console.WriteLine( msg );
}
上面的代码在debug版本中运行得很好,但是放到release版本中就会输出一个空行。输出一个空行本身没有什么,但这毕竟不是我们本来的意图。我们自己搞糟的事情,编译器也帮不上什么忙,因为我们把属于程序主逻辑的代码和条件编译代码混在一起了。在源代码中随意地使用#if和#endif将使我们很难诊断不同版本间的行为差别。
C#为此提出了一种更好的选择:Conditional特性。使用Conditional特性,我们可以将一些函数隔离出来,使得它们只有在定义了某些环境变量或者设置了某个值之后才能发挥作用。Conditional特性最常用的地方就是将代码改编为调试语句。.NET框架已经为此提供了相关的功能支持。下面的代码展示了Conditional特性的工作原理,以及适用场合。
构建Person对象时,我们一般会添加如下的方法来验证对象的不变式:
private void CheckState( )
{
// 获取正在被调用函数的名称:
string methodName =
new StackTrace( ).GetFrame( 1 ).GetMethod( ).Name;
Trace.WriteLine( "Entering CheckState for Person:" );
Trace.Write( "\tcalled by " );
Trace.WriteLine( methodName );
Debug.Assert( _lastName != null,
methodName,
"Last Name cannot be null" );
Debug.Assert( _lastName.Length > 0,
methodName,
"Last Name cannot be blank" );
Debug.Assert( _firstName != null,
methodName,
"First Name cannot be null" );
Debug.Assert( _firstName.Length > 0,
methodName,
"First Name cannot be blank" );
Trace.WriteLine( "Exiting CheckState for Person" );
}
有些读者可能对上面代码中的一些库函数还不够熟悉,我们来简单介绍一下。StackTrace类使用反射(reflection,参见条款43)来获取当前正被调用的方法名。其代价相当高,但它可以极大地简化我们的工作,例如帮助我们获取有关程序流程的信息。在上面的代码中,使用它,我们便可以得到正被调用的方法名称为CheckState。其余的方法在另外两个类中,分别为System.Diagnostics.Debug和System.Diagnostics.Trace。Debug.Assert方法用于测试某个条件,如果该条件错误,程序将被终止,其他参数定义的消息也将被打印出来。Trace.WriteLine方法则会把诊断信息打印到调试控制台上。因此,如果有Person对象状态无效,CheckState方法将会显示信息,并终止程序。我们可以将其作为前置条件和后置条件,在所有的公有方法和受保护方法中调用它。
public string LastName
{
get
{
CheckState( );
return _lastName;
}
set
{
CheckState( );
_lastName = value;
CheckState( );
}
}
当首次试图将LastName属性设置为空字符串或者null时,CheckState将引发一个断言错误。这样我们就会修正set访问器以检查传递给LastName的参数。这正是我们想要的功能。
但在每个公有函数中都做这样的额外检查显然比较浪费时间,我们可能只希望其出现在调试版本中。这就需要Conditional特性了:
[ Conditional( "DEBUG" ) ]
private void CheckState( )
{
// 代码保持不变。
}
应用了Conditional特性之后,C#编译器只有在检测到DEBUG环境变量时,才会产生对CheckState方法的调用。Conditional特性不会影响CheckState()方法的编译,它只会影响对该方法的调用。如果定义有DEBUG符号,上面的LastName属性将变为如下的代码:
public string LastName
{
get
{
CheckState( );
return _lastName;
}
set
{
CheckState( );
_lastName = value;
CheckState( );
}
}
否则,将得到如下代码:
public string LastName
{
get
{
return _lastName;
}
set
{
_lastName = value;
}
}
无论是否定义有DEBUG符号,CheckState()方法的方法体都维持不变,它都会被C#编译器处理,并生成到结果程序集中。这个例子其实也向大家展示了C#编译器的编译过程与JIT编译过程之间的区别。这种做法看起来也会带来一点效率损失,但是其中耗费的成本仅仅是磁盘空间。如果没有被调用,CheckState()方法并不会加载到内存中并进行JIT编译。将CheckState()方法生成到程序集中产生的影响是非常微不足道的。这种策略耗费很小的性能,换来的却是灵活性。如果感兴趣的话,大家可以查看.NET框架类库中的Debug类来对此获得更深的理解。在每个安装有.NET框架的机器上,System.dll程序集中都包含有Debug类中所有方法的代码。当调用这些方法的代码被编译时,系统环境变量将决定这些方法是否被调用。
我们创建的方法也可以依赖于多个环境变量。当我们应用多个Conditional特性时,它们之间的组合关系将为“或(OR)”。例如,下面的CheckState方法被调用的条件为定义有DEBUG或者TRACE环境变量:
[ Conditional( "DEBUG" ),
Conditional( "TRACE" ) ]
private void CheckState( )
要创建一个使用“与(AND)”关系的构造,我们需要自己在源代码中定义预处理符号:
#if ( VAR1 && VAR2 )
#define BOTH
#endif
是的,要创建一个依赖于多个环境变量的条件程序,我们不得不回到使用#if的老式做法中去。不过所有#if都只不过是创建新的符号而已,我们应该避免将可执行代码放在其中。
Conditional特性只可以应用在整个方法上。另外需要注意的是,任何一个使用Conditional特性的方法只能返回void类型。
我们不能在一个方法内的代码块上应用Conditional特性,也不可以在有返回值的方法上应用Conditional特性。为了应用Conditional特性,我们需要将具有条件性的行为单独放到一个方法中。虽然我们仍然需要注意那些Conditional方法可能给对象状态带来的负面效应,但Conditional特性的隔离策略总归要比#if/#endif好得多。使用#if和#endif代码块,我们很有可能会错误地删除一些重要的方法调用或者赋值语句。
|
上面的例子使用了DEBUG或者TRACE这样的预定义符号,但我们也可以将其扩展到我们自己定义的符号上。Conditional特性可以被任何方式定义的符号所控制,例如编译器命令行,操作系统shell的环境变量,或者源代码pragma。
综上所述,使用Conditional特性比使用#if/#endif产生的IL代码更有效率。同时,将其限制在函数层次上可以清晰地将条件性的代码分离出来,从而使我们的代码具有更好的结构。另外,C#编译器也为此提供了很好的支持,从而帮助我们避免以前使用#if或#endif时常犯的错误。