首页 > 编程笔记 > C#笔记 阅读:5

C#泛型用法详解(附带实例)

C# 有两种不同的机制来编写跨类型可复用的代码,分别是继承和泛型。继承的复用性来自基类,而泛型的复用性是通过带有占位符的模板类型实现的。和继承相比,泛型能够提高类型的安全性,并减少类型转换和装箱。

泛型中声明的类型参数(占位符类型)需要由泛型类型的消费者(即提供类型参数的一方)来填充。下面是一个存放类型 T 实例的泛型栈类型 Stack<T>。Stack<T> 声明了单个类型参数 T:
public class Stack<T>
{
    int position;
    T[] data = new T[100];
    public void Push(T obj) => data[position++] = obj;
    public T Pop() => data[--position];
}

Stack<T> 的使用方式如下:
var stack = new Stack<int>();
stack.Push(5);
stack.Push(10);
int x = stack.Pop(); // x is 10
int y = stack.Pop(); // y is 5

Stack<int> 用类型参数 int 填充 T,这会在运行时隐式创建一个类型 Stack<int>。若试图将一个字符串加入 Stack<int> 中则会产生一个编译时错误。Stack<int> 具有如下的定义:
public class ###
{
    int position;
    int[] data = new int[100];
    public void Push(int obj) => data[position++] = obj;
    public int Pop() => data[--position];
}

技术上,我们称 Stack<T> 是开放类型,称 Stack<int> 是封闭类型。在运行时,所有的泛型实例都是封闭的,占位符已经被类型填充。这意味着以下语句是非法的:
var stack = new Stack<T>(); // Illegal: what is T?

但是,在类或者方法内将 T 定义为类型参数是合法的:
public class Stack<T>
{
    ...
    public Stack<T> Clone()
    {
        Stack<T> clone = new Stack<T>(); // Legal
        ...
    }
}

为什么需要泛型

泛型是为了代码能够跨类型复用而设计的。假定我们需要一个整数栈,但是没有泛型的支持。那么一种解决方法是为每一个需要的元素类型硬编码不同版本的类(例如 IntStack、StringStack 等)。显然,这将导致大量的重复代码。

另一个解决方法是写一个用 object 作为元素类型的栈:
public class ObjectStack
{
    int position;
    object[] data = new object[10];
    public void Push(object obj) => data[position++] = obj;
    public object Pop() => data[--position];
}

但是 ObjectStack 类不会像硬编码的 IntStack 类一样只处理整数元素。而且 ObjectStack 需要用到装箱和向下类型转换,而这些都不能够在编译时进行检查:
// Suppose we just want to store integers here:
ObjectStack stack = new ObjectStack();
stack.Push("s");          // Wrong type, but no error!
int i = (int)stack.Pop(); // Downcast - runtime error
我们的栈既需要支持各种不同类型的元素,又需要一种简便的方法将栈的元素类型限定为特定类型,以提高类型安全性,减少类型转换和装箱。泛型恰好将元素类型参数化从而提供了这些功能。

Stack<T> 同时具有 ObjectStack 和 IntStack 的全部优点。它与 ObjectStack 的共同点是 Stack<T> 只需要书写一次就可以支持各种类型,而与 IntStack 的共同点是 Stack<T> 的元素是特定的某个类型。Stack<T> 的独特之处在于操作的类型是 T,并且可以在编程时将 T 替换为其他类型。

ObjectStack 在功能上等价于 Stack<object>。

C#泛型方法

泛型方法在方法的签名中声明类型参数。

使用泛型方法,许多基本算法就可以用通用方式实现了。以下是交换两个任意类型 T 的变量值的泛型方法:
static void Swap<T>(ref T a, ref T b)
{
    T temp = a;
    a = b;
    b = temp;
}

Swap<T> 的使用方式如下:
int x = 5;
int y = 10;
Swap(ref x, ref y);

通常调用泛型方法不需要提供类型参数,因为编译器可以隐式推断得到类型信息。如果有二义性,则可以用以下方式调用泛型方法:
Swap<int>(ref x, ref y);

在泛型中,只有引入类型参数(用尖括号标出)的方法才可归为泛型方法。泛型 Stack 类中的 Pop 方法仅仅使用了类型中已有的类型参数 T,因此不属于泛型方法。

只有方法和类可以引入类型参数。属性、索引器、事件、字段、构造器、运算符等都不能声明类型参数,虽然它们可以参与使用所在类型中已经声明的类型参数。例如,在泛型的栈中,我们可以写一个索引器返回一个泛型项:
public T this[int index] => data[index];

类似地,构造器可以参与使用已经存在的类型参数,但是不能引入新的类型参数:
public Stack<T>() { }   // Illegal

C#类型参数

可以在声明类、结构体、接口、委托和方法时引入类型参数。其他的结构(如属性)虽不能引入类型参数,但可以使用类型参数。

例如,以下代码中的属性 Value 使用了类型参数 T:
public struct Nullable<T>
{
    public T Value { get; }
}

泛型或方法可以有多个参数:
class Dictionary<TKey, TValue> { ... }

可以用以下方式实例化:
Dictionary<int, string> myDict = new Dictionary<int, string>();
// 或者
var myDict = new Dictionary<int, string>();

只要类型参数的数量不同,泛型类型名和泛型方法的名称就可以进行重载。例如,下面的三个类型名称不会冲突:
class A { }
class A<T> { }
class A<T1, T2> { }
习惯上,如果泛型类型和泛型方法只有一个类型参数,且参数的含义明确,那么一般将其命名为 T。当使用多个类型参数时,每一个类型参数都使用 T 作为前缀,后面跟一个更具描述性的名称。

C# typeof和未绑定泛型类型

在运行时不存在开放的泛型类型,开放泛型类型将在编译过程中封闭。但运行时可能存在未绑定(unbound)的泛型类型,这种泛型类型只作为 Type 对象存在。C# 中唯一指定未绑定泛型类型的方式是使用 typeof 运算符:
class A<T> { }
class A<T1, T2> { }

Type a1 = typeof(A<>);    // 未绑定类型(注意没有类型实参)
Type a2 = typeof(A<,>);   // 使用逗号表示多个类型参数

开放泛型类型一般与反射 API 一起使用。typeof 运算符也可以用于指定封闭的类型:
Type a3 = typeof(A<int, int>);

或一个开放类型(当然,它会在运行时封闭):
class B<T>
{
    void X() { Type t = typeof(T); }
}

C#泛型的默认值

default 关键字可用于获取泛型类型参数的默认值。引用类型的默认值为 null,而值类型的默认值是将值类型的所有字段按位设置为 0 的值。
static void Zap<T>(T[] array)
{
    for (int i = 0; i < array.Length; i++)
        array[i] = default(T);
}

从 C# 7.1 开始,我们可以在编译器能够进行类型推断的情况下忽略类型参数。因此以上程序最后一行可以写为:
array[i] = default;

C#泛型的约束

默认情况下,类型参数可以由任何类型来替换。在类型参数上应用约束,可以将类型参数定义为指定的类型参数。

以下列出了可用的约束:
where T : base-class    // 基类约束
where T : interface     // 接口约束
where T : class      // 引用类型约束
where T : class?      // 可空引用类型约束
where T : struct      // 值类型约束(排除 Nullable 类型)
where T : unmanaged     // 非托管约束
where T : new()      // 无参数构造器约束
where U : T        // 裸类型约束
where T : notnull     // 非可空值类型或非可空引用类型(C# 8)

在下面的例子中,GenericClass<T,U> 的 T 要求派生自(或者本身就是)SomeClass 并且实现 Interface1,要求 U 提供无参数构造器。
class SomeClass { }
interface Interface1 { }

class GenericClass<T, U>
    where T : SomeClass, Interface1
    where U : new()
{ ... }
约束可以应用在方法定义或者类型定义这些可以定义类型参数的地方。

基类约束要求类型参数必须是子类(或者匹配特定的类),接口约束要求类型参数必须实现特定的接口。这些约束要求类型参数的实例可以隐式转换为相应的类和接口。

例如,我们可以使用 System 命名空间中的 IComparable<T> 泛型接口实现泛型的 Max 方法,该方法会返回两个值中更大的一个:
public interface IComparable<T> // 简化版接口
{
    int CompareTo(T other);
}

CompareTo 方法在 this 大于 other 时返回正值。以此接口为约束,我们可以将 Max 方法写为(为了避免分散注意力,省略了 null 检查):
static T Max<T>(T a, T b) where T : IComparable<T>
{
    return a.CompareTo(b) > 0 ? a : b;
}

Max 方法可以接受任何实现了 IComparable<T> 接口的类型参数(大部分内置类型都实现了该接口,例如 int 和 string):
int z = Max(5, 10);          // 10
string last = Max("ant", "zoo"); // zoo

类约束和结构体约束规定 T 必须是引用类型或值类型(不能为空)。结构体约束的一个很好的例子是 System.Nullable<T> 结构体:
struct Nullable<T> where T : struct { ... }
非托管类型约束(C# 7.3 引入)是一个增强型的结构体约束。其中 T 必须是一个简单的值类型或该值类型中(递归的)不包含任何引用类型字段。

无参数构造器约束要求 T 有一个 public 无参数构造器。如果定义了这个约束,就可以对类型 T 使用 new() 了:
static void Initialize<T>(T[] array) where T : new()
{
    for (int i = 0; i < array.Length; i++)
        array[i] = new T();
}

裸类型约束要求一个类型参数必须从另一个类型参数中派生(或匹配)。在本例中,FilteredStack 方法返回了另一个 Stack,返回的 Stack 仅包含原来类中的一部分元素,并且类型参数 U 是类型参数 T 的子类:
class Stack<T>
{
    Stack<U> FilteredStack<U>() where U : T { ... }
}

C#继承泛型类型

泛型类和非泛型类一样都可以派生子类,并且在泛型类型子类中仍可以令基类中类型参数保持开放,如下所示:
class Stack<T> { ... }
class SpecialStack<T> : Stack<T> { ... }

子类也可以用具体的类型来封闭泛型参数:
class IntStack : Stack<int> { ... }

子类型还可以引入新的类型参数:
class List<T> { ... }
class KeyedList<T, TKey> : List<T> { ... }

技术上,子类型中所有的类型参数都是新的,可以说子类型封闭后又重新开放了基类的类型参数。因此子类可以在重新打开的类型参数上使用更有意义的新名称:
class List<T> { ... }
class KeyedList<T, TKey> : List<T> { ... }

相关文章