object 变量可指向任何类的实例,这让你能够创建可对任何数据类型进程处理的类。然而,这种方法存在几个严重的问题。使用object时,类无法将输入限定为特定类型;要对数据执行有意义的操作,必须将其从object转换为更具体的类型。这不但增加了复杂性,还在编译阶段牺牲了类型安全。
C#泛型避免了转换(即装箱和取消装箱),让通用化在编译阶段是类型安全的,从而解决了上述问题。泛型提供了非泛型类无法实现的类型安全、可重用性和效率。泛型通常与集合一起使用,但也可使用泛型来创建自定义泛型类型和泛型方法。
一、为什么应使用泛型
由于数组每个元素的数据类型都被显式地声明为int,编译器将确保只能将int值赋给每个元素。还使用为int值定义的方法和运算符对元素进行了操作。
如下所示的代码可用于找出任何int数组中的最小值。
public int Min(int[] values)
{
int min = values[0];
foreach (int value in values)
{
if (value.CompareTo(min) < 0)
{
min = value;
}
}
return min;
}
如果希望这些代码可用于任何数值数组,该怎么办呢?如果不使用泛型,就需要为每种数值类型编写一个版本的Min,这些版本只是数据类型不同。这虽然可行,但是代码很复杂,并且很多代码是重复的。
如果知道IComparable接口定义了一个CompareTo方法,就可使用object编写更通用的代码。这只需编写代码一次,而这些代码可用于任何数值类型的数组,如下代码所示:
public int Min(object[] values)
{
IComparable min = (IComparable)values[0];
foreach (object value in values)
{
if (((IComparable)value).CompareTo(min) < 0)
{
min = (IComparable)value;
}
}
return min;
}
不幸的是,虽然只需编写代码一次带来了一定的好处,但是类型安全也丧失殆尽。另外, int数组不能转换为object数组。鉴于这个方法适用于object,如果给它传递一个类似于下面这样的数组,结果将如何呢?
object[] array = { 5, 3, "a", "hello" };
这是合法的,因为数组存储的是object元素,因此任何值都将隐式地转换为object。
不仅类型安全丧失殆尽,还执行了n+1次转换操作,其中n是数组包含的元素数。数组越大,这个方法的开销越高。
借助于泛型,这个问题将变得很简单。只需编写代码一次,而不会丧失类型安全或执行多次转换操作。如下代码显示了使用泛型定义的Min方法
public T Min<T>(T[] values) where T : IComparable<T>
{
T min = values[0];
foreach (T value in values)
{
if (value.CompareTo(min) < 0)
{
min = value;
}
}
return min;
}
ps:where T : IComparable<T>
这个约束可能有点令人迷惑,因为看起来好像存在循环依存关系。事实上,它很简单,意味着T必须是可比较的类型。
最大的不同在于,泛型版本使用了泛型类型参数 T,这是在方法名后面使用语法<T>指定的。类型参数充当编译阶段提供的实际类型的占位符。在这个例子中,用于替换T的实际类型必须实现了接口IComparable<T>,其中T是泛型方法的类型参数。这要求类型参数只能是实现了该接口的类型。
ps:C#泛型、C++模板和Java泛型
虽然C#泛型、C++模板和Java泛型都支持参数化类型,但是它们之间有几项重要的差别。C#泛型的语法与Java泛型类似,但比C++模板简单。
C#泛型的所有类型替换都是在运行阶段进行的,从而保留了对象的泛型类型信息。在Java中,泛型是一种语言结构,只在编译器中以类型擦除(type erasure)的方式实现。因此,在运行阶段无法获悉对象的泛型类型信息。这不同于C++模板,C++在编译阶段展开,为每种模板类型生成额外的代码。
在有些情况下,C#泛型的灵活性没有C++模板和Java泛型高。例如, C#泛型不像Java泛型那样支持类型参数通配符;也不能像在C++模板中那样,可调用类型参数的算术运算符。
1.1 泛型类型参数
方法有参数,而在运行阶段,这些形参的值为实参。同样,泛型类型和泛型方法也有类型参数和类型实参,其中类型参数充当编译阶段提供的类型实参的占位符。
这不仅仅是简单的文本替换——使用提供的类型提供类型参数。泛型类型或泛型方法被编译后,生成的 CIL 包含元数据,指出它有类型参数。在运行阶段,JIT 用提供的类型参数进行替换,创建出构造类型(constructed type)。
泛型类型和泛型方法可以有多个用逗号(,)分隔的类型参数。有多个泛型集合类使用了多个类型参数,如Dictionary<TKey, TValue>和KeyValuePair<TKey,TValue>。Tuple类也是泛型,最多可以有8个类型参数。
约束
约束让您能够指定哪些类型可在编译阶段用作类型实参。这些限制是使用关键字 where指定的。通过约束类型参数,便可使用约束类型及其继承链中所有类型都支持的操作和方法。
约束共有6种,如下所示
约束 | 描述 |
---|---|
where T : struct | 类型实参必须是值类型,但可以为null的值类型除外 |
where T : class | 类型实参必须是引用类型,这适用于任何类、接口、委托和数据类型 |
where T : new() | 类型实参必须有不接受任何参数的共有构造函数,且为具体类型。与其他约束一起使用时,new()约束必须位于最后面 |
where T : <base class name> | 类型实参必须是指定的基类或其派生类 |
where T : <interface name> | 类型实参必须能够隐式地转换为指定的接口。约束接口可以使泛型的,还可指定多个接口约束 |
where T : U | 类型实参必须是U(另一个泛型类型参数)指定的类型或从它派生而来的 |
约束告诉编译器,类型参数支持哪些运算符和方法。没有约束的类型参数为无约束类型参数,只支持简单赋值以及System.Object支持的方法。对于无约束类型参数,不能将运算符!=和==用于它们,因为编译器不知道它们是否能获得支持。
单个类型参数可以有多个约束,也可以给多个类型参数指定约束:
CustomDictionary<TKey, TValue>
where TKey : IComparable
where TValue : class, new()
ps:泛型的值相等性检测
即使指定了约束where T : class,也不应将运算符==和!=用于类型参数。这些运算符检测引用是否相同,而不是值是否相等。
即使在类型中重载了这些运算符,情况也是如此。因为编译器只知道T是引用类型。因此,它只能使用 System.Object 定义的可用于所有引用类型的默认运算符。
要进行值相等性检测,推荐使用约束where T : IComparable<T>,并确保将用于构造泛型类的所有类都实现了该接口。通过指定该约束,可使用方法CompareTo进行值相等性测试
类型参数约束(type parameter constrain)是一个这样的泛型类型参数,即用于约束另一个类型参数。类型参数约束最常用于这样的情形:泛型方法需要将其类型参数约束为其所属类型的类型参数,如下代码所示。
在这个示例中,T是方法Add的一个类型参数约束,该方法接受一个List<U>,其中U必须是T或从T派生而来的。
public class List<T>
{
public void Add<U>(List<U> items) where U : T
{
}
}
类型参数约束还可用于泛型类,以指定两个类型参数之间的关系,如下代码所示。在这个例子中,Example有3个类型参数(T、U和V),其中T必须是V或从V派生而来,而U和V之间没有约束
public class Example<T, U, V> where T : V
{
}
1.2 泛型类型的默认值
C#是一种强类型语言,要求使用变量前给它赋值。为方便满足这种要求,每种类型都有默认值。显然,对于泛型类型,无法预先知道默认值应为 null、0 还是用零初始化的结构,那么如何为泛型类型指定适合任何类型的默认值呢?
C#提供了关键字default,它表示适合类型参数的默认值,其具体值随指定的实际类型而异。这意味着对于参数类型,它返回 null;对于所有数值类型,它返回 0;如果类型实参是结构,则根据其每个成员的数据类型,将它们初始化为null或零;对于可以为null的值类型,则返回null。
二、泛型方法
泛型方法与非泛型方法类似,但使用一组泛型类型参数而不是具体类型定义。泛型方法是在运行阶段用于生成方法的设计图。
ps:非泛型类中的泛型方法
并非只有泛型类才能有泛型方法,非泛型类也可包含泛型方法,这完全合法,也很常见。
另外,泛型类也可包含非泛型方法,且后者可访问前者的类型参数。
通过给泛型方法指定约束,可使用约束保证可用的具体操作。泛型方法和非泛型方法都可使用泛型类定义的类型参数,因此如果泛型方法定义了与其所属的类相同的类型参数,给泛型方法提供的实参T将隐藏给类提供的实参T,而编译器将发出警告。如果希望方法使用的类型实参与实例化类时提供的类型实参不同,应给类型参数指定不同的标识符,如下代码所示:
class GenericClass<T>
{
void GenerateWarning<T>()
{
}
void NoWarning<U>()
{
}
}
调用泛型方法时,必须给它定义的类型参数提供实际的数据类型。如下代码所示演示了图和调用方法Min<T>:
public static class Program
{
static void Main()
{
int[] array = { 3, 5, 7, 0, 2, 4, 6 };
Console.WriteLine(Min<int>(array));
}
}
虽然这是可以接受的,但是在大多数情况下是不必要的,这要归功于类型推断(type inference)。如果省略了类型实参,编译器将根据方法实参推断出类型。如下代码利用类型推断进行了相同的调用:
public static class Program
{
static void Main()
{
int[] array = { 3, 5, 7, 0, 2, 4, 6 };
Console.WriteLine(Min(array));
}
}
由于类型推断依赖于方法实参,因此它无法仅根据约束或返回类型推断出类型。这意味着不能将其用于没有参数的方法。
对泛型方法来说,类型参数是方法签名的一部分。可以这样重载泛型方法:声明多个泛型方法,它们的形参列表相同,但类型参数不同。
ps
:类型推断和重载解析
类型推断发生在编译阶段,并在编译器试图解析重载的方法签名之前进行。进行类型替换后,非泛型方法和泛型方法的签名可能相同。在这种情况下,将使用最具体的方法(总是为非泛型方法)。
三、创建泛型类
泛型类最常用于集合,因为无论存储的数据类型是什么,集合的行为都相同。泛型方法是运行阶段用于生成方法的设计图,同样,泛型类也是运行阶段用于构造类的设计图。
除使用.NET Framework提供的泛型类外,您还可以创建自定义泛型类。这与创建非泛型类没有什么不同,只是您需要提供类型参数而不是实际数据类型。
创建自定义泛型类时,请牢记下面几个重要问题:
哪些类型应为类型参数?一般而言,参数化的类型越多,泛型类就越灵活。然而,对实际的类型参数数量存在一定的限制,因为类型参数越多,代码的可读性越差。
应指定什么样的约束?确定这一点的方式有多种。一种方式是,确保能够使用希望的类型的情况下,指定尽可能多的约束;另一种方式是,指定尽可能少的约束,以最大限度地提高泛型类的灵活性。这两种方式都可行,但是也可采取更实用的方式,即根据泛型类要达到的目的,指定必要的约束。例如,如果知道泛型类应只用于引用类型,就应指定where T : class约束。这样既可禁止泛型类用于值类型,又能使用as运算符并进行null检查。
行为应在基类还是子类中提供?泛型类可用作基类,就像非泛型类一样。因此,适用于非泛型类的设计选项也可用于泛型类。
应实现泛型接口吗?您可能需要实现甚至创建一个或多个泛型接口,这取决于设计的泛型类是什么样的。自定义泛型类的用法也决定了它要实现哪些接口。
非泛型类可继承具体的非泛型类,也可继承抽象的非泛型类;同样,泛型类也可继承非泛型具体类或抽象类,但泛型类还可继承其他泛型类。
ps:泛型结构和泛型接口
结构也可以是泛型的,泛型结构使用的语法和类型约束与泛型类相同。泛型结构和泛型类之间的差别与非泛型结构和非泛型类之间的差别相同。
泛型接口使用的类型参数语法和约束与泛型类相同,其声明规则与非泛型接口相同。一种明显的差别是,泛型类型实现的接口对所有可能的构造类型来说都必须是唯一的,这意味着如果替换类型参数后,同一个泛型类实现的两个泛型接口相同,那么该泛型类的声明将是非法的。
虽然泛型类可以继承非泛型接口,但是最好不要这样做,而是继承泛型接口。
为理解泛型类的继承,需要明白开放类型(open type)和封闭类型(closed type)之间的差别。开放类型是包含类型参数的类型,具体地说,它是这样的泛型类型,即没有给其类型参数提供类型实参。封闭类型也叫构造类型,是不开放的泛型类型,即给它的所有类型参数都提供了类型实参。
泛型类可继承开放类型,也可继承封闭类型。派生类可给基类的所有类型参数都提供类型实参,在这种情况下,派生类为构造类型;如果派生类没有给基类提供任何类型实参,它将是开放类型。虽然泛型类可继承封闭类型和开放类型,但非泛型类只能继承封闭类型,否则编译器将无法知道应使用什么样的类型实参。
如下代码提供了一些继承开放类型和封闭类型的示例
abstract class Element { }
class Element<T> : Element { }
class BasicElement<T> : Element<T> { }
class Int32Element : BasicElement<int> { }
在这个示例中, Element<T>继承了 Element ,是开放类型;BasicElement<T>继承了Element<T>,是开放类型;而Int32Element是构造类型,因为它继承了构造类型BasicElement<int>。
然而,派生类可给基类的部分类型参数提供类型实参,在这种情况下,派生类为开放构造类型(open constructed type)。可认为开放构造类型位于开放类型和封闭类型之间,即它至少给一个类型参数提供了实参,但要成为构造类型,还至少有一个类型参数需要提供实参。
如下代码扩展了上述示例,它创建了一个有两个类型参数(T和K)的开放类型(Element),还创建了各种可能的开放构造类型。
class Element<T, K> { }
class Element1<T> : Element<T, int> { }
class Element2<K> : Element<string, K> { }
如果是开放类型指定的约束,那么其派生类提供的类型实参必须满足这些约束,可以指定约束来实现。子类的约束可以与基类相同,也可以是基类约束的超集。
如下代码演示了如何继承带约束的开放类型:
class ConstainedElement<T>
where T : IComparable<T>,new()
class ConstainedElement1<T> : ConstainedElement<T>
where T : IComparable<T>,new()
最后,如果泛型类实现了一个接口,那么其所有实例都可转换为该接口。
四、结合使用泛型和数组(泛型数组)
所有一维数组的最小索引都为零,且自动实现了IList<T>。因此,可以创建一个对 IList<T>的内容进行遍历泛型方法,而该方法可用于所有集合类型(因为它们都实现了IList<T>)和所有一维数组。
如下示例演示了使用泛型方法显示集合的元素
public static class Program
{
public static void PrintCollection<T>(IList<T> collection)
{
StringBuilder builder = new StringBuilder();
foreach (var item in collection)
{
builder.AppendFormat("{0} ", item);
}
Console.WriteLine(builder.ToString());
}
public static void Main()
{
int[] array = { 0, 2, 4, 6, 8 };
List<int> list = new List<int>() { 1, 3, 5, 7, 9 };
PrintCollection(array);
PrintCollection(list);
string[] array2 = { "hello", "world" };
list<string> list2 = new List<string>() { "now", "is", "the", "time" };
PrintCollection(array2);
PrintCollection(list2);
}
}
泛型接口的可变性
类型可变性(variance)指的是可使用不同于指定类型的类型。协变(covariance)能够使用派生程度比指定类型更高的类型,而逆变(contravariance)能够使用派生程度更低的类型。C#对返回类型支持协变,对参数支持逆变。
C#泛型集合是不可变的(invariant),这意味着必须使用指定的类型。因此,在需要派生程度低的类型的集合时,不能使用派生程度高的类型的集合。
ps:实现了可变(variant)泛型接口的类
实现了可变(variant)泛型接口的类总是不变的(invariant)。
这里的真正问题是集合是可修改的。如果可对集合进行限制,使其只支持只读行为,就可将其声明为协变的。
在C#中,如果接口的类型参数被声明为协变或逆变的,接口就是可变的。仅当两种类型之间能够进行引用转换时,才能使用协变和逆变。这意味着可变性不能用于值类型,也不能用于ref和out参数。
有多个泛型集合接口支持可变性,如下所示
接口 | 可变性 |
---|---|
IEnumerable<T> | T是协变的 |
IEnumerator<T> | T是协变的 |
IQueryable<T> | T是协变的 |
IGrouping<TKey, TElement> | TKey和TElement是协变的 |
IComparer<T> | T是逆变的 |
IEqualityComparer<T> | T是逆变的 |
IComparable<T> | T是逆变的 |
扩展可变的泛型接口
编译器不会根据被继承的接口推断可变性,因此必须显式地指定派生接口是否支持可变性,如下所示:
interface ICovariant<out T>{ }
interface IInvariant<T> : ICovariant<T>{ }
interface IExtendCovariant<out T> : ICovariant<T>{ }
虽然接口 IInvariant<T>和 IExtendedCovariant<out T>扩展的是同一个协变接口,但只有IExtendedCovariant<out T>也是协变的。可以以同样的方式扩展逆变接口。
还可在同一个接口中同时扩展协变接口和逆变接口,条件是派生接口是不可变的,如下所示同时扩展协变接口和逆变接口:
interface ICovariant<out T>{ }
interface IInvariant<in T>{ }
interface IInvariant<T> : IContravariant<T>, ICovariant<T>{ }
然而,不能使用协变接口扩展逆变接口,反之亦然,如下代码所示:
// Generates a compiler error.
interface IInvalidVariance<in T> : ICovariant<T>{ }
ps:创建自定义的可变泛型接口
可以创建自定义的泛型接口,同样,可创建自定义的可变泛型接口,方法是给泛型类型参数指定关键字in和out。
关键字out将泛型类型参数声明为协变的,而关键字in将泛型类型参数声明为逆变的。同一个接口可同时包含协变类型参数和逆变类型参数。关键字ref和out在声明和调用方法时都需指定,而关键字in和out只需在接口声明中指定。
五、元组
元组是一种数据结构,它包含特定个数值,而这些值按特定顺序排列。元组常用于以下用途:
表示一组数据;
提供一种访问数据集的简单方法;
轻松地从方法返回多个值
虽然元组最常用于函数编程语言,如F#、Ruby和Python,但是.NET Framework也提供了多个Tuple类,分别用于表示包含1~7个值的元组。还有一个表示n元组的类,其中n是大于或等于8的任何整数。表示n元组的类与表示1~7元组的类稍有不同,其第8个分量也是一个Tuple对象,定义了其他的分量。