てくメモ

trivial な notes

【Godot C#】async/await な流れで落ち物ゲームをつくる

本記事は、Godot Engine Advent Calendar 2023 参加記事となっています。

空きのある日があったので、枯れ木も山の賑わいとばかりに参加させていただきました。不勉強な部分があるかと思いますが、よろしくお願いいたします。


以下の画像(外部サイト投稿の apng につき、表示・再生されない場合があるかもしれません)のような雰囲気の落ち物ゲームを、async/await なフローの処理でつくってみました。そのアプローチについての記事です。

https://i.imgur.com/tROmIaP.png

(ゲーム利用の画像素材:Blue Archive (Global version) の digital merch より)

スイカゲームライクの落ち物です。


本記事では、async/await のアプローチに焦点を当て、全体は示しません。Godot でのスイカゲームライク落ち物については、プロジェクト全体を公開のうえで解説されている記事がありますので、紹介します。

また、昨年のアドベントカレンダーにおいて、GDScript による同様のアプローチを検証されている記事がありますので、紹介します。



メインノード

async/await のフローを使えると何が嬉しいのか。それは、状態遷移を自然に書けることです。

例えば、メインノードのスクリプトは以下のようにしました。

参考:ノードツリー

public partial class Main : Node2D
{
    public override void _Ready() => _ = LoopAsync();

    private async Task LoopAsync()
    {
        var startScene = GetNode<StartScene>("StartScene");
        var mainGame = GetNode<MainGame>("MainGame");
        var resultScene = GetNode<ResultScene>("ResultScene");

        // シーン(1):Click to Start。入力を待機
        await startScene.WaitAsync();
        startScene.QueueFree();

        for(bool continuesToPlay = true; continuesToPlay;)
        {
            resultScene.Hide();

            // シーン(2):メインゲーム。ゲームオーバーを待機し、スコア結果を取得
            int score = await mainGame.RunAsync();

            // シーン(3):リザルト。スコア結果を表示し、再度プレイするかの入力を待機
            continuesToPlay = await resultScene.ConfirmAsync(score);
        }

        // ゲーム終了
        GetTree().Quit();
    }
}

このゲームは、① 起動したら入力を待機する ⇨ ②メインの落ち物 ⇨ ③リザルトを表示しゲームを再度プレイするか確認する ⇨ 続けるなら再度②・続けないなら終了、という流れで構成されています。

async/await を利用することで、その流れのとおりに処理を書くことができます。また、シーン状態フラグ等を自前で管理する必要がありません。嬉しい。

というわけで、こうした await を実現するために各シーンのスクリプトを書いていきます。

Click to Start シーン(単純な入力待機)

まずは Click to Start なスタートシーン。決定アクション (ui_accept) かマウスクリックを待機することにします。

Click to Start

こうした単純な待機であれば、ToSignalをawaitすればよいでしょう。

public partial class StartScene : CanvasLayer
{
    [Signal] // Signal 属性 + EventHandler サフィックス命名でソースジェネレーターが諸々を自動生成してくれる
    public delegate void ClickEventHandler();

    // ToSignal で await。SignalName.Click は自動生成されている
    public async Task WaitAsync() => await ToSignal(this, SignalName.Click);

    public override void _Input(InputEvent @event)
    {
        if (@event.IsActionPressed("ui_accept")
            || @event.IsActionPressed("mouse_click_left")) // (※ このアクションは追加したもの)
        {
            EmitSignal(SignalName.Click); // EmitSignal で発火
        }
    }
} 

メインゲーム(ゲームオーバーを待機しながら落ち物)

次にメインゲームの落ち物を考えます。

今回は、デッドライン到達でのゲームオーバーでのみ、シーンが終了するとします。流れは次のようになります。

[Signal]
public delegate void ReachDeadEventHandler();

public async Task<int> RunAsync()
{
    // 開始処理
    await OnStarting();

    // ゲームオーバー(キャンセル)まで落下動作をループ
    using CancellationTokenSource cts = new();
    _ = ReleaseLoopAsync(cts.Token);

    // ゲームオーバーを待機
    await ToSignal(this, SignalName.ReachDead);

    // 終了処理
    OnClosing();
    cts.Cancel();

    // スコアを返す
    return Score.Value;
}

落下動作はゲームオーバーまで繰り返します。ゲームオーバーを待機するとすれば、落下動作の繰り返しはゲームオーバー時にキャンセルしなければなりません。


キャンセルの実現方法ですが、今回は Unity における UniTask の AsyncEventHandler の仕組みを参考とすることにしました。

Godot における UniTask ライクライブラリの GDTask では、AsyncEventHandler は(おそらく)ポートされていないようでした。というわけで、自前で用意します。以下に折りたたみます。

AsyncEventHandler 折りたたみ

internal static class AsyncEventHandler
{
    public static AsyncEventHandler<Action> Create(Action<Action> addHandler, Action<Action> removeHandler, bool callOnce, CancellationToken cancellationToken)
        => new(addHandler, removeHandler, callOnce, cancellationToken);

    public static AsyncEventHandler<TEventHandler> Create<TEventHandler>(Action<TEventHandler> addHandler, Action<TEventHandler> removeHandler, bool callOnce, CancellationToken cancellationToken)
        where TEventHandler : Delegate
        => new(addHandler, removeHandler, callOnce, cancellationToken);

    public static ValueTask OnInvokeAsync(Action<Action> addHandler, Action<Action> removeHandler, CancellationToken cancellationToken)
        => new AsyncEventHandler<Action>(addHandler, removeHandler, true, cancellationToken).OnInvokeAsync();

    public static ValueTask OnInvokeAsync<TEventHandler>(Action<TEventHandler> addHandler, Action<TEventHandler> removeHandler, CancellationToken cancellationToken)
        where TEventHandler : Delegate
        => new AsyncEventHandler<TEventHandler>(addHandler, removeHandler, true, cancellationToken).OnInvokeAsync();
}

internal sealed class AsyncEventHandler<TEventHandler> : IValueTaskSource, IDisposable
    where TEventHandler : Delegate
{
    private static readonly Action<object?> cancellationCallback = CancellationCallback;

    private TEventHandler? handler;
    private Action<TEventHandler>? removeHandler;

    private readonly CancellationToken cancellationToken;
    private readonly CancellationTokenRegistration registration;
    private bool isDisposed = false;
    private readonly bool callOnce;

    private ManualResetValueTaskSourceCore<int> core;

    public AsyncEventHandler(Action<TEventHandler> addHandler, Action<TEventHandler> removeHandler, bool callOnce, CancellationToken cancellationToken)
    {
        this.cancellationToken = cancellationToken;
        if (cancellationToken.IsCancellationRequested)
        {
            isDisposed = true;
            return;
        }

        this.removeHandler = removeHandler;
        this.callOnce = callOnce;

        Action action = Invoke;
        handler = typeof(TEventHandler) == typeof(Action)
            ? (TEventHandler)(object)action
            : action.Method.CreateDelegate<TEventHandler>(action.Target); // reflection. some other way is preferable.
        addHandler(handler);

        if (cancellationToken.CanBeCanceled)
        {
            registration = cancellationToken.Register(cancellationCallback, this);
        }
    }

    public ValueTask OnInvokeAsync()
    {
        core.Reset();
        if (isDisposed)
        {
            TrySetException();
        }
        return new ValueTask(this, core.Version);
    }

    private bool TrySetException()
    {
        // sloppy TrySetCanceled
        if (core.GetStatus(core.Version) == ValueTaskSourceStatus.Pending)
        {
            core.SetException(new OperationCanceledException(cancellationToken));
            return true;

        }
        return false;
    }

    private void Invoke()
    {
        // sloppy TrySetResult
        if (core.GetStatus(core.Version) == ValueTaskSourceStatus.Pending)
        {
            core.SetResult(0);
        }
    }

    private static void CancellationCallback(object? state)
    {
        var self = (AsyncEventHandler<TEventHandler>)state!;
        self.Dispose();
    }

    public void Dispose()
    {
        if (isDisposed) return;

        isDisposed = true;
        registration.Dispose();
        if (removeHandler is not null)
        {
            removeHandler.Invoke(handler!);
            removeHandler = null;
            handler = null;
        }
        TrySetException();
    }

    void IValueTaskSource.GetResult(short token)
    {
        try
        {
            core.GetResult(token);
        }
        finally
        {
            if (callOnce)
            {
                Dispose();
            }
        }
    }

    ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) => core.GetStatus(token);
    void IValueTaskSource.OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) => core.OnCompleted(continuation, state, token, flags);
}

参考とした UniTask はUnityEventを扱うのですが Godot には無い(当たり前)ので、イベントハンドラのアタッチ・デタッチを渡すかたちにしています。また、色々と姑息的な部分は見逃してください。


さて、これを利用することで、落下動作の繰り返しは以下のように書けます。
(コメントの「モモフレンズ」は、スイカゲームにおける果物だと思ってください1)

[Signal]
public delegate void ReleaseEventHandler();

private async Task ReleaseLoopAsync(CancellationToken cancellationToken)
{
    var releaseEventHandler = AsyncEventHandler.Create<ReleaseEventHandler>(
        h => Release += h, // アタッチ
        h => Release -= h, // デタッチ
        false, // 単発か否か
        cancellationToken); // キャンセレーショントークン

    // キャンセルがかかるまでループ
    while(cancellationToken.IsCancellationRequested is false)
    {
        var momo = CreateMomoToDrop(); // 落とすモモフレンズの生成処理

        // リリース入力を待機
        await releaseEventHandler.OnInvokeAsync();

        ReleaseMomoToDrop(momo); // 実際に落とす処理(というか Freeze = false)
    }
}

awaitできるようにしたことで、同期的な流れで記述できました。

なお実際には、各種の処理を含めて以下のようになっています。

private async Task ReleaseLoopAsync(CancellationToken cancellationToken)
{
    var releaseEventHandler = AsyncEventHandler.Create<ReleaseEventHandler>(
        h => Release += h,
        h => Release -= h,
        false,
        cancellationToken);

    // キャンセルがかかるまでループ
    while(cancellationToken.IsCancellationRequested is false)
    {
        var momo = CreateMomoToDrop(); // 落とすモモフレンズの生成処理

        void setPosition(MouseMotionNotification v) => SetPosition(momo, v.Position);
        using (MessageBroker.Default.Subscribe<MouseMotionNotification>(setPosition))
        {
            // リリース入力を待機
            await releaseEventHandler.OnInvokeAsync();
        }
        ReleaseMomoToDrop(momo); // 実際に落とす処理(というか Freeze = false)

        // ディレイ
        if (cancellationToken.IsCancellationRequested) return;
        await Task.Delay(300, cancellationToken);
    }
}

落下させるアイテムのマウス追従は、楽をして、InputEventMouseMotionの座標を発行してそれを購読するかたちにしました。ReactiveProperty というライブラリのMessageBrokerを利用しています。


落下動作の発火は_Inputで、デッドライン到達の発火は_PhysicsProcessで行いました。

internal readonly struct MouseMotionNotification(Vector2 position)
{
    public readonly Vector2 Position = position;
}

public override void _Input(InputEvent @event)
{
    if(@event.IsActionPressed("ui_accept")
        || @event.IsActionPressed("mouse_click_left"))
    {
        EmitSignal(SignalName.Release);
    }

    if(@event is InputEventMouseMotion motion)
    {
        MessageBroker.Default.Publish<MouseMotionNotification>(new(motion.Position));
    }
}

// private Area2D deadline
public override void _PhysicsProcess(double delta)
{
    if(deadline.HasOverlappingBodies())
    {
        foreach(var body in deadline.GetOverlappingBodies())
        {
            if (body is not Momo { IsDropping: false }) continue;

            // 落下判定を受けたモモフレンズがデッドライン上にいたら発火
            EmitSignal(SignalName.ReachDead);
            break;
        }
    }
}

async/await な流れで、と銘打ちつつ_PhysicsProcessを使っています。とはいえ、すべての処理をフレームループから追い出したいわけではありません。適材適所です。

……正直に言えば今回は、「_Processレスでやりたかったな~」と思いながら泣く泣く使いました。(デッドラインとなるArea2DのBodyEnteredイベントは、対象がBodyEntered ⇨ 落下判定の順でデッドライン上に留まってしまった場合に、ゲームオーバーを発火できなかった)

リザルトシーン(複数ボタンの待機)

リザルト画面では、ゲームを続けるかどうか確認します。言い換えれば、複数のボタンのいずれかが押されるまで待機します。

play again?

前項でAsyncEventHandlerを用意したので、それを利用します。ボタンくらい頻出の UI であれば、ショートハンドの拡張メソッドをつくるとよさそうです。

internal static class Extensions
{
    public static ValueTask OnPressedAsync(this BaseButton button, CancellationToken cancellationToken)
    {
        return AsyncEventHandler.OnInvokeAsync(
            h => button.Pressed += h,
            h => button.Pressed -= h,
            cancellationToken);
    }
}

これで、以下のように書けます。

public partial class ResultScene : CanvasLayer
{
    private Label scoreLabel;
    private Button okButton;
    private Button quitButton;

    public override void _Ready()
    {
        scoreLabel = GetNode<Label>("Container/ScoreLabel");
        okButton = GetNode<Button>("Container/OkButton");
        quitButton = GetNode<Button>("Container/QuitButton");
    }

    public async Task<bool> ConfirmAsync(int score)
    {
        scoreLabel.Text = $"{score} pts.";
        Show();

        using CancellationTokenSource cts = new();

        // Pressed イベントを WhenAny で待機
        int result = await ValueTaskEx.WhenAny( // ValueTaskSupplement ライブラリ利用
            okButton.OnPressedAsync(cts.Token),
            quitButton.OnPressedAsync(cts.Token));

        // キャンセル
        cts.Cancel();

        return result == 0;
    }
}

アタッチ・デタッチを外に追い出せていると、見た目がすっきりしますね。

おわりに

というわけで、async/await な流れで冒頭の apng のような落ち物ゲームをつくることができました。

色々試すためのものだったのですが、Godot のフレンドリーさもあり、とても楽しかったです。


一方、async/await アプローチには課題もありました。

まず、UniTask がない点です。UniTask は単に Task-like を提供しているだけでなく、Unity にも C# にも寄り添ったつくりになっています。ない以上、同様のことをしたいと思えば、何らかの無理が生じると感じました。

二点目として、デバッグです。これはまぁ……という感じですね。

Godot に入っては Godot に従えと現状ある流儀に従えばよい、というのもあるでしょうが、async/await の有用性は明白なのでこの方向も発展してほしいなと感じます。


最後に。Godot は不思議と触って楽しいと感じさせてくれます。これからも情報を追っていきたいです。



  1. 記事用に「アイテム」みたいな表記に変えようかと思いましたが、コードの Momo の意味が分からなくなるし、命名を変えるのも手間と思い、注釈することにしました↩

【C#】DefaultInterpolatedStringHandler を StringBuilder 的に使う


本記事は、C# Advent Calendar 2023 12日目参加記事となっています。



C#において、複雑な文字列を組み立てる手段として挙げられるのはStringBuilderでしょう。

また、そこまで複雑な組み立てでなければ、補間文字列($"foo: {bar}")が使われます。

補間文字列については、C# 10 / .NET 6 でパフォーマンス改善が行われました。コンパイラがDefaultInterpolatedStringHandlerという型を用いたコードに展開し、これは、改善以前(String.Formatへの展開)に対しパフォーマンスに優れます。

さて、このDefaultInterpolatedStringHandler。コンパイラが用いる型ながら、アクセシビリティはpublicです。すなわち、ユーザーが直接触ることができます。

というわけで本記事は、DefaultInterpolatedStringHandlerを直接触ってStringBuilder的に使ってしまおう、という内容です。


なお、情報を整理するきっかけとしたポストがありますので、引用します。



目次


TL;DR

  • DefaultInterpolatedStringHandlerがStringBuilderと似たふうに使えて、パフォーマンス的利点あり

本記事の範囲

(DefaultInterpolatedStringHandler本来の使われ方である)補間文字列については、以下の優れた参考文献の提示に留めます。


本記事では、

  • まず、DefaultInterpolatedStringHandlerを直接触るモチベーションを示します
  • 次に、ToStringAndClearメソッドについて、注意点として説明します
  • 続いて、バッファの初期サイズと拡大、初期バッファを渡すコンストラクタ、値の書き込み、といった話題を取り上げていきます
  • その後、StringBuilderを代替できないケースとなる性質に触れます

モチベーション

DefaultInterpolatedStringHandlerを直接触ってStringBuilder的に使うモチベーションは

  • 型が標準で用意されており(.NET 6~)、
  • StringBuilderとさほど変わらない記述で、
  • パフォーマンス的恩恵を受けることができる点です


書き方の点でStringBuilderとまったく違うやり方が要求される、ということはありません。

文字列を得るまでの流れは以下のとおりです。

DefaultInterpolatedStringHandler sh = new(0, 0);
sh.AppendLiteral("foo: "); // リテラルを書き込む AppendLiteral
sh.AppendFormatted(1); // 値を書き込む AppendFormatted
var result1 = sh.ToStringAndClear(); // 最後に ToStringAndClear

// 参考:StringBuilder
StringBuilder sb = new();
sb.Append("foo: ");
sb.Append(1);
var result2 = sb.ToString();


次に、簡単な例でパフォーマンス的恩恵を見てみます。

byte配列から、2桁16進数表記を", "で区切って"[]"で括った文字列を得ることを考えます。

1行コード、StringBuilder、DefaultInterpolatedStringHandlerを、BenchmarkDotNet で計測。

ベンチマークコード折りたたみ

[ShortRunJob]
[MemoryDiagnoser]
public class DefaultInterpolatedStringHandlerBenchmark
{
    private byte[] byteArray = default!;
    [Params(64)]
    public int N;

    [GlobalSetup]
    public void Setup()
    {
        byteArray = new byte[N];
        Random.Shared.NextBytes(byteArray);
    }

    [Benchmark(Baseline = true)]
    public string Oneliner() => $"[{string.Join(", ", byteArray.Select(v => v.ToString("X2")))}]";

    [Benchmark]
    public string StringBuilder()
    {
        StringBuilder sb = new();

        sb.Append('[');
        if(byteArray.Length > 0)
        {
            int i = 0;
            sb.Append($"{byteArray[i++]:X2}"); // これは Append(string) でなく、Append(AppendInterpolatedStringHandler) (なので不利にするものではない)
            while (i < byteArray.Length)
            {
                sb.Append($", {byteArray[i++]:X2}");
            }
        }
        sb.Append(']');

        return sb.ToString();
    }

    [Benchmark]
    public string Handler()
    {
        DefaultInterpolatedStringHandler sh = new(0, 0);

        sh.AppendLiteral("[");
        if(byteArray.Length > 0)
        {
            int i = 0;
            sh.AppendFormatted(byteArray[i++], "X2");
            while(i < byteArray.Length)
            {
                sh.AppendLiteral(", ");
                sh.AppendFormatted(byteArray[i++], "X2");
            }
        }
        sh.AppendLiteral("]");

        return sh.ToStringAndClear();
    }
}

| Method        | N  | Mean     | Error     | StdDev    | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|-------------- |--- |---------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| Oneliner      | 64 | 2.115 μs | 0.4605 μs | 0.0252 μs |  1.00 |    0.00 | 1.0071 |    3168 B |        1.00 |
| StringBuilder | 64 | 1.654 μs | 0.6019 μs | 0.0330 μs |  0.78 |    0.02 | 0.4482 |    1408 B |        0.44 |
| Handler       | 64 | 1.429 μs | 0.4991 μs | 0.0274 μs |  0.68 |    0.02 | 0.1698 |     536 B |        0.17 |

一例なのでざっくりで捉えてほしいのですが、この例ではDefaultInterpolatedStringHandler (Handler) が速度 (Mean) でもアロケーション (Allocated) でも優位です。

特に、アロケーション面で大きく利点があることが見て取れます。これは主に、内部のバッファをnewで用意しない仕組みになっていることが理由です。また、型が構造体なので自身のアロケーションもありません。

書き込み処理についても、ISpanFormattableというインターフェースを利用できる場合、文字列を介さずバッファへ直接書き込みます。1


というわけでDefaultInterpolatedStringHandlerは、標準で用意されていて、StringBuilderと書き方がさほど変わらず、使えばパフォーマンス的恩恵が受けられる、ということです。

これは使いたいですね。

ToStringAndClear ―― 注意点

DefaultInterpolatedStringHandlerを直接触るにあたって注意を必要とする点があります。

それは、最後に(ToString()ではなく)ToStringAndClearメソッドを呼ぶ点です。

DefaultInterpolatedStringHandler sh = new(0, 0);
sh.AppendLiteral("foo: ");
Console.WriteLine(sh.ToString()); // ToString() でも文字列を生成できるが……
sh.AppendFormatted(1);
Console.WriteLine(sh.ToStringAndClear()); // 最後は ToStringAndClear() を呼ぶ

これは、内部のバッファがArrayPool<char>.Sharedから用意されているためです。

念のためArrayPool<T>を極めて単純に説明すると、配列を使い回すためのプールです。使い回すため、使った後は返却します。
本旨ではないため、以下にドキュメントと Deep Dive な参考文献を示します。


ArrayPool<char>.Sharedから借りたバッファの返却が、ToStringAndClearメソッドをとおして行われます。

コンパイラが用いるぶんには呼び忘れはありませんが、人間が使う場合は強制されません。自己責任になります。(冒頭で引用したポストのリプライによると、DefaultInterpolatedStringHandlerが「ValueStringBuilder」とはならなかった要因がこの点のようです)

というわけで、直接触るという知識には必ずToStringAndClear()をセットにしましょう。

バッファの初期サイズと拡大 ―― new(0, 0)

ここまで、コンストラクタの引数を0, 0としてきました。これは何を示しているのでしょうか。

前者はリテラルの文字長 (literalLength) で、後者は補間される穴の数 (formattedCount) です。例えば、補間文字列$"a-{b}-c"なら、コンパイラが展開する際にはコンストラクタに4, 1が渡されます。

そして、これは厳密な数値でなくても構いません。初期バッファのサイズヒントとして使われます。

// CoreLib より抜粋 (.NET 8.0 時点)、ソース内コメントは原文のもの

private const int GuessedLengthPerHole = 11;
private const int MinimumArrayPoolLength = 256;

[MethodImpl(MethodImplOptions.AggressiveInlining)] // becomes a constant when inputs are constant
internal static int GetDefaultLength(int literalLength, int formattedCount) =>
    Math.Max(MinimumArrayPoolLength, literalLength + (formattedCount * GuessedLengthPerHole));
// 定数を代入すると、以下
int minimumLength = Math.Max(256, literalLength + (formattedCount * 11);

new(0, 0)による初期サイズは 256 になります。Math.Maxを通るので、長さが大きくない場合は細かく指定しても0, 0と変わりません。

256より大きい狙いのサイズがある場合、literalLengthが、StringBuilderなどのコンストラクタでいうcapacityの代わりになりえます。多くの可変長の型と同様、サイズを指定することでバッファ拡大の回数を効果的に軽減できる場合があります。

また、バッファサイズの拡大については2倍ずつになっています。これも、多くの可変長の型と同様です。

  • 参考:Source Browser(リンク先:DefaultInterpolatedStringHandler.GrowCore)

初期バッファを渡すコンストラクタ

自分で初期バッファを用意するコンストラクタも用意されています。

なお、何らかの明確な目的がある場合だけ利用すればよいと思います。


初期バッファとして、stackallocしたSpan<char>を渡すことが可能です。

DefaultInterpolatedStringHandler sh = new(0, 0, default, stackalloc char[128]);

実は、標準で利用されるArrayPool<T>は要求するサイズが小さい場合、単純な速度だけならnew T[]に後れます。アロケーション面も考慮すればそれでもArrayPool<T>でいいとなるわけですが、そうした得意でない区間を補う目的でstackallocを使うことができます。

サイズ6のbyte[]を Base32hex に変換して繋げた文字列にする例を、BenchmarkDotNet で較べてみます。

ベンチマークコード折りたたみ

[ShortRunJob]
[MemoryDiagnoser]
public class StackAllocHandlerBenchmark
{
    private byte[] bytes = default!;

    [GlobalSetup]
    public void Setup()
    {
        bytes = [31, 0, 30, 31, 31, 31];
    }

    private static char ToBase32Hex(byte value) => value < 10
        ? (char)(value + 48)
        : (char)(value + 55);

    private static string WithLINQ(byte[] array) => new(array.Select(v => ToBase32Hex(v)).ToArray());

    private static string WithStringBuilder(byte[] array)
    {
        StringBuilder sb = new(6);
        sb.Append(ToBase32Hex(array[0]));
        sb.Append(ToBase32Hex(array[1]));
        sb.Append(ToBase32Hex(array[2]));
        sb.Append(ToBase32Hex(array[3]));
        sb.Append(ToBase32Hex(array[4]));
        sb.Append(ToBase32Hex(array[5]));
        return sb.ToString();
    }

    // 初期バッファの比較例なので簡潔に補間文字列で(DefaultInterpolatedStringHandler に展開される)
    private static string WithDefaultInterpolatedString(byte[] array) => $"{ToBase32Hex(array[0])}{ToBase32Hex(array[1])}{ToBase32Hex(array[2])}{ToBase32Hex(array[3])}{ToBase32Hex(array[4])}{ToBase32Hex(array[5])}";

    // string.Createを利用した初期バッファ指定の補間文字列(初期バッファを与える DefaultInterpolatedStringHandler に展開される)
    private static string WithStackAlloc(byte[] array) => string.Create(default,
        stackalloc char[6], // ← 初期バッファ
        $"{ToBase32Hex(array[0])}{ToBase32Hex(array[1])}{ToBase32Hex(array[2])}{ToBase32Hex(array[3])}{ToBase32Hex(array[4])}{ToBase32Hex(array[5])}");

    [Benchmark(Baseline = true)]
    public string LINQ() => WithLINQ(bytes);
    [Benchmark]
    public string StringBuilder() => WithStringBuilder(bytes);
    [Benchmark]
    public string DefaultBuffer() => WithDefaultInterpolatedString(bytes);
    [Benchmark]
    public string StackAllocBuffer() => WithStackAlloc(bytes);
}

| Method           | Mean     | Error     | StdDev   | Ratio | RatioSD | Gen0   | Allocated | Alloc Ratio |
|----------------- |---------:|----------:|---------:|------:|--------:|-------:|----------:|------------:|
| LINQ             | 56.39 ns |  7.683 ns | 0.421 ns |  1.00 |    0.00 | 0.0408 |     128 B |        1.00 |
| StringBuilder    | 39.53 ns |  7.094 ns | 0.389 ns |  0.70 |    0.01 | 0.0408 |     128 B |        1.00 |
| DefaultBuffer    | 66.35 ns | 43.534 ns | 2.386 ns |  1.18 |    0.04 | 0.0126 |      40 B |        0.31 |
| StackAllocBuffer | 36.29 ns | 16.404 ns | 0.899 ns |  0.64 |    0.02 | 0.0127 |      40 B |        0.31 |

DefaultBuffer が初期バッファを渡さないDefaultInterpolatedStringHandlerです。なんとLINQに後れるという姿が見れました。ただ、アロケーション面での優位は変わりません。
そして、stackallocでは(StackAllocBuffer)、速度面も優位を取り返しています2。

ただ正直、この目的で初期バッファを渡すのは微に入り細を穿つ世界という感じはします。(気を払わなくてもアロケーション面の利点は変わらず受けられる)


他に考えられる利用としては、stackallocでなくてもArrayPool<char>.Shared以外から用意したい場合、あるいはバッファの参照を自分で握っておいて何かを行いたい場合、などでしょうか。

ちなみに、渡したバッファを勝手にArrayPool<char>.Sharedに返却されることはありません。逆に言えば、自分で用意したバッファの後始末が必要な場合は自分で行う必要があります。

また、サイズの拡大が必要となった場合、渡したバッファは手放して、ArrayPool<char>.Sharedから新たなバッファが用意されます。わざわざバッファを指定する場合は拡大を前提としていないと思われるので、初期バッファを渡すコンストラクタを使うなら覚えておきたい挙動かもしれません。

値の書き込み ―― ISpanFormattable

先に少し触れましたが、値の書き込みは、可能であればISpanFormattableというインターフェースを利用して行われます。このインターフェースは、プリミティブ型やその他の数値型などに実装されています。

この場合、バッファに直接書き込みが行われ、中間文字列を生成するコストがありません。


ところで、ISpanFormattable以外の書き込みはどうなるのでしょうか。

この場合、内部でToStringメソッドが呼ばれます。

  • 参考:Source Browser(リンク先:DefaultInterpolatedStringHandler.AppendFormatted<T>(T))

簡単な例で確認しましょう。ValueTuple<int, int>を書き込みます。
片方はAppendFormattedにそのまま渡し、片方は用意したISpanFormattableラッパーに包んで渡します。

ベンチマークコード折りたたみ

[ShortRunJob]
[MemoryDiagnoser]
public class ISpanFormattableBenchmark
{
    private readonly struct Wrapper((int, int) value)
        : ISpanFormattable
    {
        public readonly (int, int) Value = value;

        public string ToString(string? format, IFormatProvider? formatProvider) => Value.ToString();

        // まったく Try ではない横着実装だけれど、比較確認用の例ということで……
        public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
        {
            charsWritten = 0;
            destination[charsWritten++] = '(';
            Value.Item1.TryFormat(destination[charsWritten..], out var written, format, provider);
            charsWritten += written;
            destination[charsWritten++] = ',';
            destination[charsWritten++] = ' ';
            Value.Item2.TryFormat(destination[charsWritten..], out written, format, provider);
            charsWritten += written;
            destination[charsWritten++] = ')';
            return true;
        }
    }

    private static Wrapper AsSpanFormattable((int, int) value) => new(value);

    private (int, int) tuple = (16, 256);

    [Benchmark(Baseline = true)]
    public string NonSpanFormattable() => $"{tuple}"; // DefaultInterpolatedStringHandler に展開される
    [Benchmark]
    public string SpanFormattable() => $"{AsSpanFormattable(tuple)}"; // 同上
}

| Method             | Mean      | Error     | StdDev   | Ratio | Gen0   | Allocated | Alloc Ratio |
|------------------- |----------:|----------:|---------:|------:|-------:|----------:|------------:|
| NonSpanFormattable | 113.42 ns | 49.781 ns | 2.729 ns |  1.00 | 0.0459 |     144 B |        1.00 |
| SpanFormattable    |  57.32 ns |  1.493 ns | 0.082 ns |  0.51 | 0.0126 |      40 B |        0.28 |

そのまま渡した場合(NonSpanFormattable)、中間文字列が生成されていることがアロケーションから分かります。

型やラッパーにISpanFormattableを実装するほか、単純にISpanFormattableメンバーを個別に渡して組み立てることで、それを避けることができます。

StringBuilderを代替できないケースとなる性質

StringBuilder的に使う、と銘打ちましたが、性質により単純に代替できないケースがあります。

ref struct

DefaultInterpolatedStringHandlerには、ref structであることにともなう使用箇所の制限があります。

これはSpan<T>などにも共通する知識であり、参考文献を示します。


DefaultInterpolatedStringHandlerが ref struct なのは、バッファをSpan<char>として保持しているためです。(ref structをフィールドとして持つには、ref structでなくてはならない)
初期バッファとしてstackallocしたSpan<T>を渡すことができるのは、その恩恵です。

一方、標準のバッファ用意先であるArrayPool<T>だけなら、フィールドに持つのは配列で済みます。そういう意味では、汎用の「ValueStringBuilder」として考えると、やや過当な制限を背負っているとも言えます。(実際、汎用の ValueStringBuilder ではないわけですが……)

Remove / Insert / Replace は?

ありません。

汎用の ValueStringBuilder ではないため、文字列補間でコンパイラが使う最低限だけが用意された、ということなのだと思います。

また、内部バッファを取得させてもらえないので、ユーザー側で機能を用意することもできません。

そのため、組み立て中のバッファに対して Remove / Insert / Replace といった操作が要求される場合には使えません。

ライブラリという選択肢

上記の2点は、必ずしも「ValueStringBuilder」としての性質から来るものではありません。DefaultInterpolatedStringHandlerがそうなっている、というものです。

そこで、広く「ValueStringBuilder」を利用するため、ライブラリを利用するという選択肢もあります。

例えば ZString が挙げられます。本旨ではないため、紹介に留めます。

おわりに

というわけで、当記事ではDefaultInterpolatedStringHandlerを直接触ることを取り上げました。

文字列の組み立てが多いような場合は具体的恩恵があると思いますし、もっと進んで better StringBuilder として常用するのもありかもしれません。

ただ実際のところ、不利な要素がちょっとずつ重なって3、スタンダードな手段となりうるかはなかなか……という感じはします。とはいえそれでも、現状で明確な利点のある選択肢だと思います。



  1. StringBuilderも現在はISpanFormattableを利用するため、StringBuilderに対する優位点ではありません。↩
  2. ちなみにこの例については、パフォーマンスについて言うならString.Create<TState>(Int32, TState, SpanAction<Char,TState>)がより良いですが、初期バッファの比較例ということでお目溢しください。↩
  3. 本文中に挙げた点以外にも、型の名前が長すぎるなど色々↩