预处理指令(Preprocessor),是在程序编译期间,特别告诉编译器一些特殊行为的指令。这些指令一定是以井号开头,且以单行作为单位书写,因此预处理指令也不需要添加分号结尾。
预处理指令可以被划分为如下几类:

  • 条件编译指令:#if、#endif、#else、#elif、#define、#undef
  • 引发警告和错误指令:#warning、#error
  • 行号指示指令:#line
  • 代码折叠展开指令:#region、#endregion
  • 消除警告信息指令:#pragma warning

预处理指令的内容其实并不多。虽然也分成很多类,但是介绍其实并不是很多,因此打算将它们全部写在这一篇文章里。下面我们来看看它们的用法。

实际上,还存在一些别的预处理指令,例如 #pragma checksum。但是这个玩意儿对于初学来说,用处非常小,它一般是提供给“代码生成代码”过程所需,检查文件是否有变动。

Part 1 条件编译

1-1 条件编译的基本用法

条件编译(Conditional Compilation)是针对于编程环境来制定的一种特殊编译规则。它不能通过代码来实现,而是通过某一个特征符号来代表当前环境是如何的。举个例子,C 语言里的 int 的大小随着机器的情况,而产生变化。条件编译刚好可以通过设置一个符号来表达这样的情况。当用户在使用程序代码的时候,如果这个环境不是这样的,我们只需要删除符号就可以达到切换编译模式的效果。其中,我们使用的、用来区分不同环境的这个符号叫做条件编译符号(Conditional Compilation Symbol)。
下面我们来详细解释一下条件编译的过程。我们之前使用过指针,来完成对底层的兼容。但是很遗憾的是,C 语言的 int 和 C# 的 int 貌似并不能够直接划等号。C 语言的 int 是不定长的,因为它随着系统的位数有不同的情况;而 C# 里,int 是固定大小。那么,我们不得不在 C# 里指定大小,避免程序的崩溃。

  1. [DllImport("SomeDll")]
  2. static extern unsafe void SortArray(int* arr, int length);

比如这样的函数。在导入的时候,int* 和 int 都必须要指定长度,因此,我们需要借助 MarshalAs 特性指定。

[DllImport("SomeDll")]
static extern unsafe void SortArray(
    int* arr,
    [MarshalAs(UnmanagedType.I4)] int length
);

int* 则不必指定,因为它的类型是指针。指针类型的长度则和 C 里是一样的。这样还不够。因为 int 大小是没有指定的,因此我们这里需要借助一个操作了。
我们在 int length 的周围添加这样一段代码,使得整个函数的声明改成这样:

[DllImport("SomeDll")]
static extern unsafe void SortArray(
    int* arr,
#if TARGET_64BITS
    [MarshalAs(UnmanagedType.I8)] long length
#else
    [MarshalAs(UnmanagedType.I4)] int length
#endif
);

虽说这么写很丑陋,但是我们这里改动了一些地方。首先,我们使用 #if TARGET_64BITS、#else 和 #endif 包装了整个参数;而且我们还写上了两个截然不同的“方案”,一个是用的 long,而另外一个则是用的 int 类型。这个作用是什么呢?用户自己是知道你电脑是什么系统、什么位数的。如果你是 64 位,那么你可以去配置一个叫做 TARGET_64BITS 的符号到项目配置里,然后,C# 自动就会认为 TARGET_64BITS 这个符号存在,那么 #if TARGET_64BITS 和 #else 之间的这段代码就会得到编译,而 #else 和 #endif 之间的部分就在 C# 编译器编译代码的时候忽略掉了。
我们在项目配置菜单里找到“Conditional compilation symbols”(条件编译符号),然后填入 TARGET_64BITS,保存后关闭。c54ca46f1d97666a75d210599252f5f3474177dc.png@942w_540h_progressive.webp
接着你就可以在你的代码里,条件编译的部分,代码会有着色,但是不编译的部分,就不会有着色(是灰色的)。
c94c2f291037997f553d3ecd8ddf8b2c4eefe005.png@902w_389h_progressive (1).webp
那么这个就称为条件编译:我们可以通过自行的配置,将代码传给所有人使用的时候,按自己的需求去更替和修改条件编译符号,然后达到编译不同的代码的效果。
当然了,你可能会问我,这些符号是随便写的吗?如果是的话,我怎么知道代码里用到了什么符号呢?这个问题其实解释起来很简单:这个是写代码的这个人必须给出来的。你如果要用,那么就可以参考符号的用途,来配置或取消配置这个符号。

1-2 临时配置编译符号

条件编译符号的灵活之处在于它并不一定非得在项目配置菜单里,我们完全可以将符号写在代码里,然后提供用户到底是否是用,还是不用。
我们使用 #define 指令来完成。我们在代码文件的最开头添加 #define TARGET_64BITS 来启用这个符号。

#define TARGET_64BITS

using System.Runtime.InteropServices;

internal class Program
{
    private static unsafe void Main()
    {
    }

    [DllImport("SomeDll")]
    static extern unsafe void SortArray(
        int* arr,
#if TARGET_64BITS
        [MarshalAs(UnmanagedType.I8)] long length
#else
        [MarshalAs(UnmanagedType.I4)] int length
#endif
    );
}

这样也可以。你甚至可以不用去配置菜单里添加符号,就可以做到。但是前提是,这个符号在 #define 指令的用法下,仅对这一个文件有效。
当然了,你也可以用 #undef 来手动取消对某个符号的启用。假设你配置了这个符号,你可以使用 #undef 符号名 的格式来取消符号的定义。

1-3 多条件编译符号的判断

因为 C 语言的 int 类型随系统位数变化而变更大小,而系统位数还可能是 16 位,所以我们可以这么去操作那段代码:

#if TARGET_16BITS
    [MarshalAs(UnmanagedType.I2)] short length
#elif TARGET 32BITS
    [MarshalAs(UnmanagedType.I4)] int length
#elif TARGET 64BITS
    [MarshalAs(UnmanagedType.I8)] long length
#endif

我们使用 #elif 指令,可以对前文的条件编译符号不存在的时候,继续判断另外的符号,这相当于 else-if 组合的条件判断。

elif 是 else 和 if 两个单词的拼接。

Part 2 引发自定义警告和错误

有些时候我们可能需要一点必要的手段来提供给用户,不要随意和滥用编译符号。比如前面的例子里,如果有些用户故意三个符号都不配置的话,那么这个参数就留空了。那么前面的参数最后跟了一个逗号,这必然会产生很严重的错误。
我们可以这样。如果三个符号都不配置,我们就在最后一个代码段落里添加一个 #error 指令,来提供给用户。

#if TARGET_16BITS
    [MarshalAs(UnmanagedType.I2)] short length
#elif TARGET 32BITS
    [MarshalAs(UnmanagedType.I4)] int length
#elif TARGET 64BITS
    [MarshalAs(UnmanagedType.I8)] long length
#else
#error You should specify one symbol in 'TARGET_16BITS', 'TARGET_32BITS' and 'TARGET_64BITS'.
#endif

这里的 #error You should specify one symbol in ‘TARGET_16BITS’, ‘TARGET_32BITS’ and ‘TARGET_64BITS’. 看起来很长,实际上后面的文字是我们自定义的,这表示错误信息。当如果不配置上面的三个符号的话,第 8 行自动会产生一个编译器错误,告知用户“你不能这么用”。
那前面逗号的编译错误呢?没事,用户可能会因为别的原因,删掉那个逗号来避免编译错误,但这是很危险的行为;所以单独给它设置一条错误信息,可以提供给用户,告诉用户你不可以这么用。
在实际代码编译的时候,因为你正常的配置符号,因此前面三条代码会挨个判断,#error 指令完全碰不着。因此,你也不用担心,这段代码是否会永久遇到这个编译错误指令。

Part 3 人为变动行号和文件信息

我们简单说一下就可以了。这个符号对入门来说,也没啥大用。#line 指令表示,我们从添加这个指令开始,这一行的编号就是指定的数值了。

internal class Program
{
    private static unsafe void Main()
    {
#line 300
        Console.WriteLine("Hello");
    }
}

如果我们试着这么写代码的话,假如我们没写 using System; 的话,显然 Console 就不知道在哪里了,于是编译器就会产生错误信息:找不到 Console。
925ff4cb9af905e778ea9a8bd67cf23b40560940.png@942w_402h_progressive.webp
不过,你查看详细错误信息的时候,在最后你会发现,行号改成 300 了。

这个就是这个符号的用法。我们可以指定错误编号在哪里,来故意告知用户所在位置是一些特殊位置。你甚至可修改文件名称:

internal class Program
{
    private static unsafe void Main()
    {
#line 300 "UnknownFile"
        Console.WriteLine("Hello");
    }
}

然后,你在查看详情的时候:

这个 #line 指令就是这个用法。

Part 4 给代码增加额外的折叠块

如果代码非常复杂,我们可能会用到 #region 和 #endregion 指令。#region 和 #endregion 指令是成对出现的,和 #if 以及 #endif 是一样的,都得成对出现。
举个例子,我们用上之前的求质数的代码。我们在三大段代码之间分别加上 #region 和 #endregion 指令。

using System;

internal class Program
{
    private static void Main()
    {
        #region Input a value
        int val;
        while (true)
        {
            try
            {
                val = int.Parse(Console.ReadLine());
                break;
            }
            catch (FormatException)
            {
            }
        }
        #endregion

        #region Check whether the value is a prime number
        bool isPrime = true;
        for (int i = 2; i <= Math.Sqrt(val); i++)
        {
            if (val % i == 0)
            {
                isPrime = false;
                break;
            }
        }
        #endregion

        #region Output the result
        if (isPrime)
            Console.WriteLine("{0} is a prime number.", val);
        else
            Console.WriteLine("{0} isn't a prime number.", val);
        #endregion
    }
}

在 #region 指令后可以添加额外的补充说明文字。然后你就会发现,你的代码页面的左边,多了一个加减号。点击这个符号,这段代码就折叠起来了。
image.png
这个指令就是这么用的。我们可以人为指定代码块,然后给它写上文字;到时候,代码就可以通过 VS 提供的功能进行代码的折叠;另外,折叠之后,那个我们写在 #region 指令后面的文字是可以在折叠的时候呈现出来的。当然了,这个文字是可以不写的。

Part 5 总结

总的来说,预处理指令并不是很重要,但是对一些代码起着非常重要的作用。如果没有它们,我们可能无法做到一些行为;这些行为可能就是为了补足和完善代码书写过程。