C#.Net筑基-泛型T & 协变逆变


01、什么是泛型?

泛型(Generics)是C#中的一种强大的强类型扩展机制,在申明时用“占位符”类型参数“T”定义一个“模板类型”,比较类似于C++中的模板。泛型在使用时指定具体的T类型,从而方便的封装、复用代码,提高类型的安全性,减少类型转换和装箱。

  • 泛型就是为代码能 跨类型复用 而设计的,轻松复用代码逻辑,如List<T>Queue<T>
  • 用泛型参数来代替object,可以减少大量装箱、拆箱,显著提高代码性能,及代码安全性。比如C#中的List<T>就是泛型版的ArrayListDictionary<TKey, TValue>就是泛型版的Hashtable,非泛型版本就不建议使用了。

1.1、泛型知识点集合

知识点 说明
泛型类型 类、结构体、接口、委托:申明时类型名后指定一个或多个泛型参数,class User<T,TV,TP>{}
泛型方法 在方法上指定泛型参数,public T Add<T>(T x,T y){}
泛型参数“T” 用尖括号<T>的语法引入泛型参数“T”,表示这是一个泛型类、或泛型方法,支持一个或多个泛型参数。
泛型参数“T”命名 一般用“T”或T开头来占位,表示一个模板类型,名称可自定义
泛型约束where 对泛型参数T的条件约束,限定T的类型、范围,更方便的封装代码 class User<T> where T : struct
开放类型 List<T> 未指定泛型参数的类型叫“开放类型”,不能直接使用。只有身体,没有灵魂,并不完整
封闭类型 List<int> 指定了泛型参数后的泛型为“封闭类型”,才是完整的类型,才可以实例化,这里的泛型参数为int
静态成员共享 泛型类型中的静态成员,所有封闭类型是共享的。
  • 构造函数不可引入泛型参数。
  • 不同数量的泛型参数可以“重载”,interface IUser<T>interface IUser<T1,T2>是不同的两个泛型类型。

02、泛型约束where⭐

如果没有约束,泛型参数“T”可以用任何类型来替代。泛型约束可以约束泛型参数“T”的范围,然后可以利用约束类型的一些能力,这也是泛型比较强大的地方之一。

  • 约束条件用where:[约束1],[约束2]语法申明,跟在泛型申明后面,可以跟多个约束条件,逗号隔开。
  • 多个泛型参数,可用多个where分别约束。
泛型约束条件 约束说明
class 必须为引用类型,可以是任何类、接口、委托、数组。
struct 必须为非null值类型。
notnull 不为 null 的类型。
unmanaged “非托管类型”类型,内置的基础值类型如byte、int、char、float、double、bool、枚举、指针等。
new() 类型必须有无参构造函数,当与其他约束一起使用时,new() 约束必须在最后。
Delegate 类型必须为委托
Enum 枚举类型
INumber<T> 类型为内置数值类型,如intdouble这个不错,非常便于封装数值相关的代码!如数学运算。
具体接口、类型 任意明确的类型、接口作为约束条件,不能是封闭类型(封闭类型用泛型就没有意义了)
其他泛型类型 约束类型可以继续用其他泛型类型,where T2 : IUser<T2>
相互约束 泛型参数之间相互约束,class MyClass<T, U> where T : U 约束类型TU兼容
自引用约束 用自身类型作为约束,interface IUser<T> where T : IUser<T>

并不是所有类型都可以用于约束,严格来说只有接口、未封闭的类才能用与类型约束,不支持的有intfloatdoubleArray、数组、ValueType等,Object是万物基类,也不能作为约束,这个约束等于没有约束。

  • 泛型约束最大的好处就是可以利用约束的能力,实现更方便的封装。
public T Create<T>() where T : new()  //约束了T可以new,就是具备无参构造函数
{
	return new T();  //这里就可用这个约束能力了
}

public T Max<T>(T a, T b) where T : IComparable<T> //约束实现了比较接口
{
	return a.CompareTo(b) > 0 ? a : b;
}
// Max(1,2); //2

public class GClass<T, TV>
	where T : IComparable<T>, new()
	where TV : struct, INumber<TV> //数值类型,可以用数学运算了
{
	public TV Value { get; set; }

	public void Add(TV value)
	{
		this.Value += value;
	}
}

.NET 7 中实现的 INumber-TSelf 添加了大量数学运算接口,C#内置的数值类型(int、float、double等)都实现了该接口。官方文档《泛型数学》还有更多更细的数学运算接口。

03、协变与逆变

C#中的协变与逆变,本质就是灵活控制类型的向上(父类)转换,即保障类型安全,又兼顾灵活性和代码的复用性。这里就不得不回顾下向对象的基本原则之一——里氏替换原则。

3.1、里氏替换原则

里氏替换原则 (Liskov Substitution Principle,LSP)是面向对象编程中的基本原则之一,在各种编程语言中使用广泛。其定义为:派生类(子类)对象可以代替其基类(超类)对象,这也是面向对象多态的体现。

就是说任何子类都可以替代其父类,或者说子类可以安全的转换为父类。在接口或类的继承中,向上转换是安全的,这也是继承的基本特点。如下面的方法Foo(object value),可以传入任意object的子类,因为所有类型都继承自object

void Main()
{
	string s = null;
	object o = s;        //父类兼容子类,string 隐式转换为 object
	Foo("sam");          //类型匹配 string隐式转换为object
	Foo(new User());     //类型匹配 User隐式转换为object
	Foo<string>("sam");  //类型一致
	Foo<object>("sam");  //类型匹配 string隐式转换为object
}
public object Foo(object obj)
{
	return "sam";  //string隐式转换为object
}
public void Foo<T>(T obj) { }

日常编程中也常常用到里氏替换原则,用子类代替父类使用。

void Main()
{
	SetUser(new User());    
	SetUser(new Teacher()); //输入参数用子类代替
}
public class User { }
public class Teacher : User { }

public User FindUser(){
	return new Teacher(); //返回值用子类代替
}
public void SetUser(User user){}

在C#中,里氏替换原则的表现就是子类可以隐式转换为父类,如上面的示例,在方法调用、返回值、赋值时都支持向上的隐式转换。但是在泛型中,这却行不通,如下示例代码,这就不符合上面的里氏替换原则了,影响了编程的灵活性。

interface IFoo<T>{}

IFoo<string> s2 = default;
IFoo<object> o2 = s2; //不可隐式转换,报错

//添加out参数后,可隐式转换
interface IFoo<out T>{}

在泛型中,是需要严格类型匹配的,才能保障类型的安全。在某些场景但为了兼顾灵活性、复用性,便有了协变、逆变。

3.2、协变(Covariance/out)、逆变(Contravariance/in)

为了在泛型中支持上述隐式转换,就有协变、逆变。当然这不仅仅用于泛型,委托中的协变、逆变和泛型是一样的,还有C#中的数组是支持协变的。

  • 协变(Covariance):用<font style="color:#D22D8D;">out</font> 关键字指定类型参数是协变的,用于输出参数,如方法的返回值类型。表现为子类隐式转换为父类,就是标准的里式替换原则。
  • 逆变(Contravariance):用<font style="color:#D22D8D;">in</font>关键字指定类型参数是逆变的,一般用于输入,如方法的参数。表现为协变相反的转换过程,但其实本质上(在方法参数上)还是里式替换原则。

outin关键字只能用在泛型接口、泛型委托上。

下面是C#中内置的协变、逆变使用场景。

//数组是内置支持协变的,只支持引用类型,不支持值类型
object[] arr = new string[10];
object[] us = new User[2];

//C#中的IEnumerator<T>源码
public interface IEnumerator<out T> : IDisposable, IEnumerator
{
	new T Current { get; }
}
//C#中的Func<T1,T2,TResult>源码
public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2);

协变out的示例代码

  • IFoo<string> 隐式转换为IFoo<object>,子类(泛型)转父类(泛型),协变。
  • 在方法返回值上,IFoo<object>.Func() 返回object,支持返回stringIFoo<object>),stringobject,是符合里氏替换原则的。
void Main()
{
	IFoo<string> f1 = new Foo();
	IFoo<object> f2 = f1;  //协变,IFoo<string> 隐式转换为IFoo<object>
}

interface IFoo<out T>  //如果没有out,则上面的转换抛出异常
{
	public T Func();
}
class Foo : IFoo<string>
{
	public string Func()
	{
		return "sam";
	}
}

逆变in的示例代码

  • IFoo<object> 隐式转换为IFoo<string>,父类(泛型)转子类(泛型),相反的方向,逆变。
  • 在方法参数上,IFoo<object> 参数为object,支持用stringIFoo<string>),stringobject,是符合里氏替换原则的。
void Main()
{
	IFoo<object> f1 = new Foo();
	IFoo<string> f2 = f1;  //逆变,IFoo<object> 隐式转换为IFoo<string>
}

interface IFoo<in T>  //如果没有in,则上面的转换抛出异常
{
	public void Func(T value);
}
class Foo : IFoo<object>
{
	public void Func(object value) { }
}

warning
协变、逆变只是其表象,其本质是一样的,就是里氏替换原则!(可用子类替换为父类)

参考资料

©️版权申明:版权所有@安木夕,本文内容仅供学习,欢迎指正、交流,转载请注明出处!原文编辑地址-语雀__