2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

値型 StringBuilder を作ってみる

Posted at

はじめに

以前の記事 【Preview】ref 構造体のジェネリック制限の緩和ref 構造体がインターフェイスを継承できるようになったことをご紹介しました。今回、応用できそうなところを見つけたので遊んでみます。

C# の補間文字列 ではコンパイラが結構頑張ってコードを生成しています。

【コンパイル前】

var number = 123;
var name = "abc";
var builder = new System.Text.StringBuilder();
builder.Append($"number:{number}, name:{name}");

【コンパイル後】

int number = 123;
string name = "abc";
StringBuilder builder = new StringBuilder();
StringBuilder stringBuilder = builder;
StringBuilder stringBuilder2 = stringBuilder;
StringBuilder.AppendInterpolatedStringHandler appendInterpolatedStringHandler = new StringBuilder.AppendInterpolatedStringHandler(14, 2, stringBuilder);
appendInterpolatedStringHandler.AppendLiteral("number:");
appendInterpolatedStringHandler.AppendFormatted<int>(number);
appendInterpolatedStringHandler.AppendLiteral(", name:");
appendInterpolatedStringHandler.AppendFormatted(name);
stringBuilder2.Append(ref appendInterpolatedStringHandler);

AppendFormatted() の内部で引数(↑ の場合は int)を System.ISpanFormattable に型変換する箇所があります。このとき、引数型が値型の場合、ボックス化が起こりパフォーマンスを損ないそうです。

このボックス化を回避してパフォーマンスをよくしてみよう、というのが今回のお題です。

AppendFormatted()

System.Text.StringBuilder.AppendInterpolatedStringHandler
のメソッドの一つで、補間文字列で使用されます。

void AppendFormatted<T>(T value)
{
    if (!(value is IFormattable))
    {
        // value を書き込み
    }
    else if (typeof(T).IsEnum)
    {
        // value を書き込み
    }
    else
    {
        if (!(value is ISpanFormattable))
        {
            // value を書き込み
        }
        else
        {
            // value を書き込み
        }
    }
}

概念的にはこんな感じです。インターフェイス実装によって分岐しており、ボックス化が起こります。
ここで T に制約をつけて、where T : ISpanFormattable とすればボックス化を回避できそうです(その代わり IFormattable をサポートしないため、汎用性が落ちる)。

使い方

StackStringBuilder を作ってみました。 IFormattable をサポートしなかったり、結構適当です。

StackStringBuilder のコード
using System.Diagnostics;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

/// <summary>
/// 内部バッファに Span を使用する StringBuilder
/// バッファ長を超えて Append すると新しいバッファを確保する(new char[this.Capacity * 2])
/// </summary>
[DebuggerDisplay("{Span}")]
public ref struct StackStringBuilder
{
    private Span<char> _buffer;
    private int _length;

    /// <summary>
    /// 文字列長
    /// </summary>
    public readonly int Length
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get => this._length;
    }

    /// <summary>
    /// Span
    /// </summary>
    public readonly Span<char> Span
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get => this._buffer.Slice(0, this._length);
    }

    /// <summary>
    /// 内部配列長
    /// </summary>
    public readonly int Capacity
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get => this._buffer.Length;
    }

    private readonly Span<char> NextSpan
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get => this._buffer.Slice(this._length);
    }

    /// <summary>
    /// 指定のスバンをバッファとして使用する<br/>
    /// count &lt; 0 の場合個数をそのまま追加する
    /// </summary>
    /// <param name="buffer"></param>
    /// <param name="length"></param>
    /// <exception cref="ArgumentOutOfRangeException"></exception>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public StackStringBuilder(Span<char> buffer, int length = 0)
    {
        ArgumentOutOfRangeException.ThrowIfGreaterThan(length, buffer.Length);
        if (length < 0)
            length = buffer.Length;

        this._buffer = buffer;
        this._length = length;
    }

    private unsafe ref StackStringBuilder RefThis
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
#pragma warning disable
        get => ref this;
#pragma warning restore
    }

    /// <summary>
    /// 文字列化
    /// </summary>
    /// <returns></returns>
    public readonly override string ToString() => this.Span.ToString();

    /// <summary>
    /// バッファをクリア
    /// </summary>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder Clear()
    {
        this._length = default;
        return ref this.RefThis;
    }

    /// <summary>
    /// 文字列化してバッファをクリア
    /// </summary>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public string ToStringAndClear()
    {
        var result = this.ToString();
        this.Clear();
        return result;
    }

    /// <summary>
    /// 16 以上で指定数以上の、最小の 2 の階乗を取得
    /// </summary>
    /// <param name="min"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentOutOfRangeException"></exception>
    internal static int GetCapacity(int min)
    {
        ArgumentOutOfRangeException.ThrowIfGreaterThan(min, 1073741824);

        var result = 16;
        for (int n = 0; n < 26 && result < min; ++n)
            result <<= 1;

        return result;
    }

    [SkipLocalsInit]
    [MethodImpl(MethodImplOptions.NoInlining)]
    private void SizeUp(int next = 0)
    {
        if (next is 0)
            next = this.Capacity * 2;
        var capacity = GetCapacity(next);

        var oldBuffer = this._buffer;
        Span<char> newBuffer = new char[capacity];
        oldBuffer.Slice(0, this.Length).CopyTo(newBuffer);
        this._buffer = newBuffer;
    }

    /// <summary>
    /// 文字列を追加
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder Append(scoped ReadOnlySpan<char> value)
    {
        var max = this._length + value.Length;
        if (max > this.Capacity)
            this.SizeUp(max);

        value.CopyTo(this.NextSpan);
        this._length = max;

        return ref this.RefThis;
    }

    /// <summary>
    /// 文字列を追加
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder Append(string value)
    {
        value ??= "";
        var max = this._length + value.Length;
        if (max > this.Capacity)
            this.SizeUp(max);

        value.CopyTo(this.NextSpan);
        this._length = max;

        return ref this.RefThis;
    }

    /// <summary>
    /// 改行
    /// </summary>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder AppendLine() => ref this.Append(Environment.NewLine);

    /// <summary>
    /// 末尾に文字列を追加して改行
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder AppendLine(scoped ReadOnlySpan<char> value) => ref this.Append(value).AppendLine();

    /// <summary>
    /// 末尾に文字を追加
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="value"></param>
    /// <param name="format"></param>
    /// <param name="provider"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder Append<T>(scoped T value, scoped ReadOnlySpan<char> format = default, IFormatProvider? provider = null) where T : ISpanFormattable, allows ref struct
    {
        int count;
        while (!value.TryFormat(this.NextSpan, out count, format, provider))
            this.SizeUp();

        this._length += count;

        return ref this.RefThis;
    }

    /// <summary>
    /// 末尾に文字を追加して改行
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="value"></param>
    /// <param name="format"></param>
    /// <param name="provider"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder AppendLine<T>(scoped T value, scoped ReadOnlySpan<char> format = default, IFormatProvider? provider = null) where T : ISpanFormattable, allows ref struct
=> ref this.Append<T>(value, format, provider).AppendLine();

    /// <summary>
    /// 指定位置に文字を追加
    /// </summary>
    /// <param name="index"></param>
    /// <param name="c"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder Insert(int index, char c)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(index);
        var size = this._length;
        ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, size);

        if (this.Length == this.Capacity)
        {
            this.SizeUp(size + 1);
        }

        this._length = size + 1;

        var destination = this.Span.Slice(index + 1);
        var source = this.Span.Slice(index, destination.Length);
        for (int n = 0; n < destination.Length; ++n)
            destination[n] = source[n];

        this._buffer[index] = c;

        return ref this.RefThis;
    }

    /// <summary>
    /// 指定位置に文字列を追加
    /// </summary>
    /// <param name="index"></param>
    /// <param name="span"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder Insert(int index, scoped ReadOnlySpan<char> span)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(index);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(index, this._length);
        var size = this.Length;

        var newLength = this._length + span.Length;
        if (this.Capacity < newLength)
            this.SizeUp(newLength);

        this._length = newLength;
        var len = size - index;
        var source = this.Span.Slice(index, len);
        ref var destination = ref MemoryMarshal.GetReference(this.Span.Slice(index + span.Length));
        for (int n = source.Length - 1; n >= 0; --n)
            Unsafe.Add(ref destination, n) = source[n];

        var newDestination = this.Span.Slice(index, span.Length);
        span.CopyTo(newDestination);

        return ref this.RefThis;
    }

    /// <summary>
    /// 指定位置の文字を削除
    /// </summary>
    /// <param name="index"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentOutOfRangeException"></exception>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder RemoveAt(int index)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(index);
        ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, this._length);

        this._length--;
        if (index < this._length)
        {
            for (int n = index; n < this._length; ++n)
                this._buffer[n] = this._buffer[n + 1];
        }

        return ref this.RefThis;
    }

    /// <summary>
    /// 指定位置の文字列を削除
    /// </summary>
    /// <param name="index"></param>
    /// <param name="length"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentOutOfRangeException"></exception>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder Remove(int index, int length)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(index);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(index, this._length - 1);
        ArgumentOutOfRangeException.ThrowIfNegative(length);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(index + length, this._length);

        var len = this._length - index - length;
        ref var ptr = ref MemoryMarshal.GetReference(this._buffer);
        for (int n = 0; n < len; ++n)
            Unsafe.Add(ref ptr, index + n) = Unsafe.Add(ref ptr, index + n + length);

        this._length -= length;
        return ref this.RefThis;
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private void RemoveInsert(int index, int oldValueLength, scoped ReadOnlySpan<char> newValue)
    {
        var shift = newValue.Length - oldValueLength;
        var source = this.Span.Slice(index + oldValueLength);
        ref var destination = ref MemoryMarshal.GetReference(this._buffer.Slice(index + oldValueLength + shift));
        for (int n = 0; n < source.Length; ++n)
            Unsafe.Add(ref destination, n) = source[n];

        newValue.CopyTo(this._buffer.Slice(index));

        this._length += shift;
    }

    /// <summary>
    /// 置き換え
    /// </summary>
    /// <param name="oldValue"></param>
    /// <param name="newValue"></param>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder Replace(scoped ReadOnlySpan<char> oldValue, scoped ReadOnlySpan<char> newValue)
    {
        var index = 0;
        var span = this.Span;
    Label:
        var find = span.IndexOf(oldValue);
        if (find < 0) return ref this.RefThis;
        index += find;
        this.RemoveInsert(index, oldValue.Length, newValue);
        if (index >= this._length) return ref this.RefThis;
        span = this.Span.Slice(index);
        goto Label;
    }

    /// <summary>
    /// 削除
    /// </summary>
    /// <param name="oldValue"></param>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder ReplaceEmpty(scoped ReadOnlySpan<char> oldValue)
    {
        var index = 0;
        var span = this.Span;
    Label:
        var find = span.IndexOf(oldValue);
        if (find < 0) return ref this.RefThis;
        index += find;
        this.Remove(index, oldValue.Length);
        if (index >= this._length) return ref this.RefThis;
        span = this.Span.Slice(index);
        goto Label;
    }

    /// <summary>
    /// Span への暗黙の変換
    /// </summary>
    /// <param name="value"></param>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static implicit operator Span<char>(in StackStringBuilder value) => value.Span;

    /// <summary>
    /// ReadOnlySpan への暗黙の変換
    /// </summary>
    /// <param name="value"></param>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static implicit operator ReadOnlySpan<char>(in StackStringBuilder value) => value.Span;

    /// <summary>
    /// 文字列補間ハンドラ
    /// </summary>
    [EditorBrowsable(EditorBrowsableState.Never)]
    [InterpolatedStringHandler]
    public ref struct AppendInterpolatedStringHandler
    {
        internal StackStringBuilder _builder;

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="literalLength"></param>
        /// <param name="formattedCount"></param>
        /// <param name="builder"></param>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public AppendInterpolatedStringHandler(int literalLength, int formattedCount, StackStringBuilder builder)
        {
            this._builder = builder;
        }

        /// <summary>
        /// リテラルを追加
        /// </summary>
        /// <param name="value"></param>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendLiteral(string value)
        {
            this._builder.Append(value);
        }

        /// <summary>
        /// 値を追加
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="value"></param>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted<T>(scoped T value) where T : ISpanFormattable, allows ref struct
        {
            this._builder.Append(value);
        }

        /// <summary>
        /// 値を追加
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="value"></param>
        /// <param name="format"></param>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted<T>(T value, string format) where T : ISpanFormattable, allows ref struct
        {
            this._builder.Append(value, format);
        }

        /// <summary>
        /// 値を追加
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="value"></param>
        /// <param name="alignment"></param>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted<T>(T value, int alignment) where T : ISpanFormattable, allows ref struct
        {
            int pos = this._builder._length;
            this.AppendFormatted<T>(value);
            if (alignment != 0)
                this.AppendOrInsertAlignmentIfNeeded(pos, alignment);
        }

        /// <summary>
        /// 値を追加
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="value"></param>
        /// <param name="alignment"></param>
        /// <param name="format"></param>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted<T>(T value, int alignment, string format) where T : ISpanFormattable, allows ref struct
        {
            int pos = this._builder._length;
            this.AppendFormatted<T>(value, format);
            if (alignment != 0)
                this.AppendOrInsertAlignmentIfNeeded(pos, alignment);
        }

        /// <summary>
        /// 値を追加
        /// </summary>
        /// <param name="value"></param>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(ReadOnlySpan<char> value)
        {
            this._builder.Append(value);
        }

        /// <summary>
        /// 値を追加
        /// </summary>
        /// <param name="value"></param>
        /// <param name="alignment"></param>
        /// <param name="format"></param>
        public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string format = "")
        {
            bool flag = false;
            if (alignment < 0)
            {
                flag = true;
                alignment = -alignment;
            }

            int num = alignment - value.Length;
            if (num <= 0)
            {
                this.AppendFormatted(value);
                return;
            }

            if (alignment <= this._builder._buffer.Length - this._builder._length)
            {
                if (flag)
                {
                    value.CopyTo(this._builder._buffer.Slice(this._builder._length));
                    this._builder._length += value.Length;
                    this._builder._buffer.Slice(this._builder._length, num).Fill(' ');
                    this._builder._length += num;
                }
                else
                {
                    this._builder._buffer.Slice(this._builder._length, num).Fill(' ');
                    this._builder._length += num;
                    value.CopyTo(this._builder._buffer.Slice(this._builder._length));
                    this._builder._length += value.Length;
                }
            }
        }

        /// <summary>
        /// 値を追加
        /// </summary>
        /// <param name="value"></param>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(string value)
        {
            this._builder.Append(value);
        }

        /// <summary>
        /// 値を追加
        /// </summary>
        /// <param name="value"></param>
        /// <param name="alignment"></param>
        /// <param name="format"></param>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void AppendFormatted(string value, int alignment = 0, string format = "")
        {
            this.AppendFormatted(value.AsSpan(), alignment, format);
        }

        /// <summary>
        /// 値を追加
        /// </summary>
        /// <param name="value"></param>
        /// <param name="alignment"></param>
        /// <param name="format"></param>
        public void AppendFormatted(object value, int alignment = 0, string format = "")
        {
            if (value is null)
                return;
            if (value is ISpanFormattable spanFormattable)
                this.AppendFormatted(spanFormattable, alignment, format);
            else
                this.AppendFormatted(value.ToString() ?? "", alignment, format);
        }

        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        private void AppendOrInsertAlignmentIfNeeded(int startingPos, int alignment)
        {
            int num = this._builder._length - startingPos;
            bool flag = false;
            if (alignment < 0)
            {
                flag = true;
                alignment = -alignment;
            }
            int num2 = alignment - num;
            if (num2 <= 0)
                return;
            if (num2 <= this._builder._buffer.Length - this._builder._length)
            {
                if (flag)
                    this._builder._buffer.Slice(this._builder._length, num2).Fill(' ');
                else
                {
                    this._builder._buffer.Slice(startingPos, num).CopyTo(this._builder._buffer.Slice(startingPos + num2));
                    this._builder._buffer.Slice(startingPos, num2).Fill(' ');
                }
                this._builder._length += num2;
            }
        }
    }

    /// <summary>
    /// 文字列補間を追加
    /// </summary>
    /// <param name="handler"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder Append([InterpolatedStringHandlerArgument("")] ref AppendInterpolatedStringHandler handler)
    {
        this = handler._builder;
        return ref this.RefThis;
    }

    /// <summary>
    /// 文字列補間を追加
    /// </summary>
    /// <param name="provider"></param>
    /// <param name="handler"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder Append(IFormatProvider provider, [InterpolatedStringHandlerArgument("", nameof(provider))] ref AppendInterpolatedStringHandler handler)
    {
        this = handler._builder;
        return ref this.RefThis;
    }

    /// <summary>
    /// 文字列補間を追加して改行
    /// </summary>
    /// <param name="handler"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder AppendLine([InterpolatedStringHandlerArgument("")] ref AppendInterpolatedStringHandler handler)
    {
        this = handler._builder;
        return ref this.AppendLine();
    }

    /// <summary>
    /// 文字列補間を追加して改行
    /// </summary>
    /// <param name="provider"></param>
    /// <param name="handler"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref StackStringBuilder AppendLine(IFormatProvider provider, [InterpolatedStringHandlerArgument("", nameof(provider))] ref AppendInterpolatedStringHandler handler)
    {
        this = handler._builder;
        return ref this.AppendLine();
    }

    /// <summary>
    /// 指定のバッファで長さが指定のビルダを作成
    /// </summary>
    /// <param name="buffer"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static StackStringBuilder Create(Span<char> buffer) => new StackStringBuilder(buffer, buffer.Length);

    /// <summary>
    /// 指定長追加し指定長のバッファを取得
    /// </summary>
    /// <param name="length"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public Span<char> AppendRange(int length)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(length);
        if (length is 0) return default;
        if (this.NextSpan.Length < length)
            this.SizeUp(this._length + length);

        var result = this.NextSpan.Slice(0, length);
        this._length += length;

        result.Clear();
        return result;
    }
}

// 十分な長さのバッファを渡す
var builder = new StackStringBuilder(stackalloc char[10]);
builder.Append("PI=");
// 数値型(System.ISpanFormattable)をサポート
builder.Append(3.14);
Assert.Equal("PI=3.14", builder.ToString());

builder.Clear();
// 補間文字列をサポート
builder.Append($"PI={3.14}");
Assert.Equal("PI=3.14", builder.ToString());

void NeedCharSpanMethod(Span<char> span) { }
// Span<char> に暗黙的に変換される
NeedCharSpanMethod(builder);

// 内部バッファを超える場合、new char[] でヒープにバッファが作成される
builder.Clear();
builder.Append("123456789012345");
Assert.Equal("123456789012345", builder.ToString());

// メソッド連結可能
builder.Clear().Append("decimal:").Append(1.0M).ToString();
Assert.Equal("decimal:1.0", builder.ToString());

Span<char> buffer = ['a', 'b', 'c'];
// 最初から "abc" が入っているバッファを使用
builder = new StackStringBuilder(buffer, buffer.Length);
Assert.Equal("abc", builder.ToString());

パフォーマンス

テストコード
using System.Text;
using System.Runtime.CompilerServices;
using Xunit;

public class _StackStringBuilderTest()
{
    void StringBuilderSample1()
    {
        var number = 123;
        var name = "abc";
        var builder = new System.Text.StringBuilder();
        builder.Append($"number:{number}, name:{name}");
    }

    void StringBuilderSample2()
    {
        int number = 123;
        string name = "abc";
        StringBuilder builder = new StringBuilder();
        StringBuilder stringBuilder = builder;
        StringBuilder stringBuilder2 = stringBuilder;
        StringBuilder.AppendInterpolatedStringHandler appendInterpolatedStringHandler = new StringBuilder.AppendInterpolatedStringHandler(14, 2, stringBuilder);
        appendInterpolatedStringHandler.AppendLiteral("number:");
        appendInterpolatedStringHandler.AppendFormatted<int>(number);
        appendInterpolatedStringHandler.AppendLiteral(", name:");
        appendInterpolatedStringHandler.AppendFormatted(name);
        stringBuilder2.Append(ref appendInterpolatedStringHandler);
    }

    void AppendFormatted<T>(T value)
    {
        if (!(value is IFormattable))
        {
            // value を書き込み
        }
        else if (typeof(T).IsEnum)
        {
            // value を書き込み
        }
        else
        {
            if (!(value is ISpanFormattable))
            {
                // value を書き込み
            }
            else
            {
                // value を書き込み
            }
        }
    }

    [Fact]
    void HowToUse()
    {
        // 十分な長さのバッファを渡す
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("PI=");
        // 数値型(System.ISpanFormattable)をサポート
        builder.Append(3.14);
        Assert.Equal("PI=3.14", builder.ToString());

        builder.Clear();
        // 補間文字列をサポート
        builder.Append($"PI={3.14}");
        Assert.Equal("PI=3.14", builder.ToString());

        void NeedCharSpanMethod(Span<char> span) { }
        // Span<char> に暗黙的に変換される
        NeedCharSpanMethod(builder);

        // 内部バッファを超える場合、new char[] でヒープにバッファが作成される
        builder.Clear();
        builder.Append("123456789012345");
        Assert.Equal("123456789012345", builder.ToString());

        // メソッド連結可能
        builder.Clear().Append("decimal:").Append(1.0M).ToString();
        Assert.Equal("decimal:1.0", builder.ToString());

        Span<char> buffer = ['a', 'b', 'c'];
        // 最初から "abc" が入っているバッファを使用
        builder = new StackStringBuilder(buffer, buffer.Length);
        Assert.Equal("abc", builder.ToString());
    }

    [Fact]
    void TestConstructor()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => new StackStringBuilder(stackalloc char[10], 11));

        Assert.Equal(0, new StackStringBuilder(stackalloc char[10]).Length);
        Assert.Equal(3, new StackStringBuilder(stackalloc char[3], 3).Length);

        Assert.Equal("123", new StackStringBuilder(['1', '2', '3'], -1));
    }

    [Fact]
    void LengthSpanCapacity()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        Assert.Equal(0, builder.Length);
        Assert.Equal("", builder.Span.ToString());
        Assert.Equal(10, builder.Capacity);

        builder.Append("abc");
        Assert.Equal(3, builder.Length);
        Assert.Equal("abc", builder.Span.ToString());
        Assert.Equal(10, builder.Capacity);
    }

    [Fact]
    void Clear()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("abc");
        Assert.Equal("abc", builder.Span.ToString());
        builder.Clear();
        Assert.Equal("", builder.Span.ToString());
    }

    [Fact]
    void ToStringAndClear()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("abc");
        Assert.Equal("abc", builder.ToStringAndClear());
        Assert.Equal("", builder.Span.ToString());
    }

    [Fact]
    void BuilderToString()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("abc");
        Assert.Equal("abc", builder.ToString());
    }

    [Fact]
    void Append_String()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("abc");
        Assert.Equal("abc", builder.Span.ToString());
    }

    [Fact]
    void Append_ReadOnlySpan()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("abc".AsSpan());
        Assert.Equal("abc", builder.Span.ToString());
    }

    [Fact]
    void Append_Handler()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append($"abc{10}");
        Assert.Equal("abc10", builder.Span);
    }

    [Fact]
    void SizeUp()
    {
        var builder = new StackStringBuilder(stackalloc char[3]);
        builder.Append("123");
        Assert.Equal("123", builder.Span.ToString());
        Assert.Equal(3, builder.Capacity);
        var capacity = builder.Capacity;

        builder.Append("4567");
        Assert.Equal("1234567", builder.Span.ToString());
        Assert.True(capacity < builder.Capacity);
    }

    [Fact]
    void AppendLine()
    {
        var builder = new StackStringBuilder(stackalloc char[3]);
        builder.AppendLine();
        Assert.Equal(Environment.NewLine, builder.Span.ToString());
    }

    [Fact]
    void AppendLine_String()
    {
        var builder = new StackStringBuilder(stackalloc char[3]);
        builder.AppendLine("abc");
        Assert.Equal("abc" + Environment.NewLine, builder.Span.ToString());
    }

    [Fact]
    void Append_T()
    {
        var builder = new StackStringBuilder(stackalloc char[3]);
        builder.Append("n=");
        builder.Append(123456789);
        Assert.Equal("n=123456789", builder.Span.ToString());
    }

    [Fact]
    void AppendLine_T()
    {
        var builder = new StackStringBuilder(stackalloc char[3]);
        builder.Append("n=");
        builder.AppendLine(123456789);
        Assert.Equal("n=123456789" + Environment.NewLine, builder.Span.ToString());
    }

    [Fact]
    void Insert()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => new StackStringBuilder(default).Insert(-1, 'a'));
        Assert.Throws<ArgumentOutOfRangeException>(() => new StackStringBuilder(stackalloc char[2], 2).Insert(5, 'a'));

        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("13");
        builder.Insert(1, '2');
        Assert.Equal("123", builder.Span.ToString());
    }

    [Fact]
    void InsertRange()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => new StackStringBuilder(default).Insert(-1, "123"));
        Assert.Throws<ArgumentOutOfRangeException>(() => new StackStringBuilder(stackalloc char[2], 2).Insert(5, "123"));

        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("123890");
        builder.Insert(3, "4567");
        Assert.Equal("1234567890", builder.Span.ToString());
    }

    [Fact]
    void RemoveAt()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => new StackStringBuilder(default).RemoveAt(-1));
        Assert.Throws<ArgumentOutOfRangeException>(() => new StackStringBuilder(default).Remove(0, 1));

        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("123");
        builder.RemoveAt(1);
        Assert.Equal("13", builder.Span.ToString());
    }

    [Fact]
    void RemoveAtRange()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => new StackStringBuilder(default).Remove(-1, 1));
        Assert.Throws<ArgumentOutOfRangeException>(() => new StackStringBuilder(default).Remove(1, 1));
        Assert.Throws<ArgumentOutOfRangeException>(() => new StackStringBuilder(stackalloc char[10], 10).Remove(0, 11));
        Assert.Throws<ArgumentOutOfRangeException>(() => new StackStringBuilder(stackalloc char[10], 10).Remove(10, 5));

        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("123456");
        builder.Remove(1, 2);
        Assert.Equal("1456", builder.Span.ToString());
    }

    [Fact]
    void ImplicitOperatorSpan()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("abc");
        Span<char> span = builder;
        Assert.Equal("abc", span.ToString());
    }

    [Fact]
    void ImplicitOperatorReadOnlySpan()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("abc");
        ReadOnlySpan<char> span = builder;
        Assert.Equal("abc", span.ToString());
    }

    [Fact]
    void RefThis()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("abc").Append("def");
        Assert.Equal("abcdef", builder.Span.ToString());
    }

    [Fact]
    void ReplaceEmpty()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("1234567890");
        builder.ReplaceEmpty("123");
        Assert.Equal("4567890", builder);

        builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("1234567890");
        builder.ReplaceEmpty("4567");
        Assert.Equal("123890", builder);

        builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("1234567890");
        builder.ReplaceEmpty("567890");
        Assert.Equal("1234", builder);
    }

    [Fact]
    void Replace()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("12345");
        builder.Replace("123", "777");
        Assert.Equal("77745", builder);

        builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("123abc456");
        builder.Replace("123", "777");
        Assert.Equal("777abc456", builder);

        builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append("123abc456");
        builder.Replace("456", "7777");
        Assert.Equal("123abc7777", builder);
    }

    [Fact]
    void AppendHandler()
    {
        var builder = new StackStringBuilder(stackalloc char[10]);
        builder.Append($"{123}abc{456}");
        Assert.Equal("123abc456", builder);

        builder.Clear();
        builder.Append($"{1.23:00.0}");
        Assert.Equal("01.2", builder);

        builder.Clear();
        builder.Append($"{12,5}");
        Assert.Equal("   12", builder);

        builder.Clear();
        builder.Append($"{12,-5}");
        Assert.Equal("12   ", builder);

        builder.Clear();
        builder.Append($"{12.3,10:000.00}");
        Assert.Equal("    012.30", builder);

        builder.Clear();
        builder.Append($"{12.3,-10:000.00}");
        Assert.Equal("012.30    ", builder);
    }

    [Fact]
    void AppendLineHandler()
    {
        var builder = new StackStringBuilder(stackalloc char[12]);
        builder.AppendLine($"{123}abc{456}");
        Assert.Equal("123abc456" + Environment.NewLine, builder);

        builder.Clear();
        builder.AppendLine($"{1.23:00.0}");
        Assert.Equal("01.2" + Environment.NewLine, builder);

        builder.Clear();
        builder.AppendLine($"{12,5}");
        Assert.Equal("   12" + Environment.NewLine, builder);

        builder.Clear();
        builder.AppendLine($"{12,-5}");
        Assert.Equal("12   " + Environment.NewLine, builder);

        builder.Clear();
        builder.AppendLine($"{12.3,10:000.00}");
        Assert.Equal("    012.30" + Environment.NewLine, builder);

        builder.Clear();
        builder.AppendLine($"{12.3,-10:000.00}");
        Assert.Equal("012.30    " + Environment.NewLine, builder);
    }

    [Fact]
    void AppendHandler_SizeUp()
    {
        var builder = new StackStringBuilder(stackalloc char[4]);
        builder.Append("000");
        builder.Append($"{123}abc{456}");
        Assert.Equal("000123abc456", builder);
    }

    [Fact]
    void Create()
    {
        var builder = StackStringBuilder.Create([.. "12345"]);
        Assert.Equal("12345", builder);
    }

    [Fact]
    void GetCapacity()
    {
        Assert.Equal(16, StackStringBuilder.GetCapacity(-1));
        Assert.Equal(16, StackStringBuilder.GetCapacity(0));
        Assert.Equal(16, StackStringBuilder.GetCapacity(15));
        Assert.Equal(16, StackStringBuilder.GetCapacity(16));
        Assert.Equal(32, StackStringBuilder.GetCapacity(17));
        Assert.Equal(256, StackStringBuilder.GetCapacity(255));
        Assert.Equal(16384, StackStringBuilder.GetCapacity(10000));
        Assert.Equal(1073741824, StackStringBuilder.GetCapacity(1073741824));

        Assert.Throws<ArgumentOutOfRangeException>(() => StackStringBuilder.GetCapacity(1073741824 + 1));
        Assert.Throws<ArgumentOutOfRangeException>(() => StackStringBuilder.GetCapacity(int.MaxValue));
    }

    [Fact]
    void Capacity()
    {
        var builder = new StackStringBuilder(stackalloc char[5]);
        Assert.Equal(5, builder.Capacity);

        builder.Append("123456");
        Assert.Equal(16, builder.Capacity);

        builder.Clear();
        builder.Append(new string('x', 100));
        Assert.Equal(128, builder.Capacity);

        builder.Clear();
        builder.Append(new string('x', 255));
        Assert.Equal(256, builder.Capacity);
    }

    [Fact]
    void AppendRange()
    {
        {
            var builder = new StackStringBuilder(stackalloc char[5]);
            var got = builder.AppendRange(2);
            Assert.Equal(stackalloc char[2], got);
        }

        {
            var want = new char[] { '1', '2', '3', };
            var builder = new StackStringBuilder(want);
            var got = builder.AppendRange(3);
            Assert.Equal(stackalloc char[3], got);
        }
    }

    [SkipLocalsInit]
    static void AppendClearPerformance(Performance p)
    {
        p.AddTest("StringBuilder", () =>
        {
            for (int n = 0; n < 10000; ++n)
            {
                var builder = new StringBuilder();
                builder.Append(n);
                builder.Clear();
            }
        });

        p.AddTest("StackStringBuilder", () =>
        {
            var span = (stackalloc char[100]);
            for (int n = 0; n < 10000; ++n)
            {
                var builder = new StackStringBuilder(span);
                builder.Append(n);
                builder.Clear();
            }
        });
    }

    [SkipLocalsInit]
    static void AppendHandler1Performance(Performance p)
    {
        p.AddTest("StringBuilder", () =>
        {
            var builder = new StringBuilder(400);
            for (int n = 0; n < 100; ++n)
                builder.Append($"{n}\n");
        });

        p.AddTest("StackStringBuilder", () =>
        {
            var builder = new StackStringBuilder(stackalloc char[400]);
            for (int n = 0; n < 100; ++n)
                builder.Append($"{n}\n");
        });
    }

    [SkipLocalsInit]
    static void AppendHandler2Performance(Performance p)
    {
        p.AddTest("StringBuilder", () =>
        {
            var builder = new StringBuilder(400);
            for (int n = 0; n < 100; ++n)
                builder.Append($"{n:00}\n");
        });

        p.AddTest("StackStringBuilder", () =>
        {
            var builder = new StackStringBuilder(stackalloc char[400]);
            for (int n = 0; n < 100; ++n)
                builder.Append($"{n:00}\n");
        });
    }

    [SkipLocalsInit]
    static void CreatePerformance(Performance p)
    {
        p.AddTest("StringBuilder new", () =>
        {
            var builder = new StringBuilder("TestStringBuilder");
        });

        var buffer = new char[] { 'T', 'e', 's', 't', 'S', 't', 'r', 'i', 'n', 'g', 'B', 'u', 'i', 'l', 'd', 'e', 'r' };
        p.AddTest("StackStringBuilder new", () =>
        {
            var builder = new StackStringBuilder(buffer, -1);
        });

        p.AddTest("StackStringBuilder.Append()", () =>
        {
            var builder = new StackStringBuilder(stackalloc char["TestStringBuilder".Length]);
            builder.Append("TestStringBuilder");
        });

        p.AddTest("StackStringBuilder.Create()", () =>
        {
            var builder = StackStringBuilder.Create(buffer);
        });
    }

    [SkipLocalsInit]
    static void FastCopyToPerformance(Performance p)
    {
        var testData = new int[1000];
        p.AddTest("MemoryMarshal", () =>
        {
            ReadOnlySpan<int> source = testData;
            Span<int> destination = stackalloc int[1000];
            ref var next = ref System.Runtime.InteropServices.MemoryMarshal.GetReference(destination);
            for (int n = 0; n < source.Length; ++n)
                Unsafe.Add(ref next, n) = source[n];
        });

        p.AddTest("SpanFor", () =>
        {
            ReadOnlySpan<int> source = testData;
            Span<int> destination = stackalloc int[1000];
            for (int n = 0; n < source.Length; ++n)
                destination[n] = source[n];
        });
        p.AddTest("CopyTo", () =>
        {
            ReadOnlySpan<int> source = testData;
            Span<int> destination = stackalloc int[1000];
            source.CopyTo(destination);
        });
        p.AddTest("CopyBlock", () =>
        {
            ReadOnlySpan<int> source = testData;
            Span<int> destination = stackalloc int[1000];
            Unsafe.CopyBlock(ref System.Runtime.InteropServices.MemoryMarshal.GetReference(Unsafe.As<Span<int>, Span<byte>>(ref destination)), ref System.Runtime.InteropServices.MemoryMarshal.GetReference(Unsafe.As<ReadOnlySpan<int>, ReadOnlySpan<byte>>(ref source)), (uint)(source.Length * sizeof(int)));
        });
    }
}

Test Score % CG0
AppendClear (2)
StringBuilder 614 100.0% 76
StackStringBuilder 2,980 485.3% 0
AppendHandler1 (2)
StringBuilder 25,590 100.0% 10
StackStringBuilder 111,460 435.6% 0
AppendHandler2 (2)
StringBuilder 12,848 100.0% 1
StackStringBuilder 13,604 105.9% 0
Create (4)
StringBuilder new 1,602,955 100.0% 21
StackStringBuilder new 2,120,219 132.3% 0
StackStringBuilder.Append() 1,314,090 82.0% 0
StackStringBuilder.Create() 1,931,214 120.5% 0

実行環境: Windows11 x64 .NET Runtime 9.0.0
Score は高いほどパフォーマンスがよいです。
GC0 はガベージコレクション回数を表します(少ないほどパフォーマンスがよい)。

  • AppendHandler1 がよく使いそうな補間文字列のテストで、結構いいスコアが出ています。今回の目的は達成できました。狙い通り GC0 も 0 です。
  • Create (コンストラクタ相当)の部分は標準ライブラリよりスコアが下回っています。標準ライブラリよりスコアが上回っているものはずるいテストなので一般的なユースケースではないです。ここのパフォーマンス向上は課題です。

おわりに

概ね System.Text.StringBuilder 超えのパフォーマンスを実現できました。車輪の再発明はプログラマの華ですね。

標準ライブラリに System.Text.ValueStringBuilder が存在します(あとから気づきました)。
internal なので見えません。ref 構造体のため、今のところ使うことはできなさそうです。

次回以降、今回パフォーマンスを出すためにちょっと工夫したところの解説をしようと思います。

2
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?