类型自定义格式字符串

引言

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("<b>{0}{1}</b>", 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: <i>{0}{1}<i>" ,friend.FamilyName, friend.FirstName);
           case "B":
             return String.Format("Friend: <b>{0}{1}<b>", 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: <b>张子阳</b>

接下来我们看下第二种方式,将 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: <i>{0}{1}<i>", friend.FamilyName, friend.FirstName);
           case "B":
             return String.Format("Friend: <b>{0}{1}<b>", 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: <i>张子阳<i>

.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()静态类来实现这一过程?这样设计提供了怎样的灵活性?从这篇文章中,我期望你收获更多的不是作为一个框架使用者如何去使用这些类型,而是作为一个框架设计者来设计出这样的类型结构。

感谢阅读,希望这篇文章能带给你帮助!