一、物件導向的實現
1.1 C#中的類可以多繼承嗎?
在C#中申明一個型別時,只支援單繼承(即繼承一個父類),但支援實現多個介面(Java也是如此)。像C++可能會支援同時繼承自多個父類,但.NET的設計小組認為這樣的機制會帶來一些弊端,並且沒有必要。
首先,看看多繼承有啥好處?多繼承的好處是更加貼近地設計型別。例如,當為一個圖形編輯器設計帶文字框的矩形型別時,最方便的方法可能是這個型別既繼承自文字框型別,又繼承自矩形型別,這樣它就天生地具有輸入文字和繪畫矩形的功能。But,自從C++使用多繼承依賴,就一直存在一些弊端,其中最為嚴重的還是所謂的“磚石繼承”帶來的問題,下圖解釋了磚石繼承問題。
如上圖所示,磚石繼承問題根源在於最終的子類從不同的父類中繼承到了在它看來完全不同的兩個成員,而事實上,這兩個成員又來自同一個基類。鑑於此,在C#/Java中,多繼承的機制已經被徹底拋棄,取而代之的是單繼承和多介面實現的機制。眾所周知,介面並不做任何實際的工作,但是卻制定了介面和規範,它定義了特定的型別都需要“做什麼”,而把“怎麼做”留給實現它的具體型別去考慮。也正是因為介面具有很大的靈活性和抽象性,因此它在物件導向的程式設計中更加出色地完成了抽象的工作。
1.2 C#中重寫、過載和隱藏是什麼鬼?
在C#或其他面嚮物件語言中,重寫、過載和隱藏的機制,是設計高可擴充套件性的物件導向程式的基礎。
(1)重寫和隱藏
重寫(Override)是指子類用Override關鍵字重新實現定義在基類中的虛方法,並且在實際執行時根據物件型別來呼叫相應的方法。
隱藏則是指子類用new關鍵字重新實現定義在基類中的方法,但在實際執行時只能根據引用來呼叫相應的方法。
以下的程式碼說明了重寫和隱藏的機制以及它們的區別:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
public class Program { public static void Main(string[] args) { // 測試二者的功能 OverrideBase ob = new OverrideBase(); NewBase nb = new NewBase(); Console.WriteLine(ob.ToString() + ":" + ob.GetString()); Console.WriteLine(nb.ToString() + ":" + nb.GetString()); Console.WriteLine(); // 測試二者的區別 BaseClass obc = ob as BaseClass; BaseClass nbc = nb as BaseClass; Console.WriteLine(obc.ToString() + ":" + obc.GetString()); Console.WriteLine(nbc.ToString() + ":" + nbc.GetString()); Console.ReadKey(); } } // Base class public class BaseClass { public virtual string GetString() { return "我是基類"; } } // Override public class OverrideBase : BaseClass { public override string GetString() { return "我重寫了基類"; } } // Hide public class NewBase : BaseClass { public new virtual string GetString() { return "我隱藏了基類"; } } |
以上程式碼的執行結果如下圖所示:
我們可以看到:當通過基類的引用去呼叫物件內的方法時,重寫仍然能夠找到定義在物件真正型別中的GetString方法,而隱藏則只呼叫了基類中的GetString方法。
(2)過載
過載(Overload)是擁有相同名字和返回值的方法卻擁有不同的引數列表,它是實現多型的立項方案,在實際開發中也是應用得最為廣泛的。常見的過載應用包括:構造方法、ToString()方法等等;
以下程式碼是一個簡單的過載示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
public class OverLoad { private string text = "我是一個字串"; // 無引數版本 public string PrintText() { return this.text; } // 兩個int引數的過載版本 public string PrintText(int start, int end) { return this.text.Substring(start, end - start); } // 一個char引數的過載版本 public string PrintText(char fill) { StringBuilder sb = new StringBuilder(); foreach (var c in text) { sb.Append(c); sb.Append(fill); } sb.Remove(sb.Length - 1, 1); return sb.ToString(); } } public class Program { public static void Main(string[] args) { OverLoad ol = new OverLoad(); // 傳入不同引數,PrintText的不同過載版本被呼叫 Console.WriteLine(ol.PrintText()); Console.WriteLine(ol.PrintText(2,4)); Console.WriteLine(ol.PrintText('/')); Console.ReadKey(); } } |
執行結果如下圖所示:
1.3 為什麼不能在構造方法中呼叫虛方法?
在C#程式中,構造方法呼叫虛方法是一個需要避免的禁忌,這樣做到底會導致什麼異常?我們不妨通過下面一段程式碼來看看:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
// 基類 public class A { protected Ref my; public A() { my = new Ref(); // 構造方法 Console.WriteLine(ToString()); } // 虛方法 public override string ToString() { // 這裡使用了內部成員my.str return my.str; } } // 子類 public class B : A { private Ref my2; public B() : base() { my2 = new Ref(); } // 重寫虛方法 public override string ToString() { // 這裡使用了內部成員my2.str return my2.str; } } // 一個簡單的引用型別 public class Ref { public string str = "我是一個物件"; } public class Program { public static void Main(string[] args) { try { B b = new B(); } catch (Exception ex) { // 輸出異常資訊 Console.WriteLine(ex.GetType().ToString()); } Console.ReadKey(); } } |
下面是執行結果,異常資訊是空指標異常?
(1)要解釋這個問題產生的原因,我們需要詳細地瞭解一個帶有基類的型別(事實上是System.Object,所有的內建型別都有基類)被構造時,所有構造方法被呼叫的順序。
在C#中,當一個型別被構造時,它的構造順序是這樣的:
執行變數的初始化表示式 → 執行父類的構造方法(需要的話)→ 呼叫型別自己的構造方法
我們可以通過以下程式碼示例來看看上面的構造順序是如何體現的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
public class Program { public static void Main(string[] args) { // 構造了一個最底層的子類型別例項 C newObj = new C(); Console.ReadKey(); } } // 基類型別 public class Base { public Ref baseString = new Ref("Base 初始化表示式"); public Base() { Console.WriteLine("Base 構造方法"); } } // 繼承基類 public class A : Base { public Ref aString = new Ref("A 初始化表示式"); public A() : base() { Console.WriteLine("A 構造方法"); } } // 繼承A public class B : A { public Ref bString = new Ref("B 初始化表示式"); public B() : base() { Console.WriteLine("B 構造方法"); } } // 繼承B public class C : B { public Ref cString = new Ref("C 初始化表示式"); public C() : base() { Console.WriteLine("C 構造方法"); } } // 一個簡單的引用型別 public class Ref { public Ref(string str) { Console.WriteLine(str); } } |
除錯執行,可以看到派生順序是:Base → A → B → C,也驗證了剛剛我們所提到的構造順序。
上述程式碼的整個構造順序如下圖所示:
(2)瞭解完產生本問題的根本原因,反觀虛方法的概念,當一個虛方法被呼叫時,CLR總是根據物件的實際型別來找到應該被呼叫的方法定義。換句話說,當虛方法在基類的構造方法中被呼叫時,它的型別讓然保持的是子類,子類的虛方法將被執行,但是這時子類的構造方法卻還沒有完成,任何對子類未構造成員的訪問都將產生異常。
如何避免這類問題呢?其根本方法就在於:永遠不要在非葉子類的構造方法中呼叫虛方法。
1.4 C#如何宣告一個類不能被繼承?
這是一個被問爛的問題,在C#中可以通過sealed關鍵字來申明一個不可被繼承的類,C#將在編譯階段保證這一機制。但是,繼承式OO思想中最重要的一環,但是否想過繼承也存在一些問題呢?在設計一個會被繼承的型別時,往往需要考慮再三,下面例舉了常見的一些型別被繼承時容易產生的問題:
(1)為了讓派生型別可以順利地序列化,非葉子類需要實現恰當的序列化方法;
(2)當非葉子類實現了ICloneable等介面時,意味著所有的子類都被迫需要實現介面中定義的方法;
(3)非葉子類的構造方法不能呼叫虛方法,而且更容易產生不能預計的問題;
鑑於以上問題,在某些時候沒有派生需要的型別都應該被顯式地新增sealed關鍵字,這是避免繼承帶來不可預計問題的最有效辦法。
二、異常的處理
2.1 如何針對不同的異常進行捕捉?
相信閱讀本文的園友都已經養成了try-catch的習慣,但對於異常的捕捉和處理可能並不在意。確實,直接捕捉所有異常的基類:Exception 使得程式方便易懂,但有時這樣的捕捉對於業務處理沒有任何幫助,對於特殊異常應該採用特殊處理能夠更好地引導規劃程式流程。
下面的程式碼演示了一個對於不同異常進行處理的示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
public class Program { public static void Main(string[] args) { Program p = new Program(); p.RiskWork(); Console.ReadKey(); } public void RiskWork() { try { // 一些可能會出現異常的程式碼 } catch (NullReferenceException ex) { HandleExpectedException(ex); } catch (ArgumentException ex) { HandleExpectedException(ex); } catch (FileNotFoundException ex) { HandlerError(ex); } catch (Exception ex) { HandleCrash(ex); } } // 這裡處理預計可能會發生的,不屬於錯誤範疇的異常 private void HandleExpectedException(Exception ex) { // 這裡可以藉助log4net寫入日誌 Console.WriteLine(ex.Message); } // 這裡處理在系統出錯時可能會發生的,比較嚴重的異常 private void HandlerError(Exception ex) { // 這裡可以藉助log4net寫入日誌 Console.WriteLine(ex.Message); // 嚴重的異常需要拋到上層處理 throw ex; } // 這裡處理可能會導致系統崩潰時的異常 private void HandleCrash(Exception ex) { // 這裡可以藉助log4net寫入日誌 Console.WriteLine(ex.Message); // 關閉當前程式 System.Threading.Thread.CurrentThread.Abort(); } } |
(1)如程式碼所示,針對特定的異常進行不同的捕捉通常很有意義,真正的系統往往要針對不同異常進行復雜的處理。異常的分別處理是一種好的編碼習慣,這要求程式設計師在編寫程式碼的時候充分估計到所有可能出現異常的情況,當然,無論考慮得如何周到,最後都需要對異常的基類Exception進行捕捉,這樣才能保證所有的異常都不會被隨意地丟擲。
(2)除此之外,除了在必要的時候寫try-catch,很多園友更推薦使用框架層面提供的異常捕捉方案,以.NET為例:
- WinForm,可以這樣寫:AppDomain.CurrentDomain.UnhandledException +=new UnhandledExceptionEventHandler(UnhandledExceptionFunction);
- ASP.NET WebForm,可以在Application_Error()方法裡捕獲異常
- ASP.NET MVC,可以寫ExceptionFilter
- ASP.NET WebAPI,可以寫ExceptionHandler
2.2 如何使用Conditional特性?
大家都知道,通常在編譯程式時可以選擇Bebug版本還是Release版本,編譯器將會根據”除錯“和”釋出“兩個不同的出發點去編譯程式。在Debug版本中,所有Debug類的斷言(Assert)語句都會得到保留,相反在Release版本中,則會被通通刪除。這樣的機制有助於我們編寫出方便除錯同時又不影響正式釋出的程式程式碼。
But,單純的診斷和斷言可能並不能完全滿足測試的需求,有時可能會需要大批的程式碼和方法去支援除錯和測試,這個時候就需要用到Conditional特性。Conditional特性用於編寫在某個特定版本中執行的方法,通常它編寫一些在Debug版本中支援測試的方法。當版本不匹配時,編譯器會把Conditional特性的方法內容置為空。
下面的一段程式碼演示了Conditional特性的使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
//含有兩個成員,生日和身份證 //身份證的第6位到第14位必須是生日 //身份證必須是18位 public class People { private DateTime _birthday; private String _id; public DateTime Birthday { set { _birthday = value; if (!Check()) throw new ArgumentException(); } get { Debug(); return _birthday; } } public String ID { set { _id = value; if (!Check()) throw new ArgumentException(); } get { Debug(); return _id; } } public People(String id, DateTime birthday) { _id = id; _birthday = birthday; Check(); Debug(); Console.WriteLine("People例項被構造了..."); } // 只希望在DEBUG版本中出現 [Conditional("DEBUG")] protected void Debug() { Console.WriteLine(_birthday.ToString("yyyy-MM-dd")); Console.WriteLine(_id); } //檢查是否符合業務邏輯 //在所有版本中都需要 protected bool Check() { if (_id.Length != 18 || _id.Substring(6, 8) != _birthday.ToString("yyyyMMdd")) return false; return true; } } public class Program { public static void Main(string[] args) { try { People p = new People("513001198811290215", new DateTime(1988, 11, 29)); p.ID = "513001198811290215"; } catch (ArgumentException ex) { Console.WriteLine(ex.GetType().ToString()); } Console.ReadKey(); } } |
下圖則展示了上述程式碼在Debug版本和Release版本中的輸出結果:
①Debug版本:
②Release版本:
Conditional機制很簡單,在編譯的時候編譯器會檢視編譯狀態和Conditional特性的引數,如果兩者匹配,則正常編譯。否則,編譯器將簡單地移除方法內的所有內容。
2.3 如何避免型別轉換時的異常?
我們經常會面臨一些型別轉換的工作,其中有些是確定可以轉換的(比如將一個子類型別轉為父類型別),而有些則是嘗試性的(比如將基類引用的物件轉換成子類)。當執行常識性轉換時,我們就應該做好捕捉異常的準備。
當一個不正確的型別轉換髮生時,會產生InvalidCastException異常,有時我們會用try-catch塊做一些嘗試性的型別轉換,這樣的程式碼沒有任何錯誤,但是效能卻相當糟糕,為什麼呢?異常是一種耗費資源的機制,每當異常被丟擲時,異常堆疊將會被建立,異常資訊將被載入,而通常這些工作的成本相對較高,並且在嘗試性型別轉換時,這些資訊都沒有意義。
So,在.NET中提供了另外一種語法來進行嘗試性的型別轉換,那就是關鍵字 is 和 as 所做的工作。
(1)is 只負責檢查型別的相容性,並返回結果:true 和 false。→ 進行型別判斷
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public static void Main(string[] args) { object o = new object(); // 執行型別相容性檢查 if(o is ISample) { // 執行型別轉換 ISample sample = (ISample)o; sample.SampleShow(); } Console.ReadKey(); } |
(2)as 不僅負責檢查相容性還會進行型別轉換,並返回結果,如果不相容則返回 null 。→ 用於型別轉型
1 2 3 4 5 6 7 8 9 10 11 12 |
public static void Main(string[] args) { object o = new object(); // 執行型別相容性檢查 ISample sample = o as ISample; if(sample != null) { sample.SampleShow(); } Console.ReadKey(); } |
兩者的共同之處都在於:不會丟擲異常!綜上比較,as 較 is 在執行效率上會好一些,在實際開發中應該量才而用,在只進行型別判斷的應用場景時,應該多使用 is 而不是 as。
參考資料
(1)朱毅,《進入IT企業必讀的200個.NET面試題》
(2)張子陽,《.NET之美:.NET關鍵技術深入解析》
(3)王濤,《你必須知道的.NET》