型別自定義格式字串

coyan發表於2021-09-09

型別自定義格式字串

引言

String可能是使用最多的型別,ToString()則應該是大家使用得最多的方法了。然而它不應該僅僅是用來輸出型別的名稱,如果使用得當,它可以方便地輸出我們對型別自定義的格式。本文將循序漸進地討論ToString(),以及相關的IFormattable、IFormatProvider以及ICustomFormatter介面。

在型別內部提供自定義格式字串的能力

繼承自System.Object 基類的 ToString()

String是人們直接就可以看懂的資料型別之一,很多情況下我們都會期望能夠獲得型別的一個字串輸出。因此,Microsoft 在.Net Framework所有型別的基類System.Object中提供了一個虛擬的 ToString()方法,它的預設實現是返回物件的型別名稱。

假設我們有這樣的一個型別,它定義了“朋友”這一物件的一些資訊:

namespace CustomToString
   public class Friend {
       private string familyName;    // 姓
       private string firstName;     // 名

       public Friend(string familyName, string firstName){
          this.familyName = familyName;
          this.firstName = firstName;
       }   
       public Friend(): this("張","子陽"){}

       public string FamilyName {
          get { return familyName; }
       }

       public string FirstName {
          get { return firstName; }
       }
   }
}

當我們在Friend的例項上呼叫 ToString()方法時,便會返回型別的名稱:CustomToString.Friend。

Friend f = new Friend();
Console.WriteLine(f.ToString()); // 輸出:CustomToString.Friend

覆蓋 ToString() 方法

在上面的例子中,不管型別例項(物件)所包含的資料(欄位值)是什麼,它總是會返回相同的結果(CustomToString.Friend)。很多時候,返回一個物件的型別名稱對我們來說沒有多大的意義,拿上面來說,我們可能更加期望能夠返回朋友的姓名(famliyName和firstName欄位的值)。這時候,我們可以簡單地覆蓋System.Object基類的ToString()方法,在 Friend 類中新增如下方法:

// 覆蓋System.Object基類的 ToString() 方法
public override string ToString() {
   return String.Format("Friend: {0}{1}", familyName, firstName);
}

此時,我們再次執行程式碼:

Friend f = new Friend();
Console.WriteLine(f.ToString());    // 輸出:Friend: 張子陽
f = new Friend("王","濤");
Console.WriteLine(f.ToString()); // 輸出:Friend: 王濤

可以看到對於不同的物件,ToString()根據物件的欄位值返回了不同的結果,這樣對我們來說會更加有意義。

過載 ToString() 方法

有時候,我們可能需要將物件按照不同的方式進行格式化。就拿Friend型別來說:西方人是名在前,姓在後;而中國人是 姓在前,名在後。所以如果執行下面的程式碼,雖然程式不會出錯,但從英語語法角度來看卻有問題:

Friend a = new Friend("Zhang", "Jimmy");
Console.WriteLine(a.ToString());    // 輸出:Friend: ZhangJimmy

而我們期望輸出的是:Jimmy Zhang。這個時候,大家可以想一想想 .Net Framework 解決這個問題採用的方法:過載ToString()。讓ToString()方法接收一個引數,根據這個引數來進行格式化。比如 int a = 123; Console.WriteLine(a.ToString("c"));指定了字串"c"作為引數,產生貨幣型別的輸出:¥123.00。我們也可以使用這種方式來改進Friend類,在Friend中過載一個 ToString() 方法,使之根據一個字元引數來定義其字串格式化:

// 根據字串引數來定義型別的格式化
public string ToString(string format) {
    switch (format.ToUpper()) {
       case "W":         // West: 西方
           return String.Format("Friend : {0} {1}", firstName, familyName);
       case "E":         // East: 東方
           return this.ToString();
       case "G":         // General
       default:
           return base.ToString();
    }
}

然後我們在使用ToString()方法時可以使用過載的版本,對於英文名,我們傳入"W"作為引數,這樣就解決了上面的問題:

Friend f = new Friend();
Console.WriteLine(f.ToString());        // 輸出:Friend: 張子陽

f = new Friend("Zhang", "Jimmy");
Console.WriteLine(f.ToString("W")); // 輸出:Friend: Jimmy Zhang

NOTE:這個問題更好的解決辦法並非是過載ToString(),可以簡單地使用屬性來完成,比如這樣:

public string WesternFullName{
    get{ return String.Format("{0} {1}", firstName, familyName)}
}
public string EasternFullName{
    get{ return String.Format("{0}{1}", familyName, firstName)}
}

在本文中,我在這裡僅僅是舉一個例子來說明,所以就先不去管使用屬性這種方式了,後面也是一樣。

實現 IFormattable 介面

我們站在型別設計者的角度來思考一下:我們為使用者提供了Friend類,雖然過載的 ToString() 可以應對 東方/西方 的文化差異,但是使用者的需求總是千變萬化。比如說,使用者是一名Web開發者,並且期望人名總是以加粗的方式顯示,為了避免每次操作時都取出屬性再進行格式化,他會希望只要在型別上應用ToString()就可以達到期望的效果,這樣會更省事一些,比如:

Friend f = new Friend();
label1.Text = String.Format("{0}{1}", f.familyName, f.firstName);
// 這樣會更加方便(使用者期望):
// label1.Text = f.ToString(***);

此時我們提供的格式化方法就沒有辦法實現了。對於不可預見的情況,我們希望能讓使用者自己來決定如何進行物件的字串格式化。Microsoft顯然想到了這一問題,併為我們提供了IFormattable介面。當你作為一名型別設計者,期望為你的使用者提供自定義的格式化ToString()時,可以實現這個介面。我們現在來看一下這個介面的定義:

public interface IFormattable {
    string ToString(string format, IFormatProvider formatProvider);
}

它僅包含一個方法 ToString():引數 format 與我們上一小節過載的ToString()方法中的 format 含義相同,用於根據引數值判斷如何進行格式化;引數 formatProvider 是一個 IFormatProvider 型別,它的定義如下:

public interface IFormatProvider {
    object GetFormat(Type formatType);
}

其中 formatType 是當前物件的型別例項(還有一種可能是ICustomFormatter,後面有說明) --Type物件。在本例中,我們是對Friend這一型別進行格式化,那麼這個formatType 的值就相當於 typeof(Friend),或者 f.GetType() (f為Friend型別的例項)。GetFormat()方法返回一個Object型別的物件,由這個物件進行格式化的實際操作,這個物件實現了 ICustomFormatter 介面,它只包含一個方法,Format():

public interface ICustomFormatter{
   string Format(string format, object arg, IFormatProvider formatProvider);
}

其中 format 的含義與上面相同,arg 為欲進行格式化的型別例項,在這裡是Friend的一個例項,formatProvider 這裡通常不會用到。

看到這裡你可能會感覺有點混亂,實際上,你只要記得:作為型別設計者,你只需要實現 IFormattable 介面就可以了:先透過引數provider的 IFormatProvider.GetFormat() 方法,得到一個 ICustomFormatter 物件,再進一步呼叫 ICustomFormatter 物件的 Format()方法,然後返回 Format() 方法的返回值:

public class Friend: IFormattable{
   // 略 ...

    // 實現 IFormattable 介面
    public string ToString(string format, IFormatProvider provider) {
       if (provider != null) {
           ICustomFormatter formatter =
              provider.GetFormat(this.GetType()) as ICustomFormatter;
           if (formatter != null)
              return formatter.Format(format, this, provider);
       }

       return this.ToString(format);
    }
}

上面需要注意的地方就是 IFormatProvider.GetFormat()方法將當前的Friend物件的型別資訊(透過this.GetType())傳遞了進去。

型別設計者的工作在這裡就完結了,現在讓我們看下對於這個實現了IFormattable的型別,型別的使用者該如何使用自己定義的方法對物件進行字串格式化。作為型別的使用者,為了能夠實現物件的自定義格式字串,需要實現 IFormatProvider 和 ICustomFormatter介面。此時有兩種策略:

  1. 建立一個類,比如叫 FriendFormatter,這個類實現 IFormatProvider 和 ICustomFormatter 介面。

  2. 建立兩個類,比如叫 ObjectFormatProvider 和 FriendFormatter,分別實現 IFormatProvider 和 ICustomFormatter 介面,並且讓 ObjectFormatProvider 的 GetFormat()方法返回一個 FriendFormatter 的例項。

我們先來看看第一種策略:

public class FriendFormatter : IFormatProvider, ICustomFormatter {
    // 實現 IFormatProvider 介面,由 Friend類的 IFormattable.ToString()方法呼叫
    public object GetFormat(Type formatType) {
       if (formatType == typeof(Friend))
           return this;
       else
           return null;
    }

    // 實現 ICustomFormatter 介面
    public string Format(string format, object arg, IFormatProvider formatProvider) {
       //if (arg is IFormattable)
       //  return ((IFormattable)arg).ToString(format, formatProvider);

       Friend friend = arg as Friend;
       if (friend == null)
           return arg.ToString();

       switch (format.ToUpper()) {
           case "I":
             return String.Format("Friend: {0}{1}" ,friend.FamilyName, friend.FirstName);
           case "B":
             return String.Format("Friend: {0}{1}", friend.FamilyName, friend.FirstName);
           default:
              return arg.ToString();
       }
    }
}

結合上面的 ToString()方法一起來看,這裡的流程非常清楚:使用這種方式時,GetFormat中的判斷語句,if(formatType == typeof(Friend)) 確保 FriendFormatter 類只能應用於 Friend型別物件的格式化。隨後,透過this關鍵字返回了當前 FriendFormatter 物件的引用。因為FriendFormatter也實現了 ICustomFormatter介面,所以在Friend型別的 IFormattable.ToString()方法中,能夠將FriendFormater 轉換為一個ICustomFormatter型別,接著呼叫了ICustomFormatter.Format()方法,返回了預期的效果。

NOTE:注意上面註釋掉的部分,可能是參考了MSDN的緣故吧,有些人在實現ICustomFormatt的時候,會加上那部分語句。實際上MSND範例中使用的一個Long型別,並且使用的是String.Format()的過載方法來進行自定義格式化,與這裡不盡相同。當你遮蔽掉上面的註釋時,很顯然會形成一個無限迴圈。

我們現在來對上面的程式碼進行一下測試:

Friend f = new Friend();
FriendFormatter formatter = new FriendFormatter();
Console.WriteLine(f.ToString("b", formatter));    // 輸出:Friend: 張子陽

接下來我們看下第二種方式,將 IFormatProvider 和 ICustomFormatter 交由不同的類來實現:

public class ObjectFormatProvider : IFormatProvider {
    // 實現 IFormatProvider 介面,由 Friend類的 ToString() 方法呼叫
    public object GetFormat(Type formatType) {
       if (formatType == typeof(Friend))
           return new FriendFormatter();//返回一個實現了ICustomFormatter的型別例項
       else
           return null;
    }
}

// 實現ICustomFormatter介面,總是為一個特定型別(比如Friend)提供格式化服務
public class FriendFormatter : ICustomFormatter {

    // 實現 ICustomFormatter 介面
    public string Format(string format, object arg, IFormatProvider formatProvider) {
       //if (arg is IFormattable)
       //  return ((IFormattable)arg).ToString(format, formatProvider);

       Friend friend = arg as Friend;
       if (friend == null)
           return arg.ToString();

       switch (format.ToUpper()) {
           case "I":
             return String.Format("Friend: {0}{1}", friend.FamilyName, friend.FirstName);
           case "B":
             return String.Format("Friend: {0}{1}", friend.FamilyName, friend.FirstName);
           default:
              return arg.ToString();
       }
    }
}

看上去和上面的方法幾乎一樣,區別不過是將一個類拆成了兩個。實際上,拆分成兩個類會更加的靈活:使用一個類實現兩個介面的方式時,FriendFormatter 只能用來格式化 Friend型別。如果再有一個Book類,類似地,需要再建立一個 BookFormatter。

而將它拆分成兩個類,只需要再建立一個類實現一遍 ICustomFormatter 介面,然後對ObjectFormatProvider做些許修改就可以了。此時Provider類可以視為一個通用類,可以為多種型別提供格式化服務。現在假設我們有一個Book型別,我們只需要這樣修改一下 ObjectFormatProvider類就可以了:

public class ObjectFormatProvider : IFormatProvider {
    // 實現 IFormatProvider 介面,由 Friend類的 ToString() 方法呼叫
    public object GetFormat(Type formatType) {
       if (formatType == typeof(Friend))
           return new FriendFormatter();
       if (formatType == typeof(Book))
           return new BookFormatter();  // 返回一個BookFormatter物件
       else
           return null;
    }
}
// BookFormatter 型別省略 ...

在型別外部提供自定義格式字串的能力

現在我們站在一個型別使用者的角度來思考一下:很多時候,型別的設計者並沒有為型別實現IFormattable介面,此時我們該如何處理呢?我們再思考一下.Net Framework中的處理方式:

int a = 123;
Console.WriteLine(a.ToString("c"));        // 輸出: ¥123.00
Console.WriteLine(String.Format("{0:c}", a));  // 輸出: ¥123.00

實際上,String.Format()還提供了一個過載方法,可以一個接收IFormatProvider物件,這個IFormatProvider由我們自己定義,來實現我們所需要的格式化效果。根據上面的對比,我們再做一個總結:為了實現型別的自定義格式字串,我們總是需要實現IFormatProvider介面。如果型別實現了IFormattable介面,我們可以在型別上呼叫ToString()方法,傳遞IFormatProvider物件;如果型別沒有實現IFormattable介面,我們可以透過String.Format()靜態方法,傳遞IFormatProvider物件。

現在我們就來建立實現IFormatProvider介面的型別了,與上面的方式稍稍有些不同:透過Reflector工具(不知道的可以去百度一下)可以看到,呼叫 String.Format() 時內部會建立一個 StringBuilder型別的物件builder,然後呼叫 builder.AppendFormat(provider, format, args); 在這個方法內部,最終會呼叫provider的GetFormat()方法:

formatter = (ICustomFormatter) provider.GetFormat(typeof(ICustomFormatter));

可以看到,provider.GetFormat()傳遞了一個typeof(ICustomFormatter)物件。因此,如果要判斷是不是在型別外部透過String.Format()這種方式來使用 IFormatProvider,只需要判斷 formatType是不是等於 typeof(ICustomFormatter) 就可以了:

public class OutFriendFormatter : IFormatProvider, ICustomFormatter
{
    // 實現 IFormatProvider 介面,由 Friend類的 ToString() 方法呼叫
    public object GetFormat(Type formatType)
    {
       if (formatType == typeof(ICustomFormatter))  
           return this;
       else
           return null;
    }
   
    // 實現 ICustomFormatter 略   
}

我們再次對程式碼進行一下測試:

Friend f = new Friend();
OutFriendFormatter formatter = new OutFriendFormatter();
string output = String.Format(formatter, "{0:i}", f);
Console.WriteLine(output);      // Friend: 張子陽

.Net 中實現IFormatProvider的一個例子

.Net 中使用 IFormatProvider 最常見的一個例子就是 CultureInfo 類了。很多時候,我們需要對金額進行格式化,此時我們通常都會這樣:


int money = 100;
Console.WriteLine(String.Format("{0:c}", money));

我們期望這個輸出的結果是 ¥100.00。然而情況並非總是如此,當你將這個程式執行於中文作業系統下時,的確會如你所願得到 ¥100.00;而在英文作業系統下,你恐怕會得到一個 $100.00。這是因為在對數字以金額方式進行顯示的時候,會依據當前系統的語言環境做出判斷,如果你沒有顯示地指定語言環境,那麼就會按照預設的語言環境來進行相應的顯示。在.Net中,將語言環境進行封裝的類是 CultureInfo,並且它實現了IFormatProvider,當我們需要明確指定金額的顯示方式時,可以藉助這個類來完成:

int money = 100;
IFormatProvider provider = new CultureInfo("zh-cn");
Console.WriteLine(String.Format(provider, "{0:c}", money));    // 輸出:¥100.00

provider = new CultureInfo("en-us");
Console.WriteLine(String.Format(provider, "{0:c}", money));    // 輸出:$100.00

總結

在這篇文章中,我較系統地討論瞭如何對型別進行自定義格式化。我們透過各種方式達到了這個目的:覆蓋ToString()、過載ToString()、實現 IFormatProvider介面。我們還討論了實現IFormatProvider和ICustomFormatter的兩種方式:建立一個類實現它們,或者各自實現為不同的類。

我想很多人在讀這篇文章以前就會使用這些方法了,我在這裡希望大家能夠多進行一點思考,以一個.Net 框架設計者的角度來思考:為什麼會設計出三個介面配合 String.Format()靜態類來實現這一過程?這樣設計提供了怎樣的靈活性?從這篇文章中,我期望你收穫更多的不是作為一個框架使用者如何去使用這些型別,而是作為一個框架設計者來設計出這樣的型別結構。

感謝閱讀,希望這篇文章能帶給你幫助!

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2236/viewspace-2811701/,如需轉載,請註明出處,否則將追究法律責任。

相關文章