首頁  >  文章  >  後端開發  >  C#程式設計中的泛型

C#程式設計中的泛型

黄舟
黄舟原創
2017-02-07 15:00:471281瀏覽

.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替换原则,关于这个原则我这里无法进行专门的讲述,只能提一下:这个原则要求方法内部不应该对方法所接受的参数进行向下的强制转换。


为什么呢?我们定义继承体系的目的就是为了代码通用,让基类实现通用的职责,而让子类实现其本身的职责,当你定义了一个接受基类的方法时,设计本身是优良的,但是当你在方法内部进行强制转换时,就破坏了这个继承体系,因为尽管方法的签名是面向接口编程,方法的内部还是面向实现编程。


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


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


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

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()方法时,创建Calculator实例时也得接受一个类型参数。


为了解决这个问题,我们自然而然地会想到:有没有办法把类型参数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 总结


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

以上就是C#编程中的泛型的内容,更多相关内容请关注PHP中文网(www.php.cn)!


陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn