【C#进阶系列】12 泛型

时间:2022-06-05 10:08:12

泛型是CLR和编程语言提供的一种特殊机制,它用于满足“算法重用”  。

可以想象一下一个只有操作的参数的数据类型不同的策略模式,完全可以用泛型来化为一个函数。

以下是它的优势:

  • 类型安全
    • 给泛型算法应用一个具体的数据类型时,如果不兼容这种类型,就会编译错误或者报异常。
  • 更清晰的代码
    • 减少了强制转换,让代码更简洁
  • 更佳的性能
    • 用泛型可以有效避免装箱拆箱的操作,且无需在进行强制转换时验证是否类型安全,这两点都有效提高了代码的性能。

这就是为什么List<T>淘汰了ArrayList的原因,特别是在进行值类型操作时,因为装箱拆箱过多而差距很大。

约定:泛型参数要么为T要么以大写T开头,例如List<T>。

FCL中的泛型

System.Collections.Generic和System.Collections.ObjectModel命名空间中提供了多个泛型集合类和接口。

System.Collections.Concurrent命名空间则提供线程安全的泛型集合类。

System.Array类则提供了大量的静态泛型方法。

泛型的基础结构

.net 2.0才有泛型。

  • 开放类型和封闭类型
    • 之前我们讲到CLR会为各种类型创建类型对象,同样一个新的泛型类TroyList<T>也会创建一个类型对象,我们将具有泛型参数的类型称为开放类型。
      • 不能构造开放类型的实例
    • 而指定了泛型类型实参的泛型类型称为封闭类型,例如:TroyList<int>。
      • 可以构造封闭类型的实例
      • 如果TroyList<T>定义了静态字段或者方法,那么TroyList<int>和TroyList<string>之间并不共享,因为这其实是两个不同的类型对象。
  • 泛型类型的继承
    • 使用泛型类型并指定类型实参后,实际上是一个新的封闭类型,新的类型对象从泛型类型派生自的那个类型派生。即List<T>派生自Object,那么List<int>就派生自Object。
  • 关于代码爆炸的优化
    • 看到这里你可能想到了,一个开放类型实际上会有多个封闭类型,比如一个List<T>会有List<int>,List<string>等N多封闭类型。实际上就是N多的类型对象,生成N多的重复代码,于是这被称作代码爆炸。
    • 关于优化:
      • 两个不同的程序集用到同一种封闭类型,只会由JIT编译器变异一次
      • CLR认为所有用引用类型做类型实参的封闭类型完全相同,所以代码可以共享。也就是说List<String>和List<Stream>的方法编译后的代码可以通用。因为操作的不同的引用类型的地址大小都是一样的。

委托和接口的逆变和协变泛型类型实参

泛型委托和接口的每个泛型类型参数都可标记为协变量和逆变量,利用此功能可实现相同类型但实参类型不同的委托和接口的相互转换。(很绕,不明白可以看下面)

  • 不变量
    • 意味着泛型类型参数不可更改
  • 逆变量
    • 意味着泛型类型参数可以从一个类更改为它的派生类。用in标记,逆变量泛型类型参数只能出现在输入位置。
  • 协变量
    • 意味着泛型类型参数可以从一个类更改为它的基类。用out标记,协变量泛型类型参数只能出现在输出位置。

举个例子

public class 基类 { }
public class 派生类 : 基类 { }
public class Test{
public delegate TResult MyFunc<in T1, out TResult, T2>(T1 a, T2 b);//第一个为逆变量,第二个为协变量,第三个为不变量 void show() {
MyFunc<基类, 基类, 基类> fn1 = null;
//以下注释为我自己的理解方式,只是为了方便理解而已
MyFunc<派生类, 基类, 基类> fn2 = fn1;//MyFunc<派生类, 派生类, 基类> fn2 = fn1;转换错误
MyFunc<基类, Object, 基类> fn3 = fn1;//MyFunc<Object, Object, 基类> fn3 = fn1;转换错误
MyFunc<派生类, Object, 基类> fn4 = fn1;
}
}

依然很绕,实际上不懂也没关系,转换不了编译器自然会提示。了解有这个东西就行了,也建议用int和out指定泛型委托的类型变量。更多的时候我们会用自带的泛型委托Action和Func,这两个泛型委托的参数都用到in和out。

关于泛型方法的类型推断

 void Go() {
String s1 = "";
Object s2 = "";
Show(s1, s2);//不指定Show<T>的T的玩法就叫类型推断,类型推断通过传入的变量s1和变量s2的变量类型来推断,而不是实际类型。因为这里两个变量类型不同,所以函数编译不通过。
}
void Show<T>(T a,T b) { }

约束

泛型的约束是一个很有意思的事情。

void Show<T>(T a,T b) where T :IList { }

比如上面这个函数,约束传入的类型T必须实现了IList接口。

通过约束可以限制传入的类型,然而正式因为提供了这层约束,保证了传入的类型都实现了IList接口,我们就可以使用IList的各种方法了。

约束分类:

  • 主要约束
    • 主要约束可以是代表非密封类的一个引用类型。(可以指定0到1个主要约束)
    • 两个特殊的主要约束为class和struct,分别约束传入的参数为引用类型和值类型。(特例的特例,struct不能约束Nullable<T>)
    • 约束不能指定以下特殊引用类型:Object,Array,Delegate,MulticastDelegate,ValueType,Enum或者Void。
  • 次要约束
    • 次要约束代表接口类型。(可以指定0到多个次要约束)
    • 特殊的次要约束,即指定的两个泛型类型参数中,一个继承另一个,例如:where T2:T1。
  • 构造器约束
    • 构造器约束约束类型实参,一定是实现了公共无参构造函数的非抽象类型。(可以指定0到1个构造器约束)
    • 所有值类型都隐式提供了公共无参构造器。所以同时使用struct和new()约束被认为是多余的,会报错。

可验证性

以下几种情况因为代码不可验证是否合法,所以将报错:

  • 泛型类型变量的转换
    • 原因:不可将泛型类型T的变量转换为其它类型,因为T可能为任何变量,所以可能转换失败
    • void Show<T>(T obj){
      string a=(string)obj; //出错
      }
    • 解决方案:
      void Show<T>(T obj)
      {
      string a = obj as string;//对于string而言,其实这里用ToString方法可能更恰当一点
      }

      值类型可以先强制转换为object,再转为具体的值类型。然而我认为这样的代码还是需要开箱装箱的,也许可以考虑修改下算法。

  • 将泛型类型变量设为默认值
    • 原因:因为T可以是值类型和引用类型,所以不可能设置一个值类型或者引用类型的默认值
    • 解决方案:可以考虑加约束或者用default(T),作为默认值。
  • 两个泛型类型变量相互比较
    • 原因:因为非基元类型的值类型除非重载了==操作符,否则会报错。
    • 解决方案:可以考虑约束为class或者用Equals。(注意哦,有可能因为Equals的被覆盖所以具体不确定是判断同一性还是相等性)
  • 泛型类型变量作为操作数使用
    • 原因:因为非基元类型的值类型除非重载了操作符,否则会报错。
    • 解决方案:反射,操作符重载或者dynamic。(会有性能影响,我一般用dynamic了)