C# 2012 说明指南(五)
十五、接口
什么是接口?
一个接口是指定一组函数成员但不实现它们的引用类型。这留给了实现接口的类和结构。这种描述听起来很抽象,所以让我首先向您展示接口有助于解决的问题,以及它是如何解决的。
以下面的代码为例。如果你看看类Program中的方法Main,你会看到它创建并初始化了类CA的一个对象,并将该对象传递给方法PrintInfo。PrintInfo需要一个类型为CA的对象,并打印出类对象中包含的信息。
` class CA { public string Name; public int Age; }
class CB { public string First; public string Last; public double PersonsAge; }
class Program { static void PrintInfo( CA item ) { Console.WriteLine( "Name: {0}, Age {1}", item.Name, item.Age ); }
static void Main() { CA a = new CA() { Name = "John Doe", Age = 35 }; PrintInfo( a ); } }`
只要你给方法传递类型为CA的对象,方法PrintInfo就能很好地工作,但是如果你给它传递类型为CB的对象,它就不能工作(也显示在上面的代码中)。然而,假设方法PrintInfo中的算法非常有用,以至于您希望能够将其应用于许多不同类的对象。
有几个原因使它不能与当前的代码一起工作。首先,PrintInfo的形参指定实参必须是类型为CA的对象,所以传入类型为CB或任何其他类型的对象都会产生编译错误。但是,即使我们可以绕过这个障碍,以某种方式传入类型为CB的对象,我们仍然会有一个问题,因为CB的结构不同于CA的结构。它的字段和CA有不同的名称和类型,PrintInfo对这些字段一无所知。
但是,如果我们能够以这样一种方式创建类,它们可以被成功地传递给PrintInfo,并且PrintInfo能够处理它们,而不管类的结构如何,那会怎么样呢?接口使这成为可能。
图 15-1 中的代码通过使用一个接口解决了这个问题。您还不需要了解细节,但一般来说,它会执行以下操作:
- 首先,它声明了一个名为
IInfo的接口,该接口包含两个方法——GetName和GetAge——每个方法返回一个string。 - 类
CA和CB分别通过在其基类列表中列出接口IInfo来实现接口,然后实现接口所需的两个方法。 Main然后创建CA和CB的实例,并将它们传递给PrintInfo。- 因为类实例实现了接口,
PrintInfo可以调用方法,并且每个类实例执行它在类声明中定义的方法。
***图 15-1。*使用一个接口使 PrintInfo 方法可以被任意数量的类使用
该代码产生以下输出:
Name: John Doe, Age 35 Name: Jane Doe, Age 33
使用 IComparable 接口的例子
现在,您已经看到了一些通过接口解决的问题,我们将看第二个例子,并进行更详细的讨论。首先看一下下面的代码,它接受一个未排序的整数数组,并按升序对它们进行排序。该代码执行以下操作:
- 第一行创建了一个由五个没有特定顺序的整数组成的数组。
- 第二行使用
Array类的静态Sort方法对元素进行排序。 foreach循环将它们打印出来,显示整数现在是升序的。
` var myInt = new [] { 20, 4, 16, 9, 2 }; // Create an array of ints.
Array.Sort(myInt); // Sort elements by magnitude.
foreach (var i in myInt) // Print them out. Console.Write("{0} ", i);`
该代码产生以下输出:
2 4 9 16 20
Array类的Sort方法显然在一组int上工作得很好,但是如果你试图在你自己的一个类上使用它,会发生什么呢,如下所示?
` class MyClass // Declare a simple class. { public int TheValue; } ... MyClass[] mc = new MyClass[5]; // Create an array of five elements. ... // Create and initialize the elements.
Array.Sort(mc); // Try to use Sort--raises exception.`
当您尝试运行这段代码时,它会引发一个异常,而不是对元素进行排序。Sort不能与MyClass对象数组一起工作的原因是它不知道如何比较用户定义的对象以及如何排列它们的顺序。数组类的Sort方法依赖于一个名为IComparable的接口,该接口在 BCL 中声明。IComparable有一个叫CompareTo的单一方法。
下面的代码显示了IComparable接口的声明。注意,接口体包含方法CompareTo的声明,指定它接受类型object的单个参数。同样,尽管该方法有名称、参数和返回类型,但没有实现。相反,实现由分号表示。
Keyword Interface name ↓ ↓ public interface IComparable { int CompareTo( object obj ); } ↑ Semicolon in place of method implementation
图 15-2 显示了界面IComparable。CompareTo方法以灰色显示,说明它不包含实现。
图 15-2 。接口 IComparable 的表示
尽管接口声明没有提供方法CompareTo的实现。接口IComparable的. NET 文档描述了当你创建一个实现接口的类或结构时,这个方法应该做什么。它说当方法CompareTo被调用时,它应该返回下列值之一:
- 如果当前对象小于参数对象,则为负值
- 如果当前对象大于参数对象,则为正值
- 如果在比较中认为两个对象相等,则为零
Sort使用的算法取决于它可以使用元素的CompareTo方法来确定两个元素的顺序。int类型实现了IComparable,但是MyClass没有,所以当Sort试图调用MyClass的不存在的CompareTo方法时,它会引发一个异常。
通过让类实现IComparable,你可以让Sort方法处理MyClass类型的对象。要实现接口,类或结构必须做两件事:
- 它必须在其基类列表中列出接口名称。
- 它必须为接口的每个成员提供一个实现。
例如,下面的代码更新MyClass来实现接口IComparable。请注意以下关于代码的内容:
- 接口的名称列在类声明的基类列表中。
- 该类实现了一个名为
CompareTo的方法,其参数类型和返回类型与接口成员相匹配。 - 实现方法
CompareTo是为了满足接口文档中给出的定义。也就是说,它返回负 1、正 1 或 0,具体取决于它的值与传递到方法中的对象的比较。
` Interface name in base class list ↓ class MyClass : IComparable { public int TheValue;
public int CompareTo(object obj) // Implementation of interface method { MyClass mc = (MyClass)obj; if (this.TheValue < mc.TheValue) return -1; if (this.TheValue > mc.TheValue) return 1; return 0; } }`
图 15-3 显示了更新后的类别。从阴影接口方法指向类方法的箭头表示接口方法不包含代码,而是由类级方法实现的。
***图 15-3。*在 MyClass 中实现 I comparable
现在MyClass实现了IComparable , Sort会很好地处理它。顺便说一下,仅仅声明CompareTo方法是不够的——它必须是实现接口的一部分,这意味着将接口名称放在基类列表中。
下面显示了完整的更新代码,现在可以使用Sort方法对一组MyClass对象进行排序。Main创建并初始化一个MyClass对象的数组,然后打印出来。然后它调用Sort并再次打印出来,显示它们已经被排序。
` class MyClass : IComparable // Class implements interface. { public int TheValue; public int CompareTo(object obj) // Implement the method. { MyClass mc = (MyClass)obj; if (this.TheValue < mc.TheValue) return -1; if (this.TheValue > mc.TheValue) return 1; return 0; } }
class Program { static void PrintOut(string s, MyClass[] mc) { Console.Write(s); foreach (var m in mc) Console.Write("{0} ", m.TheValue); Console.WriteLine(""); }
static void Main() { var myInt = new [] { 20, 4, 16, 9, 2 };
MyClass[] mcArr = new MyClass[5]; // Create array of MyClass objs. for (int i = 0; i < 5; i++) // Initialize the array. { mcArr[i] = new MyClass(); mcArr[i].TheValue = myInt[i]; } PrintOut("Initial Order: ", mcArr); // Print the initial array. Array.Sort(mcArr); // Sort the array. PrintOut("Sorted Order: ", mcArr); // Print the sorted array. } }`
该代码产生以下输出:
Initial Order: 20 4 16 9 2 Sorted Order: 2 4 9 16 20
声明一个接口
上一节使用了一个已经在 BCL 中声明的接口。在这一节中,您将看到如何声明接口。关于声明接口,需要知道的重要事情如下:
- 接口声明不能包含以下内容:
- 数据成员
- 静态成员
- 接口声明只能包含以下类型的非静态函数成员的声明:
- 方法
- 性能
- 事件
- 索引器
- 这些函数成员的声明不能包含任何实现代码。相反,必须用分号来代替每个成员声明的主体。
- 按照惯例,接口名称以大写的 I 开头(例如
ISaveable)。 - 像类和结构一样,接口声明也可以被分成部分接口声明,如第六章的“部分类和部分类型”一节所述。
下面的代码展示了一个用两个方法成员声明一个接口的例子:
Keyword Interface name ↓ ↓ interface IMyInterface1 Semicolon in place of body { ↓ int DoStuff ( int nVar1, long lVar2 ); double DoOtherStuff( string s, long x ); } ↑ Semicolon in place of body
接口的可访问性和接口成员的可访问性之间有一个重要的区别:
- 一个接口声明可以有任意的访问修饰符
public、protected、internal或private。 - 然而,接口的成员是隐式公共的,并且不允许有访问修饰符,包括
public。
Access modifiers are allowed on interfaces. ↓ public interface IMyInterface2 { private int Method1( int nVar1, long lVar2 ); // Error } ↑ Access modifiers are NOT allowed on interface members.
实现一个接口
只有类或结构可以实现接口。如Sort示例所示,要实现一个接口,一个类或结构必须
- 在其基类列表中包含接口的名称
- 为接口的每个成员提供实现
例如,下面的代码显示了类MyClass的一个新声明,它实现了上一节中声明的接口IMyInterface1。请注意,接口名称列在基类列表中的冒号后面,并且该类为接口成员提供了实际的实现代码。
` Colon Interface name ↓ ↓ class MyClass: IMyInterface1 { int DoStuff ( int nVar1, long lVar2 ) { ... } // Implementation code
double DoOtherStuff( string s, long x ) { ... } // Implementation code }`
关于实现接口需要知道的一些重要事情如下:
- 如果一个类实现了一个接口,它必须实现该接口的所有成员。
- 如果一个类是从基类派生的,并且还实现了接口,基类的名称必须在基类列表中列在任何接口之前,如下所示。(请记住,只能有一个基类,因此列出的任何其他类型都必须是接口的名称。)
Base class must be first Interface names ↓ <ins> ↓ </ins> class Derived : MyBaseClass, IIfc1, IEnumerable, IComparable { ... }
简单界面示例
下面的代码声明了一个名为IIfc1的接口,其中包含一个名为PrintOut的方法。类MyClass通过在其基类列表中列出接口IIfc1并提供一个名为PrintOut的方法来实现接口IIfc1,该方法与接口成员的签名和返回类型相匹配。Main创建该类的一个对象,并从该对象调用方法。
` interface IIfc1 Semicolon in place of body // Declare interface. { ↓ void PrintOut(string s); } Implement interface ↓ class MyClass : IIfc1 // Declare class. { public void PrintOut(string s) // Implementation { Console.WriteLine("Calling through: {0}", s); } }
class Program { static void Main() { MyClass mc = new MyClass(); // Create instance. mc.PrintOut("object"); // Call method. } }`
该代码产生以下输出:
Calling through: object
一个接口是一个引用类型
接口不仅仅是要实现的类或结构的成员列表。它是一个参考类型。
您不能通过类对象的成员直接访问接口。然而,您可以通过将类对象引用转换为接口类型来获得对接口的引用。一旦有了对接口的引用,就可以对引用使用点语法表示法来调用接口成员。
例如,下面的代码显示了一个从类对象引用获取接口引用的示例。
- 在第一条语句中,变量
mc是对实现接口IIfc1的类对象的引用。该语句将该引用转换为对接口的引用,并将其赋给变量ifc。 - 第二条语句使用对接口的引用来调用实现方法。
Interface Cast to interface ↓ ↓ IIfc1 ifc = (IIfc1) mc; // Get ref to interface. ↑ ↑ Interface ref Class object ref <ins>ifc.PrintOut</ins> ("interface"); // Use ref to interface to call member. ↑ Use dot-syntax notation to call through the interface reference.
例如,下面的代码声明了一个接口和一个实现该接口的类。Main中的代码创建了类的一个对象,并通过类对象调用实现方法。它还创建一个接口类型的变量,将类对象的引用强制转换为接口类型,并通过对接口的引用调用实现方法。图 15-4 说明了类和对接口的引用。
` interface IIfc1 { void PrintOut(string s); }
class MyClass: IIfc1 { public void PrintOut(string s) { Console.WriteLine("Calling through: {0}", s); } }
class Program { static void Main() { MyClass mc = new MyClass(); // Create class object. mc.PrintOut("object"); // Call class object implementation method.
IIfc1 ifc = (IIfc1)mc; // Cast class object ref to interface ref. ifc.PrintOut("interface"); // Call interface method. } }`
该代码产生以下输出:
Calling through: object Calling through: interface
图 15-4 。类对象的引用和接口的引用
使用 as 运算符与接口
在上一节中,您看到了可以使用 cast 操作符来获取对对象接口的引用。更好的想法是使用as操作符。在第十六章中详细介绍了as操作符,但是我在这里也要提到它,因为它是一个与接口一起使用的好选择。
如果您试图将类对象引用强制转换为该类未实现的接口的引用,则强制转换操作将引发异常。您可以通过使用as操作符来避免这个问题。它的工作原理如下:
- 如果类别实作介面,运算式会传回介面的参考。
- 如果类没有实现接口,表达式返回
null而不是引发异常。(异常是代码中的意外错误。我将在第二十二章中详细讨论异常——但是你应该避免异常,因为它们会显著降低代码速度,并会使程序处于不一致的状态。)
下面的代码演示了as操作符的用法。第一行使用as操作符从一个类对象获得一个接口引用。表达式的结果将b的值设置为null或对ILiveBirth接口的引用。
第二行检查b的值,如果不是null,则执行调用接口成员方法的命令。
Class object ref Interface name ↓ ↓ ILiveBirth b = a as ILiveBirth; // Acts like cast: (ILiveBirth)a ↑ ↑ Interface Operator ref if (b != null) Console.WriteLine("Baby is called: {0}", b.BabyCalled());
实现多个接口
在到目前为止展示的例子中,这些类已经实现了一个接口。
- 一个类或结构可以实现任意数量的接口。
- 所有实现的接口都必须在基类列表中列出,并用逗号分隔(跟在基类名称后面,如果有的话)。
例如,下面的代码展示了类MyData,它实现了两个接口:IDataStore和IDataRetrieve。图 15-5 展示了MyData类中多个接口的实现。
` interface IDataRetrieve { int GetData(); } // Declare interface. interface IDataStore { void SetData( int x ); } // Declare interface. Interface Interface ↓ ↓ class MyData: IDataRetrieve, IDataStore // Declare class. { int Mem1; // Declare field. public int GetData() { return Mem1; } public void SetData( int x ) { Mem1 = x; } }
class Program { static void Main() // Main { MyData data = new MyData(); data.SetData( 5 ); Console.WriteLine("Value = {0}", data.GetData()); } }`
该代码产生以下输出:
Value = 5
***图 15-5。*实现多个接口的类
用重复成员实现接口
因为一个类可以实现任意数量的接口,所以两个或更多的接口成员可能具有相同的签名和返回类型。那么,编译器如何处理这种情况呢?
例如,假设您有两个接口——IIfc1和IIfc2——如下所示。每个接口都有一个名为PrintOut的方法,具有相同的签名和返回类型。如果要创建一个实现这两个接口的类,应该如何处理这些重复的接口方法?
` interface IIfc1 { void PrintOut(string s); }
interface IIfc2 { void PrintOut(string t); }`
答案是,如果一个类实现多个接口,其中几个接口的成员具有相同的签名和返回类型,则该类可以实现一个成员,该成员满足包含该重复成员的所有接口。
例如,下面的代码显示了类MyClass的声明,它实现了IIfc1和IIfc2。它的方法PrintOut的实现满足了两个接口的需求。
` class MyClass : IIfc1, IIfc2 // Implement both interfaces. { public void PrintOut(string s) // Single implementation for both { Console.WriteLine("Calling through: {0}", s); } }
class Program { static void Main() { MyClass mc = new MyClass(); mc.PrintOut("object"); } }`
这段代码产生以下输出:
Calling through: object
图 15-6 展示了由单个类级方法实现实现的重复接口方法。
***图 15-6。*同一个类成员实现的多个接口
引用多个接口
您之前已经看到接口是引用类型,您可以通过使用as操作符或者通过将对象引用强制转换为接口类型来获得对接口的引用。如果一个类实现了多个接口,你可以为每个接口获得单独的引用。
例如,下面的类用一个方法PrintOut实现了两个接口。Main中的代码以三种方式调用方法PrintOut:
- 通过类对象
- 通过引用
IIfc1接口 - 通过引用
IIfc2接口
图 15-7 说明了类对象以及对IIfc1和IIfc2的引用。
`interface IIfc1 // Declare interface. { void PrintOut(string s); }
interface IIfc2 // Declare interface { void PrintOut(string s); }
class MyClass : IIfc1, IIfc2 // Declare class.
{
public void PrintOut(string s)
{
Console.WriteLine("Calling through: {0}", s);
}
} class Program
{
static void Main()
{
MyClass mc = new MyClass();
IIfc1 ifc1 = (IIfc1) mc; // Get ref to IIfc1. IIfc2 ifc2 = (IIfc2) mc; // Get ref to IIfc2.
mc.PrintOut("object"); // Call through class object.
ifc1.PrintOut("interface 1"); // Call through IIfc1. ifc2.PrintOut("interface 2"); // Call through IIfc2. } }`
该代码产生以下输出:
Calling through: object Calling through: interface 1 Calling through: interface 2
***图 15-7。*对类中不同接口的单独引用
作为实现的一个继承成员
实现接口的类可以从其基类之一继承实现的代码。例如,下面的代码阐释了一个从基类继承实现代码的类。
IIfc1是一个带有名为PrintOut的方法成员的接口。MyBaseClass包含一个名为PrintOut的方法,它匹配IIfc1的方法声明。- 类
Derived有一个空的声明体,但它是从类MyBaseClass派生的,并且在其基类列表中包含IIfc1。 - 即使
Derived的声明体为空,基类中的代码也满足实现接口方法的要求。
` interface IIfc1 { void PrintOut(string s); }
class MyBaseClass // Declare base class. { public void PrintOut(string s) // Declare the method. { Console.WriteLine("Calling through: {0}", s); } } class Derived : MyBaseClass, IIfc1 // Declare class. { }
class Program { static void Main() { Derived d = new Derived(); // Create class object. d.PrintOut("object."); // Call method. } }`
图 15-8 说明了前面的代码。注意从IIfc1开始的箭头向下指向基类中的代码。
图 15-8 。在基类中实现
显式接口成员实现
在前面的章节中你已经看到,一个类可以实现多个接口所需的所有成员,如图 15-5 和 15-6 所示。
但是,如果您希望每个接口都有单独的实现,该怎么办呢?在这种情况下,您可以创建所谓的显式接口成员实现。显式接口成员实现具有以下特征:
- 像所有接口实现一样,它被放在实现接口的类或结构中。
- 它是使用一个限定的接口名声明的,该接口名由接口名和成员名组成,用点分隔。
下面的代码显示了声明显式接口成员实现的语法。由MyClass实现的两个接口中的每一个都实现了自己版本的方法PrintOut。
` class MyClass : IIfc1, IIfc2 { Qualified interface name ↓ void IIfc1.PrintOut (string s) // Explicit implementation { ... }
void IIfc2.PrintOut (string s) // Explicit implementation { ... } }`
图 15-9 说明了类和接口。请注意,表示显式接口成员实现的方框没有以灰色显示,因为它们现在表示实际的代码。
图 15-9 。显式接口成员实现
例如,在下面的代码中,类MyClass为两个接口的成员声明了显式接口成员实现。注意,在这个例子中,只有显式的接口成员实现。没有类级别的实现。
` interface IIfc1 { void PrintOut(string s); } // Declare interface. interface IIfc2 { void PrintOut(string t); } // Declare interface.
class MyClass : IIfc1, IIfc2 { Qualified interface name ↓ void IIfc1.PrintOut(string s) // Explicit interface member { // implementation Console.WriteLine("IIfc1: {0}", s); } Qualified interface name ↓ void IIfc2.PrintOut(string s) // Explicit interface member { // implementation Console.WriteLine("IIfc2: {0}", s); } }
class Program { static void Main() { MyClass mc = new MyClass(); // Create class object.
IIfc1 ifc1 = (IIfc1) mc; // Get reference to IIfc1. ifc1.PrintOut("interface 1"); // Call explicit implementation.
IIfc2 ifc2 = (IIfc2) mc; // Get reference to IIfc2. ifc2.PrintOut("interface 2"); // Call explicit implementation. } }`
该代码产生以下输出:
IIfc1: interface 1 IIfc2: interface 2
图 15-10 说明了代码。请注意图中的接口方法并不指向类级别的实现,而是包含它们自己的代码。
***图 15-10。*引用具有显式接口成员实现的接口
当有一个显式接口成员实现时,类级实现是允许的,但不是必需的。显式实现满足了类或结构必须实现方法的要求。因此,您可以选择以下三种实施方案中的任何一种:
- 类级实现
- 显式接口成员实现
- 类级和显式接口成员实现
访问显式接口成员实现
显式接口成员实现只能通过对接口的引用来访问。这意味着即使其他类成员也不能直接访问它们。
例如,下面的代码显示了类MyClass的声明,它通过显式实现实现了接口IIfc1。注意,即使是同为MyClass成员的Method1,也不能直接访问显式实现。
Method1的前两行产生编译错误,因为该方法试图直接访问实现。- 只有
Method1中的最后一行会编译,因为它将对当前对象(this)的引用强制转换为对接口类型的引用,并使用该接口引用来调用显式接口实现。
` class MyClass : IIfc1 { void IIfc1.PrintOut(string s) // Explicit interface implementation { Console.WriteLine("IIfc1"); }
public void Method1() { PrintOut("..."); // Compile error this.PrintOut("..."); // Compile error
((IIfc1)this).PrintOut("..."); // OK, call method. } ↑ Cast to a reference to the interface }`
这种限制对继承有重要的影响。由于其他类成员不能直接访问显式接口成员实现,从该类派生的类成员显然也不能直接访问它们。它们必须总是通过对接口的引用来访问。
接口可以继承接口
您之前看到接口实现可以从基类继承。但是接口本身可以从一个或多个其他接口继承。
- 要指定一个接口从其他接口继承,请将基接口的名称放在逗号分隔的列表中,并放在接口声明中接口名称后面的冒号后面,如下所示:
Colon Base interface list ↓ <ins> ↓ </ins> interface IDataIO : IDataRetrieve, IDataStore { ... - 与基类列表中只能有一个类名的类不同,接口在其基类列表中可以有任意数量的接口。
- 列表中的接口本身可以有继承的接口。
- 结果接口包含它声明的所有成员,以及它的所有基接口。
图 15-11 中的代码展示了三个接口的声明。接口IDataIO继承了前两个。右图显示了包含其他两个接口的IDataIO。
图 15-11 。接口继承多个接口的类
不同类实现一个接口的例子
下面的代码演示了已经讨论过的接口的几个方面。程序声明了一个名为Animal的类,它被用作其他几个代表各种动物的类的基类。它还声明了一个名为ILiveBirth的接口。
类Cat、Dog和Bird都是从基类Animal派生出来的。Cat和Dog都实现了ILiveBirth接口,但是类Bird没有。
在Main中,程序创建一个Animal对象的数组,并用三种动物类中每一种的类对象填充它。然后程序遍历数组,使用as操作符,检索每个对象的ILiveBirth接口的引用,并调用它的BabyCalled方法。
`interface ILiveBirth // Declare interface. { string BabyCalled(); }
class Animal { } // Base class Animal
class Cat : Animal, ILiveBirth // Declare class Cat. { string ILiveBirth.BabyCalled() { return "kitten"; } } class Dog : Animal, ILiveBirth // Declare class Dog. { string ILiveBirth.BabyCalled() { return "puppy"; } }
class Bird : Animal // Declare class Bird.
{
} class Program
{
static void Main()
{
Animal[] animalArray = new Animal[3]; // Create Animal array.
animalArray[0] = new Cat(); // Insert Cat class object.
animalArray[1] = new Bird(); // Insert Bird class object.
animalArray[2] = new Dog(); // Insert Dog class object.
foreach( Animal a in animalArray ) // Cycle through array.
{
ILiveBirth b = a as ILiveBirth; // if implements ILiveBirth...
if (b != null)
Console.WriteLine("Baby is called: {0}", b.BabyCalled());
}
}
}`
该代码产生以下输出:
Baby is called: kitten Baby is called: puppy
图 15-12 说明了内存中的数组和对象。
图 15-12 。基类 Animal 的不同对象类型散布在数组中。
十六、转换策略
什么是转换?
为了理解什么是转换,让我们从考虑一个简单的例子开始,在这个例子中,你声明了两个不同类型的变量,然后将其中一个变量的值(源)赋给另一个变量(目标)。在赋值之前,源值必须转换为目标类型的值。图 16-1 说明了类型转换。
- 转换是取一种类型的值,用它作为另一种类型的等值的过程。
- 转换得到的值应该与源值相同,但属于目标类型。
***图 16-1。*类型转换
例如,图 16-2 中的代码显示了两个不同类型变量的声明。
var1的类型是short,一个初始化为5的 16 位有符号整数。var2属于sbyte类型,一个 8 位有符号整数,被初始化为值10。- 代码的第三行将
var1的值赋给var2。因为这是两种不同的类型,所以在赋值之前,var1的值必须转换成与var2相同类型的值。这是使用 cast 表达式执行的,您很快就会看到。 - 还要注意的是,
var1的值和类型没有改变。虽然它被称为转换,但这仅意味着源值被用作目标类型的值,而不是源值被更改为目标类型。
图 16-2 。从 short 转换为 sbyte
隐式转换
对于某些类型的转换,不会丢失数据或精度。例如,很容易将一个 8 位值填充到一个 16 位类型中,而不会丢失数据。
- 该语言将自动为您完成这些转换。这些被称为隐式转换。
- 当从具有较少位的源类型转换到具有较多位的目标类型时,目标中的额外位需要用 0 或 1 来填充。
- 当从较小的无符号类型转换为较大的无符号类型时,目标的额外最高有效位用 0 填充。这叫做零延伸。
图 16-3 显示了一个 8 位值 10 转换成 16 位值 10 的零扩展的例子。
图 16-3 。无符号转换中的零扩展
对于有符号类型之间的转换,额外的最高有效位用源表达式的符号位填充。
- 这保持了转换值的正确符号和大小。
- 这被称为符号扩展,如图图 16-4 所示,首先是 10,然后是–10。
***图 16-4。*带符号转换中的符号扩展
显式转换和强制转换
当您从较短的类型转换为较长的类型时,较长的类型很容易保存较短类型的所有位。但是,在其他情况下,目标类型可能无法在不丢失数据的情况下容纳源值。
例如,假设您想将一个ushort值转换成一个byte。
- A
ushort可以保存 0 到 65,535 之间的任何值。 - 一个
byte只能保存一个 0 到 255 之间的值。 - 只要你要转换的
ushort值小于 256,就不会有任何数据丢失。然而,如果它更大,最高有效位将会丢失。
例如,图 16-5 显示试图将值为 1365 的ushort转换为byte,导致数据丢失。并非源值的所有有效位都适合目标类型,这会导致溢出和数据丢失。源值是 1,365,但目标可以容纳的最大值是 255。字节中的结果值是 85,而不是 1,365。
图 16-5 。试图将 ushort 转换为字节
显然,在可能的无符号 16 位ushort值中,只有相对较少的一部分(0.4%)可以安全地转换为无符号 8 位byte类型,而不会丢失数据。其余的导致数据溢出,产生不同的值。
铸造
对于预定义的类型,C# 将自动从一种数据类型转换为另一种数据类型,但只在那些在源类型和目标类型之间不可能丢失数据的类型之间转换。也就是说,如果源类型的任何值在转换为目标类型时会丢失数据,那么该语言不提供两种类型之间的自动转换。如果你想进行这种类型的转换,你必须使用一个叫做转换表达式的显式转换*。*
下面的代码显示了一个强制转换表达式的示例。它将var1的值转换为类型sbyte。强制转换表达式由以下内容组成:
- 包含目标类型名称的一组匹配括号
- 括号后面的源表达式
Target type ↓ (sbyte) var1; ↑ Source expression
当使用强制转换表达式时,您明确地承担了执行可能丢失数据的操作的责任。本质上,你是在说,“尽管有数据丢失的可能性,我知道我在做什么,所以无论如何都要进行转换。”(不过,要确保你 do 知道自己在做什么。)
例如,图 16-6 显示了将类型ushort的两个值转换为类型byte的转换表达式。在第一种情况下,没有数据丢失。在第二种情况下,最高有效位丢失,给出的值为 85,这显然不等于源值 1,365。
***图 16-6。*将一个 ushort 强制转换为一个字节
图中代码的输出显示了结果的十进制和十六进制值,如下所示:
sb: 10 = 0xA sb: 85 = 0x55
转换类型
对于数值和引用类型,有许多标准的预定义转换。这些类别在图 16-7 中说明。
- 除了标准转换之外,还可以为用户定义的类型定义隐式和显式转换。
- 还有一种预定义的转换类型,称为装箱,它将任何值类型转换为以下两种类型之一:
- 类型
object - 类型
System.ValueType
- 类型
- 取消装箱会将装箱的值转换回其原始类型。
***图 16-7。*转换类型
数字转换
任何数值类型都可以转换成任何其他数值类型,如图图 16-8 所示。有些转换是隐式转换,有些必须是显式转换。
图 16-8 。数字转换
隐式数值转换
隐式数值转换如图 16-9 中的所示。
- 如果存在从源类型到目标类型的路径,则存在从源类型到目标类型的隐式转换。
- 任何没有从源类型到目标类型的箭头路径的数字转换都必须是一个显式转换。
如图所示,正如您所料,占用较少位的数值类型与占用较多位的数值类型之间存在隐式转换。
***图 16-9。*隐式数值转换
溢出检查上下文
您已经看到,显式转换有可能丢失数据,并且无法在目标类型中等效地表示源值。对于整数类型,C# 允许您选择运行时在进行这些类型的转换时是否应该检查溢出结果。它通过checked操作符和checked语句来实现这一点。
- 一段代码是否被检查称为它的溢出检查上下文。
- 如果将一个表达式或一段代码指定为
checked,如果转换产生溢出,CLR 将引发一个OverflowException异常。 - 如果代码不是
checked,无论是否有溢出,转换都会继续进行。
- 如果将一个表达式或一段代码指定为
- 不检查默认溢出检查上下文。
选中的和未选中的运算符
checked和unchecked操作符控制表达式的溢出检查上下文,它放在一对括号中。表达式不能是方法。语法如下:
checked ( *Expression* ) unchecked ( *Expression* )
例如,下面的代码执行相同的转换——首先在一个checked操作符中,然后在一个unchecked操作符中。
- 在
unchecked上下文中,溢出被忽略,产生值208。 - 在
checked上下文中,引发了一个OverflowException异常。
` ushort sh = 2000; byte sb;
sb = unchecked ( (byte) sh ); // Most significant bits lost Console.WriteLine("sb: {0}", sb);
sb = checked ( (byte) sh ); // OverflowException raised Console.WriteLine("sb: {0}", sb);`
该代码产生以下输出:
`sb: 208
Unhandled Exception: System.OverflowException: Arithmetic operation resulted in an overflow. at Test1.Test.Main() in C:\Programs\Test1\Program.cs:line 21`
已检查和未检查的报表
您刚才看到的checked和unchecked 操作符作用于括号之间的单个表达式。checked和unchecked 语句执行相同的功能,但是控制代码块中的所有转换,而不是单个表达式。
checked和unchecked语句可以嵌套到任何级别。
例如,下面的代码使用了checked和unchecked语句,并产生了与前面使用了checked和unchecked表达式的示例相同的结果。然而,在这种情况下,受影响的不仅仅是表达式,还有代码块。
` byte sb; ushort sh = 2000;
unchecked // Set unchecked { sb = (byte) sh; Console.WriteLine("sb: {0}", sb);
checked // Set checked { sb = (byte) sh; Console.WriteLine("sb: {0}", sh); } }`
显式数值转换
您已经看到隐式转换会自动从源表达式转换为目标类型,因为不会丢失数据。然而,使用显式转换,有可能会丢失数据,因此作为程序员,了解转换如何处理发生的数据丢失非常重要。
在这一节中,我们将研究各种类型的显式数值转换。图 16-10 显示了图 16-8 中显示的显式转换的子集。
***图 16-10。*显式数值转换
整数类型到整数类型
图 16-11 显示了整数到整数显式转换的行为。在checked的情况下,如果转换丢失数据,操作会引发OverflowException异常。在unchecked事件中,任何丢失的比特都没有被报告。
***图 16-11。*整数到整数的显式转换
浮点或双精度到整数类型
将浮点类型转换为整数类型时,该值将向 0 舍入为最接近的整数。图 16-12 说明了转换条件。如果舍入值不在目标类型的范围内,则
- 如果溢出检查上下文是
checked,CLR 会引发一个OverflowException异常。 - 如果上下文是
unchecked,C# 没有定义它的值应该是什么。
***图 16-12。*将浮点数或双精度数转换为整数类型
小数到整数类型
当从decimal转换为整数类型时,如果结果值不在目标类型的范围内,CLR 会引发一个OverflowException异常。图 16-13 说明了转换条件。
图 16-13 。将小数转换成整数类型
双飘
类型float的值占用 32 位,类型double的值占用 64 位。当double被舍入到float时,double类型值被舍入到最接近的float类型值。图 16-14 说明了转换条件。
- 如果该值太小而不能用
float表示,则该值被设置为正 0 或负 0。 - 如果该值太大而无法用
float表示,则该值被设置为正无穷大或负无穷大。
***图 16-14。*将双精度转换为浮点
浮点或双精度到十进制
图 16-15 显示了从浮点型转换到decimal的转换条件。
- 如果值太小而不能用
decimal类型来表示,则结果被设置为 0。 - 如果该值太大,CLR 会引发一个
OverflowException异常。
图 16-15 。将浮点数或双精度数转换成十进制数
十进制为浮点数或双精度数
从decimal到浮点类型的转换总是成功的。然而,可能会损失精确度。图 16-16 显示了转换条件。
图 16-16 。将十进制转换成浮点数或双精度数
参考转换
正如你现在所知道的,引用类型对象在内存中由两部分组成:引用和数据。
- 引用保存的部分信息是它指向的数据的类型。
- 引用转换接受源引用并返回指向堆中相同位置的引用,但将该引用“标记”为不同的类型。
例如,下面的代码显示了两个引用变量,myVar1和myVar2,它们指向内存中的同一个对象。代码如图图 16-17 所示。
- 对于
myVar1,它所引用的对象看起来像是一个类型为B的对象——事实也的确如此。 - 对于
myVar2,同一个对象看起来像一个类型为A的对象。- 即使
myVar2实际上是指向一个类型为B的对象,它也看不到B延伸A的部分,因此也看不到Field2。 - 因此,第二个
WriteLine语句会导致编译错误。
- 即使
注意“转换”不会改变myVar1。
` class A { public int Field1; }
class B: A { public int Field2; }
class Program { static void Main( ) { B myVar1 = new B(); Return the reference to myVar1 as a reference to a class A. ↓ A myVar2 = (A) myVar1;
Console.WriteLine("{0}", myVar2.Field1); // Fine
Console.WriteLine("{0}", myVar2.Field2); // Compile error!
} ↑
} myVar2 can't see Field2.`
***图 16-17。*引用转换返回与对象相关联的不同类型。
隐式引用转换
正如语言会自动为您执行隐式数值转换一样,也有隐式引用转换。这些在图 16-18 中进行了说明。
- 所有引用类型都有到类型
object的隐式转换。 - 任何接口都可以隐式转换为派生它的接口。
- 类可以隐式转换为
- 衍生它的链中的任何类
- 它实现的任何接口
***图 16-18。*类和接口的隐式转换
委托可以隐式转换为 .NET BCL 类和接口如图图 16-19 所示。具有类型为 Ts 的元素的数组 ArrayS ,可以隐式转换为以下内容:
- 那个 .NET BCL 类和接口如图图 16-19 所示。
- 另一个数组,
ArrayT,元素类型为Tt,如果以下所有都为真:- 两个数组具有相同的维数。
- 元素类型
Ts和Tt是引用类型,而不是值类型。 - 类型
Ts和Tt之间有一个隐式转换。
***图 16-19。*代表和数组的隐式转换
显式引用转换
显式引用转换是从一般类型到更特殊类型的引用转换。
- 显式转换包括:
- 从
object到任何引用类型的转换 - 从基类到从基类派生的类的转换
- 从
- 通过反转图 16-18 和图 16-19 中的箭头来说明显式参考转换。
如果允许这种类型的转换而没有限制,那么您可以很容易地尝试引用实际上不在内存中的类成员。然而,编译器允许这些类型的转换。但是当系统在运行时遇到它们时,它会引发一个异常。
例如图 16-20 中的代码将基类A的引用转换为它的派生类B并赋给变量myVar2。
- 如果
myVar2试图访问Field2,它将试图访问对象的“B部分”中的一个字段,该字段并不存在,从而导致内存故障。 - 运行时将捕捉这种不适当的强制转换,并引发一个
InvalidCastException异常。然而,请注意,是而不是 导致了编译错误。
***图 16-20。*无效的强制转换引发运行时异常。
有效的显式引用转换
在三种情况下,显式引用转换会在运行时成功,也就是说,不会引发InvalidCastException异常。
第一种情况是不需要显式转换,也就是说,语言已经为您执行了隐式转换。例如,在下面的代码中,显式转换是不必要的,因为总是存在从派生类到其基类之一的隐式转换。
class A { } class B: A { } ... B myVar1 = new B(); A myVar2 = (A) myVar1; // Cast is unnecessary; A is the base class of B.
第二种情况是源参考为null。例如,在下面的代码中,尽管将基类的引用转换为派生类的引用通常是不安全的,但是这种转换是允许的,因为源引用的值是null。
class A { } class B: A { } ... A myVar1 = null; B myVar2 = (B) myVar1; // Allowed because myVar1 is null
第三种情况是由源引用指向的实际数据可以被安全地隐式转换。下面的代码显示了一个例子,图 16-21 说明了该代码。
- 第二行中的隐式转换使得
myVar2“认为”它指向类型A的数据,而实际上它指向类型B的数据对象。 - 第三行中的显式转换是将一个基类的引用强制转换为它的一个派生类的引用。通常这将引发一个异常。然而,在这种情况下,被指向的对象实际上是一个类型为
B的数据项。
B myVar1 = new B(); A myVar2 = myVar1; // Implicitly cast myVar1 to type A. B myVar3 = (B)myVar2; // This cast is fine because the data is of type B.
***图 16-21。*铸造到安全型
拳击转换
所有 C# 类型,包括值类型,都是从类型object派生的。然而,值类型是高效的轻量级类型,默认情况下,在堆中不包含它们的object组件。然而,当需要object组件时,您可以使用装箱,这是一种隐式转换,它接受值类型值,在堆中从中创建一个完整的引用类型对象,并返回对该对象的引用。
例如,图 16-22 显示了三行代码。
- 前两行代码声明并初始化值类型变量
i和引用类型变量oi。 - 在第三行代码中,您希望将变量
i的值赋给oi。但是oi是一个引用类型变量,必须被赋予一个对堆中对象的引用。然而,变量i是一个值类型,并且没有对堆中对象的引用。 - 因此,系统通过执行以下操作将
i的值装箱:- 在堆中创建类型为
int的对象 - 将
i的值复制到int对象 - 将
int对象的引用返回给oi以存储为其引用
- 在堆中创建类型为
***图 16-22。*装箱从值类型创建一个完全引用类型对象。
装箱创建副本
关于装箱的一个常见误解是它作用于被装箱的物品。它没有。它返回一个引用类型值的副本。装箱过程之后,有两个值的副本——值类型原始副本和引用类型副本——每个副本都可以单独操作。
例如,下面的代码显示了值的每个副本的单独操作。图 16-23 说明了代码。
- 第一行定义值类型变量
i,并将其值初始化为10。 - 第二行创建引用类型变量
oi,并用变量i的装箱副本初始化它。 - 最后三行代码显示了分别操作的
i和oi。
` int i = 10; // Create and initialize value type Box i and assign its reference to oi. ↓ object oi = i; // Create and initialize reference type Console.WriteLine("i: {0}, io: {1}", i, oi);
i = 12; oi = 15; Console.WriteLine("i: {0}, io: {1}", i, oi);`
该代码产生以下输出:
i: 10, io: 10 i: 12, io: 15
***图 16-23。*拳击创造了一个可以单独操纵的复制品。
拳击转换
图 16-24 显示了装箱转换。如果 ValueTypeS 实现了 InterfaceT ,任何值类型 ValueTypeS 都可以隐式转换为任意类型object、System.ValueType或 InterfaceT 。
图 16-24 。装箱是值类型到引用类型的隐式转换。
取消装箱转换
取消装箱是将装箱的对象转换回其值类型的过程。
- 取消装箱是一种显式转换。
- 将值解装箱到
ValueTypeT时,系统执行以下步骤:- 它检查被取消装箱的对象实际上是类型为
ValueTypeT的装箱值。 - 它将对象的值复制到变量中。
- 它检查被取消装箱的对象实际上是类型为
例如,下面的代码显示了取消装箱值的示例。
- 值类型变量
i被装箱并赋给引用类型变量oi。 - 然后变量
oi被取消装箱,其值被赋给值类型变量j。
static void Main() { int i = 10; Box i and assign its reference to oi. <ins> ↓ </ins> object oi = i; Unbox oi and assign its value to j. <ins> ↓ </ins> int j = (int) oi; Console.WriteLine("i: {0}, oi: {1}, j: {2}", i, oi, j); }
这段代码产生以下输出:
i: 10, oi: 10, j: 10
试图将一个值取消装箱为原始类型以外的类型会引发一个InvalidCastException异常。
拆箱转换
图 16-25 显示了拆箱转换。
图 16-25 。拆箱转换
用户自定义换算
除了标准转换,您还可以为自己的类和结构定义隐式和显式转换。
下面的代码显示了用户定义转换的语法。
- 隐式和显式转换声明的语法是相同的,除了关键字
implicit和explicit。 public和static修改器都是必需的。
Required Operator Keyword Source <ins> ↓ </ins> ↓ ↓ <ins> ↓ </ins> public static implicit operator *TargetType* ( *SourceType Identifier* ) { ↑ Implicit or explicit ... return *ObjectOfTargetType*; }
例如,下面显示了将类型为Person的对象转换为int的转换方法的语法示例:
public static implicit operator int(Person p) { return p.Age; }
用户定义转换的约束
对用户定义的转换有一些重要的约束。最重要的如下:
- 您只能为类和结构定义用户定义的转换。
- 不能重定义标准的隐式或显式转换。
- 对于源类型
S和目标类型T来说,以下是正确的:S和T必须是不同的类型。S和T不能有继承关系。即S不能从*T**T*不能从S派生。S和T都不能是接口类型或者类型object。- 转换运算符必须是
S或T的成员。
- 不能用相同的源类型和目标类型声明两个转换,一个是隐式的,另一个是显式的。
用户自定义转换的例子
下面的代码定义了一个名为Person的类,其中包含一个人的姓名和年龄。该类还定义了两个隐式转换。第一个函数将一个Person对象转换成一个int值。目标int值是人的年龄。第二个将一个int转换成一个Person对象。
` class Person { public string Name; public int Age; public Person(string name, int age) { Name = name; Age = age; }
public static implicit operator int(Person p) // Convert Person to int. { return p.Age; }
public static implicit operator Person(int i) // Convert int to Person. { return new Person("Nemo", i); // ("Nemo" is Latin for "No one".) } }
class Program { static void Main( ) { Person bill = new Person( "bill", 25);
Convert a Person object to an int. ↓ int age = bill; Console.WriteLine("Person Info: {0}, {1}", bill.Name, age);
Convert an int to a Person object. ↓ Person anon = 35; Console.WriteLine("Person Info: {0}, {1}", anon.Name, anon.Age); } }`
这段代码产生以下输出:
Person Info: bill, 25 Person Info: Nemo, 35
如果您将相同的转换操作符定义为explicit而不是implicit,那么您将需要使用转换表达式来执行转换,如下所示:
` Explicit ... ↓ public static explicit operator int( Person p ) { return p.Age; }
...
static void Main( ) { ... Requires cast expression ↓ int age = (int) bill; ...`
评估用户定义的转换
到目前为止讨论的用户自定义转换已经在一个单一的步骤中将源类型直接转换为目标类型的对象,如图图 16-26 所示。
***图 16-26。*单步用户自定义转换
但是用户定义的转换在完整转换中最多可以有三个步骤。图 16-27 说明了这些阶段,包括以下内容:
- 初步标准转换
- 用户定义的转换
- 以下标准转换
在这个链中永远不会超过一个用户定义的转换。
***图 16-27。*多步用户自定义转换
多步用户定义转换示例
下面的代码声明了从类Person派生的类Employee。
- 几节之前,代码示例声明了从类
Person到int的用户定义转换。所以如果有一个从Employee到Person的标准转换和一个从int到float的标准转换,你可以从Employee转换到float。- 从
Employee到Person有一个标准的转换,因为Employee来源于Person。 - 有一个从
int到float的标准转换,因为这是一个隐式的数字转换。
- 从
- 由于链的所有三个部分都存在,您可以从
Employee转换到float。图 16-28 说明了编译器是如何执行转换的。
` class Employee : Person { }
class Person { public string Name; public int Age;
// Convert a Person object to an int. public static implicit operator int(Person p) { return p.Age; } }
class Program { static void Main( ) { Employee bill = new Employee(); bill.Name = "William"; bill.Age = 25; Convert an Employee to a float. ↓ float fVar = bill;
Console.WriteLine("Person Info: {0}, {1}", bill.Name, fVar); } }`
该代码产生以下输出:
Person Info: William, 25
***图 16-28。*员工转浮动
是运算符
如前所示,一些转换尝试不成功,并在运行时引发一个InvalidCastExcept ion 异常。您可以使用is操作符来检查转换是否会成功完成,而不是盲目地尝试转换。
is运算符的语法如下,其中 Expr 是源表达式:
Returns a bool <ins> ↓ </ins> Expr is TargetType
如果 Expr 可以通过以下任何一种方式成功转换为目标类型,则操作员返回true:
- 参考转换
- 拳击的转变
- 取消装箱转换
例如,下面的代码使用is操作符来检查类型为Employee的变量bill是否可以转换为类型Person,然后采取适当的操作。
` class Employee : Person { } class Person { public string Name = "Anonymous"; public int Age = 25; }
class Program { static void Main() { Employee bill = new Employee(); Person p;
// Check if variable bill can be converted to type Person if( bill is Person ) { p = bill; Console.WriteLine("Person Info: {0}, {1}", p.Name, p.Age); } } }`
is运算符只能用于引用转换以及装箱和取消装箱转换。它不能用于用户定义的转换。
as 运算符
除了不引发异常之外,as操作符与 cast 操作符相似。如果转换失败,它不会引发异常,而是返回null。
as操作符的语法如下,其中
Expr是源表达式。TargetType是目标类型,必须是引用类型。
Returns a reference <ins> ↓ </ins> Expr as TargetType
因为as操作符返回一个引用表达式,所以它可以用作赋值的源。
例如,使用as运算符将类型为Employee的变量bill转换为类型为Person,并将其赋给类型为Person的变量p。然后代码在使用之前检查p是否为null。
` class Employee : Person { }
class Person { public string Name = "Anonymous"; public int Age = 25; }
class Program { static void Main() { Employee bill = new Employee(); Person p;
p = bill as Person; if( p != null ) { Console.WriteLine("Person Info: {0}, {1}", p.Name, p.Age); } } }`
像is操作符一样,as操作符只能用于引用转换和装箱转换。它不能用于用户定义的转换或值类型的转换。
十七、泛型
什么是泛型?
使用到目前为止您所学的语言构造,您可以构建许多不同类型的强大对象。这主要是通过声明封装了所需行为的类,然后创建这些类的实例来实现的。
到目前为止,类声明中使用的所有类型都是特定类型——要么是程序员定义的,要么是语言或 BCL 提供的。然而,有些时候,如果您能够“提取”或“重构”出一个类的动作,并且不仅将它们应用于为其编码的数据类型,而且还应用于其他类型,那么这个类会更有用。
泛型允许你这样做。您可以重构您的代码,并添加一个额外的抽象层,以便对于某些类型的代码,数据类型不是硬编码的。这是专门为有多个代码段执行相同的指令,但数据类型不同的情况而设计的。
这听起来可能很抽象,所以我们将从一个例子开始,这个例子将使事情变得更清楚。
一个堆栈示例
首先假设您已经创建了以下代码,它声明了一个名为MyIntStack的类,该类实现了一个int的堆栈。它允许您将int推到堆栈上并弹出它们。顺便说一下,这不是系统堆栈。
` class MyIntStack // Stack for ints { int StackPointer = 0; int[] StackArray; // Array of int ↑ int int ↓ public void Push( int x ) // Input type: int { ... } int ↓ public int Pop() // Return type: int { ... }
... }`
现在假设您想要对类型float的值使用相同的功能。有几种方法可以实现这一点。一种方法是执行以下步骤来生成后续代码:
- And cut and paste the code of
MyIntStackclass.- Change the class name to
MyFloatStack.- Change the appropriate
intdeclaration tofloatdeclaration in the whole class declaration.
` class MyFloatStack // Stack for floats { int StackPointer = 0; float [] StackArray; // Array of float ↑ float float ↓ public void Push( float x ) // Input type: float { ... } float ↓ public float Pop() // Return type: float { ... }
...
}`
这种方法当然有效,但是容易出错,并且有以下缺点:
- You need to carefully check every part of the class to determine which type declarations need to be changed and which ones don't.
- You need to repeat this process (
long,double,stringand so on) for each new type of stack class you need. After this process, you will get multiple copies of almost the same code, occupying extra space. And debugging and maintenance are not elegant and error-prone.
c# 中的泛型
泛型特性提供了一种更优雅的方式来使用一组具有多种类型的代码。泛型允许你声明类型参数化的代码,你可以用不同的类型实例化它。这意味着您可以编写带有“类型占位符”的代码,然后在创建该类的实例时提供实际的类型。
到目前为止,你应该非常熟悉类型不是对象而是对象模板的概念。同样,泛型类型不是类型,而是类型的模板。图 17-1 说明了这一点。
***图 17-1。*泛型类型是类型的模板。
C# 提供了五种泛型:类、结构、接口、委托和方法。注意,前四个是类型,方法是成员。
图 17-2 显示了泛型是如何与其他类型相适应的。
***图 17-2。*泛型和用户自定义类型
继续堆栈示例
在栈的例子中,对于类MyIntStack和MyFloatStack,类的声明体是相同的,除了在处理由栈保存的值的类型的位置。
- In
MyIntStack, these positions are occupied by typeint.- At
MyFloatStack, they were occupied byfloat.
通过执行以下操作,您可以从MyIntStack创建一个通用类:
- Take
MyIntStackclass declaration, and replacefloatwithintwith type placeholderT.- Change the class name to
MyStack.- Place the string
<T>after the class name.
结果是下面的泛型类声明。由带T的尖括号组成的字符串意味着T是一个类型的占位符。(不一定是字母T——可以是任何标识符。)在T所在的整个类声明体中,编译器需要替换一个实际类型。
` class MyStack { int StackPointer = 0; T [] StackArray; ↑ ↓ public void Push(T x ) {...}
↓ public T Pop() {...} ... }`
类属
现在你已经看到了一个泛型类,让我们更详细地看看泛型类,看看它们是如何被创建和使用的。
如您所知,创建和使用您自己的常规、非泛型类有两个步骤:声明类和创建类的实例。然而,泛型类不是实际的类,而是类的模板——所以您必须首先从它们构造实际的类类型。然后,您可以从这些构造的类类型中创建引用和实例。
图 17-3 从高层次上说明了该过程。如果还不完全清楚,不要担心——我将在接下来的章节中介绍每个部分。
- Declare a class and use placeholders for certain types.
- Provide the actual type to replace the placeholder. This gives you an actual class definition, and all the "blanks" are filled in. This is called structural type .
- Create an instance of the constructed type.
***图 17-3。*从通用类型创建实例
声明泛型类
声明一个简单的泛型类很像声明一个常规类,但有以下区别:
在类名后面放置一组匹配的尖括号。* Between the angle brackets, you put a comma-separated list of placeholder strings, which represent types and will be provided as needed. These are called type parameters . You use type parameters in the declarant of generic classes to represent the types that should be replaced.
例如,下面的代码声明了一个名为SomeClass的泛型类。类型参数列在尖括号之间,然后在整个声明体中使用,就像它们是实类型一样。
Type parameters <ins> ↓ </ins> class SomeClass < T1, T2 > { Normally, types would be used in these positions. ↓ ↓ public T1 SomeVar = new T1(); public T2 OtherVar = new T2(); } ↑ ↑ Normally, types would be used in these positions.
没有标记泛型类声明的特殊关键字。相反,用尖括号分隔的类型参数列表的存在将泛型类声明与常规类声明区分开来。
创建构造类型
一旦声明了泛型类型,就需要告诉编译器应该用什么实际类型替换占位符(类型参数)。编译器接受这些实际类型并创建一个构造类型,构造类型是一个模板,它从这个模板创建实际的类对象。
创建构造类型的语法如下所示,包括列出类名和在尖括号之间提供真实类型,以代替类型参数。被类型参数替代的实类型被称为类型 实参。
Type arguments <ins> ↓ </ins> SomeClass< short, int >
编译器接受类型实参,并用它们替换泛型类主体中相应的类型形参,从而生成构造类型——实际的类实例就是从构造类型中创建的。
图 17-4 显示了左边的泛型类SomeClass的声明。在右边,它显示了使用类型参数short和int创建的构造类。
***图 17-4。*为一个泛型类的所有类型参数提供类型实参允许编译器生成一个构造类,从这个构造类中可以创建实际的类对象。
图 17-5 说明了类型参数和类型变量之间的区别。
- Generic class declaration has type parameter , which acts as a placeholder for the type.
- Type parameter is the actual type that you provided when you created the constructed type.
***图 17-5。*类型参数与类型实参
创建变量和实例
在创建引用和实例时,构造的类类型就像常规类型一样使用。例如,下面的代码显示了两个类对象的创建。
- The first line shows the creation of an object from a regular non-generic class. This is a form that you should be fully familiar with by now.
- The second line of code shows that an object is created from the generic class
SomeClassand instantiated with the typesshortandint. This form is completely similar to the above line, and the conventional class name is replaced by the constructed class form.- The third line is semantically the same as the second line, but instead of listing the types of constructions on both sides of the equal sign, it uses the
varkeyword to let the compiler use type inference.
MyNonGenClass myNGC = new MyNonGenClass (); Constructed class Constructed class <ins> ↓ </ins> <ins> ↓ </ins> SomeClass<short, int> mySc1 = new SomeClass<short int>(); var mySc2 = new SomeClass<short, int>();
与非通用类一样,引用和实例可以分别创建,如图图 17-6 所示。该图还显示了内存中发生的事情与非泛型类相同。
- Class declares that the first behavior variable
myInstbelow allocates a reference in the stack. Its value isnull.- The second line allocates an instance in the heap and assigns its reference to the variable.
***图 17-6。*使用构造类型创建引用和实例
许多不同的类类型可以从同一个泛型类中构造。每一个都是独立的类类型,就像它有自己独立的非泛型类声明一样。
例如,下面的代码显示了从泛型类SomeClass创建两个类型。代码如图图 17-7 所示。
- One type consists of types
shortandint.- The other is composed of
intandlong.
` class SomeClass< T1, T2 > // Generic class { ... }
class Program { static void Main() { var first = new SomeClass<short, int >(); // Constructed type var second = new SomeClass<int, long>(); // Constructed type
...`
***图 17-7。*从一个泛型类创建了两个不同的构造类
使用泛型的堆栈示例
下面的代码显示了使用泛型实现的堆栈示例。方法Main定义了两个变量:stackInt和stackString。使用int和string作为类型参数创建两个构造类型。
`class MyStack { T[] StackArray; int StackPointer = 0;
public void Push(T x) { if ( !IsStackFull ) StackArray[StackPointer++] = x; }
public T Pop() { return ( !IsStackEmpty ) ? StackArray[--StackPointer] : StackArray[0]; }
const int MaxStack = 10; bool IsStackFull { get{ return StackPointer >= MaxStack; } } bool IsStackEmpty { get{ return StackPointer <= 0; } }
public MyStack() { StackArray = new T[MaxStack]; }
public void Print()
{
for (int i = StackPointer-1; i >= 0 ; i--)
Console.WriteLine(" Value: {0}", StackArray[i]);
}
} class Program
{
static void Main( )
{
MyStack StackInt = new MyStack();
MyStack StackString = new MyStack();
StackInt.Push(3); StackInt.Push(5); StackInt.Push(7); StackInt.Push(9); StackInt.Print();
StackString.Push("This is fun"); StackString.Push("Hi there! "); StackString.Print(); } }`
该代码产生以下输出:
`Value: 9 Value: 7 Value: 5 Value: 3
Value: Hi there! Value: This is fun`
比较泛型和非泛型堆栈
表 17-1 总结了栈的初始非泛型版本和最终泛型版本之间的一些差异。图 17-8 说明了其中的一些差异。
***图 17-8。*非泛型栈与泛型栈
对类型参数的约束
在通用堆栈示例中,堆栈除了存储和弹出项目之外,没有对它包含的项目做任何事情。它没有尝试添加它们,比较它们,或者做任何其他需要使用项目本身的操作的事情。这是有充分理由的。因为通用堆栈不知道它将要存储的项的类型,所以它不能知道这些类型实现了什么成员。
然而,所有的 C# 对象最终都是从类object中派生出来的,所以堆栈可以确定的一件事就是它们实现了类object的成员。这些方法包括ToString、Equals和GetType。除此之外,它无法知道哪些成员可用。
只要你的代码不访问它处理的类型的对象(或者只要它坚持类型object的成员),你的泛型类可以处理任何类型。满足这个约束的类型参数叫做无界类型参数。但是,如果您的代码试图使用任何其他成员,编译器将产生错误信息。
例如,下面的代码用一个名为LessThan的方法声明了一个名为Simple的类,该方法采用两个相同泛型类型的变量。LessThan试图返回使用小于运算符的结果。但是并不是所有的类都实现小于操作符,所以你不能用任何一个类来代替T。因此,编译器会产生一条错误信息。
class Simple<T> { static public bool LessThan(T i1, T i2) { return i1 < i2; // Error } ... }
为了使泛型更有用,你需要能够向编译器提供关于什么类型的类型可以作为参数的附加信息。这些额外的信息位被称为约束。只有满足约束的类型才能替换给定的类型参数,以生成构造类型。
Where 从句
约束被列为where子句。
- Each constrained type parameter has its own
whereclause.- If a parameter has multiple constraints, they are listed in the
whereclause, separated by commas.
where子句的语法如下:
Type parameter Constraint list ↓ <ins> ↓ </ins> where TypeParam : constraint, constraint, ... ↑ ↑ Keyword Colon
关于where条款的要点如下:
- They are listed after the closing angle bracket of the type parameter list.
- They are not separated by commas or any other symbols.
- They can be listed in any order.
- Token ] is a contextual keyword, so it can be used in other contexts.
例如,下面的泛型类有三个类型参数。T1无界。对于T2,只有Customer类型的类或者从 Customer派生的类可以用作类型参数。对于T3,只有实现接口IComparable的类才能被用作类型参数。
Unbounded With constraints ↓ <ins> ↓ </ins> No separators class MyClass < T1, T2, T3 > ↓ where T2: Customer // Constraint for T2 where T3: IComparable // Constraint for T3 { ↑ ... No separators }
约束类型和顺序
有五种类型的约束。这些在表 17-2 中列出。
where子句可以按任何顺序列出。然而,where子句中的约束必须以特定的顺序放置,如图图 17-9 所示。
- There can only be one master constraint at most, and if there is one, it must be listed first.
- There can be any number of
InterfaceNameconstraints.- If the constructor constraint exists, it must be listed last.
***图 17-9。*如果一个类型参数有多个约束,它们必须按照这个顺序。
以下声明显示了where子句的示例:
` class SortedList
where S: IComparable { ... }
class LinkedList<M,N> where M : IComparable where N : ICloneable { ... }
class MyDictionary<KeyType, ValueType> where KeyType : IEnumerable, new() { ... }`
通用方法
与其他泛型不同,方法不是类型,而是成员。你可以在泛型和非泛型类中,以及在结构和接口中声明泛型方法,如图 17-10 所示。
***图 17-10。*泛型方法可以在泛型和非泛型类型中声明。
声明一个泛型方法
泛型方法有一个类型参数列表和可选约束。
- Generic methods have two parameter lists:
- List of method parameters , enclosed in brackets.
- List of type parameters , enclosed in angle brackets.
- To declare a generic method, do the following:
- Put the type parameter list after the method name and before the method parameter list.
- Put any constraint clauses after the method parameter list.
Type parameter list Constraint clauses <ins> ↓ </ins> <ins> ↓ </ins> public void PrintData<S, T> (<ins>S p, T t</ins>) where S: Person { ↑ ... Method parameter list }
注意记住类型参数列表在方法名之后,方法参数列表之前。
调用通用方法
若要调用泛型方法,请为方法调用提供类型参数,如下所示:
Type arguments <ins> ↓ </ins> MyMethod<short, int>(); MyMethod<int, long >();
图 17-11 显示了一个名为DoStuff的泛型方法的声明,它有两个类型参数。在它下面有两个调用该方法的地方,每个地方都有一组不同的类型参数。编译器使用这些构造的实例中的每一个来产生该方法的不同版本,如图右侧所示。
***图 17-11。*具有两个实例化的通用方法
推断类型
如果将参数传递给一个方法,编译器有时可以从方法参数的类型中推断出哪些类型应该被用作泛型方法的类型参数。这可以使方法调用更简单,更容易阅读。
例如,下面的代码声明了MyMethod,它采用与类型参数相同类型的方法参数。
public void MyMethod <T> (T myVal) { ... } ↑ ↑ Both are of type T
如果用类型为int的变量调用MyMethod,如下面的代码所示,方法调用的类型参数中的信息是多余的,因为编译器可以从方法参数中看出它是一个int。
int myInt = 5; MyMethod <int> (myInt); ↑ ↑ Both are ints
因为编译器可以从方法参数中推断出类型参数,所以可以在调用中省略类型参数及其尖括号,如下所示:
MyMethod(myInt);
泛型方法的例子
下面的代码在名为Simple的非泛型类中声明了一个名为ReverseAndPrint的泛型方法。该方法将任何类型的数组作为其参数。Main声明了三种不同的数组类型。然后,它对每个数组调用该方法两次。第一次使用特定数组调用方法时,它显式使用类型参数。第二次,推断类型。
` class Simple // Non-generic class { static public void ReverseAndPrint(T[] arr) // Generic method { Array.Reverse(arr); foreach (T item in arr) // Use type argument T. Console.Write("{0}, ", item.ToString()); Console.WriteLine(""); } }
class Program { static void Main() { // Create arrays of various types. var intArray = new int[] { 3, 5, 7, 9, 11 }; var stringArray = new string[] { "first", "second", "third" }; var doubleArray = new double[] { 3.567, 7.891, 2.345 };
Simple.ReverseAndPrint(intArray); // Invoke method. Simple.ReverseAndPrint(intArray); // Infer type and invoke.
Simple.ReverseAndPrint(stringArray); // Invoke method. Simple.ReverseAndPrint(stringArray); // Infer type and invoke.
Simple.ReverseAndPrint(doubleArray); // Invoke method. Simple.ReverseAndPrint(doubleArray); // Infer type and invoke. } }`
该代码产生以下输出:
11, 9, 7, 5, 3, 3, 5, 7, 9, 11, third, second, first, first, second, third, 2.345, 7.891, 3.567, 3.567, 7.891, 2.345,
用泛型类扩展方法
扩展方法在第七章中有详细描述,并且和一般类一样有效。它们允许您将一个类中的静态方法与不同的泛型类相关联,并调用该方法,就像它是该类的构造实例上的实例方法一样。
与非泛型类一样,泛型类的扩展方法必须满足以下约束:
static
- It must be a member of a static class.
- It must contain the keyword
thisas its first parameter type, followed by the name of the generic class it extends.
下面的代码展示了一个名为Holder<T>的泛型类上名为Print的扩展方法的例子:
` static class ExtendHolder { public static void Print(this Holder h) { T[] vals = h.GetValues(); Console.WriteLine("{0},\t{1},\t{2}", vals[0], vals[1], vals[2]); } }
class Holder { T[] Vals = new T[3];
public Holder(T v0, T v1, T v2) { Vals[0] = v0; Vals[1] = v1; Vals[2] = v2; }
public T[] GetValues() { return Vals; } }
class Program { static void Main(string[] args) { var intHolder = new Holder(3, 5, 7); var stringHolder = new Holder("a1", "b2", "c3"); intHolder.Print(); stringHolder.Print(); } }`
该代码产生以下输出:
3, 5, 7 a1, b2, c3
通用结构
像泛型类一样,泛型结构可以有类型参数和约束。泛型结构的规则和条件与泛型类的规则和条件相同。
例如,下面的代码声明了一个名为PieceOfData的通用结构,它存储和检索一段数据,数据的类型是在构造类型时确定的。Main创建两种构造类型的对象——一种使用int,另一种使用string。
` struct PieceOfData // Generic struct { public PieceOfData(T value) { _data = value; } private T _data; public T Data { get { return _data; } set { _data = value; } } }
class Program { static void Main() Constructed type { ↓ var intData = new PieceOfData(10); var stringData = new PieceOfData("Hi there."); ↑ Constructed type Console.WriteLine("intData = {0}", intData.Data); Console.WriteLine("stringData = {0}", stringData.Data); } }`
该代码产生以下输出:
intData = 10 stringData = Hi there.
通用代理人
泛型委托与非泛型委托非常相似,只是类型参数决定了将接受哪些方法的特征。
- To declare a generic delegate, put the type parameter list in angle brackets after the delegate name and before the delegate parameter list.
Type parameters <ins> ↓ </ins> delegate R MyDelegate<T, R>( <ins>T value</ins> ); ↑ ↑ Return type Delegate formal parameter- Note that there are two parameter lists: delegate parameter list and type parameter list.
- The range of parameters includes the following:
- Return type
- Parameter list
- Constraint clause
下面的代码展示了一个泛型委托的例子。在Main中,通用委托MyDelegate用类型string的参数实例化,并用方法PrintString初始化。
` delegate void MyDelegate(T value); // Generic delegate
class Simple { static public void PrintString(string s) // Method matches delegate { Console.WriteLine(s); }
static public void PrintUpperString(string s) // Method matches delegate { Console.WriteLine("{0}", s.ToUpper()); } }
class Program { static void Main( ) { var myDel = // Create inst of delegate. new MyDelegate(Simple.PrintString); myDel += Simple.PrintUpperString; // Add a method.
myDel("Hi There."); // Call delegate. } }`
该代码产生以下输出:
Hi There. HI THERE.
另一个通用委托示例
由于 C# 的 LINQ 特性广泛使用了泛型委托,所以在此之前有必要展示另一个例子。我将在第十九章中讲述 LINQ 本身,以及更多关于它的一般代表。
下面的代码声明了一个名为Func的泛型委托,它采用带有两个参数和返回值的方法。方法返回类型表示为TR,方法参数类型表示为T1和T2。
` Delegate parameter type ↓ ↓ ↓ ↓ public delegate TR Func<T1, T2, TR>(T1 p1, T2 p2); // Generic delegate ↑ ↑ class Simple Delegate return type { static public string PrintString(int p1, int p2) // Method matches delegate { int total = p1 + p2; return total.ToString(); } }
class Program { static void Main() { var myDel = // Create inst of delegate. new Func<int, int, string>(Simple.PrintString);
Console.WriteLine("Total: {0}", myDel(15, 13)); // Call delegate. } }`
该代码产生以下输出:
Total: 28
通用接口
泛型接口允许您编写接口,其中接口成员的形参和返回类型是泛型类型参数。泛型接口声明类似于非泛型接口声明,但在接口名称后的尖括号中有类型参数列表。
例如,下面的代码声明了一个名为IMyIfc的通用接口。
Simpleis a general class that implements the general interfaceIMyIfc.MainInstantiate two objects of the generic class: one type isintand the other type isstring.
` Type parameter
↓
interface IMyIfc // Generic interface
{
T ReturnIt(T inValue);
}
Type parameter Generic interface
↓ ↓
class Simple : IMyIfc // Generic class
{
public S ReturnIt(S inValue) // Implement generic interface.
{ return inValue; }
}
class Program { static void Main() { var trivInt = new Simple(); var trivString = new Simple();
Console.WriteLine("{0}", trivInt.ReturnIt(5)); Console.WriteLine("{0}", trivString.ReturnIt("Hi there.")); } }`
该代码产生以下输出:
5 Hi there.
一个使用通用接口的例子
以下示例说明了通用接口的两个附加功能:
- Like other generics, instances of generic interfaces instantiated with different type parameters are different interfaces.
- You can implement a generic interface in the non-generic type .
例如,下面的代码类似于上一个例子,但是在这个例子中,Simple是一个非泛型类,它实现了一个泛型接口。事实上,它实现了IMyIfc的两个实例。一个实例用类型int实例化,另一个用类型string实例化。
` interface IMyIfc // Generic interface { T ReturnIt(T inValue); } Two different interfaces from the same generic interface ↓ ↓ class Simple : IMyIfc, IMyIfc // Nongeneric class { public int ReturnIt(int inValue) // Implement interface using int. { return inValue; }
public string ReturnIt(string inValue) // Implement interface using string. { return inValue; } }
class Program { static void Main() { Simple trivial = new Simple();
Console.WriteLine("{0}", trivial.ReturnIt(5)); Console.WriteLine("{0}", trivial.ReturnIt("Hi there.")); } }`
该代码产生以下输出:
5 Hi there.
通用接口实现必须是唯一的
当在泛型类型中实现接口时,不能有可能在该类型中创建重复接口的类型参数组合。
例如,在下面的代码中,类Simple使用了接口IMyIfc的两个实例。
- The first is the construction type, which is instantiated with the type
int.- The second one has type parameters but no arguments.
第二个接口本身没有问题,因为使用通用接口完全没问题。然而,这里的问题是,它允许一个可能的冲突,因为如果在第二个接口中使用int作为类型参数来替换S,那么Simple将有两个相同类型的接口——这是不允许的。
` interface IMyIfc
{
T ReturnIt(T inValue);
}
Two interfaces
↓ ↓
class Simple : IMyIfc, IMyIfc // Error!
{
public int ReturnIt(int inValue) // Implement first interface.
{
return inValue;
}
public S ReturnIt(S inValue) // Implement second interface, { // but if it's int, it would be return inValue; // the same as the one above. } }`
注意通用接口的名称不会与非通用接口冲突。例如,在前面的代码中,我们也可以声明一个名为
IMyIfc的非泛型接口。
协方差
正如你在本章看到的,当你创建一个泛型类型的实例时,编译器接受泛型类型声明和类型参数并创建一个构造类型。然而,人们经常犯的一个错误是,认为可以将派生类型的委托赋给基类型委托的变量。在接下来的几节中,我们将看看这个主题,它叫做方差。方差有三种类型——协方差、方差和不变性。
我们将从回顾你已经学过的一些东西开始:每个变量都有一个分配给它的类型,你可以分配一个更派生类型的对象给它的一个基本类型的变量。这被称为赋值兼容性。下面的代码演示了与基类Animal和从Animal派生的类Dog的赋值兼容性。在Main中,您可以看到代码创建了一个类型为Dog的对象,并将其赋给类型为Animal的变量a2。
` class Animal { public int NumberOfLegs = 4; }
class Dog : Animal { }
class Program { static void Main( ) { Animal a1 = new Animal( ); Animal a2 = new Dog( );
Console.WriteLine( "Number of dog legs: {0}", a2.NumberOfLegs ); } }`
该代码产生以下输出:
Number of dog legs: 4
图 17-12 说明了赋值兼容性。在该图中,显示Dog和Animal对象的方框也显示了它们的基类。
***图 17-12。*赋值兼容性意味着你可以将一个更多派生类型的引用赋给一个更少派生类型的变量。
现在让我们通过以下方式扩展代码来看一个更有趣的例子,如下面的代码所示:
- This code adds a generic delegate named
Factory, which only accepts one type parameterTand no method parameter, and returns an object of typeT.- I added a method named
MakeDog, which has no parameters and returns aDogobject. Therefore, if we useDogas the type parameter, this method matches the delegateFactory.- The first line of
Maincreates a delegate object of typedelegate Factory<Dog>and assigns its reference to variabledogMakerof the same type.- The second line attempts to assign the delegate of type
delegate Factory<Dog>to the delegate type variableanimalMakerof typedelegate Factory<Animal>.
然而,Main中的第二行引起了一个问题,编译器产生了一个错误消息,说它不能隐式地将右边的类型转换成左边的类型。
` class Animal { public int Legs = 4; } // Base class class Dog : Animal { } // Derived class
delegate T Factory( ); ← delegate Factory
class Program { static Dog MakeDog( ) ← Method that matches delegate Factory { return new Dog( ); }
static void Main( ) { Factory dogMaker = MakeDog; ← Create delegate object. Factory animalMaker = dogMaker; ← Attempt to assign delegate object.
Console.WriteLine( animalMaker( ).Legs.ToString( ) ); } }`
用基类型构造的委托应该能够保存用派生类型构造的委托,这似乎是有道理的。那么为什么编译器会给出错误信息呢?赋值兼容原则不成立吗?
原则确实成立,但不适用于这种情况!问题是,虽然Dog派生自Animal,但是委托Factory<Dog>并不而不是派生自委托Factory<Animal>。相反,这两个委托对象是对等的,从类型 d elegate派生,从类型object派生,如图图 17-13 所示。这两个委托都不是从另一个委托派生的,因此赋值兼容性不适用。
***图 17-13。*赋值兼容性不适用,因为这两个委托没有继承关系。
尽管委托类型的不匹配不允许将一种类型赋给另一种类型的变量,但在这种情况下这太糟糕了,因为在示例代码中,任何时候我们执行委托animalMaker,调用代码都会期望返回对一个Animal对象的引用。如果它返回一个对Dog对象的引用,那也没问题,因为根据赋值兼容性,对Dog的引用就是对Animal的引用。
更仔细地观察这种情况,我们可以看到,对于任何泛型委托,如果类型参数仅用作输出值,那么同样的情况也适用。在所有这些情况下,您将能够使用用派生类创建的构造委托类型,并且它将工作得很好,因为调用代码将总是期望对基类的引用——这正是它将得到的。
这种派生类型仅作为输出值的使用和构造的委托的有效性之间的常数关系被称为协方差。为了让编译器知道这是您想要的,您必须在委托声明中用out关键字标记类型参数。
如果我们通过添加关键字out来更改示例中的委托声明,如下所示,代码会编译并正常运行:
delegate T Factory<out T>( ); ↑ Keyword specifying covariance of the type parameter
图 17-14 说明了本例中协方差的组成部分:
- The variables on the left stack are of type
delegateT Factory<out T>(), in which the type variableTis of typeAnimal.- The delegate actually constructed in the right heap is declared by the type variable of class
Dog, which is derived from classAnimal. This is acceptable because when the delegate is called, the calling code receives the object of typeDoginstead of the expected object of typeAnimal. The calling code can freely operate theAnimalpart of the object, just as it expected.
***图 17-14。*协变关系允许更派生的类型在返回和输出位置。
逆变
既然你理解了协方差,我们来看一个相关的情况。下面的代码声明了一个名为Action1的委托,它接受一个类型参数和一个方法参数,方法参数的类型是类型参数的类型,并且不返回值。
代码还包含一个名为ActOnAnimal的方法,其签名和void返回类型与委托声明相匹配。
Main中的第一行使用类型Animal和方法ActOnAnimal创建一个构造的委托,其签名和void返回类型与委托声明相匹配。然而,在第二行中,代码试图将对这个委托的引用分配给一个名为dog1、类型为delegate Action1<Dog>的堆栈变量。
` class Animal { public int NumberOfLegs = 4; } class Dog : Animal { }
class Program Keyword for contravariance { ↓ delegate void Action1( T a );
static void ActOnAnimal( Animal a ) { Console.WriteLine( a.NumberOfLegs ); }
static void Main( ) { Action1 act1 = ActOnAnimal; Action1 dog1 = act1; dog1( new Dog() ); } }`
该代码产生以下输出:
4
和前面的情况一样,默认情况下,你不能分配这两个不兼容的类型。但是也和前面的情况一样,有些情况下这个任务会很好的完成。
事实上,每当类型参数仅用作委托中方法的输入参数时,这都是正确的。这样做的原因是,即使调用代码传入一个对更多派生类的引用,委托中的方法也只是期望一个对更少派生类的引用——当然,它接收并知道如何操作这个类。
这种关系,允许一个派生程度更高的对象出现在一个派生程度更低的对象出现的地方,被称为逆变。要使用它,您必须使用带有类型参数的in关键字,如代码所示。
图 17-15 说明了Main第二行的逆变成分。
- The variables on the left stack are of type
delegatevoid Action1<in T>(T p), and the type variables here are of typeDog.- The delegate actually constructed on the right is declared by the type variable of class
Animal, which is the base class of classDog.- This is good, because when the delegate is called, the calling code passes an object of type
Dogto methodActOnAnimal, which requires an object of typeAnimal. This method can freely operate theAnimalpart of the object, just as it is expected.
***图 17-15。*逆变关系允许更多的派生类型作为输入参数。
图 17-16 总结了一般委托中协方差和逆变的区别。
- The figure above illustrates the covariance.
- The variable on the left stack is of type delegate
F<out T>( ), where the type parameter is a class namedBase.- The delegate actually constructed on the right is declared by the type parameter of class
Derived, which is derived from classBase. This is good because when the delegate is called, the method returns a reference to the object of the derived type, which is also a reference to the base class, which is exactly what the calling code expects.- The following figure illustrates the comparison.
- The variables on the left stack are of type
delegatevoid F<in T>(T p), and the type parameters here are of typeDerived. The delegate actually constructed on the right is declared by the type parameter of classBase, which is the base class of classDerived. This is good because when the delegate is called, the calling code passes an object of derived type to the method, which requires an object of base type. The method can operate freely on the basic part of the object as expected.
***图 17-16。*协方差和逆变的比较
界面中的协变和逆变
现在,您应该对应用于代理的协变和逆变有所了解。同样的原则也适用于接口,包括在接口声明中使用关键字out和in的语法。
以下代码显示了一个将协方差用于接口的示例。关于代码需要注意的事项如下:
- A generic interface is declared with the type parameter
T.outThe type parameter specified by the keyword is covariant.- The generic class
SimpleReturnimplements the generic interface.- Method
DoSomethingshows how a method takes interface as a parameter. This method takes the generalIMyIfcinterface constructed by typeAnimalas its parameter.
该代码的工作方式如下:
- The first two lines of
Mainuse classDogto create and initialize the construction instance of generic classSimpleReturn.- The next line assigns the object to a variable on the stack, which is declared as the constructor interface type
IMyIfc<Animal>. Pay attention to some things about this statement:
- The type on the left of the assignment is an interface type-not a class.
- Even if the interface types do not exactly match, the compiler allows them, because there is a covariant
outspecifier in the interface declaration.- Finally, the code calls the method
DoSomethingwith the constructed covariant class that implements the interface.
` class Animal { public string Name; } class Dog: Animal{ }; Keyword for covariance ↓ interface IMyIfc { T GetFirst(); }
class SimpleReturn: IMyIfc { public T[] items = new T[2]; public T GetFirst() { return items[0]; } }
class Program { static void DoSomething(IMyIfc returner) { Console.WriteLine(returner.GetFirst().Name); }
static void Main( ) { SimpleReturn dogReturner = new SimpleReturn(); dogReturner.items[0] = new Dog() { Name = "Avonlea" };
IMyIfc animalReturner = dogReturner;
DoSomething(dogReturner); } }`
该代码产生以下输出:
Avonlea
更多关于方差
前两节解释了显式协方差和逆变。还有一种情况,编译器自动识别某个构造的委托是协变的或逆变的,并自动进行类型强制。当对象还没有分配类型时,就会发生这种情况。下面的代码显示了一个示例。
Main的第一行从一个返回类型是Dog对象而不是Animal对象的方法创建了一个Factory<Animal>类型的构造委托。当Main创建这个委托时,赋值操作符右边的方法名还不是委托对象,因此没有委托类型。此时,编译器可以确定该方法匹配委托的类型,除了它的返回类型是类型Dog而不是类型Animal。编译器足够聪明,能够意识到这是一个协变关系,并创建构造类型,将其赋给变量。
比较一下Main第三行和第四行的赋值。在这些情况下,等号右边的表达式已经是委托,因此具有委托类型。因此,这些需要委托声明中的out说明符来通知编译器允许它们协变。
` class Animal { public int Legs = 4; } // Base class class Dog : Animal { } // Derived class
class Program { delegate T Factory();
static Dog MakeDog() { return new Dog(); }
static void Main() { Factory animalMaker1 = MakeDog; // Coerced implicitly
Factory dogMaker = MakeDog; Factory animalMaker2 = dogMaker; // Requires the out specifier
Factory animalMaker3 = new Factory(MakeDog); // Requires the out specifier } }`
关于方差,你还应该知道其他一些重要的事情:
- As you can see, it is a safe problem for variance processing to replace a derived type with a basic type, and vice versa. Therefore, variance only applies to reference types-because other types cannot be derived from value types.
- Explicit differences using the keywords
inandoutonly apply to delegates and interfaces-not to classes, structures or methods.- Delegates and interface type parameters that don't contain
inoroutkeywords are called invariants . These types cannot be used in covariant or inversion.
Contravariant <ins> ↓ </ins> delegate T Factory<<ins>out R</ins>, in S, T>( ); ↑ ↑ Covariant Invariant