C#高级编程第11版

导航

第五章 Generics

5.1 泛型概述

泛型在C#乃至整个.NET中都是一个非常重要的概念。泛型不单单是C#编程语言的一部分,它们通过IL中间代码,跟程序集有很深的关联(deeply integrated in the assemblies)。通过泛型,你可以创建独立于特定类型(contained types)以外的方法和类,而不用为不同类型编写多份同样功能的代码,你只需要创建一个方法或者类。

另外一种减少大量代码的方式是使用Object类。然而,将Object类型作为参数传递并不满足类型安全的要求。泛型类使用泛型类型用来代替所需的特定类型。这一点能满足类型安全的需求:如果传入的类型不满足泛型类的定义,编译器就会及时提示一个错误。

泛型不单只限于类上,在本章,你还可以看到用在接口和方法上的泛型。第8章我们还会介绍关于委托的泛型。

泛型并非是C#专有的,其它编程语言里也有类似的概念,譬如C++里的模板。然而C++的模板和.NET的泛型之间还是有很大区别的。使用C++模板,当一个模板实例化为某个特定类型的时候,模板的源代码是必须的,C++编译器为指定模板的每个实例都生成一份独立的二进制代码。与C++不一样的是,泛型并不仅仅只由C#进行构造,它同时在CLR(Common Language Runtime)里也定义了。这使得它可以在VB里实例化同一个泛型类型,就算这个泛型类型是在C#里定义的。

接下来的章节将会探讨泛型的优缺点,尤其关于以下这些方面:

  • 性能
  • 类型安全
  • 二进制代码重用
  • 代码膨胀(code bloat)
  • 命名规范

5.1.1 性能

泛型的一大优点就是高性能。在第10章"集合"中,你将会了解到System.Collections下的非泛型的集合,以及System.Collections.Generic下的泛型集合。当你对值类型使用非泛型集合类时,将会触发装箱操作,将值类型转换成引用类型,反之亦然(拆箱,将引用类型转换成值类型)。

注意:装箱和拆箱将在第6章进行讨论,这里只是稍微提一下这些术语。

值类型存储在栈中,而引用类型存储在托管堆中。C#里的class是引用类型,而struct是值类型。.NET使得值类型到引用类型的转换变得非常容易,因此你可以在任何需要一个object(引用类型)的地方使用一个值类型。例如:一个int类型就允许直接赋值给一个object。将值类型转换成引用类型的操作我们称之为装箱(boxing)。当一个方法需要一个object类型的参数,而调用方却给这个参数传了一个值类型的时候,装箱操作就会自动处理,将值类型转换成引用类型。反过来,一个值类型装箱后的对象(引用类型)也可以重新转换成值类型,这个操作我们称之为拆箱(unboxing)。想使用拆箱操作,你需要使用强制转换运算符。

下面的例子中用到了System.Collections中的ArrayList来存储objects,它的Add方法需要接收一个object类型的参数,因此当我们传入整型时,参数就自动装箱了。当你想读取ArrayList里的某个值时,你需要显式地拆箱,将ArrayList里的object转换成整型。你可以为ArrayList里的每个Item使用强制运算符,也可以在foreach语句里使用,如下所示:

var list = new ArrayList();
list.Add(44); // 装箱 - 将整型转换成引用类型
int i1 = (int)list[0]; // 拆箱 — 将引用类型转换成整型
foreach (int i2 in list) // 拆箱
{
	Console.WriteLine(i2); 
}

装箱和拆箱操作用起来非常简单,但是容易影响到运行性能,尤其当它需要对很多枚举项进行操作的时候。

为了解决这个问题,System.Collections.Generic下的List<T>类允许你定义List里究竟存储的是什么类型。如下面这段代码显示的,List<T>类被定义成int类型,因此这个类内部使用的就是int类型的值,JIT编译器会动态生成这个类,也就不再需要装箱和拆箱的操作了:

var list = new List<int>();
list.Add(44); // 无需装箱 — value types are stored in the List<int>
int i1 = list[0]; // 也无需拆箱, 不需要任何强制转换
foreach (int i2 in list) //同上
{
	Console.WriteLine(i2);
}

5.1.2 类型安全

泛型的另外一个特点就是类型安全(type safety)。当你使用ArrayList类时,内部存储的是object类型,任何类都可以添加到这个数组列表里,就像这样:

var list = new ArrayList();
list.Add(44);
list.Add("mystring");
list.Add(new MyClass());

当你使用foreach语句枚举这个list的时候,你可以按照之前那么写,编译器不会报错。但是在运行的时候,因为存在无法隐式转换成int类型的string和MyClass实例,程序将会报错:

foreach (int i in list)
{ 
	Console.WriteLine(i); // Unable to cast object of type 'System.String' to type 'System.Int32'.”
}

为了能在编译时就及时发现这种可能存在的错误,通过使用泛型类List<T>,泛型类型T定义了能添加到List里的元素的类型。通过声明List<int>类型的变量,这意味着只有int类型的值才允许添加到List中。添加非int类型的元素将会得到一个编译器错误:

var list = new List<int>();
list.Add(44);
list.Add("mystring"); // 错误:无法从string转换为int
list.Add(new MyClass()); // 错误:无法从MyClass转换为int

5.1.3 二进制代码的重用

泛型支持更好的二进制代码重用(enable better binary code reuse)。一个泛型类可以只定义一次,并根据许多不同类型分别实例化,跟C++模板不同的是,它没必要访问源代码。

如下所示,System.Collections.Generic里的List<T>类分别根据int,string,MyClass类型进行了初始化:

var list = new List<int>();
list.Add(44);
var stringList = new List<string>();
stringList.Add("mystring");
var myClassList = new List<MyClass>();
myClassList.Add(new MyClass());

泛型还可以在.NET一种语言里定义,并供其他.NET语言使用,譬如VB里定义的泛型,C#里也可以使用。

5.1.4 代码膨胀

你可能会好奇,使用泛型的时候,为了实现不同类型的泛型实例,后台会创建多少代码。因为泛型类的定义存在程序集中,事实上实例化指定类型的泛型实例并不会重复生成多次IL代码。然而,当泛型类需要通过JIT编译器编译成原生代码时,仍然会按照指定类型创建一个对应的泛型实例类,有多少个指定类型就生成多少个泛型实例类。引用类型共享和原生类同样的代码实现(share all the same implemention of the same native class),这是因为不同操作系统的寻址位数不同,像32位系统里引用泛型实例类里引用类型的成员,只需要在泛型类里保留4字节的内存地址就行。值类型的成员则包含在泛型实例类的内存空间里,因为不同类型的成员需要不同的存储方式,因此为了适应操作系统和所有的内部成员需求,就需要创建一个新的泛型实例类。

5.1.5 命名规范

假如程序里用到了泛型,为了更好地将泛型类型与其他非泛型类型进行区分,以下有一些约定俗称的命名规范:

  • 泛型类型的名称以T开头。
  • 假如泛型类型可以被任何类型指代并且没什么特殊要求的话,并且只有一个泛型类型参数,那么用一个单独的字符T命名:
public class List<T> { }
public class LinkedList<T> { }
  • 假如对泛型类型有特别的要求,譬如它必须实现某个接口或者派生自某个类,或者使用2个以上的泛型类型,则使用能描述用途的名称作为泛型类型:
public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e);
public delegate TOutput Converter<TInput, TOutput>(TInput from);
public class SortedList<TKey, TValue> { }

5.2 创建泛型类

本小节的例子是通过一个正常的非泛型类型的简单链表类开始的,这个类存储了一个object类型的值,让我们一步一步地将它转换成泛型类。

要使用链表,其中含有指向下一个链表节点的元素。因此,你需要创建一个类,内部包含一个对象,用来指向下一个节点类。下面的LinkedListNode类包含了一个叫做Value的属性,在构造函数里初始化。另外,LinkedListNode类包含了两个引用next和previous可以用来访问前一个链表节点和下一个链表节点:

public class LinkedListNode
{
	public LinkedListNode(object value) => Value = value;
	public object Value { get; }
	public LinkedListNode Next { get; internal set; }
	public LinkedListNode Prev { get; internal set; }
}

创建完链表节点类,我们开始创建链表类。LinkedList类包含了两个LinkedListNode类型的属性First和Last,用来标识链表的起点和终点。AddLast方法则是将一个新的节点添加到链表末尾,首先创建一个新的链表节点,假如链表还是空的,就将First和Last属性都指向这个新节点;假如链表原来不为空,则将新节点作为Last节点的下一个节点。通过实现IEnumerable接口的GetEnumerator方法,我们创建的LinkedList链表类就可以通过foreach语句进行遍历。GetEnumerator方法使用yield语句来创建枚举类型:

public class LinkedList: IEnumerable
{
	public LinkedListNode First { get; private set; }
	public LinkedListNode Last { get; private set; }
	public LinkedListNode AddLast(object node)
	{
        var newNode = new LinkedListNode(node);
		if (First == null)
		{
			First = newNode;
			Last = First;
		}
		else
		{
			LinkedListNode previous = Last;
			Last.Next = newNode;
			Last = newNode;
			Last.Prev = previous;
		}
		return newNode;
	}
	public IEnumerator GetEnumerator()
	{
		LinkedListNode current = First;
		while (current != null)
		{
			yield return current.Value;
			current = current.Next;
		}
	}
}

注意:yield语句创建了一个枚举器(enumerator)的状态机,将在第7章"数组"中进行介绍。

现在你可以为任何类型使用LinkedList类了,如下所示:

var list1 = new LinkedList();
list1.AddLast(2);
list1.AddLast(4);
list1.AddLast("6");
foreach (int i in list1)
{
	Console.WriteLine(i);
}

上面的例子实例话了一个LinkedList对象并且添加了两个int类型和一个string类型。为了将int类型存入到object类型的Value属性里,前面已经讲过了,后台会进行装箱操作。而在foreach遍历时,每个元素都会被强制转换成int类型,这里喜闻乐见的报错它又来了,因为第三个元素是string类型,无法强制转换成int类型。

现在我们开始将上面的链表改造成泛型版本。泛型类跟普通类的定义其实差不多,只是多了一些泛型声明。你可以在类里面使用泛型参数,将它当成一个字段成员,或者方法参数类型。新的LinkedListNode泛型类使用了最常用的泛型类型T。然后将原先object类型的Value属性改成T类型。构造函数里同样改成接收一个T类型的参数值。泛型类型既可以当成返回值也可以当成变量类型,因此现在Next和Previous属性可以修改成LinkedListNode<T>类型了,就像下面这样:

public class LinkedListNode<T>
{
	public LinkedListNode(T value) => Value = value;
	public T Value { get; }
	public LinkedListNode<T> Next { get; internal set; }
	public LinkedListNode<T> Prev { get; internal set; }
}

在接下来的实例里,LinkedList类同样改成了泛型类。LinkedList<T>包含有LinkedListNode<T>元素。泛型类型T同样定义First和Last属性的类型,方法AddLast现在则接收T类型的参数并由此实例化LinkedListNode<T>对象实例。

除此之外,IEnumerable接口也有一个泛型版本的实现IEnumerable<T>,IEnumerable<T>继承自IEnumerable,并且新增了一个GetEnumerator方法,返回一个IEnumerator<T>类型,代码如下所示:

public class LinkedList<T>: IEnumerable<T>
{
	public LinkedListNode<T> First { get; private set; }
	public LinkedListNode<T> Last { get; private set; }
	public LinkedListNode<T> AddLast(T node)
	{
		var newNode = new LinkedListNode<T>(node);
		if (First == null)
		{
			First = newNode;
			Last = First;
		}
		else
		{
			LinkedListNode<T> previous = Last;
			Last.Next = newNode;
			Last = newNode;
			Last.Prev = previous;
		}
		return newNode;
	}
	public IEnumerator<T> GetEnumerator()
	{
		LinkedListNode<T> current = First;
		while (current != null)
		{
			yield return current.Value;
			current = current.Next;
		}
	}
	IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

注意:枚举器和接口IEnumerable和IEnumerator将会在第7章进行介绍。

使用泛型类LinkedList<T>,你可以直接实例化一个int类型对象,后台不用装箱。并且,当你试图给方法AddLast传递一个非int类型的参数时,编译器会提示你一个错误。使用泛型接口IEnumerable<T>和foreach遍历也是类型安全的:

var list2 = new LinkedList<int>();
list2.AddLast(1);
list2.AddLast(3);
list2.AddLast(5);
foreach (int i in list2)
{
	Console.WriteLine(i);
}

类似的,你也可以使用LinkedList<T>泛型类实例化一个string链表并且通过AddLast方法添加string节点:

var list3 = new LinkedList<string>();
list3.AddLast("2");
list3.AddLast("four");
list3.AddLast("foo");
foreach (string s in list3)
{
	Console.WriteLine(s);
}

注意:每个处理object类型的类都可以改成泛型版本,并且,如果这些类还有各级继承,改成泛型类将会很有用,因为中间会省略很多不必要的强制转换。

5.3 泛型类的功能

当创建泛型类时,你可能需要一些额外的C#关键字。例如,泛型类型不能赋值成null。在这种情况下就要用到default关键字了,就像下一小节中演示的那样。假如泛型类型不需要Object类的任何功能但你又需要调用泛型类里的某些特定方法的话,你可以定义约束。

接下来的几小节将讨论以下主题:

  • 默认值
  • 约束
  • 继承
  • 静态成员

让我们用一个泛型文档管理器的例子作为开始,它从一个队列里读写文档。首先,先创建一个叫DocumentManager的控制台项目,添加一个叫DocumentManager<T>的类。类中的AddDocument方法负责为队列添加一个文档,而只读属性IsDocumentAvailable则根据队列是否为空返回相应的true或false。

using System;
using System.Collections.Generic;
namespace Wrox.ProCSharp.Generics
{
	public class DocumentManager<T>
	{
		private readonly Queue<T> _documentQueue = new Queue<T>();
		private readonly object _lockQueue = new object();
		public void AddDocument(T doc)
		{
			lock (_lockQueue)
			{
				_documentQueue.Enqueue(doc);
			}
		}
		public bool IsDocumentAvailable => _documentQueue.Count > 0;
	}
}

线程和lock语句将在第21章进行讨论。

5.3.1 默认值

现在为DocumentManager<T>类添加一个GetDocument方法。在这个方法内的T需要可以是null值。然而,泛型类变量并不允许你直接赋一个null值。这是因为泛型类型允许实例化成值类型,null只能给引用类型赋初值。为了绕过这个难关,你可以使用default关键字。通过default关键字,null值将被赋值给引用类型,而0将被赋值给值类型。

public T GetDocument()
{
	T doc = default;
	lock (_lockQueue)
	{
		doc = _documentQueue.Dequeue();
	}
	return doc;
}

注意:根据上下文的不同,default关键字拥有多种含义。在switch语句里使用default来声明default case,而在泛型里default则是用来初始化泛型类型为null或者0。

5.3.2 约束

假如一个泛型类需要调用泛型类型中的某些方法,你需要添加一些约束。

在DocumentManager<T>类里,所有的文档标题需要由DisplayAllDocuments方法进行定义。Document类实现了接口IDocument,包含只读属性Title和Content:

public interface IDocument
{
	string Title { get; }
	string Content { get; }
}

public class Document: IDocument
{
	public Document(string title, string content)
	{
		Title = title;
		Content = content;
	}
	public string Title { get; }
	public string Content { get; }
}

为了显示DocumentManager<T>类中所有文档的标题,你可以将泛型T强制转换成接口IDocument类型,来显示标题:

public void DisplayAllDocuments()
{
	foreach (T doc in documentQueue)
	{
		Console.WriteLine(((IDocument)doc).Title);
	}
}

假如某个T类型没有实现IDocument接口的话,这种强制转换必然会抛出一个运行时错误。因此,我们最好能限制DocumentManager<T>中能实例化的泛型类T,都需要实现IDocument。为了更好的解释清楚这个变化,我们用TDocument来代替T作为泛型标识,并使用where关键字定义TDocument需要实现IDocument接口,如下所示:

public class DocumentManager<TDocument> where TDocument: IDocument
{
    
}

注意:如果想对泛型类型添加某些约束的话,最好使用描述性的泛型名称,就像上面我们用TDocument代替T。虽然对于编译器来说,TDocument还是T没什么区别,但TDocument显然比T更具可读性。

通过约束TDocument必须实现IDocument接口,foreach语句里可以直接使用IDocument接口的属性Title,而不再需要强制转换。

public void DisplayAllDocuments()
{
	foreach (TDocument doc in documentQueue)
	{
		Console.WriteLine(doc.Title);
	}
}

在Main函数里,DocumentManager<TDocument>则由实现了IDocument接口的Document类型进行实例化,我们添加了新文档,并展示了所有文档的标题和内容:

public static void Main()
{
	var dm = new DocumentManager<Document>();
	dm.AddDocument(new Document("Title A", "Sample A"));
	dm.AddDocument(new Document("Title B", "Sample B"));
	dm.DisplayAllDocuments();
	if (dm.IsDocumentAvailable)
	{
		Document d = dm.GetDocument();
		Console.WriteLine(d.Content);
	}
}

现在DocumentManager泛型类可以为任何实现了IDocument接口的类服务。

在示例中你已经看见了泛型的接口约束,其实它还支持其他类型,如下所示:

约束 描述
where T : struct T必须是值类型。
where T : class T必须是引用类型。
where T : IFoo T必须实现接口IFoo。
where T : Foo T必须派生自Foo。
where T : new() T必须带有无参构造函数。
where T1 : T2 泛型T1必须派生自泛型T2。

注意:构造函数约束只能针对无参构造函数,不可能对其它构造函数进行约束。

泛型可以同时拥有多类约束,譬如where T: IFoo, new()则限制T必须实现IFoo接口,并且带有无参构造函数:

public class MyClass<T> where T: IFoo, new()
{
	//...
}

注意:C#里where语句的一个重要限制就是无法定义泛型T需要实现某种运算符。运算符也无法在接口里定义,通过where语句仅仅能约束基础类,接口和无参构造函数。

5.3.3 继承

正如更早一些的LinkedList<T>类实现了接口IEnumerable<T>:

public class LinkedList<T>: IEnumerable<T>
{
	//...
}

泛型类型可以实现(继承)一个泛型接口,当然也可以继承一个泛型类:

public class Base<T>
{
	//...      
}
public class Derived<T>: Base<T>
{
    //...   
}

唯一的要求就是,继承的时候,泛型类型必须重复指定,也就是泛型参数得是一样的。又或者明确定义了指定的泛型类型是某个具体类型,这种情况下,派生类可以不是泛型类,如下所示:

public abstract class Calc<T>
{
	public abstract T Add(T x, T y);
	public abstract T Sub(T x, T y);
}
public class IntCalc: Calc<int>
{
	public override int Add(int x, int y) => x + y;
	public override int Sub(int x, int y) => x — y;
}

你也可以像下面这样,只继承部分泛型类型:

public class Query<TRequest, TResult>
{
	//...    
}
public StringQuery<TRequest> : Query<TRequest, string>
{
  	//...    
}

在这个例子中,StringQuery类继承自Query类,只定义了一个泛型参数TRequest,而参数TResult则明确指定为string。这样当你需要实例化StringQuery类时,你只需要指定TRequest的类型即可。

5.3.4 静态成员

泛型类中可以声明静态成员,它们只会在相同泛型实例类之间共享,考虑下面这样的代码:

public class StaticDemo<T>
{
	public static int x;
}

我们分别指定string和int进行泛型实例化:

StaticDemo<string>.x = 4;
StaticDemo<int>.x = 5;
Console.WriteLine(StaticDemo<string>.x); // 4

可以看到不同的泛型实例类之间互不影响。

5.4 泛型接口

使用泛型接口,你可以定义带有泛型参数的方法。就像链表示例的那样,你已经实现了接口IEnumerable<out T>,这个接口定义了一个GetEnumerator方法,并且返回IEnumerator<out T>。.NET提供了很多泛型接口以供不同场景使用,如 IComparable<T>, ICollection<T>还有IExtensibleObject<T>。其实这些接口都有更老的非泛型的版本,如.NET 1.0就有一个IComparable接口基于Object类,而新的IComparable<in T>则基于泛型类型:

public interface IComparable<in T>
{
	int CompareTo(T other);
}

注意:现在先不用为泛型参数里的in和out关键字感到疑惑,在接下来的泛型协变和逆变里会马上介绍。

旧的IComparable接口声明了一个CompareTo方法,接收一个object类型的参数。在使用的时候,需要强制转换类型,假定我们有个实现了IComparable接口的Person类,它有一个LastName属性,为了比较两个Person类实例的LastName是否相同,我们在CompareTo方法里写:

public class Person: IComparable
{
	public int CompareTo(object obj)
	{
		Person other = obj as Person; //需要强制转换才能调用到LastName属性
		return this.lastname.CompareTo(other.LastName); //这个CompareTo是string的方法
	}
    //...
}

而如果我们实现了泛型版本的接口IComparable<in T>,则再也不用将Object强制转换成Person了:

public class Person: IComparable<Person>
{
	public int CompareTo(Person other) => LastName.CompareTo(other.LastName);
}

5.4.1 协变和逆变

在.NET 4.0以前,泛型接口是不变量(invariant)。.NET 4.0为泛型接口和泛型委托做了两个重要更改:协变(Covariance)和逆变(Contra-Variance)。协变和逆变主要用在参数和返回值的类型转换上。例如,你能不能将一个Rectangle类型传递给一个需要Shape类型参数的方法?让我们通过一个例子来看看这些扩展的好处。

在.NET里,参数类型是逆变(Contra-Variance,原书中是covariant协变式)。假设你定义了Shape类和Rectangle类,并且Rectangle类是Shape类的派生类。假如另外有个方法叫Display,它接收一个Shape类型的实例,就像这样:

public void Display(Shape o)
{
   	//... 
}

现在你可以将Shape类型的实例或者派生自Shape的实例传递给这个方法,因为Rectangle派生自Shape类,Rectangle类的实例完全满足Shape类的成员需要,因此编译器允许像这样子调用:

var r = new Rectangle { Width= 5, Height=2.5 };
Display(r);

而方法的返回值则是协变(Covariance,原书是contra-variant逆变式)。当方法返回了一个Shape类型实例的时候,你就不能将它直接赋值给一个Rectangle类型的变量,因为Shape可能是Ellipse或者Triangle,不一定是Rectangle,但反过来是可以的,如下所示,假如有个GetRectangle方法返回一个Rectangle对象的话:

public Rectangle GetRectangle();

它是可以赋值给Shape类型的变量的:

Shape s = GetRectangle();

在.NET Framework 4.0以前,协变和逆变并不适用于泛型。从C# 4.0开始才扩展了语言特性,在泛型接口和泛型委托上支持协变和逆变。让我们先定义一下Shape基类和Rectangle类,以便后续的讲解:

public class Shape
{
	public double Width { get; set; }
	public double Height { get; set; }
	public override string ToString() => $"Width: {Width}, Height: {Height}";
}
public class Rectangle: Shape
{
    //...
}

5.4.2 泛型接口的协变

假如泛型类型由out关键字修饰的话,泛型接口就是协变的。这意味着泛型类型T只能用在返回值上。如下所示,接口IIndex是T协变并且根据一个只读索引返回T类型的值:

public interface IIndex<out T>
{
	T this[int index] { get; }
	int Count { get; }
}

假如我们在上面这个接口里定义了T类型的参数方法,就会得到一个编译错误:

public interface IIndex<out T>
{
    void Test(T t); // 类型参数“T”必须是在“IIndex<T>.Test(T)”上有效的 逆变式。“T”为 协变。
}

注意:假如要在IIndex里定义可读写的索引器,泛型类型T既要做参数又要当返回值,这里就不能用协变了。泛型类型不能添加任何out或者in的修饰。

IIndex<T>接口由RectangleCollection类实现,RectangleCollection里T的类型是Rectangle,如下所示:

public class RectangleCollection: IIndex<Rectangle>
{
	private Rectangle[] data = new Rectangle[3]
	{
		new Rectangle { Height=2, Width=5 },
		new Rectangle { Height=3, Width=7 },
		new Rectangle { Height=4.5, Width=2.9 }
	};
	private static RectangleCollection _coll;
	public static RectangleCollection GetRectangles() => _coll ?? (_coll = new RectangleCollection());
	public Rectangle this[int index]
	{
		get
		{
			if (index < 0 || index > data.Length)
				throw new ArgumentOutOfRangeException(nameof(index));
			return data[index];
		}
	}
	public int Count => data.Length;    
}

注意:RectangleCollection.GetRectangles方法里使用了联结运算符??,假如变量coll是null值,则运算符右侧的代码会被调用,新建一个RectangleCollection实例并将其赋值给变量coll,第6章将会讲到这个运算符。

RectangleCollection.GetRectangles返回了一个实现了IIndex<Rectangle>接口的RectangleCollection实例,你可以将它赋值给一个IIndex<Rectangle>类型的变量。因为IIndex<out T>接口是协变的,所以你也可以将返回值赋值给一个IIndex<Shape>变量。Shape变量需要的数据比Rectangle少得多,因此编译器是允许的。

然后我们通过shapes变量,就可以在for循环里通过接口里的索引器和Count属性来遍历:

public static void Main()
{
	IIndex<Rectangle> rectangles = RectangleCollection.GetRectangles();
	IIndex<Shape> shapes = rectangles;
	for (int i = 0; i < shapes.Count; i++)
	{
		Console.WriteLine(shapes[i]);
	}
}

5.4.3 泛型接口的逆变

泛型接口也可以是逆变的,如果它是用in关键字修饰的话。在这种情况下,接口仅允许泛型类型T作为方法参数使用:

public interface IDisplay<in T>
{
	void Show(T item);
}

ShapeDisplay类实现了接口IDisplay<Shape>并且使用Shape类作为泛型参数:

public class ShapeDisplay: IDisplay<Shape>
{
	public void Show(Shape s) => Console.WriteLine($"{s.GetType().Name} Width: {s.Width}, Height:{s.Height}");
}

创建一个ShapeDisplay的新实例,可以赋值给IDisplay<Shape>类型的shapeDisplay变量。因为IDisplay<T>是逆变的,因此可以将它赋值给IDisplay<Rectangle>,而Rectangle作为参数肯定能满足Shape型参数的需要:

public static void Main()
{
	//...
	IDisplay<Shape> shapeDisplay = new ShapeDisplay();
	IDisplay<Rectangle> rectangleDisplay = shapeDisplay;
	rectangleDisplay.Show(rectangles[0]);
}

实在还不理解就这么记忆吧:

  • 协变,很外向(out修饰)很和谐,子类无伤转换为父类,非常和谐。
  • 逆变,很内向(in)很拧巴,父类别扭地转换为子类。

5.5 泛型结构

跟类很相似,结构体也可以是泛型的,除了不能继承之外与泛型类几乎没有区别。在本小节中你将看到泛型结构体Nullable<T>在.NET Framework里是如何定义的。

数据库里的数字和编程语言里的数值类型有一个非常重要的区别:数据库里的数值可以是null值,但是C#里的number则无法为空。Int32是一个struct,并且因为结构体被编译器处理成值类型,因此它们不能为空。这一点经常让人很头痛,需要做额外的工作来完成数据映射。这个问题不单只存在于数据库中,也包括将XML文件映射为.NET类型。

一个解决方案是用引用类型处理数字之间的映射,因为引用类型可以为空。然而,这意味着在运行时需要额外的开销。

通过结构体Nullable<T>,这个问题就可以简单地解决。下面的代码段展示了一个Nullable<T>结构体的简化版定义。Nullable<T>的泛型参数必须为结构体。使用类作为泛型参数的话,低开销的优点就荡然无存了,也因为类本来就可以为null值,就更加没有必要为Nullable<T>使用class类型的泛型参数了。Nullable<T>里唯一的开销就是hasValue布尔字段,用来标识值是否设置了或者还是null值。除此之外,这个泛型结构定义了只读属性HasValue和Value,还有一些运算符重载。运算符重载将Nullable<T>类型强制转换成T类型声明成显式的,因为当hasValue为false时,它可以抛出一个异常。而将T类型转换成Nullable<T>类型则被声明成隐式的,因为它总是成立的:

public struct Nullable<T> where T: struct
{
	public Nullable(T value)
	{
		_hasValue = true;
		_value = value;
	}
	private bool _hasValue;
	public bool HasValue => _hasValue;
	private T _value;
	public T Value
	{
		get
		{
			if (!_hasValue)
			{
				throw new InvalidOperationException("no value");
			}
			return _value;
		}
	}
	public static explicit operator T(Nullable<T> value) => value.Value; // 原书这里是_value.Value
	public static implicit operator Nullable<T>(T value) => new Nullable<T>(value);
	public override string ToString() => !HasValue ? string.Empty : _value.ToString();
}

在下面这个例子里,Nullable<T>被实例化成Nullable<int>类型。变量x现在可以当成一个 int类型来用,给它赋值并使用运算符做一些计算。这是可以的并且由Nullable<T>类型的强制转换运算符负责支持。然而,x还可以是null值。Nullable<T>属性HasValue和Value可以检查是否已经赋值,以及值是否可以访问:

Nullable<int> x;
x = 4;
x += 3;
if (x.HasValue)
{
	int y = x.Value;
}
x= null;

因为可空类型经常会被使用,因此C#有一种特殊的语法来声明这种类型的变量。比起使用泛型结构Nullable<T>,你可以在类型后面加上?运算符。在下面的例子中,变量x1和x2都是可空的int类型:

Nullable<int> x1;
int? x2;

一个可空类型可以跟null或者数字进行比较,就像下面例子所示:

int? x = GetNullableType();
if (x == null)
{
	Console.WriteLine("x is null");
} 
else if (x < 0)
{
	Console.WriteLine("x is smaller than 0");
}	

现在你知道Nullable<T>是如何定义的了,让我们进一步使用它。可空类型可以进行算数运算。如下所示,变量x3是两个可空变量x1和x2的和,假如任何一个可空变量的值为null,那么它的算数运算结果就为null:

int? x1 = GetNullableType();
int? x2 = GetNullableType();
int? x3 = x1 + x2;

注意:这里的GetNullableType方法并不是实际方法,只是一个代指返回可空int类型的方法而已。为了进行测试你可以自己实现这个方法,简单的返回一个null值或者int值。

非空类型可以转换成可空类型。将一个非空类型转换成可空类型,内部进行了一个简单的隐式转换,因此你不需要书写强制转换符。像下面这样式可行的:

int y1 = 4;
int? x1 = y1;

反过来,想隐式地将一个可空类型转换成非空类型则不可取。假如一个可空类型是null值,此时将它赋值给一个非空类型的变量,则会引发一个InvalidOperationException异常。这也就是为何将可空类型转换成非空类型的时候编译器需要你书写一个强制转换:

int? x1 = GetNullableType();
int y1 = (int)x1;

除了使用强制转换符以外,你也可以通过联结运算符??来将一个可空类型赋值给非空类型变量。如下所示,当x1是null时,y1的值将会是0:

int? x1 = GetNullableType();
int y1 = x1 ?? 0;

5.6 泛型方法

除了定义泛型类之外,你也可以定义泛型方法。通过泛型方法,泛型类型可以在方法定义里声明。因此,泛型方法甚至可以在非泛型类里定义。

接下来的例子中,Swap<T>方法将T定义成泛型类型,并且由两个参数使用它,通过中间变量temp,交换两个参数的具体内容:

void Swap<T>(ref T x, ref T y)
{
	T temp;
	temp = x;
	x = y;
	y = temp;
}

在调用这个泛型方法时可以指定相应的泛型类型:

int i = 4;
int j = 5;
Swap<int>(ref i, ref j);

然而,因为C#编译器在调用Swap方法时可以获取到参数类型,所以你在调用这个方法的时候可以省略泛型类型的声明,这个方法也可以像非泛型方法那样调用:

int i = 4;
int j = 5;
Swap(ref i, ref j);

5.6.1 泛型方法示例

下面的例子使用泛型方法来将所有的元素添加进一个集合中。为了演示泛型方法的特性,我们先定义一个Account类,它有两个属性Name和Balance:

public class Account
{
	public string Name { get; }
	public decimal Balance { get; }
	public Account(string name, Decimal balance)
	{
		Name = name;
		Balance = balance;
	}
}

所有需要累加(accumulate)余额的账户都将添加到一个账户列表List<Account>中:

var accounts = new List<Account>()
{
	new Account("Christian", 1500),
	new Account("Stephanie", 2200),
	new Account("Angela", 1800),
	new Account("Matthias", 2400),
	new Account("Katharina", 3800),
};

传统的累加这些账户对象的方式是通过foreach语句写个循环,就像这面这样:

public static class Algorithms
{
	public static decimal AccumulateSimple(IEnumerable<Account> source)
	{
		decimal sum = 0;
		foreach (Account a in source)
		{
			sum += a.Balance;
		}
		return sum;
	}
}

因为foreach语句是通过使用IEnumerable接口来遍历集合中的所有元素,因此AccumulateSimple方法的参数需要是IEnumerable类型了,在这里我们用的是实现了IEnumerable<Account>接口的集合类。通过这个声明,在方法里就可以直接调用Account类的Balance属性。

完成后的静态方法AccumulateSimple可以这么调用:

decimal amount = Algorithms.AccumulateSimple(accounts);

5.6.2 带约束的泛型方法

在上面的实现里,它只能处理唯一的具体的Account类型的对象。这点可以通过泛型方法进行扩展。我们第二个版本的Accumulate方法允许接收实现了IAccount接口的类型。就像你在前面看到的那样,泛型类型可以通过where子句进行限制。你也可以像对class那样使用相同的子句来为泛型方法添加限制。Accumulate的参数我们直接换成了IEnumerable<T>,由泛型集合类声明的一个泛型接口,为了更具可读性,我们将T换成了TAccount:

public static decimal Accumulate<TAccount>(IEnumerable<TAccount> source) where TAccount: IAccount
{
	decimal sum = 0;
	foreach (TAccount a in source)
	{
		sum += a.Balance;
	}
	return sum;
}

现在我们可以将前面的Account类进行重构,实现IAccount接口:

public class Account: IAccount
{
	//...
}

IAccount接口定义了只读属性Balance和Name:

public interface IAccount
{
	decimal Balance { get; }
	string Name { get; }
}

新的Accumulate方法可以通过将泛型参数定义成Account类进行调用,如下所示:

decimal amount = Algorithm.Accumulate<Account>(accounts);

因为泛型类型可以由编译器自动检测出来,因此你也可以省略不写:

decimal amount = Algorithm.Accumulate(accounts);

5.6.3 带委托的泛型方法

要求所有的类型都实现IAccount接口可能有些过于严格了。接下来的示例揭示了如何将Accumulate方法修改成通过泛型委托传递。第8章将介绍更多关于泛型委托的细节,以及如何使用lambda表达式。

新的Accumulate方法使用了两个泛型参数:T1和T2。T1用来给那些实现了IEnumerable<T1>集合用,就像前面几个例子中提到的一样,而T2则是用于一个叫Func<T1,T2,TResult>的委托上的,只不过在本例中T2和TResult都是同样的类型而已。调用Accumulate的代码需要传递这两个泛型参数(T1,T2)然后获得一个T2类型的返回值:

public static T2 Accumulate<T1, T2>(IEnumerable<T1> source, Func<T1, T2, T2> action)
{
	T2 sum = default(T2);
	foreach (T1 item in source)
	{
		sum = action(item, sum);
	}
	return sum;
}

调用Accumulate方法的时候,必须制定每个泛型参数,因为编译器无法自动识别出泛型参数的类型。为了使用方法的第一个参数source,accounts集合被声明成了IEnumerable<Account>类型,而对于第二个参数,我们将一个定义了两个参数Account和decimal的lambda表达式传给action,并且表达式返回的是decimal类型的值。这个lambda表达式在Accumulate方法里被循环多次调用:

decimal amount = Algorithm.Accumulate<Account, decimal>(accounts, (item, sum) => sum += item.Balance);

先不用为这个语法挠头,这个例子只是让你看一下扩展Accumulate方法的一种方式而已。第8章我们将会更详细地介绍lambda表达式。

5.6.4 泛型方法的特化

你可以重载泛型方法,来定义特定类型的专有操作(define specializations for specific types)。对于带泛型参数的方法来说也是可行的。如下所示:

public class MethodOverloads
{
	public void Foo<T>(T obj) => Console.WriteLine($"Foo<T>(T obj), obj type: {obj.GetType().Name}");
	public void Foo(int x) => Console.WriteLine("Foo(int x)");
	public void Foo<T1, T2>(T1 obj1, T2 obj2) =>Console.WriteLine($"Foo<T1, T2>(T1 obj1, T2 obj2); " + $"{obj1.GetType().Name} {obj2.GetType().Name}");
	public void Foo<T>(int obj1, T obj2) => Console.WriteLine($"Foo<T>(int obj1, T obj2); {obj2.GetType().Name}");
	public void Bar<T>(T obj) => Foo(obj);
}

Foo方法定义了4个版本。第一个版本接收一个泛型参数,第二个则是定义了int类型的参数的专门版本(specilized version),第三个方法接收两个泛型参数,最后第四个方法同样是指定了第一个参数为int类型的专门版本(specialized version)。在编译的时候,编译器会选择最匹配的版本。假如传递过来的是int类型的参数,编译器会优先选择指定了int类型参数的方法。如果是其他类型的参数,编译器再考虑选择相应的泛型方法。

Foo方法现在可以用任意参数类型进行调用,如下所示:

static void Main()
{
    var test = new MethodOverloads();
	test.Foo(33); // Foo(int x)
	test.Foo("abc");// Foo<T>(T obj)
	test.Foo("abc", 42);// Foo<T1,T2>(T1 obj1, T2 obj2)
	test.Foo(33, "abc");// Foo<T>(int obj1, T obj2)
}

运行程序,你将会看到方法都是按最适原则进行调用输出的:

Foo(int x)
Foo<T>(T obj), obj type: String
Foo<T1, T2>(T1 obj1, T2 obj2); String Int32
Foo<T>(int obj1, T obj2); String

注意具体调用哪个方法是在编译时确定的而非运行时。这可以通过添加一个泛型的Bar方法演示,如下所示:

public class MethodOverloads
{
	// ...
	public void Bar<T>(T obj) => Foo(obj);
}

然后我们在Main函数里调用它:

var test = new MethodOverloads();
test.Bar(44);

输出的结果是:

Foo<T>(T obj), obj type: Int32

从输出上你可以看到,编译器为Bar方法选择的是泛型的Foo<T>方法,而非是指定int类型的Foo(int x)版本。这就是为什么说编译器是在编译时选择最适合的方法。因为Bar<T>方法定义了一个泛型参数,在编译时只有泛型的版本适合,因此编译器选择了个方法。这点并不会因为运行时为Bar方法传递一个int类型的参数而发生改变。

5.7 小结

本章介绍了CLR中非常重要的特性:泛型。通过泛型类你可以创建类型无关的类,通过泛型方法可以允许类型无关的方法。接口,结构体,委托都可以定义成泛型。泛型使得新的编程方式成为可能。你已经了解了算法(algorithms),尤其是操作(actions)和声明(predicates),是如何通过不同的类进行实现的——并且它们都是类型安全的。泛型委托使得算法可以从集合里解耦(make it possible to decouple algorithms from collections)。

你将在本书其他章节中看到更多的泛型特性和用法。第8章将介绍委托,通常都由泛型实现;第10章则是提供了泛型集合类的信息;而在第12章里,"LINQ",则讨论了泛型扩展方法。下一章将会着重介绍运算符和强制转换。

扩展

原文地址:https://www.cnblogs.com/zenronphy/p/ProfessionalCSharp7Chapter5.html