第 71 讲:C# 2 之泛型(五):泛型方法
为了增强泛型功能,C# 甚至允许我们对方法使用泛型。换句话说,我可以在一些本来不是泛型类型的方法上添加泛型参数,以使得方法在使用的时候更加灵活。
顺带一提。在 C# 面向对象里规定的所有成员类别里,只有方法可以是泛型的。字段、属性、索引器和事件自身只是存取数值或委托实例的一个变量信息,因此它不可能绑上泛型的概念(要绑也只能和类型的泛型参数绑上,而自身不会带泛型),而运算符重载和类型转换器自身也根本不能带上泛型,否则它们就不叫运算符和类型转换器了。想象一下你有一个比较运算符,然后带一个泛型参数,这是拿来干嘛呢?
Part 1 基本用途
考虑一种情况。我想要只是为了对一个 T
类型的数组里获取最大的那个数值,那肯定是表达成 T[]
。但问题在于,它我们无法写成一个独立的数据类型,然后在类型里加上最大数值方法。
于是我们会考虑把它放在一个独立的工具类里,然后这个类型也不让实例化,也不允许派生。同时我们创建一个 static
成员以避免去实例化后才可调用它。
可问题是,这么做又有什么用呢?我这可是泛型数据类型的数组啊。这就得用到泛型方法了。
我们看到一个新的语法。我们直接在方法名后加上了泛型部分 <T>
,并在方法声明头部的末尾加上了泛型约束 where T : struct, IComparable<T>
。使用 struct
约束是为了避免引用类型会有 null
值参与其中导致 null
调用 CompareTo
方法产生 NullReferenceException
异常。
这个 Max<>
方法带有一个泛型参数,它不写在类型上,而是写在方法上。这个方法我们称为泛型方法(Generic Method)。C# 对泛型方法没有任何限制,换句话说,不管你是实例方法还是静态方法,不管你是否包含一些 ref
、out
参数,不管你访问修饰符是不是 public
,均可使用泛型方法机制。
当然,这个例子有点奇怪。如果为了避免出现 NullReferenceException
异常,又想使用引用类型作为使用实例的话,也可以考虑改变参数表列。比如这样:
这个写法有两个要注意的地方。第一是 params
参数,第二是 ReferenceEquals
对泛型数据类型的使用。params
参数允许数组参数在写代码的时候改成变长参数的形式,因此第一个参数没有包含在 params
参数里是因为我们至少得保证序列里有一个元素传入;而 ReferenceEquals
方法用于验证对象是不是指向 null
。我们着重说明后者。
因为是泛型参数 T
,因此我们无从得知它是不是值类型。而验证它是不是 null
就显得更加重要。要想知道它是不是 null
,我们只能验证对象的地址。可问题在于,对象如果是值类型的话,调用 ReferenceEquals
方法将导致值类型对象必然装箱,而且地址也会随之变化,导致验证的失败(返回 false
)。这也是期望的现象,因为值类型从来就不可能为 null
,而装箱后的地址怎么也不可能等于 null
,对吧。所以必然会导致和 null
比较的结果不同。而我们也期望值类型在参与比较的时候,结果不同。因此,这么做是没有逻辑问题的。
如果你很不喜欢这么书写代码来判别
null
的话,即使你使用result == null
以及element == null
的语法以替换ReferenceEquals
方法调用,但在泛型类型里,由于你尚未知道它的实际类型就必须参与比较,因此等号运算符会被定位到object
里默认自带的==
操作上去。而object
里的==
就是简单调用了一下ReferenceEquals
而已,因此它俩写法最终的效果是一致的。
Part 2 泛型方法的重载和类型推断
C# 提供了一种方便的机制,对泛型方法可以进行类型推断(Type Inference)。考虑我们使用刚才的代码来完成取最大值的任务,按正常的语法应该是这样的:
按照模板去套,可以发现我们指定了泛型参数(按照基本的类的声明和使用语法就能猜到我们应该把泛型部分 <int>
写在方法名之后,因为声明也是放在这里的。而由于我们指定了泛型参数的实际类型,因此我们的 T
就会自动替换为 int
参与计算,并最终得到 int
类型的结果。所以整个调用和赋值过程都是正确的。
不过,编译器允许你省略 <int>
部分不写,即写成这样:
是的,直接省略掉,连尖括号都不要。这个称为泛型方法的类型推断。
如果有多个泛型参数的话,要想使用泛型方法的类型推断,必须全部都能成功推断才可以省略,否则必须全部写上。举个例子:
F(10, "Hello")
,那么很自然可以发现,10 是 int
字面量,而 "Hello"
是 string
字面量,因此泛型参数 T
和 U
分别为 int
和 string
。但假设我指定了其中一个泛型参数,比如 F<double, _>(10, "Hello")
,系统仍然需要让你把第二个泛型参数给写上,因为第一个泛型参数是你自己给的,只有当泛型参数全部都能进行类型推断的时候,才可以省略。
好了,讲完了。
咳咳咳,好像啥也没说清楚。先来说说这种现象为什么能够被允许,以及编译器如何判断泛型参数的实际类型。
2-1 方法的可重载性规则
我们知道,C# 的方法有着神奇的规则:它允许重载方法。重载意味着我们给出不同类型的参数表列,即使方法名完全相同(大小写字母都一致),C# 也允许我们如此声明而且不产生编译器错误。不过,我们之前出现过相当多的影响重载性质的语法,下面我们来对这些语法进行梳理。梳理这些语法的目的是,为了介绍这里的泛型参数的分析模式,并最后告知你如何正确得到泛型参数的实际类型。
接下来的内容会非常麻烦,因为规则被划分得非常细致,所以非常不容易记住。因为平时很少会出现这些下面介绍的极端情况,因此无需担心是否已经掌握它们。这些东西你可以留着遇到的时候发现编译器行为不如你的预期的时候,再回头来查看这里的这些条条款款,也是可以的。
2-1-1 初级难度:基本重载规则
如果我有如下一些方法:
void F(decimal a);
void F(string a);
void F(double a);
如果我使用 F(30D)
,那么请问我调用的是什么方法?显然是第三个,对吧。因为常量 30D
的后缀暗示着这个常量是 double
类型的,因此数据一定会按照匹配的类型进行调用。
如果我使用 F(30F)
,那么请问我调用的是什么方法?答案是第三个。这个稍微难理解一点。decimal
类型有点特殊,它往浮点数据类型(float
、double
和 decimal
这三个)上转换,或者浮点数据类型往 decimal
类型上转换,都是强制转换;换句话说,你不得不添加强制转换运算符 (decimal)
才可以把一个数值字面量往 decimal
上转换;不过 30F
是 float
类型的字面量,它和 double
之间是隐式转换的,因此编译器会优先选择正常转换的这一方作为调用,因此选的是第三个方法(参数 double
类型)来调用。
如果我使用 F(30)
,那么请问我调用的是什么方法?因为字面量 30
是 int
类型字面量,而 decimal
数据类型里规定,int
往 decimal
是隐式转换,而 int
字面量往 double
也是隐式转换的,因此两种调用无法确定最终结果,因此编译器会告知用户编译器无法找出合适的调用方法是哪一个。
2-1-2 中级难度:变长参数规则
如果我有如下的一些方法:
void F(int a);
void F(int a, params int[] arr);
void F(params int[] a);
void F(params object[] a);
如果我使用 F(30)
,那么请问我调用的是什么方法?按照默认匹配的规则,那肯定是第一个,对吧。虽然它从逻辑上也满足第二个、第三个和第四个传参的规则,但因为它只有一个参数,而从类型和个数上最匹配的项目只有第一个,所以直接选的是第一个作为调用项。
如果我使用 F(30, 40)
,那么请问我调用的是什么方法?说不上来了吧。调用的是第二个。原因在于,我第一个参数是和第二个方法参数表列的第一个参数类型 int
是匹配的,因此编译器将只从前两个方法里选取调用最终项,而第三个即使我们知道 30, 40
也可以调用第三个方法,但由于按参数顺序读取和匹配的关系,第三个只能 say goodbye 了。接着,由于 30, 40
是两个参数,因此第一个调用项被 pass 掉了,因此只能选取第二个作为匹配项。
如果我使用 F(new int[] { 30, 40 })
,那么请问我调用的是什么方法?第三个。因为只有第三个和第四个调用项能从第一个参数就开始传数组进去,而第三个参数的类型完全和传参的 new int[] { 30, 40 }
匹配。之前说过,虽然有 params
修饰符修饰参数,但我们仍允许完整传入数组类型的对象而不必非得写作变长参数的传参形式。
如果我使用 F(new double[] { 30, 40 })
,那么请问我调用的是什么方法?第四个。因为前三个数据类型全部都不匹配,而给定的数组的元素是 double
类型的,第三个调用项是 int[]
,即每一个元素必须是 int
类型。数组协变的规则还记得吧:允许按数组级别从 a 类型隐式转换为 b 类型(只要元素类型 a 到 b 允许隐式转换的话)。但 double
到 int
是显式转换(强制转换),因此只能去找合适的转换项,只有最后一个合适。所以是最后一项。一定注意,虽然数组初始化器里写的是 30 和 40 两个 int
字面量,但数组类型规定的是 double
类型,所以最终按这个 double
类型为准。
2-1-3 高级难度:泛型参数规则
如果我有如下的一些方法:
void F(int a, int b);
void F(int a, decimal b);
int F(int a, object b);
T F<T>(int a, T b);
T F<T>(T a, T b);
void F<T, U>(T a, U b);
如果我使用 F(30, 40)
,那么请问我调用的是什么方法?显然第一个,对吧。因为第一个完全匹配两个参数的数据类型。虽然它满足带泛型参数的后面三个方法的传参规则(30 是 int
字面量,那么 T
替换成 int
也是正确的调用过程),但是最匹配的是第一个,所以调用的是第一个方法。
如果我使用 F<int>(30, 40)
,那么请问我调用的是什么方法?第四个。因为第四个方法的第一个参数和这里的 30 最能匹配。按我们刚才解释到的规则来看,参数的数据类型是按顺序挨个判断的,因为第一个参数是 int
类型,最能找到的匹配项是前面四个项,而这个例子带有泛型参数,因此调用的是第四项。
如果我使用 F<int>(30D, 40)
,那么请问我调用的是什么方法?可以大概看一下,这个调用过程是有泛型参数的,因此只能从后面两个选择。但问题是,我指定的泛型参数的实际类型是 int
,但我却传入了第一个参数是 double
类型进去,因此这是不可以的。因此编译器会提示你,无法从 double
转为 int
(隐式转换的嗅探规则)。
如果我使用 F<double>(30D, 40)
,请问我调用的是什么方法?由于此时我指定了泛型参数是 double
,因此它只会在第四个和第五个里选取,因为只有这两个情况下泛型参数才是一个。而可以发现,只有第五项满足,第四项的第一个参数是 int
类型,因此最终调用的是第五项。
如果我使用 F(30, 40D)
,请问我调用的是什么方法?这个能满足的只有第三项、第四项、第五项和最后一项。但实际上可以发现,第三项的第二个参数是 object
类型的,传入的参数是 double
类型的,必须得隐式转换过去才行,还避免不了地产生装箱行为;可我既然有重载方法,那么我们肯定可以去使用泛型来完成这个任务啊,毕竟泛型避免一些潜在的装箱行为(比如这里要转 object
就得装箱)。所以它会优先看带有泛型参数的调用项有没有满足的。可以发现,第四个是最匹配的,因为第四个项目的第一个参数是 int
刚好匹配实际类型 30 的字面量对应类型 int
,因此第四项是这个题目的调用项,而编译器会根据调用项自动得到泛型参数 T
的实际类型应为 double
。
如果我使用 F(30D, 40)
,请问我调用的是什么方法?第六个。因为只有第五项和最后一项可以满足和匹配。其中第五项满足的条件必须是得把其中的 40 隐式转换为 double
类型后,才可以;但我有双泛型参数的第六项,为什么不使用不转换类型的重载版本呢?所以我们会优先选择第六项。而此时的话,编译器会自动类型推断,因此 T
是 double
类型,而 U
则是 int
类型。除非我删去其中第六项,这个时候它才会去匹配第五项,并使得 40 自动转为 double
类型调用。这种情况的话,T
是 double
类型。
如果我使用 F(30D, 40D)
,请问我调用的是什么方法?由于第一个参数是 double
类型,而前四个方法全部的第一个参数只能传入 int
,因此前四项都 pass 掉。接着,由于剩余两项也都包含泛型参数,而且第五项是同泛型参数,而第六项是不同泛型参数,按照兼容的基本规则来看,显然它们都是满足的,只是一个只需要 T
为 double
才行,而另外一个需要 T
和 U
均是 double
才行。按照基本的重载规则,泛型参数个数不一样虽然可构成重载,但在类型推断上如果不指定个数的话,编译器怎么知道你调用的是哪一个呢?非得总是泛型参数少的这一方吗?说不定吧。所以在这种调用下,两种调用均是满足且不分上下。因此,编译器会给出警告,告知你编译器无法确定你到底调用哪一个,除非你指定泛型参数,比如调用写成 F<double>(30D, 40D)
就可以确定只有一个泛型参数的版本第五项作为最终调用项;而从这个角度来说,我确定了泛型参数类型,因此可以改变参数的数据类型(字面量信息的后缀)。显然第一个会影响编译器执行和分析代码(选取哪一个调用),而第二个参数由于不影响编译器分析代码,因此字面量 40D
的 D
后缀可以省略,即 F<double>(30D, 40)
也是可以的。
2-1-4 骨灰级难度:泛型参数 + 变长参数规则
如果我有如下的一些方法:
void F<T>(double first, params T[] arr);
void F<T>(params T[] arr);
void F<T>(T first, params T[] arr);
不多说了。上最难的推断情况。如果我使用 F(30F, 40)
,请问我调用的是什么方法?混用规则确实在一些推导情况下分析起来尤为复杂,这里我给出一个编译器匹配重载方法的顺序规则:精确类型匹配 -> 泛型类型匹配 -> 变长参数匹配 -> 隐式转换类型匹配。举个例子,我找到精确的类型的话,那么就直接从精确类型这里调用就完事了;如果没有的话,我就会去查看泛型版本的方法是否有合适的。如果有的话,先获取精确类型匹配了的参数成员的这一项,然后看剩余的部分是不是匹配的。如果是就正常匹配,否则就会去试着去看别的泛型参数;最后如果都没有,就会使用模糊匹配,考虑使用隐式转换去找。如果隐式转换能够匹配上的话,那么这个项就会作为调用项结果。在这里的话,我的第一个参数是 30F
,这意味着我的第一个参数必然是 float
类型。走了一圈发现第一项、第二项和第三项均可满足。其中:
要匹配第一项,需要要求第一个参数进行从
float
到double
的隐式转换,然后看到后面的 40 是int
,因此泛型参数在匹配此项的时候T
就是int
;要匹配第二项,我们必然可以立马得到第一个参数是
float
类型,因此这里的T
将被替换为float
。在这种情况下,后面第二个参数int
类型将会产生隐式转换。另外,由于该项是params T[]
的参数,此时由于第三项的第一个参数是直接通过泛型类型匹配的规则,因此第二项还比第三项多了一个数组的匹配逻辑;要匹配第三项,我们第一个参数是
float
类型,因此类型推断会使得T
会改成float
。与此同时,由于后面跟的是params T[]
,而T
已经替换为float
,因此第二个参数 40 将会从类型int
隐式转换为float
。
通过这三点可以发现,第二项比第一项和第三项多一步匹配规则(数组匹配规则),但第一项和第三项的调用匹配规则是相同的,都是得做一次隐式转换,因此无法继续断定匹配项。正因为这个原因,编译器会给出错误信息,告知你编译器无法确定最终调用项。
解决这个调用无法编译器确定的办法是,指定泛型参数。比如我添上泛型参数:F<float>(30F, 40)
,这样就可以明确了:因为我传入了泛型参数是 float
类型,因此它自动会确定后面的传参过程(明确了隐式转换规则),所以只会在第二个参数上做一次转换;而第一个调用此时就不止一个转换了,因为第一个参数是 double
类型,而第二个参数是 float
类型,因此我第一个参数会做一次转换,而第二个参数以数组形式传参过去的时候,由于是 int
类型,因此仍需要再做一次转换,因此这个时候第一项调用需要做更多次的隐式转换才可以匹配上。因此,这个时候是第三项最匹配,因此是第三项。
2-2 类型推断的基本规则
说了重载,我们就可以大大方方开始讲类型推断的规则了。泛型方法的类型推断依赖于前面说的这些匹配规则。如果方法不带重载项的话,那么很容易就可以确定调用的数据正确性;但是一旦包含重载的话,必须遵循上面的这些规则来挨个匹配。找到合适的匹配后,会自动按照匹配的规则来计算并得到对应的泛型参数的实际类型。在正确找到泛型参数的实际类型后,我们就可以省略泛型参数的实际类型书写了,因为它们会被自动推断出来。
但是,在某些时候,我们仍旧无法正确得到推断结果,比如编译器也无法区分和辨别匹配情况。这个时候我们不得不通过指定泛型参数来确定调用的重载项到底是哪一个。另外,即使我们知道有些时候带有多泛型参数的重载方法只需指定其中若干泛型参数的实际类型即可完整表达出调用重载的项,但是按照基本的省略原则,必须是全部泛型参数均可完成推断才可省略,因此这种情况下仍需要你全部泛型参数都指定。
2-3 泛型约束和名称跟重载无关
C# 虽然允许我们指定不同类型的参数作为重载项,但泛型参数没有那么“智能”。哪怕我们从泛型约束上能够立马确定两个类型的实际情况一定类型不同,但仍然不构成重载。举个例子。
这两个方法均带有一个泛型参数,并没有参数,无返回值。但仅仅区别在于,一个的泛型约束是 where T : struct
,而另外一个则是 where T : class
。
它们构成重载吗?不构成。换句话说就是 C# 编译器仍不允许你创建这样的两个方法。原因是泛型约束仅仅是起到传参的时候的验证(比如泛型参数的实际类型满足泛型约束条件的时候,才会匹配调用),而实际上两个方法的泛型参数是一致的(包括无参无返回值也都是一致的)。C# 重载方法必须要求签名一致,在 C# 2 诞生泛型规则后,方法除了基本的“参数类型和个数均一致”规则外,还多了一项:泛型参数的个数也不一致。注意,这里说的是泛型参数是个数,而不是名称。泛型参数的名称就好比参数名一样,你参数名不一样,哪怕类型一致也不构成重载。因此这里的泛型参数名也是一个道理。而此时可以看到,这个规则里只是说了泛型参数的个数,而并未提及任何和泛型约束有关的内容。因此,泛型约束并不作为实际重载的规则其中一条,因此不要认为即使上面两个类型一定不一致,就构成重载了。
不过,这样的方法构成重载:
虽然也都带有泛型参数,也是相同个数,但因为两者的参数类型不一致(一个是泛型的 SequenceList<>
,而另外一个是泛型的 BinaryTree<>
),因此仍然属于不同的数据类型。你没见过数据类型不同但它们带的实际参数是一致的就说是一样的类型吧?
所以请区分和辨别清楚。
Part 3 泛型方法的转型
和类型使用完全一样,你完全可以使用 (T)
语法来对一个 object
类型的实例进行转型,因为 T
一定比 object
要小(毕竟 object
都最终基类型了);即使最极端的情况也就是 T
就是 object
,但这种情况我写一个 (T)
虽然是冗余的,但仍然不能算作是一个错误,因为这里是泛型数据的转型,我也不知道这里的 T
实际上真的是一个 object
类型的实例。
而强制转换为 T
类型的这一点在序列化里非常常见(虽然你不一定知道序列化是什么个流程):
formatter.Deserialize
方法返回 object
类型,我们就可以这么转换。另外,这里不可使用 as T
语法。因为 T
是不知道什么数据类型的泛型参数,as
运算符仅适用于引用类型的转换,因此除非你追加 T
的泛型约束 where T : class