2010/4/23

[入門] .NET 自訂型別

我可以理解為什麼我老婆開了十年的同樣一部車, 都不知道原來在 N 檔和 D 檔之間切換可以不必去按那個安全鈕; 但是我卻無法理解為什麼有一個已經寫了好幾年 .NET 程式的人, 在這輩子中竟然沒有寫過任何一個自訂型別。對這件事, 我唯一想得到的解釋, 就是從來沒有人跟他說過自訂型別的好用之處。

所以, 這件事也讓我決心把自訂型別特別整理成一個主題。雖然我在本文中會用到一些進階技巧, 對真正的初學者而言恐怕有點吃力, 但是這畢竟是 .NET 中很基礎、很一般的常識, 所以我還是把它歸類到「入門文章」裡面

型別? 自訂型別? 類別?

在開始之前, 我必須先把前題釐清一下。所謂的型別 (type), 指的就是一個程式語言對於資料結構的定義。有人認為只有原生型別 (primitive types, 例如 int, bool, char 等等) 才能稱為「型別」, 所以並沒有所謂「自訂型別」這種東西, 任何人也無法對一個語言加上除了原生型別之外的型別。

然而對 .NET 而言, 事實並非如此。在 .NET 中, 幾乎所有能夠用來描述資料的結構, 通通稱為型別, 包括 class、struct、interface、enum 等等, 所以我們可以時常看到類似如下的宣告:

int i;
IEnumerable iNum;
myStruct mystruct;
myClass myclass;

以上各種被宣告的變數, 都可以以 typeof() 取出其型別名稱。例如:

Type myType = typeof(myClass);

以最簡單的話來講, 我們可以說「型別」指的是一個較為籠統的稱謂; 在 .NET 中, 你可以使用 struct, interface 或 class 等等來定義你的自訂型別, 在使用上, 和原生型別並沒有太大的差異。

在我個人的定義中, 凡是非 .NET 既有的型別, 就稱為「自訂型別」。而且我在本文中並不去討論什麼學術上的嚴格定義 (其實在這部份, 還真沒有放諸四海皆準的精確定義), 以免流於空談。在這裡, 一切都以實用為主。

當然, 如果就字義上來解釋, 任何使用者自己定義的類別都可以稱為自訂型別。不過我在這裡所講的自訂型別, 自然是指那些特別寫來當做一個型別用的類別或介面等等。如果你只是寫個類別來當做「類別」用, 而不是把它當做「型別」來用, 那就不是本文所著重的了。此外, .NET 本身對於非既有型別的龐大類別庫 (.NET Class Library), 也稱為自訂型別。然而, 我在這裡所指的自訂型別, 當然不包括那一部份; 簡而言之, 我這裡所講的「自訂型別」, 就是單純的指需要由你自己下手去寫的程式碼。

以外, 在本文中我將使用類別來定義自訂型別。你可以把類別視為一種用來描述資料的「藍圖」; 使用這個藍圖來產生的資料, 我們把它稱為「物件」(不過物件並不一定是經由類別產生的)。你可以想像類別就是一個模, 由這個模所鑄造出來的物品, 我們就把它稱為「物件」。那麼, 我為什麼不使用 struct 來定義, 而要使用 class 呢? 這是由於 class 實在比較好用的關係。

如果要論 Class 與 Struct 的差異, 我們可能必須開闢另一篇新文章才講得完 (如果你真的有興趣, 不妨拜讀 James Hare 的「C# Fundamentals: The Differences Between Struct and Class」一文)。不過在這裡我不想在這個議題上耗費冗長的篇幅, 所以我盡量簡短的把它描述一下。

Class 與 Struct 最大的差別, 在於 Class 是 Reference Type 而 Struct 是 Value Type (關於這兩者的差別, 請參考「.NET: Type Fundamentals」)。

此外, 由於 Struct 一定是 Sealed, 所以 Struct 是無法被繼承的。少了繼承的能力, Struct 自然不可能在物件導向領域上扮演什麼重要的角色。

和其它 Value Type 物件一樣, Struct 物件也不可能是 null。一個剛被宣告、未經 new 建構子的 Struct 物件, 並不是 null; 你無法對 Struct 物件 myStruct 使用 if (myStruct == null) 這樣的判斷式。在你下達建構式 new 之前, 你根本無法存取這個 Struct 物件, 除非這個 Struct 內的欄位是宣告為 Public 而且你已指派值給它。舉個例子, 假設我宣告一個 Struct  如下:

struct line
{
     public Point start;
     public Point end;
}

那麼以下程式是錯的:

line l;
Point p1 = l.start;
Point p2 = l.end;

這個程式只有第一行是對的, 第二行和第三行在編譯階段就會引發錯誤, 其錯誤訊息是「使用可能為未指派的欄位」。

但是若改成如下就又對了:

line l;
l.start = new Point(500, 500);
Point p1 = l.start;

這個意思是我只需要在引用欄位之前指派其值就可以了。

然而, 只指派一個欄位的值是不夠的, 你必須把所有的欄位都指派之後才能引用, 像以下的程式就又錯了:

line l;
l.start = new Point(500, 500);
Point p1 = l.start;
Point p2 = l.end;

錯誤出現在最後一行, 因為 end 欄位尚未指派。你必須仿照預先指派 start 欄位的方法, 指派 end 欄值的值之後, 才能引用其值。

如果我們不將 line 以 Struct 宣告, 而是宣告為 Class 的話, 那麼以上這些問題都不會存在。因為你根本不可能有機會不對 line 類別物件執行建構子; 若不使用 line l = new line() 建立物件 instance 的話 在設計時期欲指派或引用 l.start 或 l.end 這兩個欄位時會看到錯誤了, 還不需要等到 Compile 時期。

綜合以上幾個因素, 我會奉勸 .Net 初學者先學習以類別來建立自訂型別 (即使對已經熟悉 C++ 的 .Net 初學者亦同), 等你可以完全掌握自訂型別的目的及其在物件導向上面的各式技巧之後, 再來考慮你要採用 Struct 或 Class, 猶時也未晚。

為什麼需要自訂型別?

如果你沒有寫過自訂型別, 我想, 那是因為你沒有那種需要, 或者說你不知道你可以這麼做。然而, 在實際上, 你可以把自訂型別當作複雜資料的具體描述; 這是一種資訊封裝的好方法 (也是好習慣)。舉個例子, 假設你在資料庫中有個 tblEmployee 員工資料表, 你也許通常都使用這種方法把某個員工的資料撈出來:

// 程式一
using (SqlDataReader dr = cmd.ExecuteReader(CommandBehavior.Default))
{
    while (dr.Read())
    {
        string name = dr["name"].ToString();
        string title = dr["title"].ToString();
        int salary = (int) dr["salary"];
    }
}

如果我們完全不理會什麼自訂型別的話, 經由如上範例中的 dr.Read() 迴圈, 我們還是可以一筆一筆的把員工資料撈出來處理, 達成所有必需達成的目的。但是如果你使用自訂型別, 會有什麼不同? 請看下例:

// 程式二
public class employee 
{
    public string name;
    public string title;
    public int salary;
}
...
SqlCommand cmd = new SqlCommand("...", conn);
List<employee> employees = null;
using (SqlDataReader dr = cmd.ExecuteReader(CommandBehavior.Default))
{
    while (dr.Read())
    {
        employee emp = new employee();
        emp.name = dr["name"].ToString();
        emp.title = dr["title"].ToString();
        emp.salary = (int)dr["salary"];
        employees.Add(emp);
    }
}
return employees;

在第二個程式中, 我們加入了一個名為 employee 的自訂型別, 並利用它來建立與儲存每一個員工的相關資訊。透過這個自訂型別, 我們便可以在程式中很方便的使用 employee.name、employee.title 與 employee.salary 來存取指定員工的各種屬性。

我並不會斷然的說程式二一定優於程式一, 因為這兩個程式都可以達成原來的目的。然而, 在程式二裡面, 我們運用了物件導向裡面很重要的「資訊封裝」(Information Encapsulation) 的概念。在程式二中, 我們利用 employee 這個自訂型別, 把關於個別員工的相關資訊包在單獨一個物件裡面。對其它程式而言, 我們只需要引用 employee 這個物件, 就可以順帶的取到它的其它相關資訊。相較於程式一, 我們可以說程式二比較易於使用。

那麼, 我們是不是應該把資料庫中每個資料表都拿來定義成一個對應的型別呢? 其實這就是所謂 Data Entity 的概念, 而且確實有很多人是這麼用的, 甚至有一些現成的工具可以幫你把整個資料庫裡的每個資料表都自動轉換成類別, 有些則是連 CRUD 指令都替你寫好了(有興趣者可以參考 MSDN 關於 Dynamic Data 的介紹)。

然而, 我雖然不能很明確的告訴你這種做法究竟是好還是不好, 但我可以告訴你, 我自己是不這樣做的。為什麼? 因為如果這樣做的話, 每次只要資料庫的結構略有改變, 那麼類別就必須重新產生一次。對我而言, 我幾乎不可能直接拿程式自動產生的碼來用, 而是一定會把它改過再用。結果, 由於實務上資料庫的結構實在不可能永遠不改 (事實上, 真的是常常改, 頻率差不多是每個月數次, 直到驗收完成為止。但就算驗收完成, 只要這個產品有人用, 未來還是有可能會有變動), 最後我只好選擇不採用這種方法了。

但是我並不是說我就不製作自訂型別了, 而是我照樣製作自訂型別, 但是每個類別中的屬性並不一定與資料表欄位一一對應。重要的是, 類別是由我自己進行維護, 而不是程式產生的。如此, 反而才能確保程式的最大彈性。

其實自訂型別還有一個好用的地方, 那就是用來擴充既有型別的功能。舉個例子, 如果在某個專案中, 我們會用到很多使用到 Regular Expression 來過濾文字的功能, 那麼我們可以自訂一個繼承自 string 型別的新型別, 並且為這個新型別加入很多已經附加 Regular Expression 判斷的功能。

同樣的道理, 我們也可以藉由擴充既有的 .NET 控制項 (控制項本身也是類別, 因此當然也是型別), 以製作專屬的嶄新控制項。例如, 我們可以把 ASP.NET 中的 DropDownList 控制項予以擴充或是改寫 (一樣是採用繼承的方法即可), 然後把它加上拖曳搬移的能力, 或者讓它具備透明浮現的效果等等。

不過, 很抱歉, 我並不打算在本文中介紹太多奇技淫巧。我只會把重點放在很基本的概念上面; 當你能夠完全體會之後, 就可以輕鬆的開發屬於你自己的各種專屬功能了。

簡單範例

以下, 我將拿程式二裡面的自訂型別 employee 當作本文中的主要範例。

我們知道, 在 .NET 的類別 (Class) 裡, 我們可以加入「欄位」(Field)、「屬性」(Property) 和「方法」(Method) 等等。我們先來看看原來的程式:

public class employee 
{
    public string name;
    public string title;
    public int salary;
    public datetime birthday;
    public int age;
}

如果你把某些資訊以欄位方式儲存的話, 未來你會發現這些欄位無法被資料繫結。所以在這裡, 我把它們全部改成屬性, 同時將各屬性的型別做了調整, 還加上了一個方法:

// 程式三
public class employee
{
    public int id { get; private set; }
    public string password { private get; set; }
    public string name { get; set; }
    public string title { get; set; }
    public int? salary { get; set; }
    public DateTime? birthday { get; set; }
    public int? ageSoFar
    {
        get
        {
            return (birthday == null) ? 
                null : 
                (int?)DateTime.Now.Year - ((DateTime)birthday).Year;
        }
    }
    public int? ageTill(DateTime then)
    {
        return (birthday == null || then == null) ? 
            null : 
            (int?)((DateTime)then).Year - ((DateTime)birthday).Year;
    }
}

程式三裡面有幾個重點, 我把它條列如下:

首先, 為因應資料庫欄位中可能有 null 值出現的狀況, 所以有某幾個屬性必須指定為 Nullable 型別, 如上例中的 int? 和 DateTime? 等等。對於一個宣告為 int 或 DateTime 的變數或屬性, 你無法賦予 null 值。因應之道, 就是你必須將它們宣告為 Nullable 型別。Nullable 型別 (如 DateTime?) 和對應的非 Nullable 型別 (如 DateTime) 之間可以直接做明確型別轉換, 但是無法隱含轉換, 這點請稍加留意。

舉個例子, 在程式三中 birthday 的型別是 DateTime?, 意思是資料庫中的 birthday 欄位的內容可以是 NULL (使用者未輸入, 而且允許使用者不必輸入)。如果從資料庫中讀到的值為 DBNULL 的話, 我們就讓這個屬性的值維持為 NULL, 如此我們才可以明確知道使用者未曾輸入其值。此外, string 是 .NET 中最奇特的內建型別了 (事實上, .NET 中所有的內建型別中, 除了 object 和 string 外, 通通稱為簡單型別; 只有 object 和 string 稱為複雜型別); 而且 string 也是唯一可以以 == 判斷彼此值 (而非指標) 是否相等的參考型別。在這個範例中, 我們可以觀察到, 對於字串屬性, 我們無需 (也無法) 使用 Nullable 型別 (沒有 string? 這種東西, 因為 String 是參考型別); 我們可以自由的指定字串變數的值為 NULL。

從 .NET 3.0 起, 我們可以使用 { get; set; } 這種簡化的語法來宣告一個屬性。但是如果有一個屬性是唯讀的話, 該怎麼宣告? 在程式三裡面, id 和 age 兩個屬性都是唯讀的 (Read Only)。如果我們只是單純的不允許一個屬性被其它程式寫入, 那麼我們不可以寫成 { get; } 而是應該如同程式三的 id 屬性一樣寫成 { get; private set; }。反之, 如果是唯寫的 (Write Only), 那麼就寫成 { private get; set; }, 如程式三裡的 password。

那麼, 既然我們可以使用 private set, 那麼當然也可以使用其它的存取修飾詞, 像 protected set (我想你應該夠聰明到不會來問我為什麼不可以宣告為 public set 吧?)。我不能幫你決定採用哪一種, 這要看你的類別是怎麼設計其繼承結構的。

至於 ageSoFar 屬性, 雖然它也是唯讀, 但是因為我們已經明確的寫程式來計算出它的值, 所以我們只需宣告 get 區段即可, 不用再寫 private set 了。

很顯然的, ageSoFar 屬性並不是資料庫中的欄位, 而是根據 birthday 的值而計算出來的 (或許你有想到這種做法只能計算員工到今天的年齡, 而不能算出到某一天的年齡; 如果你有那種需求的話, 就必須另外寫一個方法來計算, 而不是屬性; 如同範例中的 ageTill() 方法)。換句話說, 在此種情境下, 你根本不可能會對 ageSoFar 有寫入的需要, 所以你不用撰寫 set 區段, 這是完全合理的。但是像 id 屬性的寫法, 你一定要先設定它的值, 之後才能讀取, 所以你非加上一個 set 不可; 只不過設定其值的動作應該是交給誰來做呢? 這就是為什麼我們需要透過存取修飾詞來予以限制的道理了。

實作常用介面

當你遵循上述方法做出自訂型別之後, 也許你在一段或長或短的時間之內會覺得很夠用、甚至很好用; 但是如果再久一點, 你或許會懷疑, 為什麼一些 .NET 既有型別所具備的功能, 在你的自訂型別裡面都沒有?

例如, 如果你把自訂的 employee 型別物件加入到 List 裡, 這個 List 是無法排序的。

在一個 List<int> 物件中, 我們可以很方便的讓它進行排序, 如下範例:

List<int> numbers = new List<int>()
{
    5, 2, 1
};
numbers.Sort();
foreach (int i in numbers)
{
    Debug.WriteLine("i = " + i.ToString());
}

但是如果我們把 employee 型別套用上去:

List<employees> = new List<employee>() 
{
    new employee() {id=0, name="Johnny"},
    new employee() {id=1, name="Grace"}
};
employees.Sort();

那麼雖然在 Visual Studio 中可以編譯成功, 但是在執行時期就會在 Sort() 指令處發生類似「比較兩個值的時候失敗」的錯誤。會發生 Sort 失敗, 主要的原因在於你的類別並未實作 IComparable 介面的關係。

要在自訂型別中加上排序功能, 那麼你就必須實作 IComparable 介面。在 .NET 中, 公用的介面名稱都會以大寫的 I 開頭。而要實作介面, 其實也非常的簡單; 我們先看如何實作 IDisposable:

public class employee : IDisposable
{   // 省略其它程式碼
    public void Dispose() { }
}

IDisposable 介面大概是所有介面中最容易實作的, 因為你所有需要做的事情, 除了把 :IDisposable 述句加上去之外, 唯一要做的, 就只是把 public void Dispose() { } 這一行放上去就行了。我們本來是應該在 Dispose() 方法中手動將 (尤其是 unmaged) 物件給清除掉的, 但是在本文描述的情境中, 我們只是為了讓這個自訂型別可以加入到 using 區段而已, 根本沒有建立任何物件, 所以也就沒有任何 managed 和 unmaged 物件需要清除, 因此我們保留一個空的方法以符合 IDisposable 介面的規範即可。

我在這裡只是拿 IDisposable 來做範例。如果你的類別不會留下大量的暫存資訊, 你可以不需要實作這個介面。

接著, 我們再來看看如何實作 IComparable 介面。IComparable 介面稍為複雜一點, 雖然我們也是加上一個 CompareTo() 方法即可, 但是這個方法可絕對不能是空的, 而是必須寫些符合邏輯的程式碼。

基本上, 如果我們要對物件排序的話, 我們當然必須很明確的知道不同的物件之間要如何分出大小。如果物件是數字的話, 那麼 2 大於 1, 1 小於 2, 這個邏輯非常清楚。但是在本文的情境中, 兩個員工要怎麼分出大小?

由於此處我們只是打算讓自訂集合物件可以使用 Sort() 方法依大小排序, 我們必須決定一個可以區分大小的方法, 不管是依年齡排、依職等排、依姓名排等等都可以, 但是我們此處就只能採用一種。

為示範方便, 我就採用員工編號 (id) 來比好了。以下我們來看看範例:

public class employee : IComparable
{   // 省略其它程式碼
    public int CompareTo(object emp)
    {
        return id.CompareTo(((employee)emp).id);
    }
}

實作 CompareTo() 方法時, 我們必須特別注意一點, 那就是它的寫法必須就像 public int CompareTo(object emp) 這一行一樣, 除了 emp 這個變數可以任意命名之外, 其它都不能亂改; 所以 int 必須是 int, object 必須是 object。此外, 你可能看不懂 id.CompareTo() 是什麼道理, 其實我是偷懶, 把整數型別 (這裡的 id 是 int 型別) 的 CompareTo() 偷過來用而已。如果你想更深入了解其中的道理, 可以參考另文「讓自訂類別的陣列物件具有排序與搜尋的能力」。還有, 如果你不小心把程式中第一個 id 和第二個 id 順序寫反了, 那麼排序的方向也會跟著相反。

實作了 IComparable 介面之後, employee 型別的集合物件已經具有排序能力了:

List<employee< employees = new List<employee>() 
{
    new employee() {id=1, name="Johnny"},
    new employee() {id=2, name="Grace"},
    new employee() {id=0, name="Jolan"}
};
employees.Sort();
foreach (employee emp in employees)
{
    Debug.WriteLine(emp.id);
}

到這裡為止, 我們已經知道如何實作 IDisposable, 也知道如何實作 IComparable 了。那麼, 如果我剛好兩種介面都想同時實作, 該怎麼辦呢? 在 .NET 中雖不支援多重繼承, 但是支援介面的多重實作, 所以兩種介面同時實作是沒有問題的。我把程式三稍為改了一下, 再把完整的版本列在下面:

// 程式四
public class employee : IDisposable, IComparable
{
    public int id { get; internal set; }
    public string password { private get; set; }
    public string name { get; set; }
    public string title { get; set; }
    public int? salary { get; set; }
    public DateTime? birthday { get; set; }
    public int? ageSoFar
    {
        get
        {
            return (birthday == null) ?
                null :
                (int?)DateTime.Now.Year - ((DateTime)birthday).Year;
        }
    }
    public void Dispose() { }
    public int CompareTo(object emp)
    {
        return id.CompareTo(((employee)emp).id);
    }

    public int? ageTill(DateTime then)
    {
        return (birthday == null || then == null) ?
            null :
            (int?)((DateTime)then).Year - ((DateTime)birthday).Year;
    }
}

此外, 如果我們在自訂類別中實作 IEquatable<T> 介面, 那麼被放到 List 裡的類別物件就可以使用 Contains 方法。假設我們有一個型別為 List<employee> 的物件 employees, 如果你試圖使用 employees.Contains(employeeId) 來找出這個 list 裡面有沒有一個指定的員工編號, 那麼程式會發生錯誤。但是如果我們實作了 IEquatable<employee> 介面, Contains 方法就能夠正確地使用。

要實作這個泛型介面, 那麼我們可以把類別的宣告寫成:

public class employee : IEquatable<employee>

若使用 Visual Studio 2012 之後提供的自動實作介面的功能, 我們可以在 VS IDE 中, 在上面的 IEquatable 文字上面按滑鼠右鍵, 然後選取「實作介面」、「實作介面」, VS 就會自動幫你把應該實作的方法產生出來, 並且將它放置在程式的最下方。接著, 請把這個方法寫好:

public bool Equals(employee other)
{
    return this.EmployeeId == other.EmployeeId;
}

當然, 如果你想比較的屬性不是物件編號, 而是其它屬性 (例如名字) 的話, 把程式略改一下就可以了。

如此, 當你把這個自訂類別放在 List 之類的泛型集合裡的時候, 它的 Contains 方法就能正確地使用了。

我想, 到這裡為止, 你已經知道如何實作介面了。但是對自訂型別而言, 最關鍵的問題, 可能是你還沒有想到的, 那就是「到底我應該實作哪些介面」?

我本來是想要列個常用介面的清單給大家參考, 但是後來發現實在很難推薦。主要的原因在於 .NET 所提供的介面多半都是為了特定的需要而存在, 如果你的物件沒那個需要, 那麼實作那些介面就顯得多餘。如果只是為了實作介面而去實作介面, 這不符合本文「著重實用」的精神, 也不應該放在「入門文章」裡面。

不管如何, 你已經在上面學到如何實作介面了。所以下次當你發現你必須實作什麼介面時, 你可以像我一樣, 先到 MSDN 搜尋這個介面的說明, 看看它需要實作什麼屬性或方法, 再把它加到自訂類別裡面就行了, 就這麼簡單。

介面也是型別

以上我示範了實作系統內建的介面, 那麼如何自訂介面呢?

前面我講過 class、struct、interface、enum 都可以作為自訂型別, 但是 interface 卻是一個特別的例子。我們在上一節已經看到把 interface 拿來做為 contract 的範例, 但是在物件導向程式設計實務中, 我們也時常把 interface 拿來當作型別來使用, 尤其當我們套用一些 Design Pattern 的時候。

若採用上一個例子, 我們可以撰寫如下的程式:

IComparable emp = new employee() { id = 2 };
int intCompared = emp.CompareTo(new employee() { id = 1 });

在這個程式中, 我們使用 IComparable emp 宣告 emp 物件為一個 IComparable 型別, 但是卻是以 new employee() 來建構該物件。結果, 如果我們以 emp.GetType() 來查該物件的型別時, 你會發現它其實還是 employee 型別而不是 IComparable。

從這裡, 我們可以看出來, 我們可以宣告一個 「interface 物件」, 但是你並沒有方法憑空去建構 (instantiate) 一個「interface 物件」。你只能建構一個已實作該介面的類別物件, 例如本例中的 employee 類別。

那麼, 我們什麼時候可以把 interface 拿來當作型別使用呢?

我來舉一個例子。現在, 假設我在 Web Form 或者 Win Form 裡設計了好幾個 User Control, 例如說 uc1, uc2 和 uc3。當我在進行 refactoring 時, 發現其實這三個 User Control 裡面剛好都提供了可以繪製外框線的方法, 例如 uc1.DrawOutline(), uc2.DrawBorder() 和 uc3.DrawFrame()。

現在, 假設我打算在某種情況下讓不同的 User Control 繪製外框, 那麼我可以撰寫如下的方法:

public void DoSomething(UserControl uc)
{
    switch (uc.GetType().Name)
    {
        case "uc1":
            uc.DrawOutline();
            break;
        case "uc2":
            uc.DrawBorder();
            break;
        case "uc3":
            uc.DrawFrame();
            break;
    }
}

不過, 畢竟這種寫法並不是很精簡。如果你懂得運用 interface 的話, 那麼我們可以先建立一個 interface 如下:

public interface IUC
{
    void DrawBorder();
}

接著, 在三個 User Control 程式中讓它實作 IUC, 並且把原來不同的方法名稱通通改成 DrawBorder():

public partial class uc1 : IUC
{
    public void DrawBorder()
    {
          ...
    }
}

如此一來, 我們就可以把 DoSomething() 方法精簡如下: 

public void DoSomething(IUC iuc)
{
    iuc.DrawBorder();
}

其實把 interface 導進來的目的並不是讓程式精簡或者邏輯封裝而已, 主要是為了讓程式的彈性更大。導入 interface 之後, 未來不管你如何增加或刪除 User Control 的數目, 以上幾個程式都不必再去動它。但是如果你不採用 interface 的話, 每當你變動了任何一個 User Control, 你的其它部份的程式就必須特別注意是否有些什麼地方一定要記得去改, 否則就有可能出現很難除錯的問題。

當我們要建立 interface 型別時, 請特別留意它與類別的差異:

  1. 類別只可以繼承自一個父類別 (不可多重繼承), 但是卻可以同時實作 (implement) 很多個 interface (數目沒有上限, 端看 compiler 可承受多少)
  2. 類別 (即使是抽象類別) 裡可以實作程式邏輯, 在 interface 裡不行 (只能宣告)
  3. 在 interface 裡可以包含屬性 (properties), 方法 (methods), 索引 (indexers), 以及事件 (events), 但不能宣告變數 (fields) 和常數 (constants); 類別裡全部可以
  4. 不可以在 interface 裡使用存取修飾詞 (private, internal, public 等), 也沒有意義

此外, 我們通常認為 interface 可以用來製定程式之間的合約 (contract), 但是也有人持不同的見解, 認為 interface 僅僅約束語法 (syntax) 而已; 製定合約還是得靠基底類別的繼承才辦得到。

此外, 請特別留意 interface 的另一種使用方法, 稱為「外顯介面實作」(Explicit Interface Implementation)。意思是如果我們在實作一個 interface 時採用外顯式 (explicit) 宣告的方法, 那麼我們將不可以對於一個類別物件呼叫這個方法, 而只能對該類別的 interface 物件呼叫這個方法。或許你看不懂以上這一句到底在講什麼, 我們先來看看下面這個例子。

承上例, 當我們在 uc1 類別中實作 DrawBorder() 方法時, 原來的寫法如下: 

public void DrawBorder()
{
      ...
}

如果我們不這麼寫, 而寫成如下的樣子呢?

public partial class uc1 : IUC
{
    void IUC.DrawBorder() // 注意這裡多了 "IUC.", 而且不能加 public 修飾詞
    {
          ...
    }
}

採用這樣的宣告方式, 就表示我們對這裡的 DrawBorder() 方法採用了「外顯式」的介面實作。

當我們使用外顯式宣告的方法, 就不能再使用原來的使用方法了。例如以下的程式會被編譯器判定為錯誤:

uc1 uc = new uc1();
uc.DrawBorder();

為什麼會錯誤呢? 因為這裡的 uc 是 uc1 類別的實體。你不能在一個類別實體中呼叫 interface 的外顯方法。你必須改用以下的寫法:

IUC uc = new uc1();
uc.DrawBorder();

經由外顯式的方法宣告, 你將可以實作兩種具有相同方法名稱 (例如都叫做 DrawBorder) 的不同 interface, 並進而達到多型的目的。或許你會懷疑這種用法到底有什麼用? 我就來舉個例子吧! 例如, 如果我要寫一個會計程式, 那麼裡面有同樣都叫做 pay() 的方法, 我如果對員工做 pay() 的動作, 表示是要發薪水或加給; 如果對廠商做 pay() 的動作, 則表示是要付清款項。所以, 雖然同樣是做付錢的動作, 我在背後的處理邏輯 (例如會計科目的歸類、請付款日期、付款方式等等) 都會隨之不同。

如果使用 interface 的話, 我們就可以訂一個 IEmployee 和 IVendor 兩種 interface, 裡面同樣都定義了 pay() 這個方法, 但我們在這個會計程式的類別中同時實作以上這兩種 interface, 並且分開實作其 pay() 方法:

public class Accounting : IEmployee, IVendor
{
    ...
    void IEmployee.pay()
    { ... }
    void IVendor.pay()
    { ... }
    ...
}

如此, 當我們以 IEmployee 型別來宣告一個 Accounting 類別的實體時, 若呼叫 pay() 方法, 會執行到 IEmployee 的  pay() 方法; 當我們以 IVendor 型別來宣告一個 Accounting 類別的實體時, 若呼叫 pay() 方法, 會執行到 IVendor 的  pay() 方法:

IEmployee acct = new Accounting();
acct.pay(); // 呼叫 IEmployee 的 pay() 方法

IVendor acct = new Accounting();
acct.pay(); // 呼叫 IVendor的 pay() 方法

在很多情況之下, 外顯式 interface 方法宣告是一種很有用、很方便的技巧, 可別忘了喔!

實作轉換運算子

如果我們需要將自訂的型別轉換成其它型別時, 我們可以自訂轉換邏輯。例如, 我們可以將 employee 型別明確的轉換成 string 型別, 但是我們必須實作轉換運算子 (Conversion Operators):

public static explicit operator string(employee emp)
{
    return emp.name;
}

在上述程式中, 我們將把 employee 型別轉換成 string 型別。在這裡, 我是把 employee.name 取出來, 作為轉換後的值。你不一定要這麼做; 你也可以把 return emp.name; 改成 return "Employee: " + emp.name; 或者 return "Employee #" + emp.id.ToString() + ": " + emp.name; 視你自己的需要而定。

這種轉換運算子並不是只能有一個; 你可以隨你高興多寫幾個以轉換至不同的其它型別, 例如:

public static explicit operator string(employee emp)
{
    return emp.name;
}
public static explicit operator int(employee emp)
{
    return emp.id;
}

指定轉換邏輯之後, 我們就可以使用 (string) 或 (int) 以明確的轉換其型別了, 如下範例所示:

employee[] employees = new employee[]
{
    new employee() {id=1, name="Johnny"},
    new employee() {id=2, name="Grace"},
    new employee() {id=0, name="Jolan"}
};
foreach (employee emp in employees)
{
    Debug.WriteLine("Object serialized to " + (string)emp);
    Debug.WriteLine("ID: " + ((int)emp).ToString());
}

那麼, 既然可以從 employee 型別轉換成其它型別, 那麼能不能從其它型別轉回來 employee 型別呢? 能! 方法一樣, 如下例:

public static explicit operator employee (int id)
{
    employee emp = new employee();
    emp.id = id;
    return emp;
}

使用的方法也是大同小異:

employee emp1 = (employee)1;
Debug.WriteLine("New employee: ID = " + emp1.id.ToString());

不過問題來了。照以上的寫法, 除了可以證明我們能這麼做之外, 在實質上的意義並不大。一般而言, 除非你是為了把客製化 (複雜) 物件做 Serialization/Deserialization (我相信會來看入門文章的人應該不會對這個話題感到太大的興趣), 否則把其它型別轉回來自訂型別, 並不會帶給你任何幫助。不管如何, 我想你暫時知道有這麼回事就行了。

示範了「明確」型別轉換的方法之後, 那麼有沒有「不明確」的型別轉換呢? 有的! 在上面範例中我們使用了 explicit 關鍵字, 若把它換成 implicit 這個字, 就變成了「不明確」的轉換了, 如下例:

public static implicit operator string(employee emp)
{
    return emp.name;
}

以上這段程式和前面的明確轉換型別的程式只差別在 explicit 和 implicit 這個字而已, 其它語法一模一樣。

不過如果把 implicit 翻成「不明確」, 這個詞就非常不精準了 (也不正確)。最起碼, 我們可以把它稱為「非明確」。一般比較常用的翻譯稱為「隱含」; 這種轉換方式在這裡就叫做「隱含轉換」, 意思就是不需要明確的對它進行 cast 也可以自動以預設方式轉換。

如果我們不在 employee 型別中加入上面那一段程式, 那麼如果我們直接輸出 employee 物件, 結果會是一段很奇怪的文字, 如下例:

employee[] employees = new employee[]
{
    new employee() {id=1, name="Johnny"},
    new employee() {id=2, name="Grace"},
    new employee() {id=0, name="Jolan"}
};
foreach (employee emp in employees)
{
    Debug.WriteLine(emp);
}

執行上述程式後, 你會看到像 "page1+employee" 之類的莫名其妙的輸出。這是因為程式去呼叫了 object 的隱含轉換, 而它會輸出這個型別的型別名稱。但是如果你把 implicit operator 那一段程式加了上去, 那麼上述程式的輸出就會變成如預期的不同的 employee.name 那三個字串了。

不過如果你把以上程式全部放在一起, 你可能會看到編譯器出現的錯誤。你不能既實作了 string 的 explicit 轉換子, 又實作了 string 的 implicit 轉換子; 你可能必須在兩者之間做一個選擇。

或許你看到這裡, 心裡會產生很多疑問。我想那是因為你還沒有實際執行過上面那幾行程式的關係。你只要實際執行一下, 再觀察其結果, 我想你很快就能心領神會。畢竟其它型別也都是這麼使用的, 你天天在使用時, 應該不覺得難吧?

那麼, 我到底為什麼要在自訂型別中介紹轉換運算子呢? 這是因為很多人不知道怎麼撰寫轉換運算子, 或者不知道它的好用之處。但透過型別轉換, 雖然只需要花一點小工夫, 卻可以讓我們在寫作程式時獲得小小的方便。如果這裡省下幾分鐘, 那裡省下幾分鐘, 最後加總起來卻能省下鉅額的時間, 如此它的代價就會顯得十分值得了。

符號運算子

其實在 .NET 中沒有什麼「符號運算子」這種東西, 我只是把它拿來跟前面的轉換運算子作區隔、幫助大家了解而已。正確的講, 我現在要介紹的東西在其它地方都叫做「運算子」(Operator)。

以下我們先來看看簡單的範例:

public static employee operator ++(employee emp)
{
    employee newEmp = new employee();
    newEmp.id = emp.id++;
    return newEmp;
}

在以上這個範例中, 我定義了 employee 型別的 ++ 符號運算子; 它的傳回型別必須是同一個類別或其衍生類別, 在這裡一樣是 employee; 其參數則當然必須是該類別。

如範例, 在加上 ++ 運算子之後, 我就可以使用 employee++ 來取得一個 id 為傳入的 employee 物件的 id 再加上 1 的新的 employee 物件了:

employee emp1 = (employee)1;
Debug.WriteLine("emp1's sibling: " + (emp1++).id.ToString());

以上範例是只有一個引數的情況; 我們也可以使用兩個引數, 但最多就是兩個, 不能再多 (也沒有必要)。我們在下面的程式五裡面可以看到使用兩個引數的範例程式。

對於這裡的 employee 型別, 符號運算子似乎並不能給我們帶來太多的好處。因為對於複雜的商業物件來說, 簡單的運算並沒有什麼用處。但是你一定會有機會設計簡單的自訂型別, 到時候你就會發現符號運算子的好處了。

到這裡為止, 完整的 employee 型別如下:

// 程式五
public class employee : IDisposable, IComparable
{
    public int id { get; internal set; }
    public string password { private get; set; }
    public string name { get; set; }
    public string title { get; set; }
    public int? salary { get; set; }
    public DateTime? birthday { get; set; }
    public int? ageSoFar
    {
        get
        {
            return (birthday == null) ?
                null :
                (int?)DateTime.Now.Year - ((DateTime)birthday).Year;
        }
    }
    public void Dispose() { }
    public int CompareTo(object emp)
    {
        return id.CompareTo(((employee)emp).id);
    }

    //public static explicit operator string(employee emp)
    //{
    //    return emp.name;
    //}
    public static explicit operator int(employee emp)
    {
        return emp.id;
    }
    public static explicit operator employee (int id)
    {
        employee emp = new employee();
        emp.id = id;
        return emp;
    }

    public static implicit operator string(employee emp)
    {
        return emp.name;
    }

    public static employee operator ++(employee emp)
    {
        employee newEmp = new employee();
        newEmp.id = emp.id++;
        return newEmp;
    }

    public static employee operator +(employee emp1, employee emp2)
    {
        employee newEmp = new employee();
        newEmp.id = emp1.id+emp2.id;
        return newEmp;
    }

    public int? ageTill(DateTime then)
    {
        return (birthday == null || then == null) ?
            null :
            (int?)((DateTime)then).Year - ((DateTime)birthday).Year;
    }
}

支援泛型

泛型 (Generics) 是 .Net Framework 2.0 才開始提供的功能, 它允許你在程式中使用設計時期尚無法決定的型別。嚴格來講, 我並不是鼓勵你應該在你的自訂型別加上對泛型的支援 (雖然你的確是可以這樣做的, 只是意義並不是很大)。在實務上, 我們並不會特別在單純拿來作為型別用的自訂型別中有支援泛型的必要。但是所謂泛型就是「廣泛的支援各種型別」的意思, 其中當然包括你的自訂型別在內。所以我們最好還是多多了解它一下。

當你對 .Net 有更深的體會之後, 我相信你一定會愛上泛型這個功能。為什麼呢? 因為真的太方便好用了。那麼, 應該怎麼做, 才能在你的類別中加入對泛型的支援呢? 我們先來看看以下的類別程式:

using System;
using System.Net;
using System.Collections.Generic;

namespace Johnny
{
    public class myClass<T>
    {
        // Constructor
        public myClass() { }

        public List<T> myMethod()
        {
            List<T> data = new List<T>();
            T t = new T();
            data.Add(t);
            return data;
        }
    }
}

請注意, 這個類別已經不能算是我先前所定義的「自訂型別」了, 而應該算是一個具有特定功能的類別。

如果你還沒有接觸過泛型的話, 你可能看不懂上面程式中無所不在的 <T> 是什麼。在支援泛型的類別中, 當我們在類別名稱後面加上 <T> 之後, 這個 T 就代表你將會代入的型別, 所以後面我們下了 T t = new T(); 這行指令時, 並不表示真的有 T 這個型別。

事實上, 對於 myClass 的實際引用方法是這樣的:

myClass<string> mc = new myClass<string>();
List<string> list = mc.myMethod();

當你採用上述寫法時, 就等於是把 MyClass 整個類別中所有的 T 都置換成 string。同樣的, 如果你把 string 改成 int, 就等於是把 MyClass 整個類別中所有的 T 都置換成 int, 依此類推。當然, 如果你有一個自訂型別 myType, 還是一樣適用。

那麼, 為什麼要這麼做呢?

如果你希望在這個 myClass 類別中既能處理 string 型別的資料, 也能處理 int 型別的資料, 甚至於你的自訂型別, 那麼讓 myClass 支援泛型是一個很方便的做法。當然, 也許你會想說使用 Interface 不也能達成相同或相似的目的嗎? 使用繼承的類別也能達成相同或相似的目的, 不是嗎? 是的! 但就像我一再重複強調的, .Net 提供了很多看似類似但其實又不完全一樣的功能來讓你選擇。不管你選擇使用泛型或者 Interface, 或者繼承, 它們都有不同的適用條件, 也有不同的侷限性。視情況選擇不同的工具或做法, 不要太迷信於某種特定或先入為主的做法, 這才是一個聰明的程式設計師應該具備的態度。

如果你還是看不懂的話, 那麼, 在上一段程式中, 如果你以 myClass<string> 去 new 這個類別, 那麼 myMethod() 方法會傳回一個 List<string> 物件; 如果你以 myClass<int> 去 new 這個類別, 那麼 myMethod() 方法會傳回一個 List<int> 物件; 如果你以 myClass<myType> 去 new 這個類別, 那麼 myMethod() 方法則會傳回一個 List<myType> 物件。這樣了解了嗎?

看到這裡為止, 初學者或許在心裡有個疑問: 為什麼一定要使用 "T" 這個字? 其實 T 只是一個代號而已, 就好像我們在數學上經常使 x 或 n 來代表「某一個」未知的值一樣。那為什麼是 T, 而不是 U 還是 W 等等呢? 想當然爾, "T" 就是 "Type" 的縮寫, 暗示你應該代入一個型別, 如此而已, 這其中並沒有太大的學問在裡面。

此外, 雖然說是「泛型」, 我們也可以讓它不要「泛」得太厲害。什麼意思呢? 我們可以限制代入型別的種類。例如, 如果我們把上述範例中的

     public class myClass<T>

這一行寫做如下:

     public class myClass<T>where T : struct

意思就是說我們可以限制代入的型別必須是 Struct, 而不能是類別 或 Enum 等等。

如果我們把它寫做如下:

     public class myClass<T>where T : class

意思就是說我們可以限制代入的型別必須是類別 , 而不能是 Struct 或 Enum 等等。

如果我們把它寫做如下:

     public class myClass<T>where T : myType

意思就是說我們可以限制代入的型別只能是 myType 類別或者其衍生類別 , 而不能是其它。不過這裡的 myType 最好是當作基底類別的一個類別, 否則沒有意義。這是什麼意思呢? 例如, 你有一個自訂型別是 animal, 其下被繼承而衍生出 fish, tiger, chicken 等等子類別; 那麼使用 "where T: animal" 來定義 myClass 之後, 該類別就只能接受 fish, tiger 和 chicken 等等 animal 的子類別。但如果 animal 其下並沒有任何子類別, 未來也不打算衍生任何子類別, 那麼把 myClass 定義成這個樣子是毫無意義的 (因為在此種情況下根本無需使用泛型)。

關於泛型參數的約束條件, 另外還有幾種不同的寫法, 在 MSDN 上面有更詳細的描述, 有興趣的讀者請自行參考。

 

沒有留言:

張貼留言