C# 10 新特性 —— 插值字符串优化

dotNET跨平台 2021-11-26 08:16

C# 10 新特性 —— 插值字符串优化

Intro

字符串应该是我们平时使用的最多的一个类型,从 C# 6 开始我们开始支持了插值字符串,使得我们可以更方便的进行字符串的操作,现在很多分析器也推荐我们使用插值这种写法,这能够使得我们的代码更加清晰和简洁,C# 10 提供了更好的实现方式以及更好的性能

Interpolated string

什么是插值字符串呢?就是 $ 符号开始的类似 $"Hello {name}" 这样的字符串,我们来看下面的示例

var str = $"1233";
var name = "Alice";
var hello = $"Hello {name}!";
var num = 10;
var numDesc = $"The num is {num}";

简单的插值字符串会简化,对于不需要 format 的参数会直接简化为字符串,对于一些简单的字符串拼接,可以简化成 string.Concat,在 C#10/.NET 6 之前的版本中,其他的大多会翻译成 string.Format 的形式,翻译成低版本的 C#  代码则是这样的

string str = "1233";
string name = "Alice";
string hello = string.Concat("Hello ", name, "!");
int num = 10;
string numDesc = string.Format("The num is {0}", num);

对于 string.Format,参数如果是值类型会发生装箱,变为 object,我们从 IL 代码可以看得出来

IL

插值字符串格式化的时候会使用当前 CultureInfo,如果需要使用不同 CultureInfo 或者手动指定,可以借助 FormattableString/FormattableStringFactory 来实现

var num = 10;
FormattableString str1 = $"Hello {num}";
Console.WriteLine(str1.Format);
Console.WriteLine(str1.ToString(new CultureInfo("zh-CN")));

str1 = FormattableStringFactory.Create("Hello {0}", num);
Console.WriteLine(str1.Format);
Console.WriteLine(str1.ToString(new CultureInfo("en-US")));

对于 C# 10/.NET6 中,则会生成下面的代码:

string str = "1233";
string name = "Alice";
string hello = string.Concat ("Hello ", name, "!");
int num = 10;
DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(111);
defaultInterpolatedStringHandler.AppendLiteral("The num is ");
defaultInterpolatedStringHandler.AppendFormatted(num);
string numDesc = defaultInterpolatedStringHandler.ToStringAndClear();

IL in C#10/.NET6

在新版本中,会由 DefaultInterpolatedStringHandler 来处理插值字符串,而且这个新的 DefaultInterpolatedStringHandler 是一个结构体并且会有一个泛型方法 AppendFormatted<T> 来避免发生装箱,在 format 的时候性能更优,对于普通的字符串则使用 AppendLiteral() 方法处理,声明如下:

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 valuestring? format);
        public void AppendFormatted<T>(T valueint alignment);
        public void AppendFormatted<T>(T valueint alignment, string? format);

        public void AppendFormatted(ReadOnlySpan<charvalue);
        public void AppendFormatted(ReadOnlySpan<charvalueint alignment = 0string? format = null);

        public void AppendFormatted(stringvalue);
        public void AppendFormatted(stringvalueint alignment = 0string? format = null);
        public void AppendFormatted(objectvalueint alignment = 0string? format = null);

        public string ToStringAndClear();
    }
}

具体实现可以参考:https://github.com/dotnet/runtime/blob/v6.0.0/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/DefaultInterpolatedStringHandler.cs

在 .NET 6 中增加了两个 String 方法来支持使用新的插值处理方式

/// <summary>Creates a new string by using the specified provider to control the formatting of the specified interpolated string.</summary>
/// <param name="provider">An object that supplies culture-specific formatting information.</param>
/// <param name="handler">The interpolated string.</param>
/// <returns>The string that results for formatting the interpolated string using the specified format provider.</returns>
public static string Create(IFormatProvider? provider, [InterpolatedStringHandlerArgument("provider")] ref DefaultInterpolatedStringHandler handler) =>
    handler.ToStringAndClear();

/// <summary>Creates a new string by using the specified provider to control the formatting of the specified interpolated string.</summary>
/// <param name="provider">An object that supplies culture-specific formatting information.</param>
/// <param name="initialBuffer">The initial buffer that may be used as temporary space as part of the formatting operation. The contents of this buffer may be overwritten.</param>
/// <param name="handler">The interpolated string.</param>
/// <returns>The string that results for formatting the interpolated string using the specified format provider.</returns>
public static string Create(IFormatProvider? provider, Span<char> initialBuffer, [InterpolatedStringHandlerArgument("provider""initialBuffer")] ref DefaultInterpolatedStringHandler handler) =>
    handler.ToStringAndClear();

Custom Interpolated string handler

接着我们来尝试实现一个简单的插值字符串处理器,实现一个最基本的插值字符串处理器需要满足四个条件:

  • 构造函数至少需要两个 int 参数,一个是字符串中常量字符的长度(literalLength),一个是需要格式化的参数的数量(formattedCount)
  • 需要一个 publicAppendLiteral(string s) 方法来处理常量字符的拼接
  • 需要一个 publicAppendFormatted<T>(T t) 方法来处理参数
  • 自定义的处理器需要使用 InterpolatedStringHandler 来标记,处理器可以是 class 也可以是 struct
// InterpolatedStringHandlerAttribute is required for custom InterpolatedStringHandler
[InterpolatedStringHandler]
public struct CustomInterpolatedStringHandler
{
    // Storage for the built-up string
    private readonly StringBuilder builder;

    /// <summary>
    /// CustomInterpolatedStringHandler constructor
    /// </summary>
    /// <param name="literalLength">string literal length</param>
    /// <param name="formattedCount">formatted count</param>
    public CustomInterpolatedStringHandler(int literalLength, int formattedCount)
    {
        builder = new StringBuilder(literalLength);
        Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
    }

    // Required
    public void AppendLiteral(string s)
    {
        Console.WriteLine($"\tAppendLiteral called: {{{s}}}");
        builder.Append(s);
        Console.WriteLine($"\tAppended the literal string");
    }

    // Required
    public void AppendFormatted<T>(T t)
    {
        Console.WriteLine($"\tAppendFormatted called: {{{t}}} is of type {typeof(T)}");
        builder.Append(t?.ToString());
        Console.WriteLine($"\tAppended the formatted object");
    }

    public override string ToString()
    {
        return builder.ToString();
    }
}

使用示例如下:

private static void LogInterpolatedString(string str)
{
    Console.WriteLine(nameof(LogInterpolatedString));
    Console.WriteLine(str);
}

private static void LogInterpolatedString(CustomInterpolatedStringHandler stringHandler)
{
    Console.WriteLine(nameof(LogInterpolatedString));
    Console.WriteLine(nameof(CustomInterpolatedStringHandler));
    Console.WriteLine(stringHandler.ToString());
}

// Custom InterpolatedStringHandler
LogInterpolatedString("The num is 10");
LogInterpolatedString($"The num is {num}");

输出结果如下:

LogInterpolatedString
The num is 10
        literal length: 11, formattedCount: 1
        AppendLiteral called: {The num is }
        Appended the literal string
        AppendFormatted called: {10} is of type System.Int32
        Appended the formatted object
LogInterpolatedString
CustomInterpolatedStringHandler
The num is 10

除此之外,我们还可以在自定义的插值字符串处理器的构造器中增加自定义参数,我们可以使用 InterpolatedStringHandlerArgument 来引入更多构造器参数,我们在上面的示例基础上改造一下,改造后 CustomInterpolatedStringHandler代码如下:

[InterpolatedStringHandler]
public struct CustomInterpolatedStringHandler
{
    private readonly StringBuilder builder;

    private readonly int _limit;

    public CustomInterpolatedStringHandler(int literalLength, int formattedCount) : this(literalLength, formattedCount, 0)
    { }

    public CustomInterpolatedStringHandler(int literalLength, int formattedCount, int limit)
    {
        builder = new StringBuilder(literalLength);
        Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
        _limit = limit;
    }

    // Required
    public void AppendLiteral(string s)
    {
        Console.WriteLine($"\tAppendLiteral called: {{{s}}}");
        builder.Append(s);
        Console.WriteLine($"\tAppended the literal string");
    }

    // Required
    public void AppendFormatted<T>(T t)
    {
        Console.WriteLine($"\tAppendFormatted called: {{{t}}} is of type {typeof(T)}");
        if (t is int n && n < _limit)
        {
            return;
        }
        builder.Append(t?.ToString());
        Console.WriteLine($"\tAppended the formatted object");
    }

    public override string ToString()
    {
        return builder.ToString();
    }
}

调用方式我们再增加一种方式以使用新引入的构造器:

private static void LogInterpolatedString(int limit, [InterpolatedStringHandlerArgument("limit")] ref CustomInterpolatedStringHandler stringHandler)
{
    Console.WriteLine(nameof(LogInterpolatedString));
    Console.WriteLine($"{nameof(CustomInterpolatedStringHandler)} with limit:{limit}");
    Console.WriteLine(stringHandler.ToString());
}

做了一个检查,如果参数是 int 并且小于传入的 limit 参数则不会被拼接,来看一下下面的调用

LogInterpolatedString(10$"The num is {num}");
Console.WriteLine();
LogInterpolatedString(15$"The num is {num}");

输出结果如下:

        literal length: 11, formattedCount: 1
        AppendLiteral called: {The num is }
        Appended the literal string
        AppendFormatted called: {10} is of type System.Int32
        Appended the formatted object
LogInterpolatedString
CustomInterpolatedStringHandler with limit:10
The num is 10

        literal length: 11, formattedCount: 1
        AppendLiteral called: {The num is }
        Appended the literal string
        AppendFormatted called: {10} is of type System.Int32
LogInterpolatedString
CustomInterpolatedStringHandler with limit:15
The num is

从上面的结果可以看出来,我们的代码是生效的,第一次打印出来了 num,第二次没有打印 num

还有一个特殊的参数,我们可以在构造方法中引入一个 bool 类型的 out 参数,如果这个参数为 false 则不会进行字符串的拼接 Append,我们改造一下刚才的示例,示例代码如下:

public CustomInterpolatedStringHandler(int literalLength, int formattedCount, int limit, out bool shouldAppend)
{
    shouldAppend = limit < 20;

    builder = new StringBuilder(shouldAppend ? literalLength : 0);
    Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
    _limit = limit;
}

limit 参数小于 20 时进行字符串的拼接,否则就不输出,测试代码如下

LogInterpolatedString(10$"The num is {num}");
Console.WriteLine();
LogInterpolatedString(15$"The num is {num}");
Console.WriteLine();
LogInterpolatedString(20$"The num is {num}");

输出结果是这样的

        literal length: 11, formattedCount: 1
        AppendLiteral called: {The num is }
        Appended the literal string
        AppendFormatted called: {10} is of type System.Int32
        Appended the formatted object
LogInterpolatedString
CustomInterpolatedStringHandler with limit:10
The num is 10

        literal length: 11, formattedCount: 1
        AppendLiteral called: {The num is }
        Appended the literal string
        AppendFormatted called: {10} is of type System.Int32
LogInterpolatedString
CustomInterpolatedStringHandler with limit:15
The num is

        literal length: 11, formattedCount: 1
LogInterpolatedString
CustomInterpolatedStringHandler with limit:20

可以看到,当 limit 是 20 的时候,输出的是空行,没有任何内容

另外我们可以把上面的 Append 方法的返回值改成 bool,如果方法中返回 false 则会造成短路,类似于 ASP.NET Core 中中间件的短路,后面的拼接就会取消,我们再改造一下上面的示例,改造一下 Append 方法

public bool AppendLiteral(string s)
{
    if (s.Length <= 1)
        return false;

    Console.WriteLine($"\tAppendLiteral called: {{{s}}}");
    builder.Append(s);
    Console.WriteLine($"\tAppended the literal string");
    return true;
}

// Required
public bool AppendFormatted<T>(T t)
{
    Console.WriteLine($"\tAppendFormatted called: {{{t}}} is of type {typeof(T)}");
    if (t is int n && n < _limit)
    {
        return false;
    }
    builder.Append(t?.ToString());
    Console.WriteLine($"\tAppended the formatted object");
    return true;
}

再来使用 LogInterpolatedString(12, $"The num is {num} and the time is {DateTime.Now}!"); 调用一下试一下,输出结果如下:

        literal length: 29, formattedCount: 2
        AppendLiteral called: {The num is }
        Appended the literal string
        AppendFormatted called: {10} is of type System.Int32
LogInterpolatedString
CustomInterpolatedStringHandler with limit:12
The num is

更多自定义可以参考默认的 DefaultInterpolatedStringHandler

使用自定义的 InterpolatedStringHandler 时,如果是结构体,参数建议使用 ref 引用传递,可以参考 https://github.com/dotnet/runtime/issues/57538

More

有哪些场景可以用呢?下面就是一个示例,更多细节可以参考:https://github.com/dotnet/runtime/blob/v6.0.0/src/libraries/System.Private.CoreLib/src/System/Diagnostics/Debug.cs

https://github.com/dotnet/runtime/blob/v6.0.0/src/libraries/System.Private.CoreLib/src/System/Diagnostics/Debug.cs#L280

[Conditional("DEBUG")]
public static void Assert([DoesNotReturnIf(false)] bool condition, [InterpolatedStringHandlerArgument("condition")] ref AssertInterpolatedStringHandler message) =>
    Assert(condition, message.ToStringAndClear());

当然不仅于此,还有很多细节可以去挖掘,还有 StringBuilder/Memory 等也使用了新的方式来处理插值字符串

最后如果我们可以使用插值字符串,就尽可能地使用插值字符串来处理,从 .NET 6 以后就不会有装箱的问题了,性能还会更好

感兴趣的小伙伴们可以更加深入研究一下,上面的示例有需要的可以从 Github 上获取 https://github.com/WeihanLi/SamplesInPractice/blob/master/CSharp10Sample/InterpolatedStringSample.cs

References

  • https://github.com/dotnet/runtime/issues/50635
  • https://github.com/dotnet/runtime/blob/v6.0.0/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/InterpolatedStringHandlerAttribute.cs
  • https://github.com/dotnet/csharplang/blob/main/proposals/csharp-10.0/improved-interpolated-strings.md
  • https://devblogs.microsoft.com/dotnet/string-interpolation-in-c-10-and-net-6/
  • https://github.com/dotnet/runtime/blob/v6.0.0/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/DefaultInterpolatedStringHandler.cs
  • https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/tokens/interpolated#compilation-of-interpolated-strings
  • https://docs.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.defaultinterpolatedstringhandler?view=net-6.0
  • https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/interpolated-string-handler
  • https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-10.0/improved-interpolated-strings
  • https://docs.microsoft.com/en-us/dotnet/csharp/tutorials/string-interpolation
  • https://github.com/dotnet/runtime/blob/v6.0.0/src/libraries/System.Private.CoreLib/src/System/Diagnostics/Debug.cs
  • https://github.com/dotnet/runtime/blob/v6.0.0/src/libraries/System.Private.CoreLib/src/System/FormattableString.cs
  • https://github.com/dotnet/runtime/blob/v6.0.0/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/FormattableStringFactory.cs
  • https://github.com/dotnet/runtime/issues/57538
  • https://github.com/WeihanLi/SamplesInPractice/blob/master/CSharp10Sample/InterpolatedStringSample.cs


推荐阅读