【C#】UTF-8 エンコード用の StringBuilder を作ってみる

# はじめに

昨今は文字列を UTF-8 バイト列でそのまま扱いたい需要が高まりまして、UTF-8 関係の標準ライブラリの機能も充実してきました。

- [`System.IUtf8SpanFormattable` インターフェイス](https://learn.microsoft.com/ja-jp/dotnet/api/system.iutf8spanformattable?view=net-9.0)
- [`System.Text.Unicode.Utf8` クラス](https://learn.microsoft.com/ja-jp/dotnet/api/system.text.unicode.utf8?view=net-9.0)

というわけで今回は UTF-8 文字列用の `StringBuilder` を作ってみます。

## サンプルコード

<details>
<summary>InlineUtf8StringBuilder.cs</summary>

```cs
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;

/// <summary>
/// 内部バッファに Span を使用する UTF-8 StringBuilder
/// バッファ長を超えて Append すると新しいバッファを確保する
/// </summary>
[DebuggerDisplay("{Span}")]
[CollectionBuilder(typeof(InlineUtf8StringBuilder), nameof(Create))]
public ref struct InlineUtf8StringBuilder
{
    // NOTE: インラインバッファの長さ。コンパイル定数
    // 大きくしすぎるとスタックオーバーフローの危険がある
    private const int BufferLength = 128;

    [InlineArray(BufferLength)]
    private struct InlineBuffer
    {
        internal byte Value;
    }

    [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_length")]
    private static extern ref int InlineBufferLength(ref Span<byte> span);

    private InlineBuffer _buffer;
    private byte[]? _array;
    private int _length;

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

    /// <summary>
    /// Span
    /// </summary>
    public readonly Span<byte> Span
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get
        {
            if (this._array is not null)
                return this._array.AsSpan(0, this._length);

            var result = new Span<byte>(ref Unsafe.AsRef(in this._buffer.Value));
            InlineBufferLength(ref result) = this._length;

            return result;
        }
    }

    /// <summary>
    /// 内部配列長
    /// </summary>
    public readonly int Capacity
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get => this._array is not null ? this._array.Length : BufferLength;
    }

    private readonly Span<byte> NextSpan
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get
        {
            if (this._array is not null)
                return this._array.AsSpan(this._length);

            var result = new Span<byte>(ref Unsafe.AsRef(in this._buffer.Value));
            InlineBufferLength(ref result) = BufferLength;
            return result[this._length..];
        }
    }

    private readonly Span<byte> Buffer
    {
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        get
        {
            if (this._array is not null)
                return this._array.AsSpan();

            var result = new Span<byte>(ref Unsafe.AsRef(in this._buffer.Value));
            InlineBufferLength(ref result) = BufferLength;
            return result;
        }
    }

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="capacity"></param>
    /// <exception cref="ArgumentOutOfRangeException"></exception>
    public InlineUtf8StringBuilder(int capacity)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(capacity);

        if (capacity > BufferLength)
            this._array = new byte[capacity];
        this._length = 0;
    }

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="value"></param>
    public InlineUtf8StringBuilder(scoped ReadOnlySpan<byte> value) => this.Append(value);

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

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

    /// <summary>
    /// バッファをクリア
    /// </summary>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref InlineUtf8StringBuilder 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);

        Span<byte> span = this._array is not null ? this._array.AsSpan() : this._buffer;
        var newArray = new byte[capacity];
        span.CopyTo(newArray);
        this._array = newArray;
    }

    /// <summary>
    /// 文字列を追加
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref InlineUtf8StringBuilder Append(scoped ReadOnlySpan<byte> 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 InlineUtf8StringBuilder Append(scoped ReadOnlySpan<char> value)
    {
        int written;
        while (!Encoding.UTF8.TryGetBytes(value, this.NextSpan, out written))
            this.SizeUp();
        this._length += written;

        return ref this.RefThis;
    }

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

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

    /// <summary>
    /// 末尾に文字列を追加して改行
    /// </summary>
    /// <param name="value"></param>
    /// <returns></returns>
    public ref InlineUtf8StringBuilder 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 InlineUtf8StringBuilder Append<T>(scoped T value, scoped ReadOnlySpan<char> format = default, IFormatProvider? provider = null) where T : IUtf8SpanFormattable, 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 InlineUtf8StringBuilder AppendLine<T>(scoped T value, scoped ReadOnlySpan<char> format = default, IFormatProvider? provider = null) where T : IUtf8SpanFormattable, 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 InlineUtf8StringBuilder Insert(int index, byte 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[(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 InlineUtf8StringBuilder Insert(int index, scoped ReadOnlySpan<byte> 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[(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 InlineUtf8StringBuilder 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 InlineUtf8StringBuilder 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.Span);
        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<byte> newValue)
    {
        var shift = newValue.Length - oldValueLength;
        var buffer = this.Buffer;
        var source = buffer[(index + oldValueLength)..];
        ref var destination = ref MemoryMarshal.GetReference(buffer[(index + oldValueLength + shift)..]);
        for (int n = 0; n < source.Length; ++n)
            Unsafe.Add(ref destination, n) = source[n];

        newValue.CopyTo(buffer[index..]);

        this._length += shift;
    }

    /// <summary>
    /// 置き換え
    /// </summary>
    /// <param name="oldValue"></param>
    /// <param name="newValue"></param>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref InlineUtf8StringBuilder Replace(scoped ReadOnlySpan<byte> oldValue, scoped ReadOnlySpan<byte> newValue)
    {
        var index = 0;
        var span = this.Buffer;
    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.Buffer[index..];
        goto Label;
    }

    /// <summary>
    /// 削除
    /// </summary>
    /// <param name="oldValue"></param>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public ref InlineUtf8StringBuilder ReplaceEmpty(scoped ReadOnlySpan<byte> oldValue)
    {
        var index = 0;
        var span = this.Buffer;
    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.Buffer[index..];
        goto Label;
    }

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

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

    /// <summary>
    /// 指定長追加し指定長のバッファを取得
    /// </summary>
    /// <param name="length"></param>
    /// <returns></returns>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public Span<byte> 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[..length];
        this._length += length;

        result.Clear();
        return result;
    }

    /// <summary>
    /// 要素を列挙
    /// </summary>
    /// <returns></returns>
    // NOTE: メンバーを候補に表示しないようにする属性
    // 同じアセンブリからアクセスする場合は表示される
    [EditorBrowsable(EditorBrowsableState.Never)]
    [Obsolete("Span を使用してください。")]
    public Span<byte>.Enumerator GetEnumerator() => this.Span.GetEnumerator();

    internal static InlineUtf8StringBuilder Create(scoped ReadOnlySpan<byte> span) => new(span);
}
```

</details>

<details>
<summary>テストコード</summary>

```cs
using Xunit;

public class _InlineUtf8StringBuilderTest()
{
    [Fact]
    void HowToUse()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.Append("PI="u8);
        // 数値型（System.IUtf8SpanFormattable）をサポート
        builder.Append(3.14);
        Assert.Equal("PI=3.14"u8, builder);

        builder.Clear();
        // 補間文字列(※最適化されておらず ReadOnlySpan<char> を経由)
        builder.Append($"PI={3.14}");
        Assert.Equal("PI=3.14"u8, builder);

        // 暗黙的な変換
        Span<byte> _ = builder;

        // 内部バッファを超える場合、new byte[] でヒープにバッファが作成される
        builder.Clear();
        var capacity = builder.Capacity;
        builder.Append(stackalloc byte[129]);
        Assert.True(builder.Capacity > capacity);

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

    [Fact]
    void HowToUseUtf8()
    {
        var x = 100;
        var y = Math.PI;
        var buffer = (stackalloc byte[64]);

        System.Text.Unicode.Utf8.TryWrite(buffer, $"x: {x:0.0}, y: {y:0.00}", out var written);

        var utf8text = buffer[..written];
        Assert.Equal("x: 100.0, y: 3.14"u8, utf8text);
    }

    [Fact]
    void TestConstructor()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => new InlineUtf8StringBuilder(-1));

        Assert.Equal([], new InlineUtf8StringBuilder().Span);
        Assert.Equal("123"u8, new InlineUtf8StringBuilder("123"u8));
    }

    [Fact]
    void LengthSpan()
    {
        var builder = new InlineUtf8StringBuilder();
        Assert.Equal(0, builder.Length);
        Assert.Equal(""u8, builder.Span);

        builder.Append("abc"u8);
        Assert.Equal(3, builder.Length);
        Assert.Equal("abc"u8, builder.Span);
    }

    [Fact]
    void Clear()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.Append("abc"u8);
        builder.Clear();
        Assert.Equal(""u8, builder.Span);
    }

    [Fact]
    void ToStringAndClear()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.Append("abc"u8);
        Assert.Equal("abc", builder.ToStringAndClear());
        Assert.Equal(""u8, builder.Span);
    }

    [Fact]
    void BuilderToString()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.Append("abc"u8);
        Assert.Equal("abc", builder.ToString());
    }

    [Fact]
    void Append_String()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.Append("abc");
        Assert.Equal("abc"u8, builder.Span);
    }

    [Fact]
    void Append_ReadOnlySpan()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.Append("abc"u8);
        Assert.Equal("abc"u8, builder.Span);
    }

    [Fact]
    void SizeUp()
    {
        var builder = new InlineUtf8StringBuilder();
        Assert.Equal(128, builder.Capacity);
        var capacity = builder.Capacity;

        builder.Append(stackalloc byte[129]);
        Assert.True(capacity < builder.Capacity);
    }

    [Fact]
    void AppendLine()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.AppendLine();
        Assert.Equal(Environment.NewLine, builder.ToString());
    }

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

    [Fact]
    void Append_T()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.Append("n=");
        builder.Append(123456789);
        Assert.Equal("n=123456789"u8, builder.Span);
    }

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

    [Fact]
    void Insert()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => new InlineUtf8StringBuilder().Insert(-1, (byte)'a'));
        Assert.Throws<ArgumentOutOfRangeException>(() => new InlineUtf8StringBuilder("abc"u8).Insert(5, (byte)'a'));

        var builder = new InlineUtf8StringBuilder();
        builder.Append("13");
        builder.Insert(1, (byte)'2');
        Assert.Equal("123"u8, builder.Span);
    }

    [Fact]
    void InsertRange()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => new InlineUtf8StringBuilder().Insert(-1, "123"u8));
        Assert.Throws<ArgumentOutOfRangeException>(() => new InlineUtf8StringBuilder("ab"u8).Insert(5, "123"u8));

        var builder = new InlineUtf8StringBuilder();
        builder.Append("123890"u8);
        builder.Insert(3, "4567"u8);
        Assert.Equal("1234567890"u8, builder.Span);
    }

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

        var builder = new InlineUtf8StringBuilder();
        builder.Append("123"u8);
        builder.RemoveAt(1);
        Assert.Equal("13"u8, builder.Span);
    }

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

        var builder = new InlineUtf8StringBuilder();
        builder.Append("123456"u8);
        builder.Remove(1, 2);
        Assert.Equal("1456"u8, builder.Span);
    }

    [Fact]
    void ImplicitOperatorSpan()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.Append("abc"u8);
        Span<byte> span = builder;
        Assert.Equal("abc"u8, span);
    }

    [Fact]
    void ImplicitOperatorReadOnlySpan()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.Append("abc"u8);
        ReadOnlySpan<byte> span = builder;
        Assert.Equal("abc"u8, span);
    }

    [Fact]
    void RefThis()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.Append("abc"u8).Append("def"u8);
        Assert.Equal("abcdef"u8, builder.Span);
    }

    [Fact]
    void ReplaceEmpty()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.Append("1234567890"u8);
        builder.ReplaceEmpty("123"u8);
        Assert.Equal("4567890"u8, builder);

        builder = new InlineUtf8StringBuilder();
        builder.Append("1234567890"u8);
        builder.ReplaceEmpty("4567"u8);
        Assert.Equal("123890"u8, builder);

        builder = new InlineUtf8StringBuilder();
        builder.Append("1234567890"u8);
        builder.ReplaceEmpty("567890"u8);
        Assert.Equal("1234"u8, builder);
    }

    [Fact]
    void Replace()
    {
        var builder = new InlineUtf8StringBuilder();
        builder.Append("12345"u8);
        builder.Replace("123"u8, "777"u8);
        Assert.Equal("77745"u8, builder);

        builder = new InlineUtf8StringBuilder();
        builder.Append("123abc456"u8);
        builder.Replace("123"u8, "777"u8);
        Assert.Equal("777abc456"u8, builder);

        builder = new InlineUtf8StringBuilder();
        builder.Append("123abc456"u8);
        builder.Replace("456"u8, "7777"u8);
        Assert.Equal("123abc7777"u8, builder);
    }

    [Fact]
    void GetEnumerator()
    {
        var builder = new InlineUtf8StringBuilder("123"u8);
        var got = new List<byte>();
#pragma warning disable CS0618
        foreach (var n in builder)
            got.Add(n);
#pragma warning restore CS0618

        Assert.Equal("123"u8, [.. got]);
    }

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

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

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

    [Fact]
    void Capacity()
    {
        var builder = new InlineUtf8StringBuilder();
        Assert.Equal(128, builder.Capacity);

        builder.Append(stackalloc byte[129]);
        Assert.Equal(256, builder.Capacity);
    }

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

        {
            var builder = new InlineUtf8StringBuilder("123"u8);
            builder.AppendRange(3).Fill((byte)'4');
            Assert.Equal("123444"u8, builder);
        }
    }
}
```

</details>

## 使い方

```cs
var builder = new InlineUtf8StringBuilder();
builder.Append("PI="u8);
// 数値型（System.IUtf8SpanFormattable）をサポート
builder.Append(3.14);
Assert.Equal("PI=3.14"u8, builder);

builder.Clear();
// 補間文字列(※最適化されておらず ReadOnlySpan<char> を経由)
builder.Append($"PI={3.14}");
Assert.Equal("PI=3.14"u8, builder);

// 暗黙的な変換
Span<byte> _ = builder;

// 内部バッファを超える場合、new byte[] でヒープにバッファが作成される
builder.Clear();
var capacity = builder.Capacity;
builder.Append(stackalloc byte[129]);
Assert.True(builder.Capacity > capacity);

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

## `IUtf8SpanFormattable`

```cs
public void Append<T>(
    scoped T value,
    scoped ReadOnlySpan<char> format = default,
    IFormatProvider? provider = null)
    where T : IUtf8SpanFormattable, allows ref struct
{
    int count;
    while (!value.TryFormat(this.NextSpan, out count, format, provider))
        this.SizeUp();

    this._length += count;

    return ref this.RefThis;
}
```

- 今回のメイン部分で、`TryFormat()` で書き込んでいます
- バッファ長が足りない場合はバッファ長を確保します
- ジェネリックメソッドにしてボックス化を回避しています
- ジェネリックの制約で `ref` 構造体を許容しています

## `InlineArray`

[`System.Runtime.CompilerServices.InlineArrayAttribute` 属性](https://learn.microsoft.com/ja-jp/dotnet/api/system.runtime.compilerservices.inlinearrayattribute?view=net-8.0) を使用して、内部的にバッファを確保しています。これは値渡ししたときにおかしな挙動をするので注意が必要です。

関連

- [【.NET 9.0】InlineArray を使った値型リストを作ってみる](https://qiita.com/abetakahiro123/items/d3a724dba68cb16ca722)
- [メモリ連続の辞書構造を作ってみる](https://qiita.com/abetakahiro123/items/0264be778c7c618d8245)

## 文字列補間（今回は未実装）

[`System.Text.Unicode.Utf8` クラス](https://learn.microsoft.com/ja-jp/dotnet/api/system.text.unicode.utf8?view=net-9.0) によって実現できそうですが、これは通常の文字列補間(`ReadOnlySpan<char>`)を UTF-8 に変換すればいいような気がするため、今回は見送ります。

```cs
var x = 100;
var y = Math.PI;
var buffer = (stackalloc byte[64]);

System.Text.Unicode.Utf8.TryWrite(buffer, $"x: {x:0.0}, y: {y:0.00}", out var written);

var utf8text = buffer[..written];
Assert.Equal("x: 100.0, y: 3.14"u8, utf8text);
```

## おわりに

今回はUTF-8 エンコード用の StringBuilder を作ってみました。  
パフォーマンス比較は次回やります。