ISpanFormattableを使おう

# はじめに

この記事は[C# Advent Calendar 2023](https://qiita.com/advent-calendar/2023/csharplang)の13日目の記事です。
12日目の記事は @aneuf による[【C#】DefaultInterpolatedStringHandler を StringBuilder 的に使う](https://aneuf.hatenablog.com/entry/2023/12/12/000000)でした。

# ISpanFormattableとは

[ISpanFormattable](https://learn.microsoft.com/ja-jp/dotnet/api/system.ispanformattable?view=net-8.0)とは[String Interpolation](https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/tokens/interpolated)で呼ばれる**ノーアロケーション**な文字列生成インターフェースです。
このインターフェースを実装していない場合は`string ToString()`が呼ばれてstringクラスのインスタンスがアロケートされてしまうのですが、そのアロケーションがない分高速に動作します。

残念なことに[StringBuilderでは使うことができない](https://github.com/dotnet/runtime/blob/9080024d1ccd38974f0d297ac6373e6e8d62ec5d/src/libraries/System.Private.CoreLib/src/System/Text/StringBuilder.cs#L1081)ようなのですが、それでも依然として強力な機能です。
([AppendSpanFormattableというもの](https://github.com/dotnet/runtime/blob/9080024d1ccd38974f0d297ac6373e6e8d62ec5d/src/libraries/System.Private.CoreLib/src/System/Text/StringBuilder.cs#L1068)はinternal状態で存在するので`UnsafeAccessor`あたりを使えば呼べなくはなさそうですがオフィシャルには使用不可能です)

12日目の記事中にも`ISpanFormattable`についての記載がありますので合わせてご確認ください！
https://aneuf.hatenablog.com/entry/2023/12/12/000000#%E5%80%A4%E3%81%AE%E6%9B%B8%E3%81%8D%E8%BE%BC%E3%81%BF--ISpanFormattable

# 使い方

[ISpanFormattable](https://learn.microsoft.com/ja-jp/dotnet/api/system.ispanformattable?view=net-8.0)を実装するだけで動作します。

# 使い道

ログの出力など繰り返し呼ばれるような箇所で利用することで積もり積もったアロケーションコストを削減することができます。

# 具体例

以下のようなクラスを例に挙げて[ISpanFormattable](https://learn.microsoft.com/ja-jp/dotnet/api/system.ispanformattable?view=net-8.0)に対応してみます。

```cs
record class KVP(int Key, int Value)
{
    public override string ToString() => $"{Key:000000} : {Value}";
}
```

以下のような呼び出しを行った際には`ToString`が呼ばれていることがわかります。

```cs
var kvp = new KVP(101, 10);
Console.WriteLine($"{kvp}"); // 000101 : 10
```
https://sharplab.io/#gist:4fbfcdac57ec06ae0c70a07ca6e83c81

これを[ISpanFormattable](https://learn.microsoft.com/ja-jp/dotnet/api/system.ispanformattable?view=net-8.0)に対応すると以下のようになります。

```cs
record class KVP(int Key, int Value) : ISpanFormattable
{
    // これはなくてもよい
    // public override string ToString() => $"{Key:000000} : {Value}";
    
    public string ToString(string? format, IFormatProvider? formatProvider)
    {
        var buffer = (stackalloc char[128]);
        if (!TryFormat(buffer, out var charsWritten, [], formatProvider)) return "";
        return new(buffer[..charsWritten]);
    }

    public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
    {
        charsWritten = 0;
        int cw;

        if (!Key.TryFormat(destination, out cw, "000000", provider)) return false;
        destination = destination[cw..];
        charsWritten += cw;

        if (destination.Length < 3) return false;
        destination[0] = ' ';
        destination[1] = ':';
        destination[2] = ' ';
        destination = destination[3..];
        charsWritten += 3;

        if (!Value.TryFormat(destination, out cw, format, provider)) return false;
        //destination = destination[cw..];
        charsWritten += cw;
        
        return true;
    }
}
```

これを先ほどと同じように呼び出すと同じ結果になることがわかります。

```cs
var kvp = new KVP(101, 10);
Console.WriteLine($"{kvp}"); // 000101 : 10
```
https://sharplab.io/#gist:294012b47428dd923dba8011ad3fbcff

# 計測

手元でベンチマークを取ってみたところ以下のようにAllocatedが2倍ほど違う状態でした。

```
BenchmarkDotNet v0.13.11, Windows 11 (10.0.22621.2715/22H2/2022Update/SunValley2)
11th Gen Intel Core i7-11700 2.50GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.100
  [Host]     : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  DefaultJob : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI


| Method             | Mean     | Error   | StdDev  | Gen0       | Allocated |
|------------------- |---------:|--------:|--------:|-----------:|----------:|
| KVPString          | 178.0 ms | 3.51 ms | 4.04 ms | 17333.3333 |  140.9 MB |
| KVPSpanFormattable | 113.2 ms | 1.61 ms | 1.43 ms |  9200.0000 |  74.26 MB |
```
検証コード : https://gist.github.com/the9ball/5165c63aff3aa9ff23ae66e9946f8f91

> **Note**
> 上記ベンチマークでは`ISpanFormattable`の効果に絞って計測するためKVPをclassではなくstructにして使用しています。

# まとめ

[ISpanFormattable](https://learn.microsoft.com/ja-jp/dotnet/api/system.ispanformattable?view=net-8.0)を積極的に使ってノーアロケーションな世界を作りましょう！

# 参考リンク

- [ISpanFormattable インターフェイス](https://learn.microsoft.com/ja-jp/dotnet/api/system.ispanformattable?view=net-8.0)
- [dotnet/runtime - ISpanFormattable.cs](https://github.com/dotnet/runtime/blob/release/8.0/src/libraries/System.Private.CoreLib/src/System/ISpanFormattable.cs)
- [ZString – Unity/.NET CoreにおけるゼロアロケーションのC#文字列生成](https://tech.cygames.co.jp/archives/3383/)

# 追記

どうも丁寧に書き込まなくても`destination.TryWrite($"{Key:000000} : {Value}", out charsWritten)`とするだけでスタックを使ってくれるのでアロケーションが少ないみたいです。

```
BenchmarkDotNet v0.13.11, Windows 11 (10.0.22621.2861/22H2/2022Update/SunValley2)
11th Gen Intel Core i7-11700 2.50GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.100
  [Host]     : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  DefaultJob : .NET 8.0.0 (8.0.23.53103), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI


| Method              | Mean     | Error   | StdDev  | Gen0       | Allocated |
|-------------------- |---------:|--------:|--------:|-----------:|----------:|
| KVPString           | 184.4 ms | 3.61 ms | 5.93 ms | 17333.3333 | 140.89 MB |
| KVPSpanFormattable  | 127.3 ms | 2.49 ms | 3.33 ms |  9200.0000 |  74.27 MB |
| KVPSpanFormattable2 | 128.5 ms | 2.55 ms | 3.32 ms |  9200.0000 |  74.26 MB |
```

検証コード : https://gist.github.com/the9ball/24dcb9cb5036c059ec39bf249d87f205