欢迎光临散文网 会员登陆 & 注册

第 96 讲:C# 3 之扩展方法

2022-03-21 08:46 作者:SunnieShine  | 我要投稿

前文我们介绍了第一个 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 关键字修饰在第一个参数上的静态方法。

啰嗦一点。这个语法叫扩展方法,不叫拓展方法。扩展和拓展在 C# 是有明显的语义区分的。扩展指的是将功能、代码内容和一些别的东西进行推广,而拓展则指的是将原有的内容进行进一步地优化和翻新。说白了就是,扩展用的是新的东西来推广原来的东西,而拓展用的是原来的东西,就地变更内容来推广原本的东西。显然,这个语法用到了一个单独的工具类型,存储了这些方法,它明显在语义上是将类型进行的推广,因此称为扩展方法。

Part 3 使用约定和规范

既然用法我们知道了,那么肯定得有一些内容需要我们注意。

3-1 this 修饰符只能在位于静态类的静态方法上

我们观察扩展方法的语法,可以得知,实际上扩展方法就是标记上了其中一个参数,然后将其当成实例的调用规则。这样的方法可以从上面的引例看出,它一般都用作类型的扩展。也就是说,有一些 .NET 库就提供了的封装完好的数据类型,我们无法直接在封装好了的这些数据类型上加上东西,那么我们只有自己写地方装上它们,然后达到扩展的目的。

但早期的 C# 语法里只能让我们使用所谓的“工具类型”的概念去存储它们,但现在我们有了这样的语法规则,因此我们可以这样去更加流畅地写出代码来。那么,既然是一个工具类型里提供的方法,那么我们之前就说过,工具类型是不求任何别的人去实例化的,因此这样的类型我们往往都定义为静态类型,而在这个静态类型里,我们定义的方法也肯定就只能是静态方法了,因为静态类里不能声明任何实例成员。

正是因为大家写代码得到的这些经验总结和约定俗成的内容,因此 C# 3 的扩展方法有这两条限制:

  1. 扩展方法只能放在静态类里(因为静态类才是最适合作为工具类型的类型);

  2. 扩展方法只能是静态的(因为静态类要求存储的成员也都是静态的)。

3-2 this 修饰符只能修饰在方法的第一个参数上

this 修饰符作为扩展方法使用的特殊修饰符类型,它的目的是用来扩展一个数据类型,使得调用的时候显得更为灵活和美观,毕竟少了一个无意义的静态类型名称来说,代码看起来确实要好看一些,也要简短一些。

this 修饰符正是考虑到用来扩展类型的目的,它只能用在第一个参数上。假设有两个参数,比如这样的东西:

我们这样的方法也是允许使用的,虽然有三个参数,但我们只要符合 this 在第一个参数上,那么这个方法就可以用来改写为实例写法的扩展方法:

此时我们需要传参的是两个参数而不是方法要求的三个。传入的两个参数分别对应的是这个 Slice 方法的 startlength,而第一个参数 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。而 ABC 是三个不同的数据类型,其中 A 类型里没有实例方法,BC 类型则有实例方法,也都叫 F。不过 B 传参是 int 类型,C 类型的这个方法传参则是 object 类型。

接着 X 类型里则是执行和调用这些方法。试问一下,这些方法分别都对应什么方法?(答案已经写在上面了,我希望你先自己思考了然后看答案。)

下面我们针对上面给的答案解释一下原因。

  • a.F(1):这个调用下,aA 类型的实例,但问题是 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,那么显然 CN1.D 就有不同:C 类型下直接就能被看到,但 N1.D 还需要进入 N1 命名空间下,然后才能在 D 类型里发现它。因此相当于多绕了一步。所以,不同地方的扩展方法重载起来的话,看的是相对于调用方的“距离”。下面我们来举个例子。

考虑这样的代码。CN1.D 类型都带有 int 实例的重载,下面给出了 1.F()2.G()3.H() 三个调用,请问它们分别调用的都是哪一个方法?

  • 1.F():由于我们使用了 using N1 指令,因此我们在 Test 类型里调用 F 方法应该可以看到三个重载版本:C.FD.FE.F。不过,因为 E.F 距离 Main 方法调用最近,因此这里我们优先考虑的是 E.F 方法(即代码的第 23 行),C.FD.F 不论如何都是多了一层命名空间的间接引用;

  • 2.G():我们使用的 using N1 指令使得我们可以看到 N1 命名空间下的 D.G 方法,因此我们这次可以看到两处的重载:C.GD.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+ 讲解的篇目介绍这个体系,请做好心理准备。


第 96 讲:C# 3 之扩展方法的评论 (共 条)

分享到微博请遵守国家法律