格式化字符串

  • A+
所属分类:.NET技术
摘要

开发过程中,我们经常使用格式化字符串,本文学习下格式化字符串相关内容。

开发过程中,我们经常使用格式化字符串,本文学习下格式化字符串相关内容。

按照格式化字符串功能的进化,本文讨论下String.Format(),C# 6版本的字符串内插及C#10版本的字符串内插优化。

String.Format()

实现格式化字符串有多种方法,如可以使用简单的字符串相加,但是这种方式可读性较差。

最常用的是String.Format()方法,该方法是String的一个静态方法,有多种形式的重载,其内部使用StringBuilder的Append()方法进行拼接。

如果字符串中需要包含'{'或'}',需使用'{{'或'}}'进行转义。当遇到'{'字符时,如果不是两个'{',则会获取{}内索引对应的参数,并调用其ToString()方法,然后使用Append()拼接到StringBuilder。

如果需要对齐参数,可以在{}内使用','指定对齐方式。

如果需要格式化参数,可以在{}内使用':'指定格式化方式,Format()方法会检查参数是否实现了IFormattable,是则调用IFormattable.ToString(String format, IFormatProvider formatProvider)方法获取格式化后的字符串进行拼接。因此,如果想自定义类型格式化形式,需实现IFormattable接口。当然也可以实现IFormatProvider和ICustomFormatter接口,并将IFormatProvider的实现类作为参数传入。

public static String Format(String format, Object arg0); public static String Format(String format, Object arg0, Object arg1); public static String Format(String format, Object arg0, Object arg1, Object arg2); public static String Format(String format, params Object[] args); public static String Format(IFormatProvider provider, String format, Object arg0); public static String Format(IFormatProvider provider, String format, Object arg0, Object arg1); public static String Format(IFormatProvider provider, String format, Object arg0, Object arg1, Object arg2); public static String Format(IFormatProvider provider, String format, params Object[] args); private static String FormatHelper(IFormatProvider provider, String format, ParamsArray args) {     if (format == null)         throw new ArgumentNullException("format");          return StringBuilderCache.GetStringAndRelease(StringBuilderCache.Acquire(format.Length + args.Length * 8).AppendFormatHelper(provider, format, args)); }  internal StringBuilder AppendFormatHelper(IFormatProvider provider, String format, ParamsArray args) {     if (format == null) {         throw new ArgumentNullException("format");     }     Contract.Ensures(Contract.Result<StringBuilder>() != null);     Contract.EndContractBlock();       int pos = 0;     int len = format.Length;     char ch = 'x0';       ICustomFormatter cf = null;     if (provider != null) {         cf = (ICustomFormatter)provider.GetFormat(typeof(ICustomFormatter));     }       while (true) {         int p = pos;         int i = pos;         while (pos < len) {             ch = format[pos];               pos++;             if (ch == '}')             {                 if (pos < len && format[pos] == '}') // Treat as escape character for }}                     pos++;                 else                     FormatError();             }               if (ch == '{')             {                 if (pos < len && format[pos] == '{') // Treat as escape character for {{                     pos++;                 else                 {                     pos--;                     break;                 }             }               Append(ch);         }           if (pos == len) break;         pos++;         if (pos == len || (ch = format[pos]) < '0' || ch > '9') FormatError();         int index = 0;         do {             index = index * 10 + ch - '0';             pos++;             if (pos == len) FormatError();             ch = format[pos];         } while (ch >= '0' && ch <= '9' && index < 1000000);         if (index >= args.Length) throw new FormatException(Environment.GetResourceString("Format_IndexOutOfRange"));         while (pos < len && (ch = format[pos]) == ' ') pos++;         bool leftJustify = false;         int width = 0;         if (ch == ',') {             pos++;             while (pos < len && format[pos] == ' ') pos++;               if (pos == len) FormatError();             ch = format[pos];             if (ch == '-') {                 leftJustify = true;                 pos++;                 if (pos == len) FormatError();                 ch = format[pos];             }             if (ch < '0' || ch > '9') FormatError();             do {                 width = width * 10 + ch - '0';                 pos++;                 if (pos == len) FormatError();                 ch = format[pos];             } while (ch >= '0' && ch <= '9' && width < 1000000);         }           while (pos < len && (ch = format[pos]) == ' ') pos++;         Object arg = args[index];         StringBuilder fmt = null;         if (ch == ':') {             pos++;             p = pos;             i = pos;             while (true) {                 if (pos == len) FormatError();                 ch = format[pos];                 pos++;                 if (ch == '{')                 {                     if (pos < len && format[pos] == '{')  // Treat as escape character for {{                         pos++;                     else                         FormatError();                 }                 else if (ch == '}')                 {                     if (pos < len && format[pos] == '}')  // Treat as escape character for }}                         pos++;                     else                     {                         pos--;                         break;                     }                 }                   if (fmt == null) {                     fmt = new StringBuilder();                 }                 fmt.Append(ch);             }         }         if (ch != '}') FormatError();         pos++;         String sFmt = null;         String s = null;         if (cf != null) {             if (fmt != null) {                 sFmt = fmt.ToString();             }             s = cf.Format(sFmt, arg, provider);         }           if (s == null) {             IFormattable formattableArg = arg as IFormattable;   #if FEATURE_LEGACYNETCF             if(CompatibilitySwitches.IsAppEarlierThanWindowsPhone8) {                 // TimeSpan does not implement IFormattable in Mango                 if(arg is TimeSpan) {                     formattableArg = null;                 }             } #endif             if (formattableArg != null) {                 if (sFmt == null && fmt != null) {                     sFmt = fmt.ToString();                 }                   s = formattableArg.ToString(sFmt, provider);             } else if (arg != null) {                 s = arg.ToString();             }         }           if (s == null) s = String.Empty;         int pad = width - s.Length;         if (!leftJustify && pad > 0) Append(' ', pad);         Append(s);         if (leftJustify && pad > 0) Append(' ', pad);     }     return this; } 

字符串内插(C# 6)

C# 6推出了字符串内插语法,对比String.Format()方法:

  • 代码可读性更高:尤其是结合@多行显示长字符串时,代码更易读;
  • 降低了犯错的风险:使用String.Format()需注意占位符索引、参数顺序及参数个数,字符串内插无需注意;
  • 实现方式一致:字符串内插在编译时会被编译成对String.Format()方法的调用(如果行为等同于串联则生成对String.Concat()的调用);
  • 性能有微乎其微的影响:显示变量内插会导致一点开销但开销很小。
// source code string name = "world"; Console.WriteLine($"hello {name}"); int i = 10; Console.WriteLine($"i: {i}");  // IL code 0000    nop 0001    ldstr   "world" 0006    stloc.0 0007    ldstr   "hello " 000C    ldloc.0 000D    call    string [mscorlib]System.String::Concat(string, string) 0012    call    void [mscorlib]System.Console::WriteLine(string) 0017    nop 0018    ldc.i4.s    10 001A    stloc.1 001B    ldstr   "i: {0}" 0020    ldloc.1 0021    box [mscorlib]System.Int32 0026    call    string [mscorlib]System.String::Format(string, object) 002B    call    void [mscorlib]System.Console::WriteLine(string) 0030    nop 0031    ret 

字符串内插优化(C# 10)

从上文中的IL代码可以看到,调用C# 6版本的字符串内插的时候,出现了装箱操作,因此是有性能问题的。总结C# 6字符串内插的一些性能、开销、使用问题如下:

  • 值类型参数会被装箱;
  • 大多数情况下会分配一个参数数组;
  • 无法使用Span或其它的ref struct类型;
  • 无法给常量字符串赋值;
  • 当条件不成立无需创建字符串的情况下,String.Format()无法避免执行,如Debug.Assert(condition, $"{SomethingExpensiveHappensHere()}");
  • 当进行插值时,不仅需调用参数的Object.ToString()或IFormattable.ToString,还要分配临时的string对象;

C# 10对字符串内插进行了优化,如下.NET 6代码编译后使用DnSpy查看反编译后的C#代码,可以看到其实现不再是调用String.Format(),而是由DefaultInterpolatedStringHandler处理字符串内插。

// source code int i = 10; Console.WriteLine($"i: {i}");  // 反编译后 int i = 10; DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(3, 1); defaultInterpolatedStringHandler.AppendLiteral("i: "); defaultInterpolatedStringHandler.AppendFormatted<int>(i); Console.WriteLine(defaultInterpolatedStringHandler.ToStringAndClear()); 

DefaultInterpolatedStringHandler声明如下,详细实现可参考源码。编译器根据传入的literalLength和formattedCount参数估计并从ArrayPool.Shared申请内存。发出一系列的调用追加插入的字符串,调用AppendLiteral()追加字符串常量部分,调用AppendFormatted()合适的重载追加格式化的部分。调用ToStringAndClear()方法提取构建好的字符串并将资源返回给ArrayPool.Shared。

namespace System.Runtime.CompilerServices {     [InterpolatedStringHandler]     public ref struct DefaultInterpolatedStringHandler     {         public DefaultInterpolatedStringHandler(int literalLength, int formattedCount);         public DefaultInterpolatedStringHandler(int literalLength, int formattedCount, System.IFormatProvider? provider);         public DefaultInterpolatedStringHandler(int literalLength, int formattedCount, System.IFormatProvider? provider, System.Span<char> initialBuffer);          public void AppendLiteral(string value);          public void AppendFormatted<T>(T value);         public void AppendFormatted<T>(T value, string? format);         public void AppendFormatted<T>(T value, int alignment);         public void AppendFormatted<T>(T value, int alignment, string? format);          public void AppendFormatted(ReadOnlySpan<char> value);         public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null);          public void AppendFormatted(string? value);         public void AppendFormatted(string? value, int alignment = 0, string? format = null);         public void AppendFormatted(object? value, int alignment = 0, string? format = null);          public string ToStringAndClear();     } } 

总结C# 10对字符串内插进行优化后,有如下改进:

  • 对于内插参数使用泛型方法AppendFormatted避免了格式化参数装箱操作;
  • 每个插值都会有对应的AppendFormatted()重载调用,因此当传递多个参数时无需分配参数数组;
  • 通过AppendFormatted(ReadOnlySpan)方法,可以使用Span作为格式化参数;
  • 无需在运行时解析插值字符串,编译时进行了解析并生成了一系列的调用以便运行时构建字符串;
  • 提供ISpanFormattable接口,取代对object.ToString()或IFormattable.ToString()的调用,无需生成临时string。core libraries中的很多类型已实现该接口,提供更好的性能生成字符串;
  • String提供了两个静态的Create()方法重载,通过传入IFormatProvider及Span进一步优化性能;
  • StringBuilder类优化:提供Append()及AppendLine()的重载,支持字符串内插形式以优化性能;
  • 当条件不成立时,可根据out bool参数,跳过AppendLiteral()及AppendFormatted(),如.NET 6中的Debug.Assert()重载;

文中如有错误,欢迎交流指正。

参考文章