OOP的類型其實可以用這樣的等式:資料 + 方法,資料決定類的屬性,方法決定類的行為。方法在類型設計中至關重要,因為它決定了該類的功能。
C#的方法除了擁有一般OOP語言都會有的構造器方法外,還具有C++的操作符重載方法,而且它本身也有自己的特有方法:轉換操作符方法,擴充方法和分部方法。
我們先來瞭解一下構造器方法。
1.構造器方法
C#的構造器和java是一樣的,主要的作用就是初始化成員變數,就連構造器的載入順序也是一樣,只是有些地方的說法不同。
我們都知道,抽象類別的預設構造器存取權限是protected,而一般類都是public(記住,是預設構造器,如果是自訂構造器,請一定要寫上public)。類的執行個體構造器在訪問從基類繼承來的欄位前,必須調用基類的構造器來初始化這些欄位。若衍生類別沒有顯式的調用基類的構造器,編譯器也會自動產生對基類構造器的調用,這時就要求基類要有預設構造器。因為這個原因,所以我們千萬不要在基類構造器中調用會影響所構造對象的任何虛方法,因為如果該方法在當前要執行個體化的衍生類別中進行了覆寫,就會調用衍生類別的方法,但這時衍生類別的欄位還沒有被初始化(因為衍生類別的構造器還沒有被調用),這就會出現問題。
我們經常用內聯(inline)的方式來初始化執行個體欄位,所謂的內聯其實就是直接賦值,因為在儲存中是嵌入到該對象的記憶體中,所以說是內聯。它們真正的初始化也是發生在執行個體構造器被調用的時候,編譯器會把它們作為構造器中的代碼執行。
有時候,我們構造器可能會有很多重載版本(可能就是為了修改欄位),這時最好的方法就不是用內聯的方式初始化欄位,而是放在一個預設構造器中,然後讓其他構造器調用它,這樣就可以減少代碼量(當然,我們看不到它們,但是編譯器會將這些欄位的初始化動作都放在每個構造器中!)。像是這樣:
class People{ private String _Name; People(){ this._Name = "人"; } public People(String name) : this(){ _Name = "男人"; }}
調用預設構造器的方法和java一樣,都是this(),但位置不一樣。
實值型別也有自己的構造器,就是結構。我很不想說結構,因為我認為我不會經常使用結構,但還是必須說明一下。C#不允許我們為實值型別聲明一個預設構造器,因為編譯器不會為實值型別自動調用構造器,除非我們顯式的調用構造器。
struct Pratice { private String _Name; public Pratice(String name) { this._Name = name; } } class Program { public static void Main(String[] args) { Pratice pratice = new Pratice("人"); } }
因為結構不能有預設構造器,所以我們不能在結構中用內聯的方式初始化欄位(但靜態欄位可以)。但訪問結構的欄位前,我們必須保證欄位都被正確初始化,所以我們需要自訂構造器來初始化這些欄位。這樣我們會很煩,因為有時候,我們只想要在結構中儲存一些實值型別,至於它的值,預設值就可以了,但我們還是要一一初始化。幸好我們還是有一個方法可以避免這種事情發生:
struct Pratice { private String _Name; public Pratice(String name) { this = new Pratice(); } }
this表示結構的執行個體,使用new就可以自動價格欄位初始化為預設值。但這種方法不適合類,因為this引用預設是唯讀,不能被賦值。
我們上面討論的是執行個體構造器,C#還有類型構造器(type constructor),即靜態構造器。使用關鍵字static,可應用於除了介面以外的類型,作用就是設定類型的初始化狀態。執行個體構造器是佘竹執行個體的初始化狀態,而類型構造器則是設定類型的初始化狀態,這兩者有何區別呢?答案已經從名字中看出來了,類型構造器用於初始化靜態欄位。一個類型,只有一個類型構造器,並且在類被首次訪問時,先執行類型構造器,而且只執行一次(java的靜態域同樣是在類被首次載入的時候初始化,這是同樣的道理)。類型構造器沒有參數,預設是private,而且不能有任何修飾符,就算我們想要顯式的指定private也不行。這樣做就是為了防止任何程式員調用它,CLR會想盡所有辦法來阻止這種事發生。實值型別雖然也能定義靜態構造器,但最好不要這樣做,因為CLR無法保證它們一定會被調用,最普遍的情況就是永遠都不會被調用。
CLR保證類型構造器是安全執行緒的,所以它也很適合用於初始化單例對象(想知道什麼是單例對象,請看設計模式---單例模式 )。單個線程中,絕對不能讓兩個類型構造器互相引用,那樣會出現問題,有可能另一個構造器還沒有執行完畢的時候就已經被人調用了。類型構造器最好不要調用基類的構造器,因為幾乎不可能會從基類中繼承靜態欄位。
類型構造器對編譯器來說,是個不小的麻煩,因為它除了決定是否要產生代碼來調用它(大部分的程式很少會有類型構造器),還要決定應該將這個調用添加到哪個位置。有兩種可能:
1.編譯器可以在建立類型的第一個執行個體前,或者在訪問類的一個非繼承欄位或成員前產生這個調用。這種行為就稱為精確(precise)語義,因為調用時機恰到好處。
2.編譯器可以在首次訪問一個靜態欄位或者一個靜態/執行個體方法前,或者在調用一個執行個體構造器前,隨便找一個時間產生調用。這稱為欄位初始化前(before-field-init)語義,CLR只保證訪問成員前會運行靜態構造器,至於時間並不確定。這是首選的方式,因為這樣CLR的速度能夠更快。
選擇哪種方式並不是完全交給編譯器,我們也可以由這種選擇權。如果類中沒有顯式構造器,就採用欄位初始化前語義。為什麼顯式的構造器需要精確語義呢?沒有用構造器進行初始化的一般是靜態欄位,什麼時候初始化都沒有問題,但顯式構造器可能包含具有副作用的代碼(所謂的副作用,就像安全執行緒,異常等這些執行代碼可能出現的附加狀態),需要編譯器精確的調用。
2.操作符重載方法和轉換操作符方法
C#中的操作符並沒有什麼特別的東西,但有一個必須注意:^不是用來求冪,而是異或(XOR),C#的數學操作都已經封裝成靜態方法,求冪可以用Math.Pow()。重點肯定不是這個,而是操作符重載方法。
C++就有操作符重載方法,這樣我們就能將操作符應用於自訂類型,而java是沒有的(其實java認為,對類型的操作是對欄位的操作,所以它鼓勵我們更多把重心放在對欄位的操作上,像是擷取/設定欄位值等方法)。CLR規範要求我們把操作符重載方法設為public static,對於參數,C#要求至少有一個參數的類型與當前定義這個方法的類型相同。這是很重要的,畢竟我們會選擇在該類型中重載該方法,就是因為我們想要對該類型的欄位進行操作,而且也方便編譯器快速找到要綁定的方法。
一般的操作符重載方法像是這樣:
public sealed class People { private int number = 5; public static int operator +(People p1, People p2) { return p1.number + p2.number; } } class Program { public static void Main(String[] args) { People p1 = new People(); People p2 = new People(); Console.WriteLine(p1 + p2); } }
操作符重載方法在C++中非常常見,因為我們經常需要覆寫"="的操作符重載方法和拷貝建構函式(深複製和淺複製的問題)。但接下來就是新的東西:轉換操作符方法。
轉換操作符設計到類型轉換,是把一個類型轉換為截然不同的類型。為什麼要這樣做呢?這是我們的第一個疑問,畢竟語言本身就提供了類型轉換機制,而且相對安全。理由非常簡單,像是XML檔案的惡操作,就使得我們經常需要這樣做,而且就算是數實值型別,我們也會自訂一個有理數的類型來包含其他實值型別,也需要與其他實值型別的轉換功能。
我們來看一個例子:
public sealed class People { private int number = 5; public People(int number) { this.number = number; } public int ToInt32() { return this.number; } //有一個int隱式構造並返回一個People
public static implicit operator People(int number) { return new People(number); }
//由一個People顯式返回一個int public static explicit operator Int32(People people) { return people.ToInt32(); } } class Program { public static void Main(String[] args) { People people = 5; int number = (int)people; Console.WriteLine(number); } }
現在不僅能將5賦值給People,而且還能將People轉型為int!
要實現這樣的功能,我們需要一個構造器,該構造器接受想要轉換的目標類型作為參數,接著是一個ToXXX()方法,該方法能夠返回目標類型,然後就是重點戲:
實現顯式轉換和隱式轉換的方法,它們內部調用的就是我們剛才定義的方法。這就是轉換操作符方法,implicit關鍵字告訴編譯器產生代碼來調用方法,不需顯式轉換,而explicit則在發現顯式轉型時,才調用方法。
看到這裡,不知大家是否會想起C#也有用於轉型操作的is和as操作符,它們永遠也不會調用這兩個方法,而且也不會報錯,類型不對只要返回false和null就行。
仔細看這兩個方法,我們就會發現一個問題:轉換操作符方法其實都是圍繞著我們自訂的類型:將自訂類型顯式轉換為其他類型,或將其他類型隱式的轉換為自訂類型。我們無法將自訂類型隱式的轉換為其他類型,或者將其他類型顯式的轉換為自訂類型。這是有道理的,如果允許其他類型,像是int轉換為People,我們就必須在int的源碼中修改,但這是不可能的,而且容易發生嚴重的錯誤。放寬限制是必須的,但不能完全放開。從這裡例子我們還可以瞭解到,類型轉換其實也是方法的調用,像是People people = 5,就是調用implicit operator People(int),想要進行轉換的類型作為方法參數,而目標類型就是我們的傳回值。
如果說轉換操作符還不能使我跌破眼鏡的話,那麼擴充方法就真的讓我一下子反應不過來,對於我這個java人來說,從來沒有想過我們也會需要用執行個體方法的方式調用靜態方法,因為根本沒有這樣的需求!
3.擴充方法
擴充方法允許我們定義一個靜態方法,並用執行個體方法的文法來調用它。這違背了我們過往的認識:靜態方法根本不與對象執行個體掛鈎。撇開這樣做的原因,我們先來看看怎樣實現這個古怪的用法:
public static class People { public static void Show(this String name, int number) { Console.WriteLine(number); } } class Program { public static void Main(String[] args) { "人".Show(5); } }
請大家無視我這裡奇怪的調用,我們來研究下裡面的原理。編譯器看到這樣的代碼,就會去尋找String類型有沒有Show()方法,如果沒有,才會去尋找靜態類中是否也有Show()方法,而且該方法的第一個參數必須是調用該方法的類型一樣,而且這個類型必須是用this關鍵字標識。
問題來了,而且非常重要:我怎麼知道String類型有這樣一個方法?畢竟它根本就不在String的源碼中。我們只能依靠Visual Studio的智能感知視窗,就是Eclipse的工具提示,它會在我們寫下"."的時候,有一系列可調用的方法,然後在這些方法的說明中還會特別註明是擴充方法(請注意,它彈出的是副檔名,這是翻譯錯誤的問題,應該是擴充方法)。
擴充方法只能在非泛型的靜態類中聲明,參數也必須一個以上,靜態類必須要有整個檔案範圍,像是嵌套類就不適合了。擴充方法的尋找是很慢的,所以編譯器的建議是我們程式員主動匯入該類,這樣也是為了避免找錯方法,畢竟編譯器是在所有靜態類中尋找,很難保證其他靜態類不會具有同名的擴充方法,事實就是多個靜態類可以同時定義相同的擴充方法。事實上,這樣的情況我們最好的辦法就是指定該靜態類,但這樣就與靜態方法的調用沒有什麼區別。更加可怕的是,大部分程式員是不知道這樣的擴充方法,就算是C#程式員,也不會主動去找這樣的方法。最可怕的事情就是,如果該類被派生的話,它的衍生類別都會有該擴充方法!像是System.Object要是擁有哪怕一個擴充方法,我們就會發現,在智能感知視窗有大量的垃圾資訊!版本控制問題也是非常麻煩的問題:如果將來有人為我的People添加Show()執行個體方法,該擴充方法就完全廢了!
擴充方法具有這麼多的弊端,但是很多類型,像是介面類型,委託類型和枚舉類型都支援該特性,委託甚至能夠委託擴充方法。下面就是委託和介面類型使用擴充方法的樣本:
public static void Show(this IEnumerable<T> collection){}Action action = "人".Show(5);
擴充方法違背了我們的使用直覺,我甚至不認為我需要用到這東西,因為它讓我們的方法調用會變得麻煩,就算它根本就不會覆寫同名方法。為什麼我使用一個靜態方法非要這麼麻煩呢?C#的設計者是不會隨便添加一個無用的特定,它自然在該語言中會有它的用處,可能我現在根本就沒法發覺吧,還請大神指教。
4.分部方法
分部方法是本文最後一個出場的,它主要是為瞭解決一個問題:我們想要特化工具產生的源碼檔案(工具產生源碼在C#中很常見,像是ASP.Net就是極佳的例子)。如果是過去的我們,就會這樣做:繼承自該源碼然後覆寫它的虛方法。但這樣會有兩個問題:
1.該技術不能用於密封類,也不能用於實值型別(實值型別隱式密封);
2.效率問題,定義一個類型,竟然只是為了覆寫一個虛方法!你在開玩笑吧!!相信編譯器一定會這樣大喊的。
分部方法就能解決這樣的問題。分部方法只能在部分類別或結構中聲明。一般都是在基類中聲明一個用partial標識的沒有方法體的方法(就像abstract方法),然後在我們的類中實現該方法,當然也要用partial標識。分部方法傳回值必須是void,因為我們無法確定該方法是否存在,可能永遠不會有人去實現它。任何參數都不能用out修飾符,那時因為方法必須初始化它但方法的是否存在還是一個問題。但分部方法可以有ref參數,也可以是泛型方法,執行個體或靜態方法,而且可以標識為unsafe。分部方法總是被視為private,但奇怪的是,C#編譯器禁止我們顯式聲明private。
Visual Studio在我們輸入partial並按空格的時候,智能感知視窗會列出當前類型定義的,還沒有實現的所有分部方法聲明,這樣可以死方便我們選擇,而且還有自動產生方法原型。可見,MiscroSoft在提高程式員編程效率方面下了很大的功夫,至少比起他以前要厚道得多了。