C#泛型

.Net 1.1版本最受诟病的一个缺陷就是没有提供对泛型的支持。通过使用泛型,我们可以极大地提高代码的重用度,同时还可以获得强类型的支持,避免了隐式的装箱、拆箱,在一定程度上提升了应用程序的性能。本文将系统地为大家讨论泛型,我们先从理解泛型开始。

1.1 理解泛型

1.1.1 为什么要有泛型?

我想不论大家通过什么方式进入了计算机程序设计这个行业,都免不了要面对数据结构和算法这个话题。因为它是计算机科学的一门基础学科,往往越是底层的部分,对于数据结构或者算法的时间效率和空间效率的要求就越高。比如说,当你在一个集合类型(例如ArrayList)的实例上调用Sort()方法对它进行排序时,.Net框架在底层就应用了快速排序算法。.Net框架中快速排序方法名称叫QuickSort(),它位于Array类型中,这可以通过Reflector.exe工具查看到。

我们现在并不是要讨论这个QuickSort()实现的好不好,效率高还是不高,这偏离了我们的主题。但是我想请大家思考一个问题:如果由你来实现一个排序算法,你会怎么做?好吧,我们把题目限定得再窄一些,我们来实现一个最简单的冒泡排序(Bubble Sort)算法,如果你没有使用泛型的经验,我猜测你可能会毫不犹豫地写出下面的代码来,因为这是大学教程的标准实现:

public class SortHelper{
    public void BubbleSort(int[] array) {
        int length = array.Length;

        for (int i = 0; i <= length - 2; i++) {
            for (int j = length - 1; j >= 1; j--) {

                // 对两个元素进行交换
                if (array[j] < array[j - 1] ) {
                    int temp = array[j];
                    array[j] = array[j - 1];
                    array[j - 1] = temp;    
                }
            }
        }
    }
}

对冒泡排序不熟悉的读者,可以放心地忽略上面代码的方法体,它不会对你理解泛型造成丝毫的障碍,你只要知道它所实现的功能就可以了:将一个数组的元素按照从小到大的顺序重新排列。我们对这个程序进行一个小小的测试:

class Program {
    static void Main(string[] args) {
        SortHelper sorter = new SortHelper();
        
        int[] array = { 8, 1, 4, 7, 3 };

        sorter.BubbleSort(array);

        foreach(int i in array){
            Console.Write("{0} ", i);
        }

        Console.WriteLine();
        Console.ReadKey();
    }
}

输出为:

1 3 4 7 8

我们发现它工作良好,欣喜地认为这便是最好的解决方案了。直到不久之后,我们需要对一个byte类型的数组进行排序,而我们上面的排序算法只能接受一个int类型的数组,尽管我们知道它们是完全兼容的,因为byte类型是int类型的一个子集,但C#是一个强类型的语言,我们无法在一个接受int数组类型的地方传入一个byte数组。好吧,没有关系,现在看来唯一的办法就是将代码复制一遍,然后将方法的签名改一个改了:

public class SortHelper {
    public void BubbleSort(int[] array) {
        int length = array.Length;

        for (int i = 0; i <= length - 2; i++) {
            for (int j = length - 1; j >= 1; j--) {

                // 对两个元素进行交换
                if (array[j] < array[j - 1]) {
                    int temp = array[j];
                    array[j] = array[j - 1];
                    array[j - 1] = temp;
                }
            }
        }
    }


    public void BubbleSort(byte[] array) {
        int length = array.Length;

        for (int i = 0; i <= length - 2; i++) {
            for (int j = length - 1; j >= 1; j--) {

                // 对两个元素进行交换
                if (array[j] < array[j - 1]) {
                    int temp = array[j];
                    array[j] = array[j - 1];
                    array[j - 1] = temp;
                }
            }
        }
    }
}

OK,我们再一次解决了问题,尽管总觉得哪里有点别扭,但是这段代码已经能够工作,按照敏捷软件开发的思想,不要过早地进行抽象和应对变化,当变化第一次出现时,使用最快的方法解决它,当变化第二次出现时,再进行更好的构架和设计。这样做的目的是为了避免过度设计,因为很有可能第二次变化永远也不会出现,而你却花费了大量的时间精力制造了一个永远也用不到的“完美设计”。这很像一个谚语,“fool me once,shame on you. fool me twice, shame on me.”,翻译过来的意思是“愚弄我一次,是你坏;愚弄我两次,是我蠢”。

美好的事情总是很难长久,我们很快需要对一个char类型的数组进行排序,我们当然可以仿照byte类型数组的作法,继续采用复制粘贴大法,然后修改一下方法的签名。但是很遗憾,我们不想让它愚弄我们两次,因为谁也不想证明自己很蠢,所以现在是时候思考一个更佳的解决方案了。

我们仔细地对比这两个方法,会发现这两个方法的实现完全一样,除了方法的签名不同以外,没有任何的区别。如果你曾经开发过Web站点程序,会知道对于一些浏览量非常大的站点,为了避免服务器负担过重,通常会采用静态页面生成的方式,因为使用Url重写仍要要耗费大量的服务器资源,但是生成为html静态网页后,服务器仅仅是返回客户端请求的文件,能够极大的减轻服务器负担。

在Web上实现过静态页面生成时,有一种常用的方法,就是模板生成法,它的具体作法是:每次生成静态页面时,先加载模板,模板中含有一些用特殊字符标记的占位符,然后我们从数据库读取数据,使用读出的数据将模板中的占位符替换掉,最后将模板按照一定的命名规则在服务器上保存成静态的html文件。

我们发现这里的情况是类似的,我来对它进行一个类比:我们将上面的方法体视为一个模板,将它的方法签名视为一个占位符,因为它是一个占位符,所以它可以代表任何的类型,这和静态页面生成时模板的占位符可以用来代表来自数据库中的任何数据道理是一样的。接下来就是定义占位符了,我们再来审视一下这三个方法的签名:

public void BubbleSort(int[] array)
public void BubbleSort(byte[] array)
public void BubbleSort(char[] array)

会发现定义占位符的最好方式就是将int[]、byte[]、char[]用占位符替代掉,我们管这个占位符用T[]来表示,其中T可以代表任何类型,这样就屏蔽了三个方法签名的差异:

public void BubbleSort(T[] array) {
    int length = array.Length;

    for (int i = 0; i <= length - 2; i++) {
        for (int j = length - 1; j >= 1; j--) {

            // 对两个元素进行交换
            if (array[j] < array[j - 1]) {
                T temp = array[j];
                array[j] = array[j - 1];
                array[j - 1] = temp;
            }
        }
    }
}

现在看起来清爽多了,但是我们又发现了一个问题:当我们定义一个类,而这个类需要引用它本身以外的其他类型时,我们可以定义有参数的构造函数,然后将它需要的参数从构造函数传进来。但是在上面,我们的参数T本身就是一个类型(类似于int、byte、char,而不是类型的实例,比如1和'a')。很显然我们无法在构造函数中传递这个T类型的数组,因为参数都是出现在类型实例的位置,而T是类型本身,它的位置不对。比如下面是通常的构造函数:

public SortHelper(类型 类型实例名称);

而我们期望的构造函数函数是:

public SortHelper(类型);

此时就需要使用一种特殊的语法来传递这个T占位符,不如我们定义这样一种语法来传递吧:

public class SortHelper<T> {
    public void BubbleSort(T[] array){
        // 方法实现体
    }
}

我们在类名称的后面加了一个尖括号,使用这个尖括号来传递我们的占位符,也就是类型参数。接下来,我们来看看如何来使用它,当我们需要为一个int类型的数组排序时:

SortHelper<int> sorter = new SortHelper<int>();
int[] array = { 8, 1, 4, 7, 3 };
sorter.BubbleSort(array);

当我们需要为一个byte类型的数组排序时:

SortHelper<byte> sorter = new SortHelper<byte>();
byte [] array = { 8, 1, 4, 7, 3 };
sorter.BubbleSort(array);

相信你已经发觉,其实上面所做的一切实现了一个泛型类。这是泛型的一个最典型的应用,可以看到,通过使用泛型,我们极大地减少了重复代码,使我们的程序更加清爽,泛型类就类似于一个模板,可以在需要时为这个模板传入任何我们需要的类型。

我们现在更专业一些,为这一节的占位符起一个正式的名称,在.Net中,它叫做类型参数 (Type Parameter),下面一小节,我们将学习类型参数约束。

1.1.2 类型参数约束

实际上,如果你运行一下上面的代码就会发现它连编译都通过不了,为什么呢?考虑这样一个问题,假如我们自定义一个类型,它定义了书,名字叫做Book,它含有两个字段:一个是int类型的Id,是书的标识符;一个是string类型的Title,代表书的标题。因为我们这里是一个范例,为了既能说明问题又不偏离主题,所以这个Book类型只含有这两个字段:

public class Book {
    private int id;
    private string title;

    public Book() { }

    public Book(int id, string title) {
        this.id = id;
        this.title = title;
    }

    public int Id {
        get { return id; }
        set { id = value; }
    }

    public string Title {
        get { return title; }
        set { title = value; }
    }
}

现在,我们创建一个Book类型的数组,然后试着使用上一小节定义的泛型类来对它进行排序,我想代码应该是这样子的:

Book[] bookArray = new Book[2];

Book book1 = new Book(124, ".Net之美");
Book book2 = new Book(45, "C# 3.0揭秘");

bookArray[0] = book1;
bookArray[1] = book2;

SortHelper<Book> sorter = new SortHelper<Book>();
sorter.BubbleSort(bookArray);

foreach (Book b in bookArray) {
    Console.WriteLine("Id:{0}", b.Id);
    Console.WriteLine("Title:{0}\n", b.Title);
}

可能现在你还是没有看到会有什么问题,你觉得上一节的代码很通用,那么让我们看得再仔细一点,再看一看SortHelper类的BubbleSort()方法的实现吧,为了避免你回头再去翻上一节的代码,我将它复制了下来:

public void BubbleSort(T[] array) {
    int length = array.Length;

    for (int i = 0; i <= length - 2; i++) {
        for (int j = length - 1; j >= 1; j--) {

            // 对两个元素进行交换
            if (array[j] < array[j - 1]) {
                T temp = array[j];
                array[j] = array[j - 1];
                array[j - 1] = temp;
            }
        }
    }
}

尽管我们很不情愿,但是问题还是出现了,既然是排序,那么就免不了要比较大小,大家可以看到在两个元素进行交换时进行了大小的比较,那么现在请问:book1和book2谁比较大?小张可能说book1大,因为它的Id是124,而book2的Id是45;而小王可能说book2大,因为它的Title是以“C”开头的,而book1的Title是以“.”开头的(字符排序时“.”在“C”的前面)。但是程序就无法判断了,它根本不知道要按照小张的标准进行比较还是按照小王的标准比较。这时候我们就需要定义一个规则进行比较。

在.Net中,实现比较的基本方法是实现IComparable接口,它有泛型版本和非泛型两个版本,因为我们现在正在讲解泛型,而可能你还没有领悟泛型,为了避免你的思维发生“死锁”,所以我们采用它的非泛型版本。它的定义如下:

public interface IComparable {
    int CompareTo(object obj);
}

假如我们的Book类型已经实现了这个接口,那么当向下面这样调用时:

book1.CompareTo(book2);

如果book1比book2小,返回一个小于0的整数;如果book1与book2相等,返回0;如果book1比book2大,返回一个大于0的整数。

接下来就让我们的Book类来实现IComparable接口,此时我们又面对排序标准的问题,说通俗点,就是用小张的标准还是小王的标准,这里就让我们采用小张的标准,以Id为标准对Book进行排序,修改Book类,让它实现IComparable接口:

public class Book :IComparable {
    // CODE:上面的实现略

    public int CompareTo(object obj) {
        Book book2 = (Book)obj;
        return this.Id.CompareTo(book2.Id);
    }
}

为了节约篇幅,我省略了Book类上面的实现。还要注意的是我们并没有在CompareTo()方法中去比较当前的Book实例的Id与传递进来的Book实例的Id,而是将对它们的比较委托给了int类型,因为int类型也实现了IComparable接口。顺便一提,大家有没有发现上面的代码存在一个问题?因为这个CompareTo ()方法是一个很“通用”的方法,为了保证所有的类型都能使用这个接口,所以它的参数接受了一个Object类型的参数。因此,为了获得Book类型,我们需要在方法中进行一个向下的强制转换。如果你熟悉面向对象编程,那么你应该想到这里违反了Liskov替换原则,关于这个原则我这里无法进行专门的讲述,只能提一下:这个原则要求方法内部不应该对方法所接受的参数进行向下的强制转换。为什么呢?我们定义继承体系的目的就是为了代码通用,让基类实现通用的职责,而让子类实现其本身的职责,当你定义了一个接受基类的方法时,设计本身是优良的,但是当你在方法内部进行强制转换时,就破坏了这个继承体系,因为尽管方法的签名是面向接口编程,方法的内部还是面向实现编程。

注释:什么是“向下的强制转换(downcast)”?因为Object是所有类型的基类,Book类继承自Object类,在这个金字塔状的继承体系中,Object位于上层,Book位于下层,所以叫“向下的强制转换”。

好了,我们现在回到正题,既然我们现在已经让Book类实现了IComparable接口,那么我们的泛型类应该可以工作了吧?不行的,因为我们要记得:泛型类是一个模板类,它对于在执行时传递的类型参数是一无所知的,也不会做任何猜测,我们知道Book类现在实现了IComparable,对它进行比较很容易,但是我们的SortHelper<T>泛型类并不知道,怎么办呢?我们需要告诉SortHelper<T>类(准确说是告诉编译器),它所接受的T类型参数必须能够进行比较,换言之,就是实现IComparable接口,这便是本小节的主题:泛型约束。

为了要求类型参数T必须实现IComparable接口,我们像下面这样重新定义SortHelper<T>:

public class SortHelper<T> where T:IComparable {
    // CODE:实现略
}

上面的定义说明了类型参数T必须实现IComaprable接口,否则将无法通过编译,从而保证了方法体可以正确地运行。因为现在T已经实现了IComparable,而数组array中的成员是T的实例,所以当你在array[i]后面点击小数点“.”时,VS200智能提示将会给出IComparable的成员,也就是CompareTo()方法。我们修改BubbleSort()类,让它使用CompareTo()方法来进行比较:

public class SortHelper<T> where T:IComparable
{
    public void BubbleSort(T[] array) {
        int length = array.Length;

        for (int i = 0; i <= length - 2; i++) {
            for (int j = length - 1; j >= 1; j--) {
                
                // 对两个元素进行交换
                if (array[j].CompareTo(array[j - 1]) < 0 ) {
                    T temp = array[j];
                    array[j] = array[j - 1];
                    array[j - 1] = temp;
                }
            }
        }
    }
}

此时我们再次运行上面定义的代码,会看到下面的输出:

Id:45
Title:.Net之美

Id:124
Title:C# 3.0揭秘

除了可以约束类型参数T实现某个接口以外,还可以约束T是一个结构、T是一个类、T拥有构造函数、T继承自某个基类等,但我觉得将这些每一种用法都向你罗列一遍无异于浪费你的时间。所以我不在这里继续讨论了,它们的概念是完全一样的,只是声明的语法有些差异罢了,而这点差异,相信你可以很轻松地通过查看MSDN解决。

1.1.3 泛型方法

我们再来考虑这样一个问题:假如我们有一个很复杂的类,它执行多种基于某一领域的科学运算,我们管这个类叫做SuperCalculator,它的定义如下:

public class SuperCalculator {
    public int SuperAdd(int x, int y) {
        return 0;
    }

    public int SuperMinus(int x, int y) {
        return 0;
    }

    public string SuperSearch(string key) {
        return null;
    }

    public void SuperSort(int[] array) {
    }
}

由于这个类对算法的要求非常高,.Net框架内置的快速排序算法不能满足要求,所以我们考虑自己实现一个自己的排序算法,注意到SuperSearch()和SuperSort()方法接受的参数类型不同,所以我们最好定义一个泛型来解决,我们将这个算法叫做SpeedSort(),既然这个算法如此之高效,我们不如把它定义为public的,以便其他类型可以使用,那么按照前面两节学习的知识,代码可能类似于下面这样:

public class SuperCalculator<T> where T:IComparable {
    // CODE:略

    public void SpeedSort(T[] array) {      
        // CODE:实现略
    }
}

这里穿插讲述一个关于类型设计的问题:确切的说,将SpeedSort()方法放在SuperCaculator中是不合适的。为什么呢?因为它们的职责混淆了,SuperCaculator的意思是“超级计算器”,那么它所包含的公开方法都应该是与计算相关的,而SpeedSort()出现在这里显得不伦不类,当我们发现一个方法的名称与类的名称关系不大时,就应该考虑将这个方法抽象出去,把它放置到一个新的类中,哪怕这个类只有它一个方法。

这里只是一个演示,我们知道存在这个问题就可以了。好了,我们回到正题,尽管现在SuperCalculator类确实可以完成我们需要的工作,但是它的使用却变得复杂了,为什么呢?因为SpeedSort()方法污染了它,仅仅为了能够使用SpeedSort()这一个方法,我们却不得不将类型参数T加到SuperCalculator类上,使得即使不调用SpeedSort()方法时,创建SuperCalculator实例时也得接受一个类型参数。

为了解决这个问题,我们自然而然地会想到:有没有办法把类型参数T加到方法上,而非整个类上,也就是降低T作用的范围。答案是可以的,这便是本小节的主题:泛型方法。类似地,我们只要修改一下SpeedSort()方法的签名就可以了,让它接受一个类型参数,此时SuperCalculator的定义如下:

public class SuperCalculator{
    // CODE:其他实现略

    public void SpeedSort<T>(T[] array) where T : IComparable {
        // CODE:实现略
    }
}

接下来我们编写一段代码来对它进行一个测试:

Book[] bookArray = new Book[2];

Book book1 = new Book(124, "C# 3.0揭秘");
Book book2 = new Book(45, ".Net之美");

SuperCalculator calculator = new SuperCalculator();
calculator.SpeedSort<Book>(bookArray);

因为SpeedSort()方法并没有实现,所以这段代码没有任何输出,如果你想看到输出,可以简单地把上面冒泡排序的代码贴进去,这里我就不再演示了。这里我想说的是一个有趣的编译器能力,它可以推断出你传递的数组类型以及它是否满足了泛型约束,所以,上面的SpeedSort()方法也可以像下面这样调用:

calculator.SpeedSort(bookArray);

这样尽管它是一个泛型方法,但是在使用上与普通方法已经没有了任何区别。

1.1.4 总结

本节中我们学习了掌握泛型所需要的最基本知识,你看到了需要泛型的原因,它可以避免重复代码,还学习到了如何使用类型参数约束和泛型方法。拥有了本节的知识,你足以应付日常开发中的大部分场景。

在下面两节,我们将继续泛型的学习,其中包括泛型在集合类中的应用,以及泛型的高级话题。

posted @ 2017-01-19 15:27  仰望-星空  阅读(148)  评论(0编辑  收藏  举报