[C#]泛型

概述

泛型是一种代码重用的机制。我们知道,面向对象可以重用对象的行为,而泛型是为了重用算法。

C#的引用类型(class)与值类型(struct)都允许使用泛型,但是枚举类型(enum)不可以。C#还可以定义泛型接口(interface)、泛型方法(静态方法与成员方法都可以)、泛型委托(delegate)。

使用泛型封装算法的好处:

  • 源代码保护(不太理解,和C++模板有联系)
  • 类型安全。非泛型算法,由于类型未知,只能在运行时才会知道类型是否有问题。使用泛型则在编译时就可以将类型问题找出。
  • 更清晰的代码
  • 更好的性能。非泛型算法可能会带来装箱与拆箱的损耗,而且可以避免强制类型转换。

泛型的结构

开放类型与封闭类型

我们知道,C#的任何一个类型,都有对应类型对象(type object)储存类型的元数据。泛型类型也拥有类型对象,称为开放类型。但是我们不可以构造一个开放类型的实例。

1
2
3
4
5
6
7
8
9
using System;
using System.Collections.Generic;

public static void Main(string[] args)
{
Type t = typeof(List<>);//List<>是一个开放类型
object obj = Activator.CreateInstance(t);
Console.WriteLine(obj);
}

程序会在第7行抛出System.ArgumentException异常

当我们直接打印一个开放类型的完整名称,例如打印System.Collections.Generic.List<>,会得到"System.Collections.Generic.List`1"。末尾的"`1"代表有多少个泛型参数。

确定了泛型类型的类型称为封闭类型。

泛型类型内的静态字段

泛型类型内部可以定义静态字段、属性。与普通类型的静态字段全局唯一不同,泛型类型内的任何静态字段不会在泛型类型间共享,它们会被独立出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Array<T>
{
public static int Meta;
private T _element;
}

public static void Main(string[] args)
{
Array<Program>.Meta = 2;
Array<double>.Meta = 3;
Console.WriteLine(Array<Program>.Meta);
Console.WriteLine(Array<double>.Meta);
}

控制台会打印2和3,而不是两个3。

泛型类型的继承

泛型类型也是类型,所以能从任何类型派生。继承泛型类型时指定实参,那么相当于定义了新得类型对象,新类型从填入实参后的泛型类型派生。例如Dictionary<K,V>派生自Object,那么Dictionary<int,double>也派生自Object。当StrMap<V>派生自Dictionary<string,V>,那么StrMap<int>也派生自Dictionary<string,int>

使用using简化泛型

有时候我们会用一个类来封装泛型

1
2
3
public class IntArray : List<int> 
{
}

但C#提供了更好的方式,来为泛型类型重命名

1
2
3
4
5
6
7
using IntArray = System.Collections.Generic.List<int>;

private static void Main(string[] args)
{
var intArr = new IntArray();
intArr.Add(1);
}

使用using语句来定义一个符号代替泛型

泛型方法的背后

一般来说,当我们调用了一个泛型方法并填入想要的泛型参数,我们希望参数就像C/C++的宏一样,将方法内的泛型类型替换成我们填入的参数。但这会导致,为每一个填入的参数都生成一个对应的方法,让方法数量爆炸,影响性能。当然,C#已经解决了这个问题,C#的运行时CLR(Common Language Runtime)如果发现,填入的参数是引用类型,就只会生成一套完全相同的代码,运行时填入实例,因为引用类型可以看作一个指向堆内存的指针,而指针的大小是固定的,所有指针都可以统一操作。但如果填入的是值类型,CLR才会生成独立的方法,因为每一个值类型的大小都不确定,编译器无法确定要使用的栈空间大小。

泛型的协变与逆变

协变与逆变允许将修改类型但泛型参数类型不变

  • 逆变:泛型参数可以改变为它的派生类型,在泛型参数前填in
  • 协变:泛型参数可以改变为它的基类,在泛型参数前填out

只有委托和接口中可以定义协变和逆变

委托

例如标准库中的Func委托

1
2
3
4
5
public static void Main(string[] args)
{
Func<Exception, NullReferenceException> func = null;
Func<ArgumentException, Exception> e = func;
}

这段代码是合法的。

来看看Func<,>的定义

1
public delegate TResult Func<in T, out TResult>(T arg);

说明第一个参数可以逆变,第二个参数可以协变。而ArgumentException派生自Exception,NullReferenceException派生自Exception,因此这段代码是合法的。

接口

与委托类似,IEnumerator<>的定义是

1
2
3
4
public interface IEnumerator<in T> : IEnumerator
{
//其余代码省略
}

因此IEnumerator<>可以逆变

1
2
3
4
5
private static void Main(string[] args)
{
IEnumerator<Program> e = null;
IEnumerator<object> p = e;
}

泛型约束

有时候,我们希望在泛型方法内调用传入形参的方法

1
2
3
4
5
6
7
8
9
10
11
public interface INormalProperty
{
bool IsUnbreakable { get; }
bool IsGod { get; }
}

private static bool CanBoom<T>(T target, T attacker)
{
if (target.IsUnbreakable) return false;
return attacker.IsGod;
}

噢,这段代码是无法通过编译的,因为我们根本不知道T类型有没有IsUnbreakable和IsGod属性!

但我们可以约束T的类型,使得它可以调用这些属性!

1
2
3
4
5
private static bool CanBoom<T>(T target, T attacker) where T : INormalProperty
{
if (target.IsUnbreakable) return false;
return attacker.IsGod;
}

在上面的例子中,使用where关键字表示:指定泛型类型时,任何传入的类型都必须实现INormalProperty接口。这样,在调用方法时,如果传入参数未实现接口,则无法通过编译。

事实上,任何可以用泛型来定义的地方,都可以用where来约束。

泛型约束时,可以使用几个特殊的参数

  • new():表示泛型可以支持无参构造
1
2
3
4
public static T Generate<T>() where T : new()
{
return new T();
}
  • class:表示泛型必须是引用类型
  • struct:表示泛型必须是值类型
  • unmanaged:表示泛型必须是值类型且不包含任何委托类型

其中class、struct、unmanaged是互斥的,不能同时出现,new()必须放在所有约束类型的末尾且与struct、unmanaged是互斥的。

还有几个特殊的类不能用于约束:

  • System.Object
  • System.Array
  • System.Delegate
  • System.MulticastDelegate
  • System.ValueType
  • System.Enum
  • System.Void

一些小细节

将泛型变量设为默认值

我们有时候会写出这样的代码

1
2
3
4
public void Foo()
{
object o = null;
}

这没问题,C#中的变量在使用前不可以只声明不初始化,但是泛型变量这样做可能会出一些问题。

1
2
3
4
public void Foo<T>()
{
T o = null;
}

这段代码无法通过编译,因为不知道它是值类型还是引用类型。我们可以用default关键字设置它的默认值,就像接口一样

1
2
3
4
public void Foo<T>()
{
T o = default;
}

泛型间的运算符

比较运算符

  • 任何泛型变量都可以与null比较,如果泛型是值类型,arg == null永远都会返回false,因为值类型不可能是null
  • 两个引用类型的泛型变量可以直接用==或!=比较,结果和普通方法是一样的
  • 两个未知类型的泛型变量不可以用操作符比较。可以约束接口IEquatable<>

其他操作符

  • 由于C#的限制,我们没有办法写出能处理任何数据类型的算法,任何操作符都不能用于泛型类型间的运算。C++可以通过重载运算符来成功编译模板函数,但C#尚不支持这样的做法。

[C#]泛型
https://ksgfk.github.io/2020/07/04/CSharp-泛型/
作者
ksgfk
发布于
2020年7月4日
许可协议