🙄

DefaultInterpolatedStringHandlerを.NET Framework向けに実装してみた記録

2021/08/16に公開1

.NET 6.0(C# 10.0)と共に、Improved interpolated stringsが導入されます。

単独の補間文字列を使用した場合、DefaultInterpolatedStringHandlerへのシンタックスシュガーとなりますが、.NET 6.0より古い環境には当然存在しません。

古い環境(.NET Framework)でもDefaultInterpolatedStringHandler を自前で用意して動かしてみた、という実験の記録です。

.NET Frameworkではなくても、.NET Standard 2.0でも使用できるかと思います。

6.0.100-preview.7.21379.14 での結果です。

DefaultInterpolatedStringHandler

.NET 6のDefaultInterpolatedStringHandler.cs<TargetFramework>net48</TargetFramework>環境にそのまま移植しても、コンパイル出来ません。

同じく .NET 6.0と同時に公開されたISpanFormattable<T>インターフェースが古い環境には存在せず、それらを実装したクラスもありません。(.NET Core系ならinternalで存在しますが。)

ISpanFormattable<T>インターフェースを使用している箇所をコメントアウトするだけだとToString()を使用してボックス化が多発するので、(恐らく)あまり性能が良くありません。

そのため、ISpanFormattable<T>を使用することは諦めてプリミティブ型に対するAppendがあるStringBuilderへ移譲することにします。
DateTime等は目を瞑ります。
(本格的に対応するなら、Cysharp/ZStringを採用する方がいいでしょう。)

毎度StringBuilderをnewしていると遅そうなので、TLSでインスタンスを保持することにしておきます。

※なお、Production環境で使用することを想定してません。

以下がソースコードです。
#nullable enable

using System.Text;
using System.Diagnostics;
using System.Globalization;
using System.Threading;

namespace System.Runtime.CompilerServices
{
    //[InterpolatedStringHandler]
    internal partial struct DefaultInterpolatedStringHandler
    {
        private const int DefaultBufferSize = 256;

        [ThreadStatic]
        private static StringBuilder? t_buffer;

        private static StringBuilder AcquireBuffer()
        {
            var buffer = t_buffer;
            if (buffer == null)
            {
                return new StringBuilder(DefaultBufferSize);
            }
            else
            {
                buffer.Clear();
                return buffer;
            }
        }
        private readonly StringBuilder _buffer;
        private readonly IFormatProvider? _provider;
        private readonly ICustomFormatter? _formatter;

        public DefaultInterpolatedStringHandler(int literalLength, int formattedCount)
        {
            _provider = null;
            _buffer = AcquireBuffer();
            _formatter = null;
        }

        public DefaultInterpolatedStringHandler(int literalLength, int formattedCount, IFormatProvider? provider)
        {
            _provider = provider;
            _buffer = AcquireBuffer();
            _formatter = GetCustomFormatter(provider);
        }

        public override string ToString() => _buffer.ToString();

        public string ToStringAndClear()
        {
            string result = _buffer.ToString();
            if (_buffer.Capacity <= 65536)
            {
                t_buffer = _buffer;
            }
            return result;
        }


        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendLiteral(string value)
        {
            _buffer.Append(value);
        }

        public void AppendFormatted(object? value, string? format = null)
        {
            if (_formatter?.Format(format, value, _provider) is string customFormatted)
            {
                _buffer.Append(customFormatted);
                return;
            }

            var result =
                value is IFormattable formattable ?
                formattable.ToString(format, _provider) :
                (value?.ToString());

            if (result is not null)
            {
                _buffer.Append(result);
            }
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(string? value)
        {
            if (value is not null)
            {
                if (_formatter is null)
                {
                    _buffer.Append(value);
                }
                else if (_formatter?.Format(null, value, _provider) is string customFormatted)
                {
                    _buffer.Append(customFormatted);
                }
            }
        }

        public void AppendFormatted(object? value, int alignment, string? format = null)
        {
            if (alignment is not 0) throw new NotImplementedException();
            AppendFormatted(value, format);
        }

        private static ICustomFormatter? GetCustomFormatter(IFormatProvider? provider)
        {
            return provider is not null && provider.GetType() != typeof(CultureInfo) ?
                provider.GetFormat(typeof(ICustomFormatter)) as ICustomFormatter : null;
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.Boolean value) => _buffer.Append(value);

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.SByte value) => _buffer.Append(value);

        public void AppendFormatted(System.SByte value, string? format) => _buffer.Append(value.ToString(format));
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.Byte value) => _buffer.Append(value);

        public void AppendFormatted(System.Byte value, string? format) => _buffer.Append(value.ToString(format));
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.Char value) => _buffer.Append(value);

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.Int16 value) => _buffer.Append(value);

        public void AppendFormatted(System.Int16 value, string? format) => _buffer.Append(value.ToString(format));
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.Int32 value) => _buffer.Append(value);

        public void AppendFormatted(System.Int32 value, string? format) => _buffer.Append(value.ToString(format));
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.Int64 value) => _buffer.Append(value);

        public void AppendFormatted(System.Int64 value, string? format) => _buffer.Append(value.ToString(format));
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.Single value) => _buffer.Append(value);

        public void AppendFormatted(System.Single value, string? format) => _buffer.Append(value.ToString(format));
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.Double value) => _buffer.Append(value);

        public void AppendFormatted(System.Double value, string? format) => _buffer.Append(value.ToString(format));
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.Decimal value) => _buffer.Append(value);

        public void AppendFormatted(System.Decimal value, string? format) => _buffer.Append(value.ToString(format));
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.UInt16 value) => _buffer.Append(value);

        public void AppendFormatted(System.UInt16 value, string? format) => _buffer.Append(value.ToString(format));
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.UInt32 value) => _buffer.Append(value);

        public void AppendFormatted(System.UInt32 value, string? format) => _buffer.Append(value.ToString(format));
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(System.UInt64 value) => _buffer.Append(value);

        public void AppendFormatted(System.UInt64 value, string? format) => _buffer.Append(value.ToString(format));
    }
}

ベンチマーク

下記の様に、コンパイルし直しただけで.NET Framework 4.8 環境で約27%高速化しました。

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000
AMD Ryzen 5 5600X, 1 CPU, 12 logical and 6 physical cores
  [Host]               : .NET Framework 4.8 (4.8.4400.0), X64 RyuJIT
  .NET Framework 4.7.2 : .NET Framework 4.8 (4.8.4400.0), X64 RyuJIT

Job=.NET Framework 4.7.2  Runtime=.NET Framework 4.7.2

.NET SDK 6.0.100-preview.7.21379.14で コンパイルした場合

Method Mean Error StdDev Gen 0 Allocated
HelloWorld 171.5 ns 3.45 ns 3.39 ns 0.0262 168 B

.NET SDK 5.0.400 でコンパイルした場合

Method Mean Error StdDev Gen 0 Allocated
HelloWorld 234.9 ns 1.83 ns 1.62 ns 0.0377 241 B

ベンチマークコード

SDKの切り替えは、別途global.jsonで行ってます。

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(System.Reflection.Assembly.GetEntryAssembly()).Run(args);

internal class BenchmarkConfig : BenchmarkDotNet.Configs.ManualConfig
{
    public BenchmarkConfig()
    {
        AddDiagnoser(MemoryDiagnoser.Default);
    }
}
[SimpleJob(RuntimeMoniker.Net472)]
[Config(typeof(BenchmarkConfig))]
public class Program
{
    private int a = 100, b = 200, c = 300;

    [Benchmark]
    public string HelloWorld() => $"Hello World!{a}.{b}.{c}";
}

Discussion

kenichiudakenichiuda

自己レス。
ZStrngでDefaultInterpolatedStringHandlerを実装したが、とても遅かった。

 src/ZString/DefaultInterpolatedStringHandler.cs | 68 +++++++++++++++++++++++++
 src/ZString/Utf16ValueStringBuilder.cs          |  2 +-
 2 files changed, 70 insertions(+), 1 deletion(-)

diff --git a/src/ZString/DefaultInterpolatedStringHandler.cs b/src/ZString/DefaultInterpolatedStringHandler.cs
new file mode 100644
index 0000000..5277bcf
--- /dev/null
+++ b/src/ZString/DefaultInterpolatedStringHandler.cs
@@ -0,0 +1,68 @@
+using Cysharp.Text;
+using System;
+
+#if !NET6_0_OR_GREATER
+namespace System.Runtime.CompilerServices
+{
+    public ref struct DefaultInterpolatedStringHandler
+    {
+        Utf16ValueStringBuilder builder;
+
+        public DefaultInterpolatedStringHandler(int literalLength, int formattedCount)
+        {
+            builder = ZString.CreateStringBuilder();
+            builder.Grow(literalLength + formattedCount);
+        }
+
+        public override string ToString() => builder.ToString();
+
+        public string ToStringAndClear()
+        {
+            var result = ToString();
+            builder.Dispose();
+            return result;
+        }
+
+        public void AppendFormatted<T>(T value)
+        {
+            builder.Append(value);
+        }
+
+        public void AppendFormatted<T>(T value, int alignment, string format = null)
+        {
+            builder.AppendFormatInternal(value, alignment, format.AsSpan(), nameof(value));
+        }
+
+        public void AppendFormatted<T>(T value, string format)
+        {
+            builder.AppendFormatInternal(value, 0, format.AsSpan(), nameof(value));
+        }
+
+        public void AppendFormatted(ReadOnlySpan<char> value) => builder.Append(value);
+
+        public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string format = null)
+        {
+            if (alignment <= 0)
+            {
+                alignment *= -1;
+                int padding = alignment - value.Length;
+                if (alignment > 0 && padding > 0)
+                {
+                    builder.Append(' ', padding);
+                }
+            }
+            else
+            {
+                int padding = alignment - value.Length;
+                if (padding > 0)
+                {
+                    builder.Append(' ', padding);
+                }
+                builder.Append(value);
+            }
+        }
+
+        public void AppendLiteral(string value) => builder.Append(value);
+    }
+}
+#endif
diff --git a/src/ZString/Utf16ValueStringBuilder.cs b/src/ZString/Utf16ValueStringBuilder.cs
index 170c716..155eb17 100644
--- a/src/ZString/Utf16ValueStringBuilder.cs
+++ b/src/ZString/Utf16ValueStringBuilder.cs
@@ -625,7 +625,7 @@ namespace Cysharp.Text
             throw new FormatException("Input string was not in a correct format.");
         }
 
-        void AppendFormatInternal<T>(T arg, int width, ReadOnlySpan<char> format, string argName)
+        internal void AppendFormatInternal<T>(T arg, int width, ReadOnlySpan<char> format, string argName)
         {
             if (width <= 0) // leftJustify
             {