原则4:用条件属性而不是#if

JerryXia 发表于 , 阅读 (886)

使用#if/#endif
块可以在同样源码上生成不同的编译(结果),大多数debug和release两个版本。但它们决不是我们喜欢用的工具。由于#if/#endif很容易被滥用,使得编写的代码难于理解且更难于调试。程序语言设计者有责任提供更好的工具,用于生成在不同运行环境下的机器代码。C#就提供了条件属性(Conditional
attribute)来识别哪些方法可以根据环境设置来判断是否应该被调用。

(译注:属性在C#里有两个单词,一个是property另一个是attribute,它们有不是的意思,但译为中文时一般都是译为了属性。property是指一个对象的性质,也就是Item1里说的属性。而这里的attribute指的是.net为特殊的类,方法或者property附加的属性。可以在MSDN里查找attribute取得更多的帮助,总之要注意:attribute与property的意思是完全不一样的。)

这个方法比条件编译#if/#endif更加清晰明白。编译器可以识别Conditional属性,所以当条件属性被应用时,编译器可以很出色的完成工作。条件属性是在方法上使用的,所以这就使用你必须把不同条件下使用的代码要写到不同的方法里去。当你要为不同的条件生成不同的代码时,请使用条件属性而不是#if/#endif块。

很多编程老手都在他们的项目里用条件编译来检测先决条件(per-conditions)和后续条件(post-conditions)。

(译注:per-conditions,先决条件,是指必须满足的条件,才能完成某项工作,而post-conditions,后续条件,是指完成某项工作后一定会达到的条件。例如某个函数,把某个对象进行转化,它要求该对象不能为空,转化后,该对象一定为整形,那么:per-conditions就是该对象不能为空,而post-conditions就是该对象为整形。例子不好,但可以理解这两个概念。)

你可能会写一个私有方法来检测所有的类及持久对象。这个方法可能会是一个条件编译块,这样可以使它只在debug时有效。

private void CheckState( )
{
    // The Old way:
    \#if DEBUG
    Trace.WriteLine( "Entering CheckState for Person" );
    // Grab the name of the calling routine:
    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编译选项(pragmas),你已经为你的发布版(release)编译出了一个空方法。这个CheckState()方法会在所有的版本(debug和release)中调用。而在release中它什么也不做,但它要被调用。因此你还是得为例行公事的调用它而付出小部份代价。

不管怎样,上面的实践是可以正确工作的,但会导致一个只会出现在release中的细小BUG。下面的就是一个常见的错误,它会告诉你用条件编译时会发生什么:

public void Func( )
{
    string msg = null;

   \#if DEBUG
    msg = GetDiagnostics( );
   \#endif
    Console.WriteLine( msg );
}

这一切在Debug模式下工作的很正常,但在release下却输出的为空行。release模式很乐意给你输出一个空行,然而这并不是你所期望的。傻眼了吧,但编译器帮不了你什么。你的条件编译块里的基础代码确实是这样逻辑。一些零散的#if/#endif块使你的代码在不同的编译条件下很难得诊断(diagnose)。

C#有更好的选择:这就是条件属性。用条件属性,你可以在指定的编译环境下废弃一个类的部份函数,
而这个环境可是某个变量是否被定义,或者是某个变量具有明确的值。这一功能最常见的用法就是使你的代码具有调试时可用的声明。.Net框架库已经为你提供了了基本泛型功能。这个例子告诉你如何使用.net框架库里的兼容性的调试功能,也告诉你条件属性是如何工作的以及你在何时应该添加它:

当你建立了一个Person的对象时,你添加了一个方法来验证对象的不变数据(invariants):

private void CheckState( )
{
    // Grab the name of the calling routine:
    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
类通过反射取得了调用方法的的名字。这样的代价是昂贵的,但它确实很好的简化了工作,例如生成程序流程的信息。这里,断定了CheckState所调用的方法的名字。被判定(determining)的方法是System.Diagnostics.Debug类的一部份,或者是System.Diagnostics.Trace类的一部份。Degbug.Assert方法用来测试条件是否满足,并在条件为false时会终止应用程序。剩下的参数定义了在断言失败后要打印的消息。Trace.WriteLine输出诊断消息到调试控制台。因此,这个方法会在Person对象不合法时输出消息到调试控制台,并终止应用程序。你可以把它做为一个先决条件或者后继条件,在所有的公共方法或者属性上调用这个方法。

public string LastName
{
  get
  {
    CheckState();
    return \_lastName;
  }
  set
  {
    CheckState();
    \_lastName = value;
    CheckState();
  }
}

在某人试图给LastName赋空值或者null时,CheckState会在第一时间引发一个断言。然后你就可以修正你的属性设置器,来为LastName的参数做验证。这就是你想要的。

但这样的额外检测存在于每次的例行任务里。你希望只在调试版中才做额外的验证。这时候条件属性就应运而生了:

[Conditional("DEBUG")]
private void CheckState( )
{
  // same code as above
}

Conditional属性会告诉C#编译器,这个方法只在编译环境变量DEBUG有定义时才被调用。同时,Conditional属性不会影响CheckState()函数生成的代码,只是修改对函数的调用。如果DEBGU标记被定义,你可以得到这:

public string LastName
{
  get
  {
    CheckState( );
    return \_lastName;
  }
  set
  {
    CheckState( );
    \_lastName = value;
    CheckState( );
  }
}

如果不是,你得到的就是这:

public string LastName
{
  get
  {
    return \_lastName;
  }
  set
  {
    \_lastName = value;
  }
}

不管环境变量的状态如何,CheckState()的函数体是一样的。这只是一个例子,它告诉你为什么要弄明白.Net里编译和JIT之间的区别。不管DEBUG环境变量是否被定义,CheckState()方法总会被编译且存在于程序集中。这或许看上去是低效的,但这只是占用一点硬盘空间,CheckState()函数不会被载入到内存,更不会被JITed(译注:这里的JITed是指真正的编译为机器代码),除非它被调用。它存在于程序集文件里并不是本质问题。这样的策略是增强(程序的)可伸缩性的,并且这样只是一点微不足道的性能开销。你可以通过查看.Net框架库中Debug类而得到更深入的理解。在任何一台安装了.Net框架库的机器上,System.dll程序集包含了Debug类的所有方法的代码。由环境变量在编译时来决定是否让由调用者来调用它们。

你同样可以写一个方法,让它依懒于不只一个环境变量。当你应用多个环境变量来控制条件属性时,他们时以or的形式并列的。例如,下面这个版本的CheckState会在DEBUG或者TRACE为真时被调用:

[Conditional("DEBUG"), Conditional("TRACE")]
private void CheckState()

如果要产生一个and的并列条件属性,你就要自己事先直接在代码里使用预处理命令定义一个标记:

\#if ( VAR1 && VAR2 )
\#define BOTH
\#endif

是的,为了创建一个依懒于前面多个环境变量的条件例程(conditional
routine),你不得不退到开始时使用的#if实践中了。#if为我们产生一个新的标记,但避免在编译选项内添加任何可运行的代码。

Conditional属性只能用在方法的实体上,另外,必须是一个返回类型为void的方法。你不能在方法内的某个代码块上使用Conditional,也不能在一个有返回值的方法上使用Conditional属性。取而代之的是,你要细心构建一个条件方法,并在那些方法上废弃条件属性行为。你仍然要回顾一下那些具有条件属性的方法,看它是否对对象的状态具有副作用。但Conditional属性在安置这些问题上比#if/#endif要好得多。在使用#if/#endif块时,你很可能错误的移除了一个重要的方法调用或者一些配置。

前面的例子合用预先定义的DEBUG或者TRACE标记,但你可以用这个技巧,扩展到任何你想要的符号上。Conditional属性可以由定义标记来灵活的控制。你可以在编译命令行上定义,也可以在系统环境变量里定义,或者从源代码的编译选择里定义。

使用Conditional属性可以比使用#if/#endif生成更高效的IL代码。在专门针对函数时,它更有优势,它会强制你在条件代码上使用更好的结构。编译器使用Conditional属性来帮助你避免因使用#if/#endif而产生的常见的错误。条件属性比起预处理,它为你区分条件代码提供了更好的支持。

添加新评论