改善C#程式的50種方法
- 摘要:為什麼程式已經可以正常工作了,我們還要改變它們呢?答案就是我們可以讓它們變得更好。我們常常會改變所使用的工具或者語言,因為新的工具或者語言更富生產力。如果固守舊有的習慣,我們將得不到期望的結果。對於C#這種和我們已經熟悉的語言(如C++或Java)有諸多共通之處的新語言,情況更是如此。人們很容易回到舊的習慣中去。當然,這些舊的習慣絕大多數都很好,C#語言的設計者們也確實希望我們能夠利用這些舊習慣下所獲取的知識。但是,為了讓C#和公共語言執行庫(CommonLanguageRu
-
為什麼程式已經可以正常工作了,我們還要改變它們呢?答案就是我們可以讓它們變得更好。我們常常會改變所使用的工具或者語言,因為新的工具或者語言更富生產力。如果固守舊有的習慣,我們將得不到期望的結果。對於C#這種和我們已經熟悉的語言(如C++或Java)有諸多共通之處的新語言,情況更是如此。人們很容易回到舊的習慣中去。當然,這些舊的習慣絕大多數都很好,C#語言的設計者們也確實希望我們能夠利用這些舊習慣下所獲取的知識。但是,為了讓C#和公共語言執行庫(Common Language Runtime,CLR)能夠更好地整合在一起,從而為面向元件的軟體開發提供更好的支援,這些設計者們不可避免地需要新增或者改變某些元素。本章將討論那些在C#中應該改變的舊習慣,以及對應的新的推薦做法。
C#將屬性從其他語言中的一種特殊約定提升成為一種第一等(first-class)的語言特性。如果大家還在型別中定義公有的資料成員,或者還在手工新增get和set方法,請趕快停下來。屬性在使我們可以將資料成員暴露為公有介面的同時,還為我們提供了在面向物件環境中所期望的封裝。在C#中,屬性(property)是這樣一種語言元素:它們在被訪問的時候看起來好像是資料成員,但是它們卻是用方法實現的。
有時候,一些型別成員最好的表示形式就是資料,例如一個客戶的名字、一個點的x/y座標,或者上一年的收入。使用屬性我們可以建立一種特殊的介面——這種介面在行為上像資料訪問,但卻仍能獲得函式的全部好處。客戶程式碼[1]對屬性的訪問就像訪問公有變數一樣。但實際的實現採用的卻是方法,這些方法內部定義了屬性訪問器的行為。
.NET框架假定我們會使用屬性來表達公有資料成員。事實上,.NET框架中的資料繫結類只支援屬性,而不支援公有資料成員。這些資料繫結類會將物件的屬性關聯到使用者介面控制元件(Web控制元件或者Windows Forms控制元件)上。其資料繫結機制事實上是使用反射來查詢一個型別中具有特定名稱的屬性。例如下面的程式碼:
textBoxCity.DataBindings.Add("Text",address, "City");
便是將textBoxCity控制元件的Text屬性和address物件的City屬性繫結在一起。(有關資料繫結的細節,參見條款38。)如果City是一個公有資料成員,這樣的資料繫結就不能正常工作。.NET框架類庫(Framework Class Library)的設計者們之所以不支援這樣的做法,是因為將資料成員直接暴露給外界不符合面向物件的設計原則。.NET框架類庫這樣的設計策略從某種意義上講也是在推動我們遵循面向物件的設計原則。對於C++和Java程式設計老手,我想特別指出的是這些資料繫結程式碼並不會去查詢get和set函式。在C#中,我們應該忘掉get_和set_這些舊式的約定,而全面採用屬性。
當然,資料繫結所應用的類一般都要和使用者介面打交道。但這並不意味著屬性只在UI(使用者介面)邏輯中有用武之地。對於其他類和結構,我們也需要使用屬性。隨著時間的推移,新的需求或行為往往會影響原來型別的實現,採用屬性比較容易能夠應對這些變化。例如,我們可能很快就會發現Customer型別不能有一個空的Name。如果我們使用一個公用屬性來實現Name,那麼只需要在一個地方做更改即可:
public classCustomer
{
privatestring _name;
publicstring Name
{
get
{
return _name;
}
set
{
if (( value == null ) ||
( value.Length == 0 ))
throw new ArgumentException( "Name cannot be blank",
"Name" );
_name = value;
}
}
// ……
}
如果使用的是公有資料成員,我們就要尋找並修改所有設定Customer的Name的程式碼,那將花費大量的時間。
另外,由於屬性是採用方法來實現的,因此為它們新增多執行緒支援就更加容易——直接在get和set方法中提供同步資料訪問控制即可:
public stringName
{
get
{
lock( this )
{
return _name;
}
}
set
{
lock( this )
{
_name = value;
}
}
}
既然是採用方法來實現的,那麼屬性也就具有了方法所具有的全部功能。
比如,屬性可以實現為虛屬性:
public classCustomer
{
privatestring _name;
publicvirtual string Name
{
get
{
return _name;
}
set
{
_name = value;
}
}
// 忽略其他實現程式碼。
}
自然,屬性也可以實現為抽象屬性,或者作為介面定義的一部分:
publicinterface INameValuePair
{
objectName
{
get;
}
objectValue
{
get;
set;
}
}
最後,我們還可以藉助屬性的特點來建立const和非const版本的介面:
publicinterface IConstNameValuePair
{
objectName
{
get;
}
objectValue
{
get;
}
}
publicinterface INameValuePair
{
objectValue
{
get;
set;
}
}
// 上述介面的應用:
public classStuff : IConstNameValuePair, INameValuePair
{
privatestring _name;
privateobject _value;
#regionIConstNameValuePair Members
publicobject Name
{
get
{
return _name;
}
}
objectIConstNameValuePair.Value
{
get
{
return _value;
}
}
#endregion
#regionINameValuePair Members
publicobject Value
{
get
{
return _value;
}
set
{
_value = value;
}
}
#endregion
}
屬性在C#中已經成為一項比較完善的、第一等的語言元素。我們可以針對成員函式做的任何事情,對於屬性也同樣適用。畢竟,屬性是對訪問/修改內部資料的方法的一種擴充套件。
我們知道,屬性訪問器在編譯後事實上是兩個分離的方法。在C# 2.0中,我們可以為一個屬性的get訪問器和set訪問器指定不同的訪問修飾符。這使得我們可以更好地控制屬性的可見性。
// 合法的C#2.0程式碼:
public classCustomer
{
privatestring _name;
publicvirtual string Name
{
get
{
return _name;
}
protected set
{
_name = value;
}
}
// 忽略其他實現程式碼。
}
C#的屬性語法擴充套件自簡單的資料欄位。如果型別介面需要包含一些索引資料項,則可以使用一種稱作索引器(indexer)的型別成員。索引器在C#中又稱含參屬性(parameterized property)。這種“使用屬性來返回一個序列中的資料項”的做法對於很多場合非常有用,下面的程式碼展示了這一用法:
public intthis [ int index ]
{
get
{
return _theValues [ index ] ;
}
set
{
_theValues[ index ] = value;
}
}
// 訪問索引器:
int val =MyObject[ i ];
索引器和一般的屬性(即支援單個數據項的屬性)在C#中有同樣的語言支援,它們都用方法實現,我們可以在其內部做任何校驗或者計算工作。索引器也可以為虛索引器,或者抽象索引器。它們可以宣告在介面中,也可以成為只讀索引器或者讀—寫索引器。以數值作為引數的“一維索引器”還可以參與資料繫結。使用非數值的索引器則可以用來定義map或者dictionary等資料結構:
public Addressthis [ string name ]
{
get
{
return _theValues[ name ] ;
}
set
{
_theValues[ name ] = value;
}
}
與C#中的多維陣列類似,我們也可以建立“多維索引器”——其每一維上的引數型別可以相同,也可以不同。
public intthis [ int x, int y ]
{
get
{
return ComputeValue( x, y );
}
}
public intthis[ int x, string name ]
{
get
{
return ComputeValue( x, name );
}
}
注意所有的索引器都使用this關鍵字來宣告。我們不能為索引器指定其他的名稱。因此,在每個型別中,對於同樣的引數列表,我們只能有一個索引器。
屬性顯然是一個好東西,相較於以前的各種訪問方式來講,它的確是一個進步。但是,有些讀者可能會有如下的想法:剛開始先使用資料成員,之後如果需要獲得屬性的好處時,再考慮將資料成員替換為屬性。這種做法聽起來似乎有道理,但實際上是錯的。讓我們來看下面一段程式碼:
// 使用公有資料成員,不推薦這種做法:
public classCustomer
{
publicstring Name;
// 忽略其他實現程式碼。
}
這段程式碼描述了一個Customer類,其內包含一個成員Name。我們可以使用成員訪問符來獲取/設定其Name的值:
string name =customerOne.Name;
customerOne.Name= "This Company, Inc.";
這段程式碼非常簡潔和直觀。有人據此就認為以後如果有需要,再將Customer類的資料成員Name替換為屬性就可以了,而使用Customer型別的程式碼無需做任何改變。這種說法從某種程度上來講是對的。
屬性在被訪問的時候和資料成員看起來沒有什麼差別。這正是C#引入新的屬性語法的一個目標。但屬性畢竟不是資料,訪問屬性和訪問資料產生的是不同的MSIL。前面那個Customer型別的Name欄位在編譯後將產生如下MSIL程式碼:
.field publicstring Name
而訪問該欄位的部分編譯後的MSIL程式碼如下:
ldloc.0
ldfld string NameSpace.Customer::Name
stloc.1
向該欄位儲存資料的部分編譯後的MSIL程式碼如下:
ldloc.0
ldstr "This Company, Inc."
stfld string NameSpace.Customer::Name
大家不必擔憂,我們不會整天圍繞著IL程式碼轉。為了讓大家清楚“在資料成員和屬性之間做改變會打破二進位制相容性”,在這裡展示一下IL程式碼還是很重要的。我們再來看下面的Customer型別實現,這次我們採用了屬性的方案:
public classCustomer
{
privatestring _name;
publicstring Name
{
get
{
return _name;
}
set
{
_name = value;
}
}
// 忽略其他實現程式碼。
}
當我們在C#中訪問Name屬性時,使用的語法和前面訪問欄位的語法一模一樣。
string name =customerOne.Name;
customerOne.Name= "This Company, Inc.";
但是,C#編譯器對於兩段相同的C#程式碼產生的卻是完全不同的MSIL程式碼。我們來看新版Customer型別的Name屬性編譯後的MSIL:
.propertyinstance string Name()
{
.getinstance string NameSpace.Customer::get_Name()
.setinstance void NameSpace.Customer::set_Name(string)
} // 屬性Customer::Name結束。
.method publichidebysig specialname instance string
get_Name() cil managed
{
// 程式碼長度 11 (0xb)
.maxstack 1
.localsinit ([0] string CS$00000003$00000000)
IL_0000: ldarg.0
IL_0001: ldfld stringNameSpace.Customer::_name
IL_0006: stloc.0
IL_0007: br.s IL_0009
IL_0009: ldloc.0
IL_000a: ret
} // 方法Customer::get_Name結束。
.method publichidebysig specialname instance void
set_Name(string 'value') cil managed
{
// 程式碼長度 8 (0x8)
.maxstack 2
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: stfld stringNameSpace.Customer::_name
IL_0007: ret
} // 方法Customer::set_Name結束。
在將屬性定義從C#程式碼轉換為MSIL的過程中,有兩點需要我們注意:首先,.property指示符定義了屬性的型別,以及實現屬性get訪問器和set訪問器的兩個函式。這兩個函式被標記為hidebysig和specialname。對我們來說,這兩個標記意味著它們所修飾的函式不能直接在C#原始碼中被呼叫,也不被認為是型別正式定義的一部分。要訪問它們,我們只能通過屬性。
當然,大家對於屬性定義產生不同的MSIL應該早有預期。更重要的是,對屬性所做的get和set訪問的客戶程式碼編譯出來的MSIL也不同:
// get
ldloc.0
callvirt instance string NameSpace.Customer::get_Name()
stloc.1
// set
ldloc.0
ldstr "This Company, Inc."
callvirt instance void NameSpace.Customer::set_Name(string)
大家看到了,同樣是訪問客戶(Customer)名稱(Name)的C#原始碼,由於所使用的Name成員不同——屬性或者資料成員,編譯後產生出的MSIL指令也不同。儘管訪問屬性和訪問資料成員使用的是同樣的C#原始碼,但是C#編譯器卻將它們轉換為不同的IL程式碼。
換句話說,雖然屬性和資料成員在原始碼層次上是相容的,但是在二進位制層次上卻不相容。這意味著如果將一個型別的公有資料成員改為公有屬性,那麼我們必須重新編譯所有使用該公有資料成員的C#程式碼。本書第4章“建立二進位制元件”討論了二進位制元件的相關細節,但是在此之前大家要清楚,將一個數據成員改為屬性會破壞二進位制相容性。如果這樣的程式集已經被部署,那麼升級它們的工作將變得非常麻煩。
看了屬性產生的IL程式碼之後,有讀者可能想知道使用屬性和使用資料成員在效能上有什麼差別。雖然使用屬性不會比使用資料成員的程式碼效率更快,但是它也不見得就會比使用資料成員的程式碼慢,因為JIT編譯器會對某些方法呼叫(包括屬性訪問器)進行內聯處理。如果JIT編譯器對屬性訪問器進行了內聯處理,那麼屬性和資料成員的效率將沒有任何差別。即使屬性訪問器沒有被內聯,實際的效率差別相對於函式呼叫的成本來講也是可以忽略不計的。只有在很少的一些情況下,這種差別才值得我們注意。
綜上所述,只要打算將資料暴露在型別的公有介面或者受保護介面中,我們都應該使用屬性來實現。對於具有序列或者字典特徵的型別,則應該採用索引器。所有的資料成員都應一律宣告為私有。使用屬性的好處顯而易見:我們可以得到更好的資料繫結支援,我們可以更容易地在將來對其訪問方法的實現做任何改變。將變數封裝在屬性中只不過增加一兩分鐘程式碼錄入時間。如果剛開始使用資料成員,後來又發現需要使用屬性,這時再來修改的成本將是幾個小時。今天的一點投入,會為明天節省許多時間。
C#語言有兩種不同的常量機制:一種為編譯時 (compile-time)常量,一種為執行時(runtime)常量。兩種常量有著非常迥異的行為,使用不正確會導致程式的效能下降或者出現錯誤。這兩種代價,哪一個都沒有人願意承擔,但是如果必須承擔一個,那麼“慢、但是能夠正確執行的”程式總比“快、但是可能出錯的”程式要好。因此,我們說執行時常量優於編譯時常量。編譯時常量比執行時常量稍微快一點,但卻缺乏靈活性。只有在效能非常關鍵,並且其值永遠不會改變的情況下,我們才應該使用編譯時常量。
在C#中,我們使用readonly關鍵字來宣告執行時常量,用const關鍵字來宣告編譯時常量。
// 編譯時常量:
public const int_Millennium = 2000;
// 執行時常量:
public static readonlyint _ThisYear = 2004;
編譯時常量與執行時常量行為的不同處在於它們的訪問方式。編譯時常量在編譯後的結果程式碼中會被替換為該常量的值,例如下面的程式碼:
if ( myDateTime.Year == _Millennium )
其編譯後的IL和下面的程式碼編譯後的IL一樣:
if ( myDateTime.Year == 2000 )
執行時常量的值則在執行時被計算。對於使用執行時常量的程式碼,其編譯後的IL將維持對readonly變數(而非它的值)的引用。
這種差別會為我們使用兩種常量型別帶來一些限制。編譯時常量只可以用於基元型別(包括內建的整數型別和浮點型別)、列舉型別或字串型別。因為只有這些型別才允許我們在初始化器中指定有意義的常量值[2]。在使用這些常量的程式碼編譯後得到的IL程式碼中,常量將直接被替換為它們的字面值(literal)。例如,下面的程式碼就不會通過編譯。事實上,C#不允許我們使用new操作符來初始化一個編譯時常量,即使被初始化的常量型別為一個值型別。
// 下面的程式碼不會通過編譯,但是換成readonly就可以:
private const DateTime _classCreation = new DateTime( 2000, 1, 1, 0, 0, 0 );
編譯時常量僅限於數值和字串。只讀(read- only)欄位之所以也被稱作一種常量,是因為它們的構造器一旦被執行,我們將不能對它們的值做任何修改。與編譯時常量不同的地方在於,只讀欄位的賦值操作發生在執行時,因此它們具有更多的靈活性。比如,只讀欄位的型別就沒有任何限制。對於只讀欄位,我們只能在構造器或者初始化器中為它們賦值。在上面的程式碼中,我們可以宣告readonly的DateTime結構變數,但是卻不能宣告const的DateTime結構變數。
我們可以宣告readonly的例項常量,從而為一個型別的每個例項儲存不同的值。但是const修飾的編譯時常量預設就被定義為靜態常量。
我們知道,執行時常量和編譯時常量最重要的區別就在於執行時常量值的辨析發生在執行時,而編譯時常量值的辨析發生編譯時。換言之,使用執行時常量編譯後的IL程式碼引用的是readonly變數,而非它的值;而使用編譯時常量編譯後的IL程式碼將直接引用它的值——就像我們直接在程式碼中使用常量值一樣。即使我們使用的是數值常量並跨程式集引用,情況也是一樣:如果在程式集A中引用程式集B中的常量,那麼編譯後程序集A中出現的那個常量將被它的值所替換。這種差別對於程式碼的維護性而言有著相當的影響。
編譯時常量與執行時常量被辨析的方式影響著執行時的相容性。假設我們在一個名為Infrastructure的程式集中分別定義了一個const欄位和一個readonly欄位:
public class UsefulValues
{
public static readonly int StartValue = 5;
public const int EndValue = 10;
}
在另外一個程式集Application中,我們又引用著這些值:
for ( int i = UsefulValues.StartValue; i <UsefulValues.EndValue; i++ )
Console.WriteLine( "value is {0}", i);
如果我們執行上面的程式碼,將得到以下輸出:
Value is 5
Value is 6
...
Value is 9
假設隨著時間的推移,我們又釋出了一個新版的Infrastructure程式集:
public classUsefulValues
{
public static readonly int StartValue = 105;
public const int EndValue = 120;
}
我們將新版的Infrastructure程式集分發出去,但並沒有重新編譯Application程式集。我們期望得到如下的輸出:
Value is 105
Value is 106
...
Value is 119
但實際上,我們卻沒有得到任何輸出。因為現在那個迴圈語句將使用105作為它的起始值,使用10作為它的結束 條件。其根本原因在於C#編譯器在第一次編譯Application程式集時,將其中的EndValue替換成了它對應的常量值10。而對於 StartValue來說,由於它被宣告為readonly,所以它的辨析發生在執行時。因此,Application程式集在沒有被重新編譯的情況下, 仍然可以使用新的StartValue值。為了改變所有使用readonly常量的客戶程式碼的行為,簡單地安裝一個新版的Infrastructure 程式集就足夠了。“更改一個編譯時常量的值”應該被視作對型別介面的更改,其後果是我們必須重新編譯所有引用該常量的程式碼。“更改一個公有的執行時常量的值”應該被視作對型別實現的更改,它與其客戶程式碼在二進位制層次上是相容的。大家看看上述程式碼中的迴圈編譯後的MSIL,就會對這裡所談的更加清楚了:
IL_0000: ldsfld int32Chapter1.UsefulValues::StartValue
IL_0005: stloc.0
IL_0006: br.s IL_001c
IL_0008: ldstr "value is {0}"
IL_000d: ldloc.0
IL_000e: box [mscorlib]System.Int32
IL_0013: call void [mscorlib]System.Console::WriteLine
(string,object)
IL_0018: ldloc.0
IL_0019: ldc.i4.1
IL_001a: add
IL_001b: stloc.0
IL_001c: ldloc.0
IL_001d: ldc.i4.s 10
IL_001f: blt.s IL_0008
大家可以在這段MSIL程式碼的頂端看到StartValue的確是被動態載入的,而在其末尾可以看到結束條件被硬編碼(hard-code)為10。
不過,有時候有些值確實可以在編譯時確定,這時候就應該使用編譯時常量。例如,考慮在物件的序列化形式(有關物件序列化,可參見條款25)中使用一組常量來區分不同版本的物件。其中,標記特殊版本號的持久化資料應該採用編譯時常量,因為它們的值永遠不會改變。但是標記當前版本號的資料應該採用執行時常量,因為它的值會隨著每個不同的版本而改動。
private const int VERSION_1_0 = 0x0100;
private const int VERSION_1_1 = 0x0101;
private const int VERSION_1_2 = 0x0102;
// 主發行版本:
private const int VERSION_2_0 = 0x0200;
// 標記當前版本:
private static readonly int CURRENT_VERSION =VERSION_2_0;
我們使用執行時版本[3]來將當前的版本號儲存在每一個序列化檔案中:
// 從持久層資料來源讀取物件,將儲存的版本號與編譯時常量相比對:
protected MyType( SerializationInfo info, StreamingContext cntxt )
{
int storedVersion = info.GetInt32("VERSION" );
switch ( storedVersion )
{
case VERSION_2_0:
readVersion2( info, cntxt );
break;
case VERSION_1_1:
readVersion1Dot1( info, cntxt );
break;
// 忽略其他細節。
}
}
// 寫入當前版本號:
[SecurityPermissionAttribute(SecurityAction.Demand, SerializationFormatter =true ) ]
voidISerializable.GetObjectData( SerializationInfo inf, StreamingContext cxt )
{
// 使用執行時常量來標記當前版本號:
inf.AddValue( "VERSION", CURRENT_VERSION );
// 寫入其他元素……
}
使用const較之於使用readonly的唯一好處就是效能:使用已知常量值的程式碼效率要比訪問readonly值的程式碼效率稍好一點。但是這其中的效 率提升是非常小的,大家應該和其所失去的靈活性進行一番權衡比較。在打算放棄靈活性之前,一定要對兩者的效能差別做一個評測。
綜上所述,只有當某些情況要求變數的值必須在編譯時可用,才應該考慮使用const,例如:特性(attribute)類的引數,列舉定義,以及某些不隨元件版本變化而改變的值。否則,對於其他任何情況,都應該優先選擇readonly常量,從而獲得其所具有的靈活性。
C#是一門強型別語言。一般情況下,我們最好避免將一個型別強制轉換為其他型別。但是,有時候執行時型別檢查是無法避免的。相信大家都寫過很多以System.Object型別為引數的函式,因為. NET框架預先為我們定義了這些函式的簽名。在這些函式內部,我們經常要把那些引數向下轉型為其他型別,或者是類,或者是介面。對於這種轉型,我們通常有兩種選擇:使用as操作符,或者使用傳統C風格的強制轉型。另外還有一種比較保險的做法:先使用is來做一個轉換測試,然後再使用as操作符或者強制轉 型。
正確的選擇應該是儘可能地使用as操作符,因為它比強制轉型要安全,而且在執行時層面也有比較好的效率。需要注意的是,as和is操作符都不執行任何使用者自定義的轉換。只有當執行時型別與目標轉換型別匹配時,它們才會轉換成功。它們永遠不會在轉換過程中構造新的物件。
我們來看一個例子。假如需要將一個任意的物件轉換為一個MyType的例項。我們可能會像下面這樣來做:
object o = Factory.GetObject( );
// 第一個版本:
MyType t = o as MyType;
if ( t != null )
{
// 處理t, t現在的型別為MyType。
} else
{
// 報告轉型失敗。
}
或者,也可以像下面這樣來做:
object o =Factory.GetObject( );
// 第二個版本:
try {
MyType t;
t = (MyType ) o;
if (t != null )
{
// 處理t, t現在的型別為MyType。
}else
{
// 報告空引用失敗。
}
} catch
{
// 報告轉型失敗。
}
相信大家都同意第一個版本的轉型程式碼更簡單,也更 容易閱讀。其中沒有新增額外的try/catch語句,因此也就避免了其帶來的負擔。注意,第二個版本中除了要捕捉異常外,還要對null的情況進行檢 查,因為如果o本來就是null,那麼強制轉型可以將它轉換成任何引用型別。但如果是as操作符,且被轉換物件為null,那麼執行結果將返回null。 因此,如果使用強制轉型,我們既要檢查其是否為null,還要捕捉異常。如果使用as操作符,我們只需要檢查返回的引用是否為null就可以了。
cast 和as操作符之間最大的區別就在於如何處理使用者自定義的轉換。操作符as和is都只檢查被轉換物件的執行時型別,並不執行其他的操作。如果被轉換物件的執行時型別既不是所轉換的目標型別,也不是其派生型別,那麼轉型將告失敗。但是,強制轉型則會使用轉換操作符來執行轉型操作,這包括任何內建的數值轉換。例如,將一個long型別強制轉換為一個short型別將會導致部分資訊丟失。
在我們使用使用者自定義的轉換時,也會有同樣的問題,來看下面的程式碼:
public class SecondType
{
private MyType _value;
// 忽略其他細節。
// 轉換操作符。
// 將SecondType 轉換為MyType,參見條款29。[4]
public static implicit operator MyType(SecondType t )
{
return t._value;
}
}
假設下面第一行程式碼中的Factory.GetObject()返回的是一個SecondType物件:
object o = Factory.GetObject( );
// o 為一個SecondType:
MyType t = o as MyType; // 轉型失敗,o的型別不是MyType。
if ( t != null )
{
// 處理t, t現在的型別為MyType。
} else
{
// 報告轉型失敗。
}
// 第二個版本:
try {
MyType t1;
t1= ( MyType ) o; // 轉型失敗,o的型別不是MyType。
if (t1 != null )
{
// 處理t1, t1現在的型別為MyType。
}else
{
// 報告空引用失敗。
}
} catch
{
// 報告轉型失敗。
}
兩個版本的轉型操作都失敗了。大家應該還記得我前面說過強制轉型會執行使用者自定義的轉換,有讀者據此認為強制轉型的那個版本會成功。這麼想本身沒有錯誤,只是編譯器在產生程式碼時依據的是物件o的編譯時類 型。編譯器對於o的執行時型別一無所知——編譯器只知道o的型別是System.Object。因此編譯器只會檢查是否存在將System.Object 轉換為MyType的使用者自定義轉換。它會到System.Object型別和MyType型別的定義中去做這樣的檢查。由於沒有找到任何使用者自定義轉換,編譯器將產生程式碼來檢查o的執行時型別,並將其和MyType進行比對。由於o的執行時型別為SecondType,因此轉型將告失敗。編譯器不會檢查在o的執行時型別SecondType和MyType之間是否存在使用者自定義的轉換。
當然,如果將上述程式碼做如下修改,轉換就會成功執行:
object o = Factory.GetObject( );
// 第三個版本:
SecondType st = o as SecondType;
try {
MyType t;
t = ( MyType ) st;
if ( t != null )
{
// 處理t, t現在的型別為MyType。
} else
{
// 報告空引用失敗。
}
} catch
{ // 報告轉型失敗。
}
在正式的開發中,我們絕不能寫如此醜陋的程式碼,但它卻向我們揭示了問題的所在。雖然大家永遠都不可能像上面那樣寫程式碼,但可以使用一個以System.Object型別為引數的函式,讓該函式在內部執行正確的轉換。
object o = Factory.GetObject( );
DoStuffWithObject( o );
private void DoStuffWithObject( object o2 )
{
try {
MyType t;
t = ( MyType ) o2; // 轉型失敗,o的型別不是MyType
if ( t != null )
{
// 處理t, t現在的型別為MyType。
} else
{
// 報告空引用失敗。
}
} catch
{
// 報告轉型失敗。
}
}
記住,使用者自定義的轉換操作符只作用於物件的編譯時型別,而非執行時型別上。至於o2的執行時型別和MyType之間是否存在轉換,並不重要。事實上,編譯器對此並不瞭解,也不關心。對於下面的語句,如果st的宣告型別不同,會有不同的行為:
t = ( MyType ) st;
但對於下面的語句,不管st的宣告型別是什麼,都會產生同樣的結果[5]。因此,我們說as操作符要優於強制轉型——它的轉型結果相對比較一致。
但如果as操作符兩邊的型別沒有繼承關係,即使存在使用者自定義轉換操作符,也會產生編譯時錯誤。例如,下面的語句:
t = st as MyType;
我們已經知道在轉型的時候應該儘可能地使用as操作符。下面我們來談談一些不能使用as操作符的情況。首先,as操作符不能應用於值型別。例如,下面的程式碼編譯的時候就會報錯:
object o = Factory.GetValue( );
int i = o as int; // 不能通過編譯。
這是因為int是一個值型別,所以不可以為null。如果o不是一個整數,那這個i裡面還能存放什麼呢?存入的任何值都必須是有效的整數,所以as不能和值型別一起使用。那就只能使用強制轉型了:
object o = Factory.GetValue( );
int i = 0;
try {
i = ( int ) o;
} catch
{
i =0;
}
但是,我們也並非只能這樣。我們還可以使用is語句來避免其中對異常的檢查或者強制轉型:
object o = Factory.GetValue( );
int i = 0;
if ( o is int )
i = ( int ) o;
如果o是某個其他可以轉換為int的型別,例如double,那麼is操作符將返回false。如果o的值為null,is操作符也將返回false。
只有當我們不能使用as操作符來進行型別轉換時,才應該使用is操作符。否則,使用is將會帶來程式碼的冗餘:
// 正確, 但是冗餘:
object o = Factory.GetObject( );
MyType t = null;
條款3:操作符is或as優於強制轉型 26
if ( o is MyType )
t = o as MyType;
上面的程式碼和下面的程式碼事實上是一樣的:
// 正確, 但是冗餘:
object o = Factory.GetObject( );
MyType t = null;
if ( ( o as MyType ) != null )
t = o as MyType;
這種做法顯然既不高效,也顯得冗餘。如果我們打算使用as來做轉型,那麼再使用is檢查就沒有必要了。直接將as操作符的運算結果和null進行比對就可以了,這樣比較簡單。
既然我們已經明白了is操作符、as操作符和強制轉型之間的差別,那麼大家猜猜看foreach迴圈語句中使用的是哪個操作符來執行型別轉換呢?
public void UseCollection( IEnumerable theCollection )
{
foreach ( MyType t in theCollection )
t.DoStuff( );
}
答案是強制轉型。事實上,下面的程式碼和上面foreach語句編譯後的結果是一樣的:
public void UseCollection( IEnumerable theCollection )
{
IEnumerator it = theCollection.GetEnumerator();
while ( it.MoveNext( ) )
{
MyType t = ( MyType ) it.Current;
t.DoStuff( );
}
}
之所以使用強制轉型,是因為foreach語句需要同時支援值型別和引用型別。無論轉換的目標型別是什麼,foreach語句都可以展現相同的行為。但是,由於使用的是強制轉型,foreach語句可能產生BadCastException異常[6]。
由於IEnumerator.Current返回 的是System.Object,而Object中又沒有定義任何的轉換操作符,因此轉換操作符就不必考慮了。如果集合中是一組SecondType物件,那麼運用在UseCollection()函式中將會出現轉型失敗,因為foreach語句使用的是強制轉型,而強制轉型並不關心集合元素的執行時類 型。它只檢查在System.Object類(由IEnumerator.Current返回的型別)和迴圈變數的宣告型別MyType之間是否存在轉換。
最後,有時候我們可能想知道一個物件的確切型別, 而並不關心它是否可以轉換為另一種型別。如果一個型別繼承自另一個型別,那麼is操作符將返回true。使用System.Object的GetType ()方法,可以得到一個物件的執行時型別。利用該方法可以對型別進行比is或as更為嚴格的測試,因為我們可以拿它所返回的物件的型別和一個具體的型別做對比。
再來看下面的函式:
public void UseCollection( IEnumerable theCollection )
{
foreach ( MyType t in theCollection )
t.DoStuff( );
}
如果建立了一個繼承自MyType的類NewType,那便可以將一組NewType物件集合應用在UseCollection函式中。
public class NewType : MyType
{
// 忽略實現細節。
}
如果我們打算編寫一個函式來處理所有與 MyType型別相容的例項物件,那麼UseCollection函式所展示的做法就挺好。但如果打算編寫的函式只處理執行時型別為MyType的物件,那就應該使用GetType()方法來對型別做精確的測試。我們可以將這種測試放在foreach迴圈中。執行時型別測試最常用的地方就是相等判斷(參見 條款9)。對於絕大多數其他的情況,as和is操作符提供的.isinst比較[7]在語義上都是正確的。
好的面向物件實踐一般都告誡我們要避免轉型,但有時候我們別無選擇。不能避免轉型時,我們應該儘可能地使用C#語言中提供的as和is操作符來更清晰地表達意圖。不同的轉型方式有不同的規則,is和as操作符絕大多數情況下都能滿足我們的要求,只有當被測試的物件是正確的型別時,它們才會成功。一般情況下不要使用強制轉型,因為它可能會帶來意想不到的負面效應,而且成功或者失敗往往在我們的預料之外。
#if/#endif 條件編譯常用來由同一份原始碼生成不同的結果檔案,最常見的有debug版和release版。但是,這些工具在具體應用中並不是非常得心應手,因為它們太容易被濫用了,使用它們建立的程式碼通常都比較難理解,且難以除錯。C#語言的設計者們對這種問題的解決方案是建立更好的工具,以達到為不同環境建立不同 機器碼的目的。C#為此添加了一個Conditional特性,該特性可以標示出某種環境設定下某個方法是否應該被呼叫。使用這種方式來描述條件編譯要比 #if/#endif更加清晰。由於編譯器理解Conditional特性,所以它可以在Conditional特性被應用時對程式碼做更好的驗證。 Conditional特性應用在方法這一層次上,因此它要求我們將條件程式碼以方法為單位來表達。當需要建立條件程式碼塊時,我們應該使用 Conditional特性來代替傳統的#if/#endif。
大多數程式老手都使用過條件編譯來檢查物件的前置條件和後置條件。例如,編寫一個私有方法來檢查所有類與物件的不變式(invariant)[8],然後將這樣的方法進行條件編譯,從而讓其只出現在debug版本的程式中。
private void CheckState( )
{
// 老式的做法:
#if DEBUG
Trace.WriteLine( "Entering CheckState for Person" );
// 獲取正在被呼叫函式的名稱:
string methodName = new StackTrace( ).GetFrame(1 ).GetMethod( ).Name;
Debug.Assert( _lastName != null,methodName,"Last Name cannot be null" );
Debug.Assert( _lastName.Length > 0,methodName, "Last Name cannot be blank" );
Debug.Assert( _firstName != null, methodName,"First Name cannot be null" );
Debug.Assert( _firstName.Length > 0,methodName, "First Name cannot be blank" );
Trace.WriteLine( "Exiting CheckState forPerson" );
#endif
}
條件編譯#if和#endif使得最終 release版本中的CheckState()成為一個空方法,但它在release版和debug版中都將得到呼叫。雖然在release版中, CheckState()什麼也不做,但是我們必須為方法的載入、JIT編譯和呼叫付出成本。
就正確性而言,這種做法一般沒什麼問題,但有時候還是可能會在release版本中導致一些詭異的bug。下面的程式碼展示了使用#if和#endif條件編譯時可能常犯的錯誤:
public void Func( )
{
string msg = null;
#if DEBUG
msg =GetDiagnostics( );
#endif
Console.WriteLine( msg );
}
上面的程式碼在debug版本中執行得很好,但是放 到release版本中就會輸出一個空行。輸出一個空行本身沒有什麼,但這畢竟不是我們本來的意圖。我們自己搞糟的事情,編譯器也幫不上什麼忙,因為我們把屬於程式主邏輯的程式碼和條件編譯程式碼混在一起了。在原始碼中隨意地使用#if和#endif將使我們很難診斷不同版本間的行為差別。
C#為此 提出了一種更好的選擇:Conditional特性。使用Conditional特性,我們可以將一些函式隔離出來,使得它們只有在定義了某些環境變數或者設定了某個值之後才能發揮作用。Conditional特性最常用的地方就是將程式碼改編為除錯語句。.NET框架已經為此提供了相關的功能支援。下面的 程式碼展示了Conditional特性的工作原理,以及適用場合。
構建Person物件時,我們一般會新增如下的方法來驗證物件的不變式:
private void CheckState( )
{
// 獲取正在被呼叫函式的名稱:
string methodName = new StackTrace( ).GetFrame(1 ).GetMethod( ).Name;
Trace.WriteLine( "Entering CheckState forPerson:" );
Trace.Write( "/tcalled by " );
Trace.WriteLine( methodName );
Debug.Assert( _lastName != null, methodName,"Last Name cannot be null" );
Debug.Assert( _lastName.Length > 0,methodName, "Last Name cannot be blank" );
Debug.Assert( _firstName != null, methodName,"First Name cannot be null" );
Debug.Assert( _firstName.Length > 0,methodName, "First Name cannot be blank" );
Trace.WriteLine( "Exiting CheckState forPerson" );
}
有些讀者可能對上面程式碼中的一些庫函式還不夠熟悉,我 們來簡單介紹一下。StackTrace類使用反射(reflection,參見條款43)來獲取當前正被呼叫的方法名。其代價相當高,但它可以極大地簡化我們的工作,例如幫助我們獲取有關程式流程的資訊。在上面的程式碼中,使用它,我們便可以得到正被呼叫的方法名稱為CheckState。其餘的方法在另外兩個類中,分別為System.Diagnostics.Debug和System.Diagnostics.Trace。Debug.Assert方 法用於測試某個條件,如果該條件錯誤,程式將被終止,其他引數定義的訊息也將被打印出來。Trace.WriteLine方法則會把診斷資訊列印到除錯控 制臺上。因此,如果有Person物件狀態無效,CheckState方法將會顯示資訊,並終止程式。我們可以將其作為前置條件和後置條件,在所有的公有 方法和受保護方法中呼叫它。
public string LastName
{
get
{
CheckState( );
return _lastName;
}
set
{
CheckState( );
_lastName = value;
CheckState( );
}
}
當首次試圖將LastName屬性設定為空字串或者null時,CheckState將引發一個斷言錯誤。這樣我們就會修正set訪問器以檢查傳遞給LastName的引數。這正是我們想要的功能。
但在每個公有函式中都做這樣的額外檢查顯然比較浪費時間,我們可能只希望其出現在除錯版本中。這就需要Conditional特性了:
[ Conditional( "DEBUG" ) ]
private void CheckState( )
{
// 程式碼保持不變。
}
應用了Conditional特性之後,C#編譯器只有在檢測到DEBUG環境變數時,才會產生對CheckState方法的呼叫。Conditional特性不會影響CheckState()方法的編 譯,它只會影響對該方法的呼叫。如果定義有DEBUG符號,上面的LastName屬性將變為如下的程式碼:
public string LastName
{
get
{
CheckState( );
return _lastName;
}
set
{
CheckState( );
_lastName = value;
CheckState( );
}
}
否則,將得到如下程式碼:
public string LastName
{
get
{
return _lastName;
}
set
{
_lastName = value;
}
}
無論是否定義有DEBUG符號,CheckState()方法的方法體都維持不變,它都會被C#編譯器處理,並生成到結果程式集中。這個例子其實也向大家展示了C#編譯器的編譯過程與 JIT編譯過程之間的區別。這種做法看起來也會帶來一點效率損失,但是其中耗費的成本僅僅是磁碟空間。如果沒有被呼叫,CheckState()方法並不會載入到記憶體中並進行JIT編譯。將CheckState()方法生成到程式集中產生的影響是非常微不足道的。這種策略耗費很小的效能,換來的卻是靈活 性。如果感興趣的話,大家可以檢視.NET框架類庫中的Debug類來對此獲得更深的理解。在每個安裝有.NET框架的機器上,System.dll程式 集中都包含有Debug類中所有方法的程式碼。當呼叫這些方法的程式碼被編譯時,系統環境變數將決定這些方法是否被呼叫。
我們建立的方法也可以依賴於多個環境變數。當我們應用多個Conditional特性時,它們之間的組合關係將為“或(OR)”。例如,下面的CheckState方法被呼叫的條件為定義有DEBUG或者TRACE環境變數:
[Conditional( "DEBUG" ), Conditional( "TRACE" ) ]
private void CheckState( )
要建立一個使用“與(AND)”關係的構造,我們需要自己在原始碼中定義預處理符號:
#if ( VAR1 &;&; VAR2 )
#define BOTH
#endif
是的,要建立一個依賴於多個環境變數的條件程式,我們不得不回到使用#if的老式做法中去。不過所有#if都只不過是建立新的符號而已,我們應該避免將可執行程式碼放在其中。
Conditional特性只可以應用在整個方法上。另外需要注意的是,任何一個使用Conditional特性的方法只能返回void型別。
我們不能在一個方法內的程式碼塊上應用Conditional特性,也不可以在有返回值的方法上應用 Conditional特性。為了應用Conditional特性,我們需要將具有條件性的行為單獨放到一個方法中。雖然我們仍然需要注意那些 Conditional方法可能給物件狀態帶來的負面效應,但Conditional特性的隔離策略總歸要比#if/#endif好得多。使用#if和 #endif程式碼塊,我們很有可能會錯誤地刪除一些重要的方法呼叫或者賦值語句。
上面的例子使用了DEBUG或者TRACE這樣的預定義符號,但我們也可以將其擴充套件到我們自己定義的符號上。Conditional特性可以被任何方式定義的符號所控制,例如編譯器命令列,作業系統shell的環境變數,或者原始碼pragma。
綜上所述,使用Conditional特性比使用 #if/#endif產生的IL程式碼更有效率。同時,將其限制在函式層次上可以清晰地將條件性的程式碼分離出來,從而使我們的程式碼具有更好的結構。另外, C#編譯器也為此提供了很好的支援,從而幫助我們避免以前使用#if或#endif時常犯的錯誤。
System.Object.ToString ()恐怕是.NET中最常用的方法了。應該為我們的類的所有客戶程式碼提供一個合理的版本,否則這些程式碼就只能使用我們的類的一些屬性來自己定製可讀的表示了。型別的字串表示非常有用,可以在很多地方向使用者顯示物件的有關資訊,例如在Windows Forms上、Web Forms上、控制檯輸出視窗中,以及除錯環境中。為此,我們建立的每一個型別都應該重寫Object類的ToString()方法。如果建立的是更復雜 的型別,則應該實現Iformattable.ToString()方法。如果我們沒有重寫該方法,或者寫得不夠好,那麼使用它們的客戶程式碼就要自己想辦法修補了。
System.Object 預設提供的ToString()方法會返回型別的名稱。這樣的資訊一般沒有什麼用處,像"Rect"、"Point"、"Size"這樣的字串大多都不是我們希望顯示給使用者的。但如果我們不重寫Object的ToString()方法,使用者看到的就將是這些。我們只需要寫一次,但是客戶將享用無數次。一點點付出,就可以讓很多人(包括我們自己)受益。
讓我們來看看重寫System.Object.ToString()這個最簡單的需求。該方法主要的功能就是為型別提供一個最常用的文字表示。例如,考慮下面這個具有三個欄位的Customer類:
public class Customer
{
private string _name;
private decimal _revenue;
private string _contactPhone;
}
如果不提供重寫的版本,Customer將繼承 Object類的ToString()方法,也就是返回一個"Customer"字串。這個字串實在沒有什麼用處。即使ToString()方法只應用於除錯的目的,它也應該輸出一個更有意義的字串。我們重寫的時候應該儘量考慮客戶所希望的表示。就Customer類來說,返回_name是一個不錯 的選擇:
public override string ToString()
{
return _name;
}
即使大家不遵循本條款中的其他建議,也要遵循這裡所展示的實踐。它可以節省很多人的時間。在我們為Customer類重寫了ToString()方法之後,該類的物件將可以更容易地新增到Windows Forms控制元件、Web Forms控制元件或者控制檯上。.NET FCL在將物件顯示到各個控制元件(如Combo Box、List Box、Text Box等)上時,使用的就是Object.ToString()的重寫版本。如果我們在Windows Forms或者Web Forms上建立了一個Customer物件的列表,其文字顯示將為Customer的名稱(_name)。System.Console.WriteLine()方法、System.String.Format()方法等內部也都呼叫到了ToString() 方法。只要當.NET FCL需要獲取Customer的字串表示時,我們的Customer型別都將以其名稱(_name)來響應。僅僅提供一個具有三行程式碼的方法,就可以 處理所有這些基本的需求。
雖然簡單的ToString()方法很多時候已經可以滿足我們的需求,但有時候,我們還需要功能更強的方法。上述Customer型別有三個欄位:_name、 _revenue和_contactPhone,而我們僅使用了_name一個欄位。我們可以通過實現IFormattable介面來解決這個問題。 IFormattable介面包含了一個過載的ToString()方法,它允許我們為型別指定某種格式資訊。當我們需要為型別建立不同形式的字串輸出 時,這個介面非常有用。Customer型別就是一個例子。比如,有些使用者可能希望建立一個報表,在其中以表格的形式包含客戶的名稱和上一年的收入。 IFormattable.ToString()方法允許使用者為我們的型別指定某種格式的字串輸出。其簽名如下:
string System.IFormattable.ToString( string format,IFormatProvider formatProvider )
我們可以使用格式字串來為我們的型別指定自己的格式。比如,使用特定的字元來表示某種格式資訊。在Customer型別的例子中,我們可以使用n來表示name,使用r來表示revenue,使用p來 表示phone。另外,還可以指定這些字元的組合形式。下面的程式碼展示了一種可能的做法:
#region IFormattable Members
// 所支援的格式:
// 用n 表示name。
// 用r 表示revenue。
// 用p 表示contactphone。
// 同時支援組合格式: nr、np、npr等。
// "G" 表示通用格式。
string System.IFormattable.ToString( string format,IFormatProvider formatProvider )
{
if ( formatProvider != null )
{
ICustomFormatter fmt = formatProvider.GetFormat(this.GetType( ) ) asICustomFormatter;
if ( fmt != null )
return fmt.Format(format, this, formatProvider );
}
switch ( format )
{
case "r":
return_revenue.ToString( );
case "p":
return _contactPhone;
case "nr":
return string.Format("{0,20}, {1,10:C}", _name, _revenue );
case "np":
return string.Format("{0,20}, {1,15}", _name, _contactPhone );
case "pr":
return string.Format("{0,15}, {1,10:C}", _contactPhone, _revenue );
case "pn":
return string.Format("{0,15}, {1,20}", _contactPhone, _name );
case "rn":
return string.Format("{0,10:C}, {1,20}", _revenue, _name );
case "rp":
return string.Format("{0,10:C}, {1,20}", _revenue, _contactPhone );
case "nrp":
return string.Format("{0,20}, {1,10:C}, {2,15}", _name, _revenue, _contactPhone );
case "npr":
return string.Format("{0,20}, {1,15}, {2,10:C}", _name, _contactPhone, _revenue );
case "pnr":
return string.Format("{0,15}, {1,20}, {2,10:C}", _contactPhone, _name, _revenue );
case "prn":
return string.Format("{0,15}, {1,10:C}, {2,15}", _contactPhone, _revenue, _name );
case "rpn":
return string.Format("{0,10:C}, {1,15}, {2,20}", _revenue, _contactPhone, _name );
case "rnp":
return string.Format("{0,10:C}, {1,20}, {2,15}", _revenue, _name, _contactPhone );
case "n":
case"G":
default:
return _name;
}
}
#endregion
新增該函式使得Customer型別的客戶可以定製Customer型別的表示:
IFormattable c1 = new Customer();
Console.WriteLine( "Customer record: {0}",c1.ToString( "nrp", null ) );
IFormattable.ToString ()的實現一般來說是依型別而異的,但有些工作是每一個型別中我們都需要處理的。首先,我們必須支援表示“通用格式”的"G"。其次,我們必須支援兩種形式的“空格式”,即""和null。這三種格式返回的字串必須與Object.ToString()的重寫版本返回的字串相同。.NET FCL對每一個實現了IFormattable介面的型別,會呼叫IFormattable.ToString(),而非Object.ToString ()。.NET FCL通常會用一個null的格式字串來呼叫IFormattable.ToString(),只是在一小部分場合會使用"G"來表示通用格式。如果我們的型別支援IFormattable介面,但又不支援這些標準格式,那麼我們就打破了FCL中的自動字串轉換規則。
IFormattable.ToString ()方法的第二個引數為一個實現了IFormatProvider介面的物件。該物件允許客戶程式提供一些我們不能預料的格式化選項。如果看前面 IFormattable.ToString()的實現,總會有一些我們期望、但實際上卻沒有提供的格式化選項。如果我們希望提供的輸出容易為人所讀懂,這種情況便不可避免。不管我們支援多少種格式化選項,使用者總有一天會期望某種我們無法預料的格式。這就是上面的程式碼示例中最開始的幾行所做的工作:尋找實現了IFormatProvider介面的物件,然後將格式化任務交給其中的ICustomFormatter來完成。
下面,將我們的視角從類的作者轉到類的使用者上來。假設我們期望的某種格式沒有獲得支援,例如某些customer的name字元數要大於20,這時候我們希望提供字元數為50的name。這就是IFormatProvider介面的用武之地了。我們需要建立兩個類:一個實現IFormatProvider介面,另一個實現 ICustomFormatter介面——該類用於建立自定義的輸出格式。IFormatProvider介面中定義有一個方法:GetFormat (),該方法會返回一個實現了ICustomFormatter介面的物件。ICustomFormatter介面中包含了實際執行格式化的方法。下面的程式碼實現了提供字元數為50的name輸出:
// IFormatProvider示例:
public class CustomerFormatProvider : IFormatProvider
{
#region IFormatProvider Members
// IFormatProvider 僅包含一個方法。
// 該方法返回一個使用指定介面格式的物件。
// 一般情況下,只有ICustomFormatter被實現。
public object GetFormat( Type formatType )
{
if ( formatType == typeof(ICustomFormatter ))
return new CustomFormatter( );
return null;
}
#endregion
}
// 一個巢狀類,為Customer類提供定製格式。
public class CustomFormatter: ICustomFormatter
{
#region ICustomFormatter Members
public string Format( stringformat, object arg, IFormatProvider formatProvider )
{
Customer c = arg asCustomer;
if ( c == null )
return arg.ToString();
return string.Format("{0,50}, {1,15}, {2,10:C}", c.Name, c.ContactPhone, c.Revenue );
}
#endregion
}
上面的GetFormat()方法建立了一個實現了ICustomFormatter介面的物件。ICustomFormatter.Format()方法則按指定的方式執行實際的格式化輸出工作,將物件轉換為一個字串格式。我們可以為ICustomFormatter.Format()方法定義format引數,以便指定多種格式化選項。引數 formatProvider則是用於呼叫GetFormat()方法的一個IFormatProvider物件。
要指定我們自己定製的格式,需要顯式呼叫string.Format()方法,並傳遞一個IFormatProvider物件:
Class Customer : CustomFormatter
{
…
}
Customer c1 = new Customer();
Console.WriteLine(c1.Format("",c1,newCustomFormatter());
不管一個類是否實現了IFormattable接 口,我們都可以為其建立IformatProvider和ICustomFormatter的實現類。因此即使一個類的原作者沒有提供合理的 ToString()行為,我們仍然可以為其提供格式化支援。當然,作為一個類的外部訪問者,我們只能通過訪問其中的公有屬性和資料成員來構造字串。雖然編寫兩個類(分別實現IFormatProvider和ICustomFormatter)需要很多工作,且其目的僅僅是為了得到一個字串。但是,一旦使用了這種方式來實現我們自己定義的字串輸出,它們將在.NET框架的各個地方得到支援。
現在,再讓我們回到類作者這一角色上來。重寫 Object.ToString()是為類提供字串表示的最簡單方式。每當我們建立一個型別時,都要提供該方法。它應該是我們的型別最明顯、最常用的一 種表示。只有在一些比較少的情況下,當我們期望為型別提供更復雜的輸出格式時,才應該實現IFormattable介面。它為“型別的使用者定製型別的字元 串輸出”提供了一種標準的方式。如果我們沒有做這些工作,使用者就要自己來實現自定義格式化器。那樣的做法需要更多的程式碼,因為使用者處於類外,無法訪問物件的內部狀態。
人們總有獲取型別資訊的需求,而字串對於人來說是最容易理解的。我們應該積極地去做這件事,而重寫所有型別中的ToString()方法可能是所有做法中用最簡單的。