思考并回答以下问题:
- 泛型可以促进算法的重用怎么理解?
- 为什么可以使用Stack类型的集合来实现多次撤销(undo)操作?
本章涵盖:
- 如果C#没有泛型
- 泛型类型概述
- 约束
- 泛型方法
随着项目日趋复杂,需要用更好的方式重用和定制现有软件。C#通过泛型来促进代码重用,尤其是算法的重用。方法因为能获取参数而强大;类似地,类型和方法也会因为能获取类型参数而变得强大。
泛型在词义上等价于Java中的泛型类型和C++中的模板。在这三种语言中,该功能都使算法和模式只需实现一次,而不必为每个类型都实现一次。然而,与Java中的泛型和C++中的模板相比,C#中的泛型在实现细节和对类型系统的影响方面差异甚大。
注意,泛型是自C#2.0起引入才添加到“运行时”和C#中的。
如果C#没有泛型
开始讨论泛型之前,先看看一个没有使用泛型的类。这个类是System.Collections.Stack,用于表示一个对象集合,使加入集合的最后一项是从集合中获取的第一项(称为后进先出或LIFO)。Push()和Pop()是Stack类的两个主要方法,分别用于在栈中添加和移除数据项。代码清单1展示了Stack类的Pop()和Push()方法的声明。
代码清单1 System.Collections.Stack类的方法签名
1 | public class Stack |
程序经常使用Stack类型的集合来实现多次撤销(undo)操作。例如,代码清单2利用Stack类在儿童玩具像素绘画板游戏程序中执行撤销操作。
代码清单2的结果如输出1所示。
输出1
代码清单2 在类似像素绘画游戏的程序中模拟撤销操作
1 | using System; |
path是声明为System.Collections.Stack的一个变量。为了利用path来保存上一次移动,只需使用path.Push(currentPosition)方法,将一个自定义类型Cell传入Stack.Push()方法。如果用户输入Z(或者Ctrl+Z),表明需要撤销上一次移动。为此,程序使用一个Pop()方法获取上一次移动,将光标位置设为上一个位置,然后调用Undo()。虽然代码能够正常工作,但System.Collections.Stack类存在重大缺陷。如代码清单1所示,Stack类收集object类型的变量。由于CLR的每个对象都从object派生,所以Stack无法验证放到其中的元素是不是希望的类型。例如,传递的可能不是currentPosition而是string。在这个string中,X和Y坐标通过小数点拼接到一起。不过,编译器必须允许不一致的数据类型。因为Stack类被设计成获取任意对象,其中包括较具体类型的对象。
此外,使用Pop()方法从栈中获取数据时,必须将返回值转型为Cell。但是,假如Pop()返回的不是Cell,就会引发异常。通过强制类型转换将类型检查推迟到运行时进行(程序运行起来才检查,编辑的时候编辑器不会提示错误),程序变得更加脆弱。在不用泛型的情况下创建支持多种数据类型的类,根本的问题在于它们必须支持一个公共基类(或接口),通常是object。
为使用object的方法使用struct或整数这样的值类型,问题会变得更糟。将值类型的实例传给Stack.Push()方法,“运行时”将自动对它进行装箱。类似地,获取值类型的实例时需要显式对数据进行拆箱,将从Pop()获取的object引用转型为值类型。引用类型转换为基类或接口对性能的影响可忽略不计,但值类型装箱的开销较大,因为必须分配内存、复制值以及进行垃圾回收。
C#鼓励“类型安全”。许多类型错误(比如将整数赋值给string变量)能在编译时捕捉到。目前的根本问题是Stack类不是类型安全的。为了修改Stack类来确保类型安全,强迫它存储特定的数据类型,在不使用泛型的前提下,只能创建一个特殊的Stack类,如代码清单3所示。
代码清单3 定义特殊Stack类
1 | public class CellStack() |
由于CellStack只能存储Cell类型的对象,所以这个解决方案要求对栈的各个方法进行自定义的实现,所以并不是理想的解决方案。例如,为了实现类型安全的整数栈,就需要另一个自定义实现。每一个实现看起来都差不多。最终将产生大量重复的、冗余的代码。
初学者主题:另一个例子——可空值类型
在声明值类型的变量时,可以使用可空修饰符?声明允许包含null值的变量。C#从2.0开始支持这个功能,它需要泛型才能正确实现。在引入泛型之前,程序员主要有两个选择。
第一个选择是为需要处理null值的每个值类型都声明可空数据类型,如代码清单4所示。
代码清单4 为各个值类型声明可以存储null的版本
1 |
代码清单4只显示了NullableInt和NullableGuid的实现。如果程序需要更多的可空值类型,就不得不创建更多的结构,并修改属性来使用所需的值类型。如果可空值类型的实现发生了改变(例如,为了支持一个用户定义的从基础类型向可空类型的隐式转换),就不得不修改所有可空类型声明。
第二个选择是声明可空类型,在其中包含object类型的Value属性,如代码清单5所示。
代码清单5 声明可空类型,其中包含object类型的Value属性
1 |
虽然这个方案只需可空类型的一个实现,但“运行时”在设置Value属性时总是对值类型进行装箱。此外,从Value属性获取基础值需要进行一次强制类型转换,而这个操作在运行时可能无效。
以上两种方案都不理想。为了解决这个问题,C#2.0引入了泛型的概念。事实上,可空类型是作为泛型类型Nullable
泛型类型概述
可利用泛型创建一个数据结构,该数据结构能进行特化以处理特定的类型。程序员定义这种参数化类型,使泛型类型的每个变量都具有相同的内部算法,但数据类型和方法签名可随类型参数而变。
为了减轻开发者的学习负担,C#的设计者选择了与C++模板相似的语法。所以, C#中的泛型类和结构要求使用尖括号声明泛型类型参数以及指定泛型类型实参。
泛型类的使用
代码清单6展示了如何指定泛型类使用的实际类型。为了指示path变量使用Cell类型,在实例化和声明语句中都要用尖括号表示法指定Cell。换言之,使用泛型数据类型声明变量(本例是path)时, C#要求指定泛型类型使用的类型实参。代码清单6展示了新的泛型Stack类。
代码清单6 使用泛型Stack类实现撤销
1 |
代码清单6的结果如输出2所示
输出2
代码清单6中将path声明为System.Collections.Generic.Stack
简单泛型类的定义
泛型允许开发人员把精力放在创建算法和模式上,并确保代码能由不同数据类型重用。代码清单7创建了泛型Stack\
代码清单7 声明泛型类Stack\
1 |
泛型的优点
使用泛型类而不是非泛型版本(比如使用System.Collections.Generic.Stack\
(1)泛型促进了类型安全。它确保在参数化的类中,只有成员明确希望的数据类型才可使用。在代码清单7中,参数化栈类限制为Stack\
(2)编译时类型检查减小了在运行时发生InvalidCastException异常的几率。
(3)为泛型类成员使用值类型,不再造成到object的装箱转换。例如,path.Pop()和path.Push()不需要在添加一个项时装箱,或者在删除一个项时拆箱。
(4)C#泛型缓解了代码膨胀的情况。泛型类型保持了具体类版本的优势,但没有具体类版本的开销(例如,没有必要定义像CellStack这样的一个类)。
(5)性能得到了提高。一个原因是不再需要从object的强制类型转换,从而避免了类型检查。另一个原因是不再需要为值类型执行装箱。
(6)泛型减小了内存消耗。由于避免了装箱,因此减少了堆上的内存的消耗。
(7)代码的可读性更好。一个原因是转型检查次数变少了。另一个原因是现在需要较少的类型特定的实现。
(8)支持IntelliSense的代码编辑器现在能直接处理来自泛型类的返回参数。没有必要为了使IntelliSense工作起来,而对返回数据执行转型。
最核心的是,泛型允许写代码来实现模式,并在以后出现这种模式的时候重用那个实现。模式描述了在代码中反复出现的问题,而泛型类型为这些反复出现的模式提供了单一的实现。
类型参数命名规范
和方法参数的命名相似,类型参数的命名应该尽量具有描述性。除此之外,为了强调它是类型参数,名称应包含T前缀。例如,在定义诸如EntityCollection\
唯一不需要使用描述性类型参数名称的时候是描述没有意义的时候。例如,在Stack\
之后会介绍约束。使用约束描述类型名称是一种良好的编程习惯。例如,假定类型参数必须实现IComponent,则类型名称可以是“TComponent”。
规范
要为类型参数选择有意义的名称,并为名称附加“T”前缀。
考虑在类型名称中指明约束。
泛型接口和结构
C#支持在语言中全面地使用泛型,其中包括接口和结构。语法和类的语法完全相同。要声明包含类型参数的接口,将类型参数放到接口名称后面的一对尖括号中即可,比如代码清单8中的IPair\
代码清单8声明泛型接口
1
2
3
4
5 interface IPair<T>
{
T First {get; set; }
T Second {get; set; }
}
该接口代表一对相似对象,比如一个点的平面坐标、一个人的生身父母,或者一个二叉树的节点,等等。pair中的两个数据项具有相同类型。
实现接口的语法与非泛型类的语法相同。注意,一个泛型的类型实参可以成为另一个泛型类型的类型参数。这既合法又普遍,如代码清单9所示。接口的类型实参是类声明的类型参数。除此之外,这个例子使用了结构而不是类,表明C#支持自定义的泛型值类型。
代码清单9 实现泛型接口
1 |
对泛型接口的支持对于集合类尤其重要。使用泛型最多的地方就是集合类。假如没有泛型,开发者就要依赖于System.Collections命名空间中的一系列接口。和它们的实现类一样,这些接口只能使用object类型,因此,此接口要求进出这些集合类的所有访问都要执行转型。使用类型安全的泛型接口,就可以避免执行转型。
高级主题:在类中多次实现同一个接口
相同泛型接口的不同构造被看成是不同的类型,所以类或结构能多次实现“同一”泛型接口。来看看代码清单10的例子。
代码清单10 在类中重复一个接口实现
1 |
在这个例子中,Items属性通过显式的接口实现多次出现,每次类型参数都有所不同。没有泛型这是不可能的。在没有泛型的情况下,编译器只允许一个显式的IContainer.Items属性。
然而,像这样实现“同一个”接口的多个版本不是好的编码风格,因为它会造成混淆(尤其是在接口允许协变或逆变转换的情况下)此外,Person类的设计似乎也有问题,因为一般不会认为人是“能提供一组电子邮件地址”的东西。与其实现同一个接口的三个版本,不如实现3个属性:EmailAddresses、PhoneNumbers和MailingAddresses,每个属性都返回泛型接口的相应构造。
规范
避免在类型中实现同一个泛型接口的多个构造。
构造器和终结器的定义
令人惊讶的是,泛型类或结构的构造器(和终结器)不要求类型参数。换言之,不要求写成Pairs\
代码清单11 声明泛型类型的构造器
1 |
默认值的指定
在代码清单11中,构造器获取First和Second的初始值,并把它们赋给_First和_Second。由于Pairs\
如代码清单12所示,定义这样的构造器会造成编译错误,因为在构造结束的时候,字段_Second仍然处于未初始化的状态。对_Second进行初始化有一个问题,因为不知道T的数据类型。如果是引用类型,那么可以使用null来初始化。然而,假如是非空值类型,使用null进行初始化就行不通了。
代码清单12 不初始化所有字段,造成编译错误
1 |
为了应对这样的局面,C#提供了default操作符,这个操作符最早是在讨论的。可以使用default(int)指定int的默认值。对于T,可以使用default(T)来初始化Second,如代码清单13所示。
代码清单13 用default操作符初始化字段
1 |
default操作符可提供任意类型的默认值,包括类型参数。
多个类型参数
泛型类型可以使用任意数量的类型参数。在前面的Pairs\
代码清单14 使用多个类型参数声明泛型
1 |
小结
从C# 2.0开始引入的泛型类型和泛型方法从根本上改变了C#开发人员的编码风格。在C# 1.0代码中,凡是使用了object的地方,在C# 2.0和更高的版本中都最好用泛型来代替。至少,集合问题应考虑用泛型来解决。避免转型对类型安全性的提升、避免装箱对性能的促进以及重复代码的减少为泛型赋予了无穷的魅力。
第16章将讨论最常用的泛型命名空间之一System.Collections.Generic。该命名空间几乎完全由泛型类型构成。它清晰地演示了如何将最初使用object的类型转换为使用泛型。但在深入接触这些主题之前,我们先来探讨一下Lambda表达式。作为C# 3.0(和以后版本)最引人注目的一项增强,它极大地改进了操作集合的方式。