关于C#里IFormatProvider与IFormattable的一些思考

mikodopants / 2023-08-24 / 原文

一,从时间(DateTime)出发

先上一段处理时间格式化的代码。该代码在Net6.0框架下运行。

            var time = new DateTime(2023, 8, 24);

            Console.WriteLine(time);
            
            Console.WriteLine(string.Format("{0:yyyyMMdd}", time));//写法1
            
            Console.WriteLine("Time is {0:yyyyMMdd}", time);//写法2
                                                            
            Console.WriteLine(time.ToString("yyyyMMdd"));//写法3

            //2023 / 8 / 24 0:00:00
            //20230824
            //Time is 20230824
            //20230824

以上代码通过不同的方式将时间格式化,在“yyyyMMdd”的格式要求下,都输出了同样的“20230824”。在DateTime格式化的时候又是怎样识别到“yyyyMMdd”并返回对应的结果?

二,初探源码

        public static string Format(string format, object? arg0)
        {
            return FormatHelper(null, format, new ParamsArray(arg0));
        }
Format
        private static string FormatHelper(IFormatProvider? provider, string format, ParamsArray args)
        {
            if (format == null)
                throw new ArgumentNullException(nameof(format));

            var sb = new ValueStringBuilder(stackalloc char[256]);
            sb.EnsureCapacity(format.Length + args.Length * 8);
            sb.AppendFormatHelper(provider, format, args);
            return sb.ToString();
        }
FormatHelper

 通过string.Fomat的源代码往上找,可以看到实际调用的ValueStringBuilder的AppendFormatHelper方法后,后返回ToString()。分别查找AppendFormatHelper和ToString的实现做了什么。

 1 internal void AppendFormatHelper(IFormatProvider? provider, string format, ReadOnlySpan<object?> args)
 2         {
 3             
 4             //省略部分代码...
 5 
 6             // Query the provider (if one was supplied) for an ICustomFormatter.  If there is one,
 7             // it needs to be used to transform all arguments.
 8             ICustomFormatter? cf = (ICustomFormatter?)provider?.GetFormat(typeof(ICustomFormatter));
 9 
10             // Repeatedly find the next hole and process it.
11             int pos = 0;
12             char ch;
13             while (true)
14             {
15 
16                 ReadOnlySpan<char> itemFormatSpan = default; // used if itemFormat is null
17 
18                 string? s = null;
19                 string? itemFormat = null;
20 
21 
22                 if (cf != null)
23                 {
24                     if (!itemFormatSpan.IsEmpty)
25                     {
26                         itemFormat = new string(itemFormatSpan);
27                     }
28 
29                     s = cf.Format(itemFormat, arg, provider);
30                 }
31 
32                 if (s == null)
33                 {
34                     // Otherwise, fallback to trying IFormattable or calling ToString.
35                     if (arg is IFormattable formattableArg)
36                     {
37                         if (itemFormatSpan.Length != 0)
38                         {
39                             itemFormat ??= new string(itemFormatSpan);
40                         }
41                         s = formattableArg.ToString(itemFormat, provider);
42                     }
43                     else
44                     {
45                         s = arg?.ToString();
46                     }
47 
48                     s ??= string.Empty;
49                 }
50 
51                 //省略部分代码...
52             }
53         }
AppendFormatHelper

可以看到如果提供IFormatProvider,则可以通过自定义的ICustomFormatter.Format获取对应的格式。而如果参数继承了IFormattable,则调用IFormattable.ToString(string? format, IFormatProvider? formatProvider)方法。

        public readonly partial struct DateTime : IComparable, ISpanFormattable, IConvertible, IComparable<DateTime>, IEquatable<DateTime>, ISerializable

        public string ToString(string? format)
        {
            return DateTimeFormat.Format(this, format, null);
        }

        public string ToString(string? format, IFormatProvider? provider)
        {
            return DateTimeFormat.Format(this, format, provider);
        }
DateTime

其实’string.Format‘(写法1)和 ’time.ToString("yyyyMMdd")‘(写法2)都是调用到DateTimeFormat.Format(this, format, null);

而关于 DateTimeFormat.Format 的具体实现这里不详细说明,感兴趣者可点击链接跳转源码自行研究。而本文着重于IFormatProvider与IFormattable。

DateTime实现了接口ISpanFormattable,该接口也继承自IFormattable。当DateTime需要进行字符串格式化时,会调用到IFormattable.ToString(string? format, IFormatProvider? formatProvider)。format传入格式,而IFormatProvider可以对不同的参数类型

提供不同的自定义的ICustomFormatter对格式进行不同的处理。

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

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

        string Format(string? format, object? arg, IFormatProvider? formatProvider);
    }
IFormattable,IFormatProvider,ICustomFormatter

 

三,拓展

 那么,如果想对某个类做到类似于time.ToString("yyyyMMdd")同等的效果,该如何改造呢?先查看以下例子。

        void Test()
        {
            //example
            var p = new Person();
            p.Name = "路人甲";
            //输出Name
            Console.WriteLine("{0:N}", p);

            Console.WriteLine(p.ToString("N"));
        }
        internal class Person
        {
            public string Name { get; set; }
            public int Age { get; set; }
            public DateTime Birthdate { get; set; }
        }

 

Person类有属性Name。当Person实例进行字符串格式化输出,以“N”为格式,输出Name属性。当然编译器不会让上述代码通过编译,因为会报出错误CS1501:“ToString”方法没有采用 1 个参数的重载

那么可以分两种情况进行修改,

1,有修改Person类的权限,可以重新编译Person类所在的项目并生成新的程序集

     1)让Person类继承IFormattable并实现其方法。

    internal class Person:IFormattable
    {
        public string Name { get; set; }

        public string ToString(string? format, IFormatProvider? formatProvider=null)
        {
            switch (format)
            {
                case "N":
                    return Name;      
                default:
                    return this.ToString();
            }
        }
    }

2,没有修改Person类的权限

  1)通过扩展方法实现类似于time.ToString("yyyyMMdd")同等的效果

        public static class PersonExtenion
        {
            public static string ToString(this Person person2, string? format, IFormatProvider? formatProvider)
            {
                if (formatProvider == null)
                {
                    switch (format)
                    {
                        case "N":
                            return person2.Name;
                        default:
                            return person2.ToString();
                    }
                }
                else
                {
                    var custom = formatProvider.GetFormat(person2.GetType());
                    return ((ICustomFormatter)custom).Format(format, person2, formatProvider);
                }
            }
        }
PersonExtenion

  2)在调用string.Format时传入自定义的IFormatProvider和ICustomFormatter

    internal class PersonFormatProvider : IFormatProvider
    {
        public object? GetFormat(Type? formatType)
        {
            return new PersonDefaultFormatter<Person>();
        }
    }
    public class PersonDefaultFormatter<T> : ICustomFormatter where T: Person
    {
        public string Format(string? format, object? arg, IFormatProvider? formatProvider)
        {

            switch (format)
            {
                case "N":
                    return ((T)arg).Name;

                default:
                    return arg.ToString();
            }
        }
PersonFormatProvider,PersonDefaultFormatter

当然,上述代码依然有瑕疵。当传入多个相同类型或不同参数时(如下),因为同一字符串共用同一个IFormatProvider,则传入args的类型作多次判断分开处理。

 Console.WriteLine(string.Format(new PersonFormatProvider(),
                "{0:N}'s birthday is {1:yyyyMMdd}", person,person.Birthdate ));

此时建议选择StringBuilder实例的appendFomat或append方法将字符串分开处理。