てくメモ

trivial な notes

【C#】String.Join 的なことを InterpolatedStringHandler を使ってやってみる

【C#】文字列補間 - てくメモ
上記記事において、文字列補間は現在InterpolatedStringHandlerによってパフォーマンスチューニングがなされていることに触れた。
そのなかでは標準のDefaultInterpolatedStringHandlerを前提としていたが、InterpolatedStringHandlerはカスタムのものを用意することもできる。

Improvement Interpolated Strings 完全に理解した - 鷲ノ巣
例えば上記記事において、ログ出力の補間文字列に独自ハンドラを用意し、実際には出力されない文字列の作成を行わないようにするという用途が紹介されている。


この記事では、補間の仕方をカスタムするようなことを試しにやってみる。
具体的には、String.Joinを行う。

// int[] array;
public string ByStringJoin()
{
    return string.Join(", ", array);
}

String.Joinがあるのだから別にString.Joinでやればいいのだけれど、このAPISpanを受けるシグニチャがなかったりするので、一応の動機はそれということで。


まずわざわざ InterpolatedStringHandlerなんて使わなくても、StringBuilderを使うのは思い浮かぶところなので、あとで比較するために触れておく。

StringBuilder版折りたたみ

public string ByStringBuilder()
{
    static string stringJoin<T>(ReadOnlySpan<T> span, string separator = ", ")
    {
        var enumerator = span.GetEnumerator();
        if (enumerator.MoveNext() is false) return "";

        var initialCapacity = ((span.Length - 1) * separator.Length) + span.Length;
        StringBuilder stringBuilder = new(initialCapacity);

        stringBuilder.Append(enumerator.Current);
        while (enumerator.MoveNext())
        {
            stringBuilder.Append(separator);
            stringBuilder.Append(enumerator.Current);
        }

        return stringBuilder.ToString();
    }

    return stringJoin<int>(array);
}


また、上記に関連して、DefaultInterpolatedStringHandlerアロケーションの無い Better StringBuilder として使いうると思うのでそのように使った例も。

DefaultInterpolatedStringHandler版折りたたみ

public string ByDefaultInterpolatedStringHandler()
{
    static string stringJoin<T>(ReadOnlySpan<T> span, string separator = ", ")
    {
        var enumerator = span.GetEnumerator();
        if (enumerator.MoveNext() is false) return "";

        var literalLength = ((span.Length - 1) * separator.Length);
        DefaultInterpolatedStringHandler stringHandler = new(literalLength, span.Length);

        stringHandler.AppendFormatted(enumerator.Current);
        while (enumerator.MoveNext())
        {
            stringHandler.AppendLiteral(separator);
            stringHandler.AppendFormatted(enumerator.Current);
        }

        return stringHandler.ToStringAndClear();
    }

    return stringJoin<int>(array);
}


さて、カスタムのInterpolatedStringHandler版。
実装方法として、バッファを自前で管理するのをいちから書くのはかなりしんどいので、DefaultInterpolatedStringHandlerをラップするものとした。
参考:ufcpp 氏の gist

そして、String.Joinしたい対象のシグニチャAppendFormatted()を追加し、必要な処理を行うようにした。(以下、表示部分は抜粋。全文を折りたたみ。)

[InterpolatedStringHandler]
public ref struct StringJoinHandler
{
    private DefaultInterpolatedStringHandler inner;
    private readonly string separator;

    public StringJoinHandler(int literalLength, int formattedCount, string separator = ", ")
    {
        inner = new DefaultInterpolatedStringHandler(literalLength, formattedCount);
        this.separator = separator;
    }

    // 特別な処理をする型の AppendFormatted を追加。オーバーロード解決で呼ばれる
    public void AppendFormatted<T>(T[] span) => AppendFromSpan<T>(span);
    private void AppendFromSpan<T>(scoped ReadOnlySpan<T> span)
    {
        var enumerator = span.GetEnumerator();
        if (enumerator.MoveNext() is false) return;

        inner.AppendFormatted(enumerator.Current);

        while (enumerator.MoveNext())
        {
            inner.AppendLiteral(separator);
            inner.AppendFormatted(enumerator.Current);
        }
    }

    // ラップ部分など省略、全文は折りたたみ
}

StringJoinHandler折りたたみ

[InterpolatedStringHandler]
public ref struct StringJoinHandler
{
    private DefaultInterpolatedStringHandler inner;
    private readonly string separator;

    public StringJoinHandler(int literalLength, int formattedCount, string separator = ", ")
    {
        inner = new DefaultInterpolatedStringHandler(literalLength, formattedCount);
        this.separator = separator;
    }

    public StringJoinHandler(int literalLength, int formattedCount, IFormatProvider? provider, string separator = ", ")
    {
        inner = new DefaultInterpolatedStringHandler(literalLength, formattedCount, provider);
        this.separator = separator;
    }

    public void AppendFormatted<T>(Span<T> span) => AppendFromSpan<T>(span);
    public void AppendFormatted<T>(ReadOnlySpan<T> span) => AppendFromSpan<T>(span);
    public void AppendFormatted<T>(T[] span) => AppendFromSpan<T>(span);
    private void AppendFromSpan<T>(scoped ReadOnlySpan<T> span)
    {
        var enumerator = span.GetEnumerator();
        if (enumerator.MoveNext() is false) return;

        inner.AppendFormatted(enumerator.Current);

        while (enumerator.MoveNext())
        {
            inner.AppendLiteral(separator);
            inner.AppendFormatted(enumerator.Current);
        }
    }

    public string ToStringAndClear() => inner.ToStringAndClear();

    public void AppendLiteral(string value) => inner.AppendLiteral(value);
    public void AppendFormatted(string? value) => inner.AppendFormatted(value);
    public void AppendFormatted(scoped ReadOnlySpan<char> value) => inner.AppendFormatted(value);
    public void AppendFormatted(object? value, int align = 0, string? format = null) => inner.AppendFormatted(value, align, format);
    public void AppendFormatted(string? value, int align = 0, string? format = null) => inner.AppendFormatted(value, align, format);
    public void AppendFormatted(scoped ReadOnlySpan<char> value, int align = 0, string? format = null) => inner.AppendFormatted(value, align, format);
    public void AppendFormatted<T>(T value) => inner.AppendFormatted(value);
    public void AppendFormatted<T>(T value, string? format) => inner.AppendFormatted(value, format);
    public void AppendFormatted<T>(T value, int align) => inner.AppendFormatted(value, align);
    public void AppendFormatted<T>(T value, int align, string? format) => inner.AppendFormatted(value, align, format);
}


以下のような感じで使える。

public string ByCustomHandler()
{
    static string stringJoin(StringJoinHandler handler) => handler.ToStringAndClear();
    return stringJoin($"{array}"); // コンパイラが StringJoinHandler に展開してくれる
}


さて、とりあえず測ってみる。
arrayEnumerable.Range(1, 16).ToArray();とした単純な使用。

                             Method |     Mean |     Error |   StdDev |   Gen0 | Allocated |
----------------------------------- |---------:|----------:|---------:|-------:|----------:|
                       ByStringJoin | 359.3 ns |  51.24 ns |  2.81 ns | 0.1221 |     384 B |
                    ByStringBuilder | 438.0 ns | 428.60 ns | 23.49 ns | 0.3414 |    1072 B |
 ByDefaultInterpolatedStringHandler | 206.1 ns |  18.70 ns |  1.03 ns | 0.0408 |     128 B |
                    ByCustomHandler | 234.7 ns |   5.32 ns |  0.29 ns | 0.0408 |     128 B |

使用例が単純過ぎてかStringBuilderではむしろ遅くなってしまっているが、InterpolatedStringHandlerを使えばとりあえずString.Joinよりパフォーマンスは上がる。


とはいえこれだけだと独自ハンドラを用意する甲斐がない。

しかし、次のような例だと強みが出る。

// もうひとつ追加
double[] array2 = new double[] { 0.1, 1.2, 3.5 };

public string ByDefaultInterpolatedStringHandler()
{
    static string stringJoin<T>(ReadOnlySpan<T> span, string separator = ", ")
    {
        // 省略
    }

    return $"A:[{stringJoin<int>(array)}], B[{stringJoin<double>(array2)}]";
}

public string ByCustomHandler()
{
    static string stringJoin(StringJoinHandler handler) => handler.ToStringAndClear();

    return stringJoin($"A:[{array}], B[{array2}]");
}
                             Method |     Mean |    Error |  StdDev |   Gen0 | Allocated |
----------------------------------- |---------:|---------:|--------:|-------:|----------:|
 ByDefaultInterpolatedStringHandler | 803.8 ns | 25.79 ns | 1.41 ns | 0.1116 |     352 B |
                    ByCustomHandler | 625.1 ns | 76.61 ns | 4.20 ns | 0.0553 |     176 B |

上記のような場合、独自ハンドラは全体をひとつのハンドラで処理し、途中で各要素の結果としてのstring生成を挟まないという特有の利点が出る。

// コンパイラによる展開
StringJoinHandler handler2 = new StringJoinHandler(9, 2);
handler2.AppendLiteral("A:[");
handler2.AppendFormatted(array);
handler2.AppendLiteral("], B[");
handler2.AppendFormatted(array2);
handler2.AppendLiteral("]");
return stringJoin(handler2);


ところで、コンストラクタでセパレーターに使う文字列を渡せるようにしているが、どうやって渡すのか。
それには、InterpolatedStringHandlerArgument属性を用いる。

public static string StringJoin(
    string separator,
    [InterpolatedStringHandlerArgument(nameof(separator))] StringJoinHandler stringJoinHandler)
        => stringJoinHandler.ToStringAndClear();
var a = new int[] { 1, 2, 3 };
var ar = SpanUtil.StringJoin(" ", $"[{a}]");
// [1 2 3]


この属性で指定するパラメータの引数は属性に先行する必要がある。
そのため最後の引数にできず、デフォルト引数は設定できない。

// デフォルトを設定するならオーバーロードを足す必要あり
public static string StringJoin(StringJoinHandler stringJoinHandler)
    => StringJoin(", ", stringJoinHandler);

// あるいは使う目的に特化してそちらでデフォルト引数を使うか
public static string ToListString<T>(this Span<T> span, string separator = ", ") => SpanUtil.StringJoin(separator, $"[{span}]");


さすがにString.JoinのためにInterpolatedStringHandlerに触るのは労多くして――、という感じはあるけれど、仕組みそのものは活かせるところで使えればかなり良さそうだと感じた。