- A+
开发过程中,我们经常使用格式化字符串,本文学习下格式化字符串相关内容。
按照格式化字符串功能的进化,本文讨论下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
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()重载;
文中如有错误,欢迎交流指正。