はじめに
今回はStringBuilder
とDefaultInterpolatedStringHandler
の処理速度・メモリ確保量を比べてみたいと思います。
大抵のStringBuilderの利用シーン、new StringBuilderの代わりにnew DefaultInterpolatedStringHandler(0, 0)を使ったほうが良いと思うのだけど、new DefaultInterpolatedStringHandler(0, 0)という呼びづらさが微妙にそれを躊躇わせる。
— neuecc (@neuecc) October 17, 2023
概要
実はDefaultInterpolatedStringHandler
をStringBuilder
のように扱うことができます。
ただし全てが同じ機能というわけではありませんので注意してください。単純にStringBuilder.Append
やStringBuilder.AppendFormat
、StringBuilder.ToString
をするくらいなら実現できます。
// Hello. John! Age : 20 var builder = new StringBuilder(); builder.Append("Hello, John!\n"); builder.AppendFormat("Age : {0}", 20); Console.WriteLine(builder.ToString()); // Hello. John! Age : 20 var handler = new DefaultInterpolatedStringHandler(0, 0); handler.AppendLiteral("Hello, John!\n"); handler.AppendLiteral("Age : "); handler.AppendFormatted(20); Console.WriteLine(handler.ToStringAndClear());
もうちょい詳しく
前回DefaultInterpolatedStringHandler
についての記事を書いたのですが、本来は以下のように引数を渡します。
public DefaultInterpolatedStringHandler (int literalLength, int formattedCount);
DefaultInterpolatedStringHandler Constructor (System.Runtime.CompilerServices) | Microsoft Learn
literalLength
: 「{}」(interpolation expressions)を除いた部分の文字列長formattedCount
: 「{}」(interpolation expressions)の個数
// string.Format("{0}, {1}, {2}", a, b, c)をDefaultInterpolatedStringHandlerで実現する // literalLength : 「{}」(interpolation expressions)を除いた部分の文字列長 // formattedCount : 「{}」(interpolation expressions)の個数 var handler = new DefaultInterpolatedStringHandler(4, 3); handler.AppendFormatted(a); handler.AppendLiteral(", "); handler.AppendFormatted(b); handler.AppendLiteral(", "); handler.AppendFormatted(c); _ = handler.ToStringAndClear();
https://www.hanachiru-blog.com/entry/2024/03/15/120000
引数のliteralLength
とformattedCount
はArrayPool<char>.Shared.Rent
の引数として使われ、あくまで文字列の必要最低限な長さを記述します。
public DefaultInterpolatedStringHandler(int literalLength, int formattedCount) { this._provider = (IFormatProvider) null; this._chars = (Span<char>) (this._arrayToReturnToPool = ArrayPool<char>.Shared.Rent(DefaultInterpolatedStringHandler.GetDefaultLength(literalLength, formattedCount))); this._pos = 0; this._hasCustomFormatter = false; }
/// <summary> /// Retrieves a buffer that is at least the requested length. /// </summary> /// <param name="minimumLength">The minimum length of the array needed.</param> /// <returns> /// An array that is at least <paramref name="minimumLength"/> in length. /// </returns> /// <remarks> /// This buffer is loaned to the caller and should be returned to the same pool via /// <see cref="Return"/> so that it may be reused in subsequent usage of <see cref="Rent"/>. /// It is not a fatal error to not return a rented buffer, but failure to do so may lead to /// decreased application performance, as the pool may need to create a new buffer to replace /// the one lost. /// </remarks> public abstract T[] Rent(int minimumLength);
バッファが足りなくなった場合はArrayPool
から新たな配列を確保してあげることで実現してるわけですね。
/// <summary>Grow the size of <see cref="_chars"/> to at least the specified <paramref name="requiredMinCapacity"/>.</summary> [MethodImpl(MethodImplOptions.AggressiveInlining)] // but reuse this grow logic directly in both of the above grow routines private void GrowCore(uint requiredMinCapacity) { // We want the max of how much space we actually required and doubling our capacity (without going beyond the max allowed length). We // also want to avoid asking for small arrays, to reduce the number of times we need to grow, and since we're working with unsigned // ints that could technically overflow if someone tried to, for example, append a huge string to a huge string, we also clamp to int.MaxValue. // Even if the array creation fails in such a case, we may later fail in ToStringAndClear. uint newCapacity = Math.Max(requiredMinCapacity, Math.Min((uint)_chars.Length * 2, string.MaxLength)); int arraySize = (int)Math.Clamp(newCapacity, MinimumArrayPoolLength, int.MaxValue); char[] newArray = ArrayPool<char>.Shared.Rent(arraySize); _chars.Slice(0, _pos).CopyTo(newArray); char[]? toReturn = _arrayToReturnToPool; _chars = _arrayToReturnToPool = newArray; if (toReturn is not null) { ArrayPool<char>.Shared.Return(toReturn); } }
実験
BenchmarkDotNet
を用いてStringBuilder
とDefaultInterpolatedStringHandler
の処理速度・メモリ確保量を比べてみようと思います。
public class Program { public static void Main(string[] args) { BenchmarkRunner.Run<TestClass>(); } [MemoryDiagnoser(false)] public class TestClass { private const string s1 = "AAAAAAAAA"; private const string s2 = "BBBBBBBBB"; private const string s3 = "CCCCCCCCC"; private const int a = 10; private const int b = 20; private const int c = 30; private const string x = "10"; private const string y = "20"; private const string z = "30"; [Benchmark] public void StringBuilderTest1() { // Mean : 37.21ns // GC.Alloc : 288B var builder = new StringBuilder(); builder.Append(s1); builder.Append(s2); builder.Append(s3); _ = builder.ToString(); } [Benchmark] public void StringBuilderTest2() { // Mean : 51.55ns // GC.Alloc : 224B var builder = new StringBuilder(); builder.AppendFormat("{0}, {1}, {2}", a, b, c); _ = builder.ToString(); } [Benchmark] public void StringBuilderTest3() { // Mean : 49.58ns // GC.Alloc : 152B var builder = new StringBuilder(); builder.AppendFormat("{0}, {1}, {2}", x, y, z); _ = builder.ToString(); } [Benchmark] public void DefaultInterpolatedStringHandlerTest1() { // Mean : 22.09ns // GC.Alloc : 80B var handler = new DefaultInterpolatedStringHandler(0, 0); handler.AppendLiteral(s1); handler.AppendLiteral(s2); handler.AppendLiteral(s3); _ = handler.ToStringAndClear(); } [Benchmark] public void DefaultInterpolatedStringHandlerTest2() { // Mean : 26.79ns // GC.Alloc : 48B var handler = new DefaultInterpolatedStringHandler(0, 0); handler.AppendFormatted(a); handler.AppendLiteral(", "); handler.AppendFormatted(b); handler.AppendLiteral(", "); handler.AppendFormatted(c); _ = handler.ToStringAndClear(); } [Benchmark] public void DefaultInterpolatedStringHandlerTest3() { // Mean : 22.49ns // GC.Alloc : 48B var handler = new DefaultInterpolatedStringHandler(0, 0); handler.AppendFormatted(x); handler.AppendLiteral(", "); handler.AppendFormatted(y); handler.AppendLiteral(", "); handler.AppendFormatted(z); _ = handler.ToStringAndClear(); } } }
結果
Method | Mean | Error | StdDev | Allocated |
---|---|---|---|---|
StringBuilderTest1 | 37.21 ns | 0.494 ns | 0.462 ns | 288 B |
StringBuilderTest2 | 51.55 ns | 0.435 ns | 0.385 ns | 224 B |
StringBuilderTest3 | 49.58 ns | 0.342 ns | 0.320 ns | 152 B |
DefaultInterpolatedStringHandlerTest1 | 22.09 ns | 0.175 ns | 0.164 ns | 80 B |
DefaultInterpolatedStringHandlerTest2 | 26.79 ns | 0.226 ns | 0.200 ns | 48 B |
DefaultInterpolatedStringHandlerTest3 | 22.49 ns | 0.210 ns | 0.196 ns | 48 B |
Test1
・Test2
・Test3
がそれぞれ同じ処理をしていて対応しているのですが、全て処理速度・メモリ確保量ともにDefaultInterpolatedStringHandler
が勝っています。