第 95 讲:C# 3 之分部方法
下面我们要介绍两个方法的语法拓展特性:分部方法和扩展方法。先来看简单的:分部方法(Partial Method)。
Part 1 partial
用在方法上
考虑一种情况。假设我对这个类型实现了众多的情况,它们完成的任务各不相同。
比如我有一个 Work
类型,里面包含 Do
方法表示完成一个任务。这个任务有众多的步骤需要我们挨个完成。我们假设它们分别表示为不返回任何数值的 Step1
、Step2
、Step3
方法(当然也可以有 Step4
等等,这里就假设三个)。
问题是,我把这个实现过程的步骤定义好了,却在发现计划只完成了一半。另一半则是实现 Step1
、Step2
、Step3
方法。但问题是,有些时候我们不总是非得需要将所有的步骤全给实现了,比如调试的时候。假如我只是想试着调试运行一下前面的步骤是否成功执行的话,那么此时可能 Step1
、Step2
由我们实现完成,而 Step3
是没逻辑的。
那么,代码可以成这样:
此时我们不得不屏蔽 Step3
,或是试着实现一个空的 Step3
方法。
不过有些时候,这样的实现并不是完美的。因为它不够灵活:我们必须屏蔽一些我们暂时不需要的代码,或者是强制性地实现一些地方的代码,只不过给大括号里写成空的而已。于是,分部方法就诞生了。
我们试着替换 private
关键字,改成 partial
关键字,然后把实现代码单独放在一个文件里,而声明则放在当前文件里。举个例子,假设我主要实现逻辑(包含 Do
方法的这个文件)放下面的这些代码:
partial
关键字;而第 12 到第 14 行代码的写法则以分号结尾。然后我们试着创建一个新的文件叫 Work.Impl.cs
,表示实现部分。此时 Step3
Step1
方法和 Step2
方法的逻辑,而 Step3
我们不去关心它有没有实现。注意关键字使用:因为类型分为两个文件存储,一个是定义,一个是实现,所以文件都得带上 partial
修饰符暗示两个类型是同一个类型,但放在不同的文件里。然后在实现的方法上,将 private
改为 partial
。不过一个文件表示的是定义方法的签名,而带 Impl
后缀的文件,则是去实现我们需要实现的部分。
这个语法是 C# 3 诞生的新语法:分部方法。分部方法允许我们在同一个类型里将方法分为两个文件存储:定义和实现。分离定义和实现,是分部方法的目的和效果。
Part 2 为什么设计这么古怪?
可能你会觉得,这种设计的目的和意义。好吧实际上这么设计代码是有用途的。我们最常用的一个点是,分部方法可以不实现,即提前用签名来调用方法。如果方法没有实现,编译器会将其自动删除掉。
考虑前面的代码。我们如果这么去设计一个类的话,我们可以定义一大堆的 partial void 方法(参数)
的签名,但不去实现它。我们可以只挑选其中一些去实现,它也不影响代码的运行,编译仍旧是通过的。就像是上面给的这个例子里,partial void 方法(参数)
格式语法的地方有三处,分别是 Step1
、Step2
和 Step3
这三个方法的签名定义。它们定义后直接以分号就结尾了。只要我们实现了它,那么在 Do
方法里就会执行;相反如果不实现,那么你不更改 Do
里的代码也是 OK 的,但是因为它没有实现,因此会被编译器自动删除。毕竟反正也没给出实现,保留也没有意义。
Part 3 分部方法的限制
这种设计是有道理的,但正是因为有这种设计规定,因此也确实拥有很多限制。下面我们来说一下它们。
3-1 分部方法默认为 private
级别;不允许修饰访问修饰符
可以看出,这样的代码是类型内级别访问。因为它只用来内部讨论和操作,如果暴露到任何别的地方的话,都会导致滥用和错误使用,因此这种限制是有必要的。
C# 3 的分部方法语法规定,我们给的分部方法默认就是私有的。而正是因为它是默认的也是不可改变的,因此分部方法甚至不允许显式地给出任何访问修饰符的关键字,即使是 private
也不行。
3-2 分部方法必须不返回任何数值
C# 3 也不允许分部方法返回任何数值。这是因为,返回值可能会改变方法的执行行为。比如说下面的代码:
F
是分部方法的话,那么如果方法没有给予实现部分,那么这个 result
就不可能存在。因为 F
会被编译器自动删除,但 result
的牵连会使得编译器处理起来困难不少——我后面所有使用 result
的地方也都得删除,说不定任何用到 result
的地方,也会被改变逻辑和执行意义。因此,这样的问题是显著的,因此,C# 3 分部方法不允许返回值。正是因为这个原因,partial
关键字后必须跟 void
3-3 分部方法不能带有 out
参数
既然返回值都不允许了,那么输出参数也肯定不能被允许。因为 out
修饰的参数是从参数位置去返回的类型,它解决的是方法只返回一个数的问题。
out
,而 ref
是可以修饰的。有人可能会问,为啥 ref
可以?ref
不是也是跟 out
差不多,只是 ref
双向,而 out
注意分部方法的实现,参数是有 ref
修饰符的。
下面我们调用它。
F
是的,这样的代码因为 x
并未和方法关联起来,传入引用也只是是理论上可以在 F
方法里改变 x
的数值,但实际上,如果我不调用它的话,编译器也知道,F(ref x)
调用被删除不影响代码的编译。
这里插一句你可能没有注意过的细节。不论是不是分部方法,所有方法里只要带有
ref
参数的方法,那么编译器都会要求你调用传参之前必须有初始值。比如说这里的int x = 20;
,如果在调用F(ref x)
之前没有给x
赋初始值,编译器会直接报错。因为这样的调用过程就有可能因为实现F
方法里改动了它的数值。因为方法体里是无法知道x
是否有初始化。如果x
没初始化就在使用,这必将导致代码执行起来的不安全性。因此为了严谨,编译器会要求你必须对ref
参数传参的这个变量先具有初始值之后才可以使用起来。
3-4 分部方法修饰符只允许 static
和 unsafe
;别的都不行
C# 诞生了各种各样的类型后,也拥有了完成的继承体系。在 C# 里,关于继承的关键字有这些:
sealed
关键字;abstract
关键字;virtual
关键字;override
关键字;new
关键字。
不过,这些关键字全部都不能用于分部方法。原因很简单:因为继承机制无法防止你的实现的派生和重写,导致代码的不安全。举个例子:
这种就是典型的错误使用。因为 F
本身就是抽象的了,它本身就不应该有实现部分。但是,你对这种本身就抽象的东西修饰 partial
是什么意思?分部方法意味着你可以按需在别的文件里给出 F
的实现,但 abstract
修饰符又防止你在这个类型里去实现它,这不是自相矛盾的吗?
在派生和继承机制下,上述五个修饰符全部都不行,别的四个你就自己分析思考一下为什么吧。
哦对,extern
关键字也不行,虽然这个修饰符用于导入 dll 文件里带有的库函数。但是,这样的方法本身就不可能有实现,因为实现并不在 C# 语言里实现,而是在 dll 文件里,因此它本身就没有实现机制,那么 partial
就没有意义了。
所以,上述的修饰符一个都不行。除了 static
。
unsafe
这样就行。用法也很简单。
比如这样。
3-5 分部方法必须用于分部类型里
这个是显而易见的。你要用分部类,那么你有可能会对这个方法给予实现。那么既然有可能提供实现,那么自然就需要一个环境提供这样的行为可以使用。那么分部方法必须放在分部类型里就先得非常正常了。
Part 4 泛型分部方法
分部方法牛逼就牛逼在它可以使用泛型。考虑内部排序方法:
这便是一个实现。假设我使用接口来完成这个任务的话,那么我们需要设置泛型约束。
可以从这个例子里看出,我们具有的泛型参数必须是值类型,而且还得是实现了大小比较行为的类型,这样我们才能确保操作能够大小比较成功,并且交换成功。
显然,调用方就不多说了。这里关注一下代码的写法。这里要注意两个地方。
4-1 泛型参数是可以换名字的
可以看到,泛型参数在定义里用的是 TComparable
,而实现里却用的是 T
。这是允许的。原因是编译器对泛型的分部方法如何看待的。编译器对泛型参数的名字并不关心,它只关心泛型参数的名字是否有重复,个数有几个,都对应哪里。举个例子,A<T>
和 A<TypeParameter>
是同一个类型,虽然泛型参数名字不同,但大家都知道,泛型参数在底层的实现机制,是看个数不同来区分类型的。因此,对于上面的分部方法来说,泛型参数的名字换没换其实并不重要。
4-2 泛型参数的约束是必须重复的
虽然泛型约束不是签名的一部分,但只在签名里书写,或者只在实现里书写泛型约束,都是不够明显的。因此,你必须重复写泛型约束,便于编译器分析的同时还能更轻松地掌握执行过程。
不过,这一点在官方要求的文档里并不是这么写的。C# 的里说,“泛型约束用于分部方法的定义部分,但实现部分是可以不写的”。

但事实是,你这么写代码,会收到编译器错误,告诉你“定义和实现上,泛型约束有所不同”。

是的,就是这么离谱。
考虑如下代码:
F
方法有实现,但 G
方法没有。有些人会为了去钻空子去试试看,有没有办法引用到 G
请问,这样的代码对不对?答案肯定不对,而且是第 2 行必然报错。原因很简单,因为 G
方法没有实现,因此不论如何你都是没有办法去调用它的。哪怕是委托意味着“我们稍后调用”,但也由于没有实现,因此这样的机制是不成立的。
因此,分部方法也是做了一个相当严谨的措施去防止以任何形式调用。