DefaultInterporatedStringHandlerは如何にして補完文字列の最適化を行うのか
.NET 6(c#10)から文字列補完の最適化が入り、以前までコンパイラはString.Format
として解釈していたものを、ボックス化のコストを削減・無駄にStringを生成しないDefaultInterporatedStringHandler
に置き換えています。
最適化方法に興味があり実装などを調べてみたら面白かったので簡単にまとめておきます。
※ 本ポストに記載しているプログラムはLINQPad 8
・.NET 8
環境で動作したものを利用しています。
String.Formatの問題点
そもそもなぜ、文字列補完に最適化の必要があったのか。
文字列補完は元々String.Format
として解釈されていました。String.Format
はボックス化を伴う実装となっていてマネージドヒープにゴミが残ってしまいます。
public partial class Program {
public static void Main() {
int a = 100;
int b = 200;
int c = 300;
// 第2引数以降はobject
Console.WriteLine(string.Format("{0}-{1}-{2}", a, b, c));
}
}
ILを見るとより分かりやすいです。
引数に渡すすべての数値に対し、box命令によるボックス化が発生しています。
これは非常に効率が悪いです。
DefaultInterpolatedStringHandlerを利用した実装
先ほどのプログラムを文字列補完で実装し、再度ILを見てみるとDefaultInterporatedStringHandler
が利用されていることが分かります。
public partial class Program {
public static void Main() {
int a = 100;
int b = 200;
int c = 300;
// 文字列補完
Console.WriteLine($"{a}-{b}-{c}");
}
}
このILをC#にすると👇のような形になる。
public partial class Program
{
public static void Main()
{
int a = 100;
int b = 200;
int c = 300;
var handler = new DefaultInterpolatedStringHandler(2, 3);
handler.AppendFormatted(a);
handler.AppendLiteral("-");
handler.AppendFormatted(b);
handler.AppendLiteral("-");
handler.AppendFormatted(c);
Console.WriteLine(handler.ToStringAndClear());
}
}
これを見て私は「もしAppendFormatted
の内部でToString
していたら結局パフォーマンス悪くない?」と疑問に思ったわけです。
そこで実装を見てきたので👇にまとめておきます。
コンストラクタの挙動
コンストラクタにはリテラルの文字数と、動的に挿入される変数の数を指定する。DefaultInterpolatedStringHandler
はArrayPool<char>.Shared.Rent
メソッドを利用してバッファを確保する。
この時バッファのサイズはリテラルの文字数 * 変数の数 * 11
と256
を比較し大きい方の分だけメモリを確保する。
確保したバッファは内部のSpan<char> _chars
に保持される。
AppendFormattedメソッドの挙動
引数がIFormattable
とISpanFomattable
を実装している場合、TryFormat
メソッドを呼び出し、バッファに対して文字を書き込む。
TryFormatは低レベルな実装となっており、内部でToString
されることは基本的にない。
また、バッファのサイズが足りずにバッファへの書き込みに失敗した場合、さらに大きいバッファを確保しなおして再度書き込み処理を実行する。ISpanFormattable
を実装していない場合ToString
を呼び出し、String
の文字列をバッファに書き込む。
つまり、DefaultInterpolatedStringHandler
の恩恵を100%受けるにはIFormattable
とISpanFomattable
の実装が必要みたいですね。Int32.TryFormat
等の実装を見ると面白いかも。
AppendLiteralメソッドの挙動
こちらはAppendFormatted
メソッドと比べると簡単で、引数の文字列をバッファにコピーする。AppendFormatted
と同様にバッファのサイズが足りない場合、バッファを再確保して文字列を書き込む。
ToStringAndClearメソッドの挙動
new String(ReadonlySpan<char>)
を呼び出し、String
を生成し、文字列を返却する。
この時ArrayPool<char>.Shared.Return
メソッドを利用してバッファをプールに返還する。
気を付けなければいけないのがDefaultInterpolatedStirngHandler.ToString
だとArrayPool<char>.Shared.Return
を呼び出す処理は実行されない。
まとめ
StringBuilder
の代わりにもなるので、とりあえずDefaultInterpolatedStringHandler
をガンガン使いたい、ところではありますが、絶妙に利用しづらいAPIな上にToStringAndClear
を利用しないと事故る可能性があるので、何とも言えない。
(一定以上の理解度の人間がそろってないと事故りそう)
直接利用するは個人開発のアプリやツールにとどめ、業務ではコンパイラに全てを委ねるかぁと思いました。