2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

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

サンプルコード

InlineUtf8StringBuilder.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);
}
テストコード
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);
        }
    }
}

使い方

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

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 属性 を使用して、内部的にバッファを確保しています。これは値渡ししたときにおかしな挙動をするので注意が必要です。

関連

文字列補間(今回は未実装)

System.Text.Unicode.Utf8 クラス によって実現できそうですが、これは通常の文字列補間(ReadOnlySpan<char>)を UTF-8 に変換すればいいような気がするため、今回は見送ります。

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 を作ってみました。
パフォーマンス比較は次回やります。

2
2
0

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
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?