【C#】文字列補間 - てくメモ
上記記事において、文字列補間は現在InterpolatedStringHandler
によってパフォーマンスチューニングがなされていることに触れた。
そのなかでは標準のDefaultInterpolatedStringHandler
を前提としていたが、InterpolatedStringHandler
はカスタムのものを用意することもできる。
Improvement Interpolated Strings 完全に理解した - 鷲ノ巣
例えば上記記事において、ログ出力の補間文字列に独自ハンドラを用意し、実際には出力されない文字列の作成を行わないようにするという用途が紹介されている。
この記事では、補間の仕方をカスタムするようなことを試しにやってみる。
具体的には、String.Join
を行う。
// int[] array; public string ByStringJoin() { return string.Join(", ", array); }
String.Join
があるのだから別にString.Join
でやればいいのだけれど、このAPIはSpan
を受けるシグニチャがなかったりするので、一応の動機はそれということで。
まずわざわざ InterpolatedStringHandler
なんて使わなくても、StringBuilder
を使うのは思い浮かぶところなので、あとで比較するために触れておく。
StringBuilder
版折りたたみ
public string ByStringBuilder() { static string stringJoin<T>(ReadOnlySpan<T> span, string separator = ", ") { var enumerator = span.GetEnumerator(); if (enumerator.MoveNext() is false) return ""; var initialCapacity = ((span.Length - 1) * separator.Length) + span.Length; StringBuilder stringBuilder = new(initialCapacity); stringBuilder.Append(enumerator.Current); while (enumerator.MoveNext()) { stringBuilder.Append(separator); stringBuilder.Append(enumerator.Current); } return stringBuilder.ToString(); } return stringJoin<int>(array); }
また、上記に関連して、DefaultInterpolatedStringHandler
がアロケーションの無い Better StringBuilder として使いうると思うのでそのように使った例も。
DefaultInterpolatedStringHandler
版折りたたみ
public string ByDefaultInterpolatedStringHandler() { static string stringJoin<T>(ReadOnlySpan<T> span, string separator = ", ") { var enumerator = span.GetEnumerator(); if (enumerator.MoveNext() is false) return ""; var literalLength = ((span.Length - 1) * separator.Length); DefaultInterpolatedStringHandler stringHandler = new(literalLength, span.Length); stringHandler.AppendFormatted(enumerator.Current); while (enumerator.MoveNext()) { stringHandler.AppendLiteral(separator); stringHandler.AppendFormatted(enumerator.Current); } return stringHandler.ToStringAndClear(); } return stringJoin<int>(array); }
さて、カスタムのInterpolatedStringHandler
版。
実装方法として、バッファを自前で管理するのをいちから書くのはかなりしんどいので、DefaultInterpolatedStringHandler
をラップするものとした。
参考:ufcpp 氏の gist
そして、String.Join
したい対象のシグニチャのAppendFormatted()
を追加し、必要な処理を行うようにした。(以下、表示部分は抜粋。全文を折りたたみ。)
[InterpolatedStringHandler] public ref struct StringJoinHandler { private DefaultInterpolatedStringHandler inner; private readonly string separator; public StringJoinHandler(int literalLength, int formattedCount, string separator = ", ") { inner = new DefaultInterpolatedStringHandler(literalLength, formattedCount); this.separator = separator; } // 特別な処理をする型の AppendFormatted を追加。オーバーロード解決で呼ばれる public void AppendFormatted<T>(T[] span) => AppendFromSpan<T>(span); private void AppendFromSpan<T>(scoped ReadOnlySpan<T> span) { var enumerator = span.GetEnumerator(); if (enumerator.MoveNext() is false) return; inner.AppendFormatted(enumerator.Current); while (enumerator.MoveNext()) { inner.AppendLiteral(separator); inner.AppendFormatted(enumerator.Current); } } // ラップ部分など省略、全文は折りたたみ }
StringJoinHandler
折りたたみ
[InterpolatedStringHandler] public ref struct StringJoinHandler { private DefaultInterpolatedStringHandler inner; private readonly string separator; public StringJoinHandler(int literalLength, int formattedCount, string separator = ", ") { inner = new DefaultInterpolatedStringHandler(literalLength, formattedCount); this.separator = separator; } public StringJoinHandler(int literalLength, int formattedCount, IFormatProvider? provider, string separator = ", ") { inner = new DefaultInterpolatedStringHandler(literalLength, formattedCount, provider); this.separator = separator; } public void AppendFormatted<T>(Span<T> span) => AppendFromSpan<T>(span); public void AppendFormatted<T>(ReadOnlySpan<T> span) => AppendFromSpan<T>(span); public void AppendFormatted<T>(T[] span) => AppendFromSpan<T>(span); private void AppendFromSpan<T>(scoped ReadOnlySpan<T> span) { var enumerator = span.GetEnumerator(); if (enumerator.MoveNext() is false) return; inner.AppendFormatted(enumerator.Current); while (enumerator.MoveNext()) { inner.AppendLiteral(separator); inner.AppendFormatted(enumerator.Current); } } public string ToStringAndClear() => inner.ToStringAndClear(); public void AppendLiteral(string value) => inner.AppendLiteral(value); public void AppendFormatted(string? value) => inner.AppendFormatted(value); public void AppendFormatted(scoped ReadOnlySpan<char> value) => inner.AppendFormatted(value); public void AppendFormatted(object? value, int align = 0, string? format = null) => inner.AppendFormatted(value, align, format); public void AppendFormatted(string? value, int align = 0, string? format = null) => inner.AppendFormatted(value, align, format); public void AppendFormatted(scoped ReadOnlySpan<char> value, int align = 0, string? format = null) => inner.AppendFormatted(value, align, format); public void AppendFormatted<T>(T value) => inner.AppendFormatted(value); public void AppendFormatted<T>(T value, string? format) => inner.AppendFormatted(value, format); public void AppendFormatted<T>(T value, int align) => inner.AppendFormatted(value, align); public void AppendFormatted<T>(T value, int align, string? format) => inner.AppendFormatted(value, align, format); }
以下のような感じで使える。
public string ByCustomHandler() { static string stringJoin(StringJoinHandler handler) => handler.ToStringAndClear(); return stringJoin($"{array}"); // コンパイラが StringJoinHandler に展開してくれる }
さて、とりあえず測ってみる。
array
をEnumerable.Range(1, 16).ToArray();
とした単純な使用。
Method | Mean | Error | StdDev | Gen0 | Allocated | ----------------------------------- |---------:|----------:|---------:|-------:|----------:| ByStringJoin | 359.3 ns | 51.24 ns | 2.81 ns | 0.1221 | 384 B | ByStringBuilder | 438.0 ns | 428.60 ns | 23.49 ns | 0.3414 | 1072 B | ByDefaultInterpolatedStringHandler | 206.1 ns | 18.70 ns | 1.03 ns | 0.0408 | 128 B | ByCustomHandler | 234.7 ns | 5.32 ns | 0.29 ns | 0.0408 | 128 B |
使用例が単純過ぎてかStringBuilder
ではむしろ遅くなってしまっているが、InterpolatedStringHandler
を使えばとりあえずString.Join
よりパフォーマンスは上がる。
とはいえこれだけだと独自ハンドラを用意する甲斐がない。
しかし、次のような例だと強みが出る。
// もうひとつ追加 double[] array2 = new double[] { 0.1, 1.2, 3.5 }; public string ByDefaultInterpolatedStringHandler() { static string stringJoin<T>(ReadOnlySpan<T> span, string separator = ", ") { // 省略 } return $"A:[{stringJoin<int>(array)}], B[{stringJoin<double>(array2)}]"; } public string ByCustomHandler() { static string stringJoin(StringJoinHandler handler) => handler.ToStringAndClear(); return stringJoin($"A:[{array}], B[{array2}]"); }
Method | Mean | Error | StdDev | Gen0 | Allocated | ----------------------------------- |---------:|---------:|--------:|-------:|----------:| ByDefaultInterpolatedStringHandler | 803.8 ns | 25.79 ns | 1.41 ns | 0.1116 | 352 B | ByCustomHandler | 625.1 ns | 76.61 ns | 4.20 ns | 0.0553 | 176 B |
上記のような場合、独自ハンドラは全体をひとつのハンドラで処理し、途中で各要素の結果としてのstring
生成を挟まないという特有の利点が出る。
// コンパイラによる展開 StringJoinHandler handler2 = new StringJoinHandler(9, 2); handler2.AppendLiteral("A:["); handler2.AppendFormatted(array); handler2.AppendLiteral("], B["); handler2.AppendFormatted(array2); handler2.AppendLiteral("]"); return stringJoin(handler2);
ところで、コンストラクタでセパレーターに使う文字列を渡せるようにしているが、どうやって渡すのか。
それには、InterpolatedStringHandlerArgument
属性を用いる。
public static string StringJoin( string separator, [InterpolatedStringHandlerArgument(nameof(separator))] StringJoinHandler stringJoinHandler) => stringJoinHandler.ToStringAndClear();
var a = new int[] { 1, 2, 3 }; var ar = SpanUtil.StringJoin(" ", $"[{a}]"); // [1 2 3]
この属性で指定するパラメータの引数は属性に先行する必要がある。
そのため最後の引数にできず、デフォルト引数は設定できない。
// デフォルトを設定するならオーバーロードを足す必要あり public static string StringJoin(StringJoinHandler stringJoinHandler) => StringJoin(", ", stringJoinHandler); // あるいは使う目的に特化してそちらでデフォルト引数を使うか public static string ToListString<T>(this Span<T> span, string separator = ", ") => SpanUtil.StringJoin(separator, $"[{span}]");
さすがにString.Join
のためにInterpolatedStringHandler
に触るのは労多くして――、という感じはあるけれど、仕組みそのものは活かせるところで使えればかなり良さそうだと感じた。