泛型

如果一个类成员允许使用多种数据类型(例如设计一个通用的数据结构),一种实现方法是将类成员指定为object类型,但这种写法代码可读性较差。C# 2.0引入了泛型,泛型可以实现将类成员的类型作为一个参数抽离出来,在某些场景下使用泛型定义类,能够极大提高代码的可读性和可复用性。

泛型定义和使用

C#中的类、方法、接口、委托都支持泛型。

泛型类

泛型类的参数使用一对尖括号<>指定,泛型参数允许存在多个,使用逗号分隔。下面例子我们定义了一个泛型类,两个属性DataA和DataB的数据类型是通过泛型参数T和U指定的,这里类实例化时会传入具体的类型名。

class Container<T, U>
{
    public T DataA { get; set; }
    public U DataB { get; set; }
}

实例化泛型类时需要传入对应的类型参数,写法如下:

Container<int, string> container = new Container<int, string>();
container.DataA = 1;
container.DataB = "hello";

泛型方法

泛型方法的声明和泛型类差不多,也是增加一组泛型参数。下面例子中,我们在一个普通的类里声明了泛型方法。

class A
{
    public void Foo<T, U>(T t, U u)
    {
        Console.WriteLine(u);
        Console.WriteLine(t);
    }
}

调用泛型方法时,也可以使用尖括号传入泛型参数。

A a = new A();
a.Foo<int, string>(1, "hello");

不过实际上,这里我们声明的Foo方法的两个类型参数T和U显然可以从其参数t和u类型中推断出来,因此调用该方法时我们可以不必传泛型参数,写作a.Foo(1, "hello");也是可以的,但不能自动推断的时候就必须显式的写出泛型参数了。

约束泛型类型

我们定义泛型时,可以使用where关键字对其进行限制,例如:必须继承某个类或接口,必须有无参构造方法,必须是引用类型或是值类型等,这些约束还可以组合使用。类、方法、接口、委托都支持泛型约束。

约束 说明
where T : class T必须是引用类型
where T : struct T必须是值类型
where T : new() T必须有无参构造函数,如果和其他约束组合使用,new()必须放在最后
where T : 类名或接口名 T必须派生自某类或接口

下面代码对类的泛型进行了约束,T类型必须继承Animal类。

public class Zoo<T> where T : Animal { }

多个约束可以组合使用,使用逗号分隔。

public class Zoo<T> where T : Animal, new() { }

下面代码对方法的泛型进行了约束,T类型必须有无参构造函数。

public static T GetInstance<T>() where T : new() { }

C#和Java在泛型上的区别

C#的泛型和Java的泛型虽然写法相似,但有本质上的不同。

Java中的泛型是采用类型擦除实现的,这种实现方式经常被诟病为“假泛型”,所谓的假泛型是一种编译期的语法糖,且只适用于引用类型,Java字节码中使用的还是Object类型,只不过编译器帮我们添加了类型转换的代码。而C#泛型是运行时级别支持的,且适用于引用类型和值类型,这导致两者在用法上有很多区别,C#支持很多Java程序员想都不敢想的写法!

下面例子代码我们编写了一个实例化泛型类型的代码,代码中直接使用new关键字实例化了一个泛型类型,这在Java中是无法实现的,Java实现类似功能只能传入一个类型对象,然后使用反射来实例化,写法十分扭曲。

public class ServiceFactory
{
    public static T GetInstance<T>() where T : new()
    {
        T t = new T();
        return t;
    }
}

代码中我们使用了where T : new()约束保证T有无参构造函数。

调用该泛型方法:

StudentService s = ServiceFactory.GetInstance<StudentService>();

泛型的协变和逆变

前面委托和事件章节介绍过委托的协变和逆变,泛型接口和泛型委托上同样存在变体的概念,而且实际开发中更为常见。

协变

先看一个直觉上应该成立、但实际上不成立的例子。

class Animal { }
class Dog : Animal { }

List<Dog> dogs = new List<Dog>();
// List<Animal> animals = dogs; // 编译报错

虽然Dog继承自Animal,但List<Dog>和List<Animal>之间没有任何继承关系,这个限制是合理的:如果允许这种赋值,我们就可以通过animals变量向列表中插入一个Cat,破坏原列表的类型安全。

但另一方面,IEnumerable<Dog>却可以赋给IEnumerable<Animal>,这是合法的。

IEnumerable<Animal> animals = dogs; // 合法!

原因在于IEnumerable<out T>的类型参数T上标注了out关键字,这被称为协变。out标注表示T只会出现在返回值(输出)的位置,既然元素只能被读出而不能被写入,那么把Dog当作Animal读出来就是安全的,编译器也因此允许IEnumerable<Dog>向IEnumerable<Animal>的隐式转换。

逆变

与协变相反,用in关键字标注的类型参数称为逆变,它表示T只会出现在参数(输入)的位置,允许从更宽泛的类型向更具体的类型转换。典型的例子是比较器接口IComparer<in T>。

// IComparer<in T>是逆变的
IComparer<Animal> animalComparer = GetAnimalComparer();
// 能比较Animal的东西,自然也能比较Dog
IComparer<Dog> dogComparer = animalComparer; // 合法!

变体的规则和记忆法

  • 只有泛型接口和泛型委托支持变体标注,泛型类不支持,这就是上面List<Dog>不能赋给List<Animal>的原因之一。
  • out表示协变,类型参数只能用于返回值等输出位置;in表示逆变,类型参数只能用于参数等输入位置。可以按字面记忆:协变out是"往外拿",逆变in是"往里塞"。
  • 标准库中常见的变体接口有IEnumerable<out T>、IReadOnlyList<out T>(协变)和IComparer<in T>、IEqualityComparer<in T>、Action<in T>(逆变)等。

数组协变的历史遗留

最后提一个容易踩坑的历史遗留:引用类型的数组是支持协变的,string[]可以直接赋给object[],但这个设计并不安全。

string[] strs = new string[3];
object[] objs = strs; // 合法,数组协变
objs[0] = 1;          // 编译通过,运行时抛出ArrayTypeMismatchException!

写入不兼容类型的元素时编译器无法发现问题,错误被推迟到了运行期。数组协变是.NET早期为了兼容而做出的妥协,实际开发中应避免依赖它,使用IEnumerable<out T>等接口才是安全的协变方式。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。