第 96 讲:C# 3 之扩展方法
前文我们介绍了第一个 C# 3 对方法语法的拓展功能,下面来看看第二个:扩展方法(Extension Method)。
Part 1 引例
考虑一种情况。我现在有一个 int
的数据,我想获取这个 int
的所有比特位是 1 的位置,比如 13 的二进制是 1101,我想通过某个方法得到 0、2、3 构成的数组,表示的是 13 的二进制表达 1101B 的第 0、2、3 位上都是 1。
其实实现起来并不难。
从代码看出,其实逻辑也不复杂。不过这里用到三个处理技巧:BitOperations.PopCount
方法、>>= 1
操作和 & 1
操作。这也不必多说了,>>= 1
和 /= 2
是一个意思;& 1
和 % 2
是一个意思;而 BitOperations.PopCount
方法是获取一个整数有多少个比特位是 1。
那么,我们调用这个方法就先得比较轻松了:
我们保证了方法处理过程是严谨的,因此我们可以留给别的人使用,因此我们可以通过这样的机制来调用这个方法。可问题在于,这里我们的“类型”我们并不关心是什么。我们要想包装这个方法,我们必须使用类型来包装起来。C# 要求所有的方法必须都分门别类放在相同或不同的数据类型里,因此,我们必须给这个方法规划一个类型而存储起来。
假定这个类型叫 Int32Extensions
,专门放的是关于 int
数据类型的一些拓展操作的方法:
这么做也是有意义的,不过我们总不能用一次就写一遍 Int32Extensions
吧,这太不友好了。于是,扩展方法就诞生了。
Part 2 语法
扩展方法需要我们做两处修改和变动。第一处是方法本身。我们给参数 digit
修饰 this
关键字。是的,this
关键字在这里当成修饰符来用:
GetAllSets
就当成是 int
是的,a.GetAllSets()
。我们试着将 a
这个变量当作是 int
类型的实例,而 GetAllSets
虽然是静态方法,但我们也可以通过此语法改成类似实例方法的调用方式,而由于 a
按照实例方法的写法而被提前了,因此小括号里原本传入 a
现在就会变为空的小括号。这就是扩展方法:带有 this
关键字修饰在第一个参数上的静态方法。
Part 3 使用约定和规范
既然用法我们知道了,那么肯定得有一些内容需要我们注意。
3-1 this
修饰符只能在位于静态类的静态方法上
我们观察扩展方法的语法,可以得知,实际上扩展方法就是标记上了其中一个参数,然后将其当成实例的调用规则。这样的方法可以从上面的引例看出,它一般都用作类型的扩展。也就是说,有一些 .NET 库就提供了的封装完好的数据类型,我们无法直接在封装好了的这些数据类型上加上东西,那么我们只有自己写地方装上它们,然后达到扩展的目的。
但早期的 C# 语法里只能让我们使用所谓的“工具类型”的概念去存储它们,但现在我们有了这样的语法规则,因此我们可以这样去更加流畅地写出代码来。那么,既然是一个工具类型里提供的方法,那么我们之前就说过,工具类型是不求任何别的人去实例化的,因此这样的类型我们往往都定义为静态类型,而在这个静态类型里,我们定义的方法也肯定就只能是静态方法了,因为静态类里不能声明任何实例成员。
正是因为大家写代码得到的这些经验总结和约定俗成的内容,因此 C# 3 的扩展方法有这两条限制:
扩展方法只能放在静态类里(因为静态类才是最适合作为工具类型的类型);
扩展方法只能是静态的(因为静态类要求存储的成员也都是静态的)。
3-2 this
修饰符只能修饰在方法的第一个参数上
this
修饰符作为扩展方法使用的特殊修饰符类型,它的目的是用来扩展一个数据类型,使得调用的时候显得更为灵活和美观,毕竟少了一个无意义的静态类型名称来说,代码看起来确实要好看一些,也要简短一些。
而 this
修饰符正是考虑到用来扩展类型的目的,它只能用在第一个参数上。假设有两个参数,比如这样的东西:
this
此时我们需要传参的是两个参数而不是方法要求的三个。传入的两个参数分别对应的是这个 Slice
方法的 start
和 length
,而第一个参数 s
,已经被我们这样的语法改写而前置当作实例去了。这种现象也叫扩展方法的实例前置(Prepositional Instance)。
如果允许 this
修饰符放在别处的话,那么就乱套了:
就拿这个来举例,假设我放在最后去了,但因为扩展方法语法的严谨性和编译器的方便处理,我们放在第一个显然就会更好一些,因为每一个扩展方法总是在第一个是 this
参数,这就非常好用了,也方便编译器读懂我们使用的代码和语法规则。而写在末尾的话,每一个方法有或多或少有不同个数的参数,这样就不方便编译器处理和阅读我们的代码。
3-3 this
修饰符在同一个方法里只能用一次
显然,this
修饰符的出现对编译器那是有特殊的用途。试想一下,如果下面的代码是可以的的话,那么这样的代码是啥意思:
这肯定是不可能读得懂的代码。这要是翻译为实例写法的话,那到底是 a
是实例调用还是 b
是实例调用呢?这不就说不清楚了。
C# 3 的扩展方法要求,this
修饰符专门用作实例调用的写法,所以它只能在一个方法里出现一次,多了就不行了。
3-4 扩展方法可以是任何访问修饰级别
扩展方法的用途和作用的是代换工具类型,改用实例的方式来优化调用过程,进而让代码更好看。那么,访问修饰符对于这个特性来说,就没有任何影响了。哪怕它是私有的,但是只要你在类型里使用,不也不影响?所以,扩展方法可以用任何的访问修饰符,只要你愿意。
还有一个原因是,扩展方法只是多了一个参数用了 this
修饰符,但它的基本语法都和普通的方法是一样的,所以没有意义也没有必要对扩展方法的访问修饰符单纯做一次限制。
3-5 除了指针,任何数据类型都可以用来当作扩展方法的扩展类型
C# 的扩展方法基本上能将所有的类型都进行扩展,所以是一个非常强大的语言特性。但问题是,唯一一种类型,扩展方法是不能用的;换句话说,就是这个类型是不能使用 this
修饰符的。这就是指针类型了。
比如这样的代码就不合理。有人会问,为什么指针不行呢?下面我从两个角度给大家说明一下为什么。
第一,指针没有单独的类型声明。在 C# 的世界里,所有的数据类型都需要我们自己独立给出类型,存储到一个文件或多个文件里去。但是,指针是没有自己的类型声明的。这说起来不好理解。我这么举个例吧。int?
对应 Nullable<T>
类型,而 int
对应 Int32
类型,int[]
则对应 Array
类型。所有 C# 里的类型,不论它写成啥样,最终我们都能找到一个合理的基本类型来表达和表示它,但指针类型不行。C# 的指针类型是一种“奇怪”的类型,它甚至不走 object
的派生体系,因此你无法这么写代码:
void*
和 object
是两种不同的类型,object
以及子类型是一个派生类型体系,而 void*
则不是派生体系的一员。所以,你扩展方法实现了这个 void*
这种指针类型的扩展,有什么意义呢?它扩展了什么?
第二,指针具有 .
和 ->
运算,允许指针使用扩展方法将导致两个运算符的推算不再严谨。考虑下面的代码。
倘若有这样的方法,我们在使用的时候有如下的写法:
而我们知道,指针类型变量在 C# 里只能使用指针成员访问运算符 ->
。因此,这破坏了 C# 对这个行为的基本规则和约定。
所以,不论如何,C# 没有允许扩展方法使用指针类型当扩展。
Part 4 泛型扩展方法
是的,扩展方法甚至支持泛型。考虑下面的代码:
那么,这样的代码如果允许 C# 使用,那么咋用?
答案很简单。T
是泛型类型,这意味着任何一种数据类型(这里指针仍然除外,因为指针类型不在泛型处理的考虑范畴里)都支持和兼容这个方法的使用和调用。因此,不论你传入一个什么类型,都可以执行这里给出的代码逻辑。哪怕 T
仅仅是一个值类型。值类型虽然没有 null
一说,但 T
是泛型的,所以它并不知道来者何人,而且再加上但凡它有数值,即使它是值类型,那么被装箱后也会有对应的装箱实例的地址,因此怎么说也不可能为 null
。因此 ReferenceEquals(instance, null)
是合理的代码。
用法很简单。但凡在这里给一个实例,只要不是指针,就能使用上这个扩展方法:
唯一需要注意的是,带泛型约束的时候。
其实,问题也不大。调用的时候,只需要看看你给的实例是不是满足泛型约束即可。如果不满足,那么就不能作为扩展方法来用,仅此而已。
Part 5 重载方法的调用优先级规则推广
由于这个语法和普通的实例方法的调用语法是完全相同的,所以它牵扯到了语义处理的机制,而不单单只是语法上的新规则。正是因为它和实例方法的调用规则写法完全一致,因此方法的重载在 C# 3 扩展方法诞生后有所推广。
什么意思呢?你想想看,我在一个类型 T
里有一个 F
方法,而我也对 T
类型写了一个扩展方法,名字也叫 F
。那么这样两个方法由于不在一起,但也构成重载。这就有一个比较奇特的现象:我完全可以实现一个扩展方法,让它的调用写法和我直接调用实例方法的写法是完全一样的。比如 new T().F()
可以指代 T
类型的实例方法 F
(无参的),也可以指代给 T
类型写了一个扩展方法,比如它是 TExtensions
类型下的 F
方法,那么完全可以对应上去的是 TExtensions.F(this T instance);
这样静态方法的签名。由于方法存放的地方不一样,所以它们不会冲突;而正是因为这样的特殊性,因此扩展方法和普通实例方法就会交织在一起构成新的重载规则。对于这种重载规则,我们应该如何去处理和理解呢?
你始终记住,扩展方法是最接近兼容类型的实例方法。意思是说,除了 T
类型自带的实例方法,跟 T
类型相关(就是写成 this T
的意思)的这些扩展方法也会被视为这个 T
类型的真正存在的实例方法,只是唯一一个区别是,优先先看 T
类型自身的方法。如果这个类型没有这个方法,则会去匹配扩展方法。
5-1 扩展方法和实例方法在一起时候的重载
下面我们来看一个例子。
在 E
类型里包含两个扩展方法 F
,都是针对于 object
的扩展。不过传参有所不同,一个是 int
,而另外一个是 string
。而 A
、B
是三个不同的数据类型,其中 A
类型里没有实例方法,B
和 C
类型则有实例方法,也都叫 F
。不过 B
传参是 int
类型,C
类型的这个方法传参则是 object
类型。
接着 X
类型里则是执行和调用这些方法。试问一下,这些方法分别都对应什么方法?(答案已经写在上面了,我希望你先自己思考了然后看答案。)
下面我们针对上面给的答案解释一下原因。
a.F(1)
:这个调用下,a
是A
类型的实例,但问题是A
类型没有自己的实例方法,于是就只能去看扩展方法。扩展方法里有一个object
类型的扩展方法,这意味着所有object
类型的实例都可以使用此扩展方法。但问题是它是A
类型的实例,那么能不能用呢?当然可以啦。因为A
是自己定义的类型,它是class
关键字定义的,因此属于类,那么类的最终基类型就是object
。因此,只要属于这个object
类型派生链条上的所有类型(包括它自己)都是可以用这种扩展方法的。接着,可以发现E
类型给了两个扩展方法,都是F
,参数类型换了一下。而 1 是int
类型的字面量,显然第一个方法就可以是完美匹配的。因此,a.F(1)
对应调用的方法是E
类型里的第一个F
扩展方法(即代码第 3 行的这个方法);a.F("hello")
:显然这就没办法在A
类型里去找到匹配了。因为A
类型里只有一个F
的重载,而这个重载只能去看扩展方法里有没有了。显然,扩展方法里是有的,这个F(this object, string)
的扩展方法就非常对味。所以,这个方法调用的是E
这个静态类里的F
扩展方法(代码里的第二个,即代码第 4 行这个方法);b.F(1)
:B
类型里有自带的实例,它调用的F
应该是哪一个呢?答案看的是“就近原则”。因为B
类型自身就有一个F
方法,传参是完全匹配的(参数 1 恰好是int
类型的字面量,而B
类型的F
方法也确实是传参int
类型,因此外部存在的所有扩展方法都是无效的,因为没有机会匹配上它们;b.F("hello")
:不多说,因为B
类型没有string
类型的重载,所以只得去看扩展方法。扩展方法里有兼容,所以它就调用这个;c.F(1)
:这个稍微麻烦一些。显然扩展方法里有一个完全兼容的方法重载版本,它可以要求传入int
当参数,而我们这里页恰好只需要一个int
当参数。可问题是,C
类型自己有一个F
实例方法,而且要求传入的参数,类型兼容的范围太广了——它能到object
。那么c.F(1)
调用谁呢?当然就是这个类型内部的这个方法了,因为它最近嘛。虽然有一个扩展方法,参数是完美兼容的,但是传入的this
参数是object
类型,这里做了一次隐式转换相当于绕了一步;而它又在别的类型里放着,所以要想发现它又需要绕一步,所以要想调用到c.F(1)
,需要绕两步;但C
类型的这个F
方法,只需要转换参数类型做一次隐式转换即可,所以只绕一步。所以,c.F(1)
调用的是这个类型自己带的这个方法;c.F("hello")
:也不必多说,都兼容完了,所以不管传入啥,都只看C
类型自己,不看外面的扩展方法。
5-2 命名空间距离的概念
上面我们说了一下类型自身的方法和扩展方法的重载,下面我们来说一下,命名空间不同导致的不同扩展方法之间的重载。
不同扩展方法的重载,看的是,调用的地方距离哪一个方法更近。换句话说,每一个方法的调用都会层层使用到命名空间名称,然后才是类型,最后是这个方法。假如引用扩展方法 1 需要是 C.F
,而引用扩展方法 2 则需要是 N1.D.F
,那么显然 C
和 N1.D
就有不同:C
类型下直接就能被看到,但 N1.D
还需要进入 N1
命名空间下,然后才能在 D
类型里发现它。因此相当于多绕了一步。所以,不同地方的扩展方法重载起来的话,看的是相对于调用方的“距离”。下面我们来举个例子。
C
和 N1.D
类型都带有 int
实例的重载,下面给出了 1.F()
、2.G()
和 3.H()
三个调用,请问它们分别调用的都是哪一个方法?
1.F()
:由于我们使用了using N1
指令,因此我们在Test
类型里调用F
方法应该可以看到三个重载版本:C.F
、D.F
和E.F
。不过,因为E.F
距离Main
方法调用最近,因此这里我们优先考虑的是E.F
方法(即代码的第 23 行),C.F
和D.F
不论如何都是多了一层命名空间的间接引用;2.G()
:我们使用的using N1
指令使得我们可以看到N1
命名空间下的D.G
方法,因此我们这次可以看到两处的重载:C.G
和D.G
。由于我们要发现C.G
需要走出N2
命名空间,但D.G
是我们通过using
指令已经导入的内容,所以它会被优先发现到。你可以类比理解为一个“虫洞”。从Main
(假设看成“地球”)往D.G
前进,你只需要穿越虫洞(虫洞几乎不消耗能力)就可以到达;但你要去看C.G
,你需要走出地球所在的太阳系,然后去别的星系才能看到。所以using
指令导入了的会被优先选择和读取到,所以这个地方应该调用的是D.G
方法(即代码的第 12 行代码);3.H()
:这个不多说,因为只有一个扩展方法,它没有重载版本,因此直接调用即可,所以3.H()
调用的是C.H
方法(即代码的第 5 行)。
5-3 同距离或同转换步骤数的重载咋办?
再极端一些,如果你遇到了同样距离访问的扩展方法的话,那么编译器肯定是区分不了调用谁的。此时,编译器会直接生成编译器错误告诉你不要这么去使用。
同理,如果转换次数也是一样的的话,编译器也会直接告诉你,它也不知道调用哪一个,于是编译也不通过。因此一定要规避这样的现象(虽然这样的极端情况也基本上遇不到)。
Part 6 总结
总的来说,扩展方法是一个相当有趣的语言特性,虽然它在重载的优先级调用和匹配上理解起来比较难,但也不是不能理解,而且这种情况也不常遇到。尽量规避出现这样的问题就可以了。
下面我们将要介绍的是 C# 3 里最后一个新语言特性,也是目前接触到的最复杂的语言语法体系:集成语言查询(Language Integrated Query,简称 LINQ),它可能会有 10+ 讲解的篇目介绍这个体系,请做好心理准备。