てくメモ

trivial な notes

【C#】DefaultInterpolatedStringHandler を StringBuilder 的に使う


本記事は、C# Advent Calendar 2023 12日目参加記事となっています。



C#において、複雑な文字列を組み立てる手段として挙げられるのはStringBuilderでしょう。

また、そこまで複雑な組み立てでなければ、補間文字列($"foo: {bar}")が使われます。

補間文字列については、C# 10 / .NET 6 でパフォーマンス改善が行われました。コンパイラDefaultInterpolatedStringHandlerという型を用いたコードに展開し、これは、改善以前(String.Formatへの展開)に対しパフォーマンスに優れます。

さて、このDefaultInterpolatedStringHandlerコンパイラが用いる型ながら、アクセシビリティpublicです。すなわち、ユーザーが直接触ることができます。

というわけで本記事は、DefaultInterpolatedStringHandlerを直接触ってStringBuilder的に使ってしまおう、という内容です。


なお、情報を整理するきっかけとしたポストがありますので、引用します。



目次


TL;DR

  • DefaultInterpolatedStringHandlerStringBuilderと似たふうに使えて、パフォーマンス的利点あり

本記事の範囲

DefaultInterpolatedStringHandler本来の使われ方である)補間文字列については、以下の優れた参考文献の提示に留めます。


本記事では、

  • まず、DefaultInterpolatedStringHandlerを直接触るモチベーションを示します
  • 次に、ToStringAndClearメソッドについて、注意点として説明します
  • 続いて、バッファの初期サイズと拡大、初期バッファを渡すコンストラクタ、値の書き込み、といった話題を取り上げていきます
  • その後、StringBuilderを代替できないケースとなる性質に触れます

モチベーション

DefaultInterpolatedStringHandlerを直接触ってStringBuilder的に使うモチベーションは

  • 型が標準で用意されており(.NET 6~)、
  • StringBuilderとさほど変わらない記述で、
  • パフォーマンス的恩恵を受けることができる点です


書き方の点でStringBuilderとまったく違うやり方が要求される、ということはありません。

文字列を得るまでの流れは以下のとおりです。

DefaultInterpolatedStringHandler sh = new(0, 0);
sh.AppendLiteral("foo: "); // リテラルを書き込む AppendLiteral
sh.AppendFormatted(1); // 値を書き込む AppendFormatted
var result1 = sh.ToStringAndClear(); // 最後に ToStringAndClear

// 参考:StringBuilder
StringBuilder sb = new();
sb.Append("foo: ");
sb.Append(1);
var result2 = sb.ToString();


次に、簡単な例でパフォーマンス的恩恵を見てみます。

byte配列から、2桁16進数表記を", "で区切って"[]"で括った文字列を得ることを考えます。

1行コード、StringBuilderDefaultInterpolatedStringHandlerを、BenchmarkDotNet で計測。

ベンチマークコード折りたたみ

[ShortRunJob]
[MemoryDiagnoser]
public class DefaultInterpolatedStringHandlerBenchmark
{
    private byte[] byteArray = default!;
    [Params(64)]
    public int N;

    [GlobalSetup]
    public void Setup()
    {
        byteArray = new byte[N];
        Random.Shared.NextBytes(byteArray);
    }

    [Benchmark(Baseline = true)]
    public string Oneliner() => $"[{string.Join(", ", byteArray.Select(v => v.ToString("X2")))}]";

    [Benchmark]
    public string StringBuilder()
    {
        StringBuilder sb = new();

        sb.Append('[');
        if(byteArray.Length > 0)
        {
            int i = 0;
            sb.Append($"{byteArray[i++]:X2}"); // これは Append(string) でなく、Append(AppendInterpolatedStringHandler) (なので不利にするものではない)
            while (i < byteArray.Length)
            {
                sb.Append($", {byteArray[i++]:X2}");
            }
        }
        sb.Append(']');

        return sb.ToString();
    }

    [Benchmark]
    public string Handler()
    {
        DefaultInterpolatedStringHandler sh = new(0, 0);

        sh.AppendLiteral("[");
        if(byteArray.Length > 0)
        {
            int i = 0;
            sh.AppendFormatted(byteArray[i++], "X2");
            while(i < byteArray.Length)
            {
                sh.AppendLiteral(", ");
                sh.AppendFormatted(byteArray[i++], "X2");
            }
        }
        sh.AppendLiteral("]");

        return sh.ToStringAndClear();
    }
}

| Method        | N  | Mean     | Error     | StdDev    | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|-------------- |--- |---------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| Oneliner      | 64 | 2.115 μs | 0.4605 μs | 0.0252 μs |  1.00 |    0.00 | 1.0071 |    3168 B |        1.00 |
| StringBuilder | 64 | 1.654 μs | 0.6019 μs | 0.0330 μs |  0.78 |    0.02 | 0.4482 |    1408 B |        0.44 |
| Handler       | 64 | 1.429 μs | 0.4991 μs | 0.0274 μs |  0.68 |    0.02 | 0.1698 |     536 B |        0.17 |

一例なのでざっくりで捉えてほしいのですが、この例ではDefaultInterpolatedStringHandler (Handler) が速度 (Mean) でもアロケーション (Allocated) でも優位です。

特に、アロケーション面で大きく利点があることが見て取れます。これは主に、内部のバッファをnewで用意しない仕組みになっていることが理由です。また、型が構造体なので自身のアロケーションもありません。

書き込み処理についても、ISpanFormattableというインターフェースを利用できる場合、文字列を介さずバッファへ直接書き込みます。1


というわけでDefaultInterpolatedStringHandlerは、標準で用意されていて、StringBuilderと書き方がさほど変わらず、使えばパフォーマンス的恩恵が受けられる、ということです。

これは使いたいですね。

ToStringAndClear ―― 注意点

DefaultInterpolatedStringHandlerを直接触るにあたって注意を必要とする点があります。

それは、最後に(ToString()ではなくToStringAndClearメソッドを呼ぶ点です。

DefaultInterpolatedStringHandler sh = new(0, 0);
sh.AppendLiteral("foo: ");
Console.WriteLine(sh.ToString()); // ToString() でも文字列を生成できるが……
sh.AppendFormatted(1);
Console.WriteLine(sh.ToStringAndClear()); // 最後は ToStringAndClear() を呼ぶ

これは、内部のバッファがArrayPool<char>.Sharedから用意されているためです。

念のためArrayPool<T>を極めて単純に説明すると、配列を使い回すためのプールです。使い回すため、使った後は返却します。
本旨ではないため、以下にドキュメントと Deep Dive な参考文献を示します。


ArrayPool<char>.Sharedから借りたバッファの返却が、ToStringAndClearメソッドをとおして行われます。

コンパイラが用いるぶんには呼び忘れはありませんが、人間が使う場合は強制されません。自己責任になります。(冒頭で引用したポストのリプライによると、DefaultInterpolatedStringHandlerが「ValueStringBuilder」とはならなかった要因がこの点のようです)

というわけで、直接触るという知識には必ずToStringAndClear()をセットにしましょう。

バッファの初期サイズと拡大 ―― new(0, 0)

ここまで、コンストラクタの引数を0, 0としてきました。これは何を示しているのでしょうか。

前者はリテラルの文字長 (literalLength) で、後者は補間される穴の数 (formattedCount) です。例えば、補間文字列$"a-{b}-c"なら、コンパイラが展開する際にはコンストラクタに4, 1が渡されます。

そして、これは厳密な数値でなくても構いません。初期バッファのサイズヒントとして使われます。

// CoreLib より抜粋 (.NET 8.0 時点)、ソース内コメントは原文のもの

private const int GuessedLengthPerHole = 11;
private const int MinimumArrayPoolLength = 256;

[MethodImpl(MethodImplOptions.AggressiveInlining)] // becomes a constant when inputs are constant
internal static int GetDefaultLength(int literalLength, int formattedCount) =>
    Math.Max(MinimumArrayPoolLength, literalLength + (formattedCount * GuessedLengthPerHole));
// 定数を代入すると、以下
int minimumLength = Math.Max(256, literalLength + (formattedCount * 11);

new(0, 0)による初期サイズは 256 になります。Math.Maxを通るので、長さが大きくない場合は細かく指定しても0, 0と変わりません。

256より大きい狙いのサイズがある場合、literalLengthが、StringBuilderなどのコンストラクタでいうcapacityの代わりになりえます。多くの可変長の型と同様、サイズを指定することでバッファ拡大の回数を効果的に軽減できる場合があります。

また、バッファサイズの拡大については2倍ずつになっています。これも、多くの可変長の型と同様です。

  • 参考:Source Browser(リンク先:DefaultInterpolatedStringHandler.GrowCore

初期バッファを渡すコンストラク

自分で初期バッファを用意するコンストラクタも用意されています。

なお、何らかの明確な目的がある場合だけ利用すればよいと思います。


初期バッファとして、stackallocしたSpan<char>を渡すことが可能です。

DefaultInterpolatedStringHandler sh = new(0, 0, default, stackalloc char[128]);

実は、標準で利用されるArrayPool<T>は要求するサイズが小さい場合、単純な速度だけならnew T[]に後れます。アロケーション面も考慮すればそれでもArrayPool<T>でいいとなるわけですが、そうした得意でない区間を補う目的でstackallocを使うことができます。

サイズ6のbyte[]を Base32hex に変換して繋げた文字列にする例を、BenchmarkDotNet で較べてみます。

ベンチマークコード折りたたみ

[ShortRunJob]
[MemoryDiagnoser]
public class StackAllocHandlerBenchmark
{
    private byte[] bytes = default!;

    [GlobalSetup]
    public void Setup()
    {
        bytes = [31, 0, 30, 31, 31, 31];
    }

    private static char ToBase32Hex(byte value) => value < 10
        ? (char)(value + 48)
        : (char)(value + 55);

    private static string WithLINQ(byte[] array) => new(array.Select(v => ToBase32Hex(v)).ToArray());

    private static string WithStringBuilder(byte[] array)
    {
        StringBuilder sb = new(6);
        sb.Append(ToBase32Hex(array[0]));
        sb.Append(ToBase32Hex(array[1]));
        sb.Append(ToBase32Hex(array[2]));
        sb.Append(ToBase32Hex(array[3]));
        sb.Append(ToBase32Hex(array[4]));
        sb.Append(ToBase32Hex(array[5]));
        return sb.ToString();
    }

    // 初期バッファの比較例なので簡潔に補間文字列で(DefaultInterpolatedStringHandler に展開される)
    private static string WithDefaultInterpolatedString(byte[] array) => $"{ToBase32Hex(array[0])}{ToBase32Hex(array[1])}{ToBase32Hex(array[2])}{ToBase32Hex(array[3])}{ToBase32Hex(array[4])}{ToBase32Hex(array[5])}";

    // string.Createを利用した初期バッファ指定の補間文字列(初期バッファを与える DefaultInterpolatedStringHandler に展開される)
    private static string WithStackAlloc(byte[] array) => string.Create(default,
        stackalloc char[6], // ← 初期バッファ
        $"{ToBase32Hex(array[0])}{ToBase32Hex(array[1])}{ToBase32Hex(array[2])}{ToBase32Hex(array[3])}{ToBase32Hex(array[4])}{ToBase32Hex(array[5])}");

    [Benchmark(Baseline = true)]
    public string LINQ() => WithLINQ(bytes);
    [Benchmark]
    public string StringBuilder() => WithStringBuilder(bytes);
    [Benchmark]
    public string DefaultBuffer() => WithDefaultInterpolatedString(bytes);
    [Benchmark]
    public string StackAllocBuffer() => WithStackAlloc(bytes);
}

| Method           | Mean     | Error     | StdDev   | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|----------------- |---------:|----------:|---------:|------:|--------:|-------:|----------:|------------:|
| LINQ             | 56.39 ns |  7.683 ns | 0.421 ns |  1.00 |    0.00 | 0.0408 |     128 B |        1.00 |
| StringBuilder    | 39.53 ns |  7.094 ns | 0.389 ns |  0.70 |    0.01 | 0.0408 |     128 B |        1.00 |
| DefaultBuffer    | 66.35 ns | 43.534 ns | 2.386 ns |  1.18 |    0.04 | 0.0126 |      40 B |        0.31 |
| StackAllocBuffer | 36.29 ns | 16.404 ns | 0.899 ns |  0.64 |    0.02 | 0.0127 |      40 B |        0.31 |

DefaultBuffer が初期バッファを渡さないDefaultInterpolatedStringHandlerです。なんとLINQに後れるという姿が見れました。ただ、アロケーション面での優位は変わりません。
そして、stackallocでは(StackAllocBuffer)、速度面も優位を取り返しています2

ただ正直、この目的で初期バッファを渡すのは微に入り細を穿つ世界という感じはします。(気を払わなくてもアロケーション面の利点は変わらず受けられる)


他に考えられる利用としては、stackallocでなくてもArrayPool<char>.Shared以外から用意したい場合、あるいはバッファの参照を自分で握っておいて何かを行いたい場合、などでしょうか。

ちなみに、渡したバッファを勝手にArrayPool<char>.Sharedに返却されることはありません。逆に言えば、自分で用意したバッファの後始末が必要な場合は自分で行う必要があります。

また、サイズの拡大が必要となった場合、渡したバッファは手放して、ArrayPool<char>.Sharedから新たなバッファが用意されます。わざわざバッファを指定する場合は拡大を前提としていないと思われるので、初期バッファを渡すコンストラクタを使うなら覚えておきたい挙動かもしれません。

値の書き込み ―― ISpanFormattable

先に少し触れましたが、値の書き込みは、可能であればISpanFormattableというインターフェースを利用して行われます。このインターフェースは、プリミティブ型やその他の数値型などに実装されています。

この場合、バッファに直接書き込みが行われ、中間文字列を生成するコストがありません。


ところで、ISpanFormattable以外の書き込みはどうなるのでしょうか。

この場合、内部でToStringメソッドが呼ばれます。

  • 参考:Source Browser(リンク先:DefaultInterpolatedStringHandler.AppendFormatted<T>(T)

簡単な例で確認しましょう。ValueTuple<int, int>を書き込みます。
片方はAppendFormattedにそのまま渡し、片方は用意したISpanFormattableラッパーに包んで渡します。

ベンチマークコード折りたたみ

[ShortRunJob]
[MemoryDiagnoser]
public class ISpanFormattableBenchmark
{
    private readonly struct Wrapper((int, int) value)
        : ISpanFormattable
    {
        public readonly (int, int) Value = value;

        public string ToString(string? format, IFormatProvider? formatProvider) => Value.ToString();

        // まったく Try ではない横着実装だけれど、比較確認用の例ということで……
        public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
        {
            charsWritten = 0;
            destination[charsWritten++] = '(';
            Value.Item1.TryFormat(destination[charsWritten..], out var written, format, provider);
            charsWritten += written;
            destination[charsWritten++] = ',';
            destination[charsWritten++] = ' ';
            Value.Item2.TryFormat(destination[charsWritten..], out written, format, provider);
            charsWritten += written;
            destination[charsWritten++] = ')';
            return true;
        }
    }

    private static Wrapper AsSpanFormattable((int, int) value) => new(value);

    private (int, int) tuple = (16, 256);

    [Benchmark(Baseline = true)]
    public string NonSpanFormattable() => $"{tuple}"; // DefaultInterpolatedStringHandler に展開される
    [Benchmark]
    public string SpanFormattable() => $"{AsSpanFormattable(tuple)}"; // 同上
}

| Method             | Mean      | Error     | StdDev   | Ratio | Gen0   | Allocated | Alloc Ratio |
|------------------- |----------:|----------:|---------:|------:|-------:|----------:|------------:|
| NonSpanFormattable | 113.42 ns | 49.781 ns | 2.729 ns |  1.00 | 0.0459 |     144 B |        1.00 |
| SpanFormattable    |  57.32 ns |  1.493 ns | 0.082 ns |  0.51 | 0.0126 |      40 B |        0.28 |

そのまま渡した場合(NonSpanFormattable)、中間文字列が生成されていることがアロケーションから分かります。

型やラッパーにISpanFormattableを実装するほか、単純にISpanFormattableメンバーを個別に渡して組み立てることで、それを避けることができます。

StringBuilderを代替できないケースとなる性質

StringBuilder的に使う、と銘打ちましたが、性質により単純に代替できないケースがあります。

ref struct

DefaultInterpolatedStringHandlerには、ref structであることにともなう使用箇所の制限があります。

これはSpan<T>などにも共通する知識であり、参考文献を示します。


DefaultInterpolatedStringHandlerref struct なのは、バッファをSpan<char>として保持しているためです。(ref structをフィールドとして持つには、ref structでなくてはならない)
初期バッファとしてstackallocしたSpan<T>を渡すことができるのは、その恩恵です。

一方、標準のバッファ用意先であるArrayPool<T>だけなら、フィールドに持つのは配列で済みます。そういう意味では、汎用の「ValueStringBuilder」として考えると、やや過当な制限を背負っているとも言えます。(実際、汎用の ValueStringBuilder ではないわけですが……)

Remove / Insert / Replace は?

ありません。

汎用の ValueStringBuilder ではないため、文字列補間でコンパイラが使う最低限だけが用意された、ということなのだと思います。

また、内部バッファを取得させてもらえないので、ユーザー側で機能を用意することもできません。

そのため、組み立て中のバッファに対して Remove / Insert / Replace といった操作が要求される場合には使えません。

ライブラリという選択肢

上記の2点は、必ずしも「ValueStringBuilder」としての性質から来るものではありません。DefaultInterpolatedStringHandlerがそうなっている、というものです。

そこで、広く「ValueStringBuilder」を利用するため、ライブラリを利用するという選択肢もあります。

例えば ZString が挙げられます。本旨ではないため、紹介に留めます。

おわりに

というわけで、当記事ではDefaultInterpolatedStringHandlerを直接触ることを取り上げました。

文字列の組み立てが多いような場合は具体的恩恵があると思いますし、もっと進んで better StringBuilder として常用するのもありかもしれません。

ただ実際のところ、不利な要素がちょっとずつ重なって3、スタンダードな手段となりうるかはなかなか……という感じはします。とはいえそれでも、現状で明確な利点のある選択肢だと思います。



  1. StringBuilderも現在はISpanFormattableを利用するため、StringBuilderに対する優位点ではありません。
  2. ちなみにこの例については、パフォーマンスについて言うならString.Create<TState>(Int32, TState, SpanAction<Char,TState>)がより良いですが、初期バッファの比較例ということでお目溢しください。
  3. 本文中に挙げた点以外にも、型の名前が長すぎるなど色々