65.9K
CodeProject 正在变化。 阅读更多。
Home

消除 #if 预处理器指令

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.67/5 (7投票s)

2014年7月15日

CPOL

5分钟阅读

viewsIcon

22801

也许只是我,也许我有点老派,又或者,我还没够老派

在我看来,使用 #if 预处理器指令应该是一种最后的手段,而不是首选。想象一下你正在团队中参与一个项目。你创建了一个包含有用的方法的类……然后大家开始基于它编写代码。当然,他们信任你提供的稳定实现,并且不需要在调用之前审查你的代码。所以,也许你的实现看起来是这样的……

static class BestUtilityEver
{
    static void DoSomethingUseful(int id
        #if DEBUG
        , string value
        #endif
        )
    {
        #if DEBUG
        Console.WriteLine("{0:N} = {1}", id, value);
        #else
        Console.WriteLine("{0:N}", id);
        #endif
    }
}

于是,你的同事过来开始使用这个新工具,并觉得它非常有帮助。他的实现可能看起来是这样的……

static class AwesomeProgram
{
    static void Main(string[] args)
    {
        BestUtilityEver.DoSomethingUseful(100, "this is great!");
    }
}

代码可以编译,并且测试起来很棒!它顺利通过了 CI(持续集成)流程,甚至在 QA 中也没有任何问题。然后代码被构建用于发布……而那个配置并没有定义 DEBUG 指令。那么现在代码会发生什么?

  • AwesomeProgram.cs: 错误 CS1501:没有接受 2 个参数的方法重载 ‘DoSomethingUseful

现在,你同事的代码破坏了整个团队都认为通过 QA 的稳健的构建。对 DoSomethingUseful 的调用本应只在 #if DEBUG 的情况下传递第二个参数,但你的同事怎么会知道呢?没有任何 IDE 可以识别的注释来警告用户‘value’参数是条件性的,参数名也没有表明它仅在 #if DEBUG 时存在,所以除非不信任你的实现并阅读你提供的每一个方法(现在整个团队都会这样做),否则任何人都没有办法知道。所有这些尴尬、麻烦以及由此产生的对你代码的不信任都可以避免。

另一个更痛苦的场景是编译器无法捕获的,并且很容易进入生产环境。下面这个过于简化的例子表明了滥用预处理器指令如何轻易地在生产环境中造成麻烦,代码无论是否定义了 DEBUG 预处理器符号都可以完美编译。这很容易是 #if 中一个更重要或关键的代码片段,在这里,它只会避免我们在定义 DEBUG 时出现空引用异常,并在生产环境中引入一个可能的空引用异常。

using System.Linq;

namespace Blog.PreprocessorDirectives
{
    class Program
    {
        static void Main(string[] args)
        {
            BestUtilityEver.DoSomethingUseful(args.FirstOrDefault());
        }
    }

    public static class BestUtilityEver
    {
        public static void DoSomethingUseful(string id)
        {
            string IdInternal = id;
#if DEBUG
            IdInternal = string.IsNullOrWhiteSpace(IdInternal) ? string.Empty : IdInternal;
#endif
            foreach (var c in IdInternal)
            {
                // Do something fantastic here
            }
        }
    }
}

在这个例子中,根本就不应该使用该指令。也许一个可能正确使用 #if 的更好例子是……

#if DEBUG
            IdInternal = string.IsNullOrWhiteSpace(IdInternal) ? "some value": IdInternal;
#else
            if (string.IsNullOrWhiteSpace(IdInternal)) return;
#endif

……至少现在我们可以看到备用代码的明显好处。如果是调试模式,就确保我们有一个 string 值,无论出于何种原因,否则则提前返回,因为没有理由继续下去。

如何使用 ConditionalAttribute 避免这种情况?

using System.Linq;

namespace Blog.PreprocessorDirectives
{
    class Program
    {
        static void Main(string[] args)
        {
            BestUtilityEver.DoSomethingUseful(args.FirstOrDefault());
        }
    }

    public static class BestUtilityEver
    {
        public static void DoSomethingUseful(string id)
        {
            string IdInternal = id;
            DebugFixUpString(ref IdInternal);
            if (string.IsNullOrWhiteSpace(IdInternal)) return;

            foreach (var c in IdInternal)
            {
                // Do something fantastic here
            }
        }

        [System.Diagnostics.Conditional("DEBUG")]
        private static void DebugFixUpString(ref string IdInternal)
        {
            IdInternal = string.IsNullOrWhiteSpace(IdInternal) ? "some value" : IdInternal;
        }
    }
}

有了 ConditionalAttribute,我们获得了一些好处。根据你的具体情况,你可能会获得其他好处。

  1. 所有代码始终存在,并且始终需要编译。
  2. 所有代码都可以被编辑器看到,以便进行重构和符号重命名等高级操作。
  3. 即使是调试场景也可以像发布场景一样运行,而无需修改任何其他内容,只需修改调试方法体。

这对发布来说是可行的吗?简单来说,编译器会将 DebugFixUpString 方法变成一个空操作(no-op),因此任何运行时调用它的代码现在都只是调用一个空操作。

上面关于 ConditionalAttribute 的示例,其发布版本的反射代码看起来是这样的。

using System;
using System.Diagnostics;

namespace Blog.PreprocessorDirectives
{
    public static class BestUtilityEver
    {
        [Conditional("DEBUG")]
        private static void DebugFixUpString(ref string IdInternal)
        {
            IdInternal = (string.IsNullOrWhiteSpace(IdInternal) ? "some value" : IdInternal);
        }

        public static void DoSomethingUseful(string id)
        {
            string IdInternal = id;
            if (string.IsNullOrWhiteSpace(IdInternal))
            {
                return;
            }
            string str = IdInternal;
            for (int i = 0; i < str.Length; i++)
            {
                char chr = str[i];
            }
        }
    }
}

你可以清楚地看到我们的条件方法仍然存在,但你也可以清楚地看到编译器已经删除了对其的调用,因为在编译时没有定义 DEBUG 符号。

预处理器指令的另一个常见场景是兼容性。考虑一下你正在创建一个 SDK 并提供支持多个 .NET Framework 版本的功能。你可能在想,这肯定是一个使用 #if 的地方!其实不然。我们仍然可以很容易地在多版本目标定位中避免使用 #if

使用简单的项目结构和部分类,我们可以定位不同的 .NET Framework,提供不同的框架功能和底层实现,而不会影响最终的实现细节。这是一个项目结构示例,其中创建了一个 .NET Framework 4.0 项目,然后在一个 .NET Framework 3.5 项目中引入了向后兼容性,该项目只是链接了所有 4.0 项目文件,并将具有兼容性问题的类拆分为部分类,为 .NET Framework 3.5 目标提供备用实现。

BestUtilityEver 类现在被拆分到三个文件中。一个通用文件,其实现与两个框架版本兼容;一个 v3.5 文件,用于 v3.5 特有功能;以及一个 v4.0 文件,用于 v4.0 特有功能。所有这三个文件在运行时共同构成一个单一类,并为消费实现提供所有必需的签名。这些文件看起来是这样的

namespace Blog.PreprocessorDirectives
{
    public static partial class BestUtilityEver
    {
        public static void DoSomethingUseful(string id)
        {
            string IdInternal = id;
            DebugFixUpString(ref IdInternal);
            if (StringIsNullOrWhiteSpace(IdInternal)) return;

            foreach (var c in IdInternal)
            {
                // Do something fantastic here
            }
        }
    }
}
namespace Blog.PreprocessorDirectives
{
    public static partial class BestUtilityEver
    {
        private static void DebugFixUpString(ref string IdInternal)
        {
            IdInternal = string.IsNullOrEmpty(IdInternal) ? "some value" : IdInternal;
        }

        private static bool StringIsNullOrWhiteSpace(string value)
        {
            return string.IsNullOrEmpty(value) || value.Trim().Length == 0;
        }
    }
}
namespace Blog.PreprocessorDirectives
{
    public static partial class BestUtilityEver
    {
        private static void DebugFixUpString(ref string IdInternal)
        {
            return;
        }

        private static bool StringIsNullOrWhiteSpace(string value)
        {
            return string.IsNullOrEmpty(value) || value.Trim().Length == 0;
        }
    }
}

那么,如何在代码中避免使用 #if

  • 不要使用预处理器指令。
    找出你出现这种差异的原因,并尽量在你的解决方案中规避它。如果不行,尝试使用其他机制,可能是 ConditionalAttribute 或类似的东西,让编译器能够理解并优雅地处理差异。
  • 了解你的工具。
    了解你的开发工具和编译器有哪些能力。
  • 注释你的代码。
    如果你尝试了所有其他选项后不得不使用预处理器指令,那么请以你的团队使用的 IDE 可以理解的方式注释你的代码,这样其他团队成员至少有机会理解他们需要比平常更关注这个特定的实现。给你的同事一个避免破坏构建的机会。

在我写这篇文章的时候,我的一个同事给我发了一个案例研究链接,题为“#ifdef 被证实有害:促进可理解的软件变体”,声称预处理器指令实际上对管理软件变体是有害的。这是一篇非常有趣的读物,你可以自己看看。你对预处理器指令有什么想法和经验?我说得对吗?我说得错了吗?我是否彻底脱离了实际?就像我一开始说的;也许只是我,也许我有点老派,又或者,我还没够老派,但我很想知道你的想法。

如果你去寻找,你找到更好解决方案的可能性会无限增加。

© . All rights reserved.