てくメモ

trivial な notes

【C#】文字列補間

C# の文字列補間($"[{i}, {j}"]: こういうリテラルでやりたいようなこと)は、C#(.NET)の進化によって手筋が変わっている部分があるので整理する。


まず箇条書きなポイントとして、一定程度新しい環境を利用する場合

  • 文字列補間のリテラルString.Formatと同等という記述は古い
  • ミュータブルに文字列を組み立てるのにStringBuilder一択、ということはない

最初に結論、的な思うところは

  • 基本的には、普通にリテラルを使う
  • 発展的には、String.Createを使う


それでは各方法を見ていく。

まず古典的な手段として、String.Formatがある。

// int i, j;
string.Format("[{0:X2}, {1:X2}]", i, j);

なお VisualStudio では、クイックアクションで文字列補間リテラルに置き換えることができる。


次に文字列補間リテラル

$"[{i:X2}, {j:X2}]";

これは、C# 9.0 以前はString.Formatに展開されていたが、現在は異なる。
参考:C# 10.0 の補間文字列の改善 - C# によるプログラミング入門 | ++C++; // 未確認飛行 C

次のような補間ハンドラを用いたものになる。

DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(4, 2);
defaultInterpolatedStringHandler.AppendLiteral("[");
defaultInterpolatedStringHandler.AppendFormatted(i, "X2");
defaultInterpolatedStringHandler.AppendLiteral(", ");
defaultInterpolatedStringHandler.AppendFormatted(j, "X2");
defaultInterpolatedStringHandler.AppendLiteral("]");

このハンドラは内部でミュータブルなバッファを持っていて最後それを文字列にして返す、StringBuilderのようなことをしてくれている。(さらにDefaultInterpolatedStringHandlerは構造体のため、そのもののアロケ無し)

記述も簡潔なのにチューニングされているということで、お得。


次にStringBuilder
文字列を組み立てると言ったらコレ、みたいな立ち位置だが果たして……

StringBuilder sb = new(8);
sb.Append($"[{i:X2}, {j:X2}]");
sb.ToString();

Appendに文字列補間リテラルを書いたらstringがつくられてからStringBuilderへの追加になるから意味ないよ! となりそうだけれど、これは(Append(string)でなく)Append(ref AppendInterpolatedStringHandler handler)となり前述のハンドラのように展開される。
個別 Append と同等みたいなものなので、最後に行う比較はこれで。

StringBuilder.AppendInterpolatedStringHandler 折りたたみ

StringBuilder.AppendInterpolatedStringHandler handler = new StringBuilder.AppendInterpolatedStringHandler(4, 2, stringBuilder);
handler.AppendLiteral("[");
handler.AppendFormatted(i, "X2");
handler.AppendLiteral(", ");
handler.AppendFormatted(j, "X2");
handler.AppendLiteral("]");
stringBuilder.Append(ref handler);


そして、String.Createを用いるもの。
これは2種類のオーバーロードを取り上げる。

まず、バッファと文字列補間リテラル(正確にはそれが変換されたDefaultInterpolatedStringHandler)を渡すもの。

// CultureInfo cultureInfo;
Span<char> buf = stackalloc char[8];
string.Create(cultureInfo, buf, $"[{i:X2}, {j:X2}]");

バッファ用のSpanを用意して渡す、というのがいかにもSpan導入後の C# という感じ。

なお、カルチャを省略できるオーバーロードはないため、最後に行う比較でもカルチャは渡す。
実用のうえでは、逆にカルチャ事故がなくていいかもしれない。


次にデリゲートによりいちから組み立てるもの。

buffer[i, j] = string.Create(8, (i, j, cultureInfo), static (buf, args) =>
{
    var (i, j, cultureInfo) = args;
    buf[0] = '[';
    i.TryFormat(buf[1..3], out int _, "X2", cultureInfo);
    ", ".AsSpan().CopyTo(buf[3..]);
    j.TryFormat(buf[5..7], out int _, "X2", cultureInfo);
    buf[7] = ']';
});

長さを指定したバッファをデリゲートの中で直接加工する。
抽象度を投げ捨てたストロングスタイルながら、よくよく考えればstringBuilder.Append('[')と書くのもbuffer[0] = '['と書くのもそう大きくは違わない気もしないでもない。


以上をBenchmarkDotNet にて比較。

比較用コード折りたたみ

int N = 256;
string[,] buffer = default!;

[GlobalSetup]
public void Setup()
{
    buffer = new string[N, N];
}

[Benchmark]
public string[,] ByStringFormat()
{
    for (int i = 0; i < N; i++)
    {
        for (int j = 0; j < N; j++)
        {
            buffer[i, j] = string.Format("[{0:X2}, {1:X2}]", i, j);
        }
    }

    return buffer;
}

[Benchmark]
public string[,] ByRegularInterpolation()
{
    for (int i = 0; i < N; i++)
    {
        for (int j = 0; j < N; j++)
        {
            buffer[i, j] = $"[{i:X2}, {j:X2}]";
        }
    }

    return buffer;
}

[Benchmark]
public string[,] ByStringBuilder()
{
    for (int i = 0; i < N; i++)
    {
        for (int j = 0; j < N; j++)
        {
            StringBuilder sb = new(8);
            sb.Append($"[{i:X2}, {j:X2}]");
            buffer[i, j] = sb.ToString();
        }
    }

    return buffer;
}

private readonly CultureInfo cultureInfo = CultureInfo.GetCultureInfo("ja-JP");

[Benchmark]
public string[,] ByStringCreate()
{
    Span<char> buf = stackalloc char[8];
    for (int i = 0; i < N; i++)
    {
        for (int j = 0; j < N; j++)
        {
            buffer[i, j] = string.Create(cultureInfo, buf, $"[{i:X2}, {j:X2}]");
        }
    }

    return buffer;
}


[Benchmark]
public string[,] ByStringCreateSpanAction()
{
    for (int i = 0; i < N; i++)
    {
        for (int j = 0; j < N; j++)
        {
            buffer[i, j] = string.Create(8, (i, j, cultureInfo), static (buf, args) =>
            {
                var (i, j, cultureInfo) = args;
                buf[0] = '[';
                i.TryFormat(buf[1..3], out int _, "X2", cultureInfo);
                ", ".AsSpan().CopyTo(buf[3..]);
                j.TryFormat(buf[5..7], out int _, "X2", cultureInfo);
                buf[7] = ']';
            });
        }
    }

    return buffer;
}

                   Method |      Mean |     Error |    StdDev |      Gen0 |     Gen1 |     Gen2 | Allocated |
------------------------- |----------:|----------:|----------:|----------:|---------:|---------:|----------:|
           ByStringFormat | 18.298 ms | 4.3878 ms | 0.2405 ms |  906.2500 | 875.0000 |        - |    5.5 MB |
   ByRegularInterpolation |  7.935 ms | 3.7761 ms | 0.2070 ms |  406.2500 | 390.6250 |        - |    2.5 MB |
          ByStringBuilder | 15.262 ms | 3.2114 ms | 0.1760 ms | 1343.7500 | 890.6250 | 796.8750 |      8 MB |
           ByStringCreate |  5.875 ms | 2.4146 ms | 0.1323 ms |  414.0625 | 406.2500 |        - |    2.5 MB |
 ByStringCreateSpanAction |  4.994 ms | 0.5416 ms | 0.0297 ms |  414.0625 | 406.2500 |        - |    2.5 MB |
  • パフォーマンスに優れ記述も簡潔な文字列補間リテラルが第一の選択肢と言えると思う。
  • 事前にバッファを渡したりで頑張る選択肢ならString.Create
  • 今回の例は違ったけれど、より複雑な組み立てを必要(特に長さを簡単に読めない)とするならStringBuilder
  • 今回は触れていないが、標準以外なら ZString


Deep Dive
C# の文字列について
ZString – Unity/.NET CoreにおけるゼロアロケーションのC#文字列生成 | Cygames Engineers' Blog
(※ ただし、文字列補間リテラルの記述が現在と違う(String.Formatだった当時))

・文字列補間ハンドラについて
Improvement Interpolated Strings 完全に理解した - 鷲ノ巣