本記事は、C# Advent Calendar 2023 12日目参加記事となっています。
- 11日目:【C#】抽象クラスとインターフェースを併用する理由を考えた by @seiya2130
- 13日目:ISpanFormattableを使おう by @Shaula
C#において、複雑な文字列を組み立てる手段として挙げられるのはStringBuilder
でしょう。
また、そこまで複雑な組み立てでなければ、補間文字列($"foo: {bar}"
)が使われます。
補間文字列については、C# 10 / .NET 6 でパフォーマンス改善が行われました。コンパイラがDefaultInterpolatedStringHandler
という型を用いたコードに展開し、これは、改善以前(String.Format
への展開)に対しパフォーマンスに優れます。
さて、このDefaultInterpolatedStringHandler
。コンパイラが用いる型ながら、アクセシビリティはpublic
です。すなわち、ユーザーが直接触ることができます。
というわけで本記事は、DefaultInterpolatedStringHandler
を直接触ってStringBuilder
的に使ってしまおう、という内容です。
なお、情報を整理するきっかけとしたポストがありますので、引用します。
大抵のStringBuilderの利用シーン、new StringBuilderの代わりにnew DefaultInterpolatedStringHandler(0, 0)を使ったほうが良いと思うのだけど、new DefaultInterpolatedStringHandler(0, 0)という呼びづらさが微妙にそれを躊躇わせる。
— neuecc (@neuecc) 2023年10月17日
目次
- TL;DR
- 本記事の範囲
- モチベーション
- ToStringAndClear ―― 注意点
- バッファの初期サイズと拡大 ―― new(0, 0)
- 初期バッファを渡すコンストラクタ
- 値の書き込み ―― ISpanFormattable
- StringBuilderを代替できないケースとなる性質
- おわりに
TL;DR
DefaultInterpolatedStringHandler
がStringBuilder
と似たふうに使えて、パフォーマンス的利点あり
本記事の範囲
(DefaultInterpolatedStringHandler
本来の使われ方である)補間文字列については、以下の優れた参考文献の提示に留めます。
- C# 10.0 の補間文字列の改善 - C# によるプログラミング入門 | ++C++; // 未確認飛行 C
- Improvement Interpolated Strings 完全に理解した - 鷲ノ巣
- String Interpolation in C# 10 and .NET 6 - .NET Blog
本記事では、
- まず、
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行コード、StringBuilder
、DefaultInterpolatedStringHandler
を、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<T>.Shared プロパティ (System.Buffers) | Microsoft Learn
- (C#) ArrayPool<T>.Shared 解体新書 - ネコのために鐘は鳴る
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>
などにも共通する知識であり、参考文献を示します。
DefaultInterpolatedStringHandler
が ref 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、スタンダードな手段となりうるかはなかなか……という感じはします。とはいえそれでも、現状で明確な利点のある選択肢だと思います。