アジョブジ星通信

進捗が出た頃に更新されるブログ。

無駄なく文字列生成! System.Text.Formatting の使い方と動作の流れ

Advent Calendar の季節ですが、何にも参加していないので好き勝手書いていきたいと思います。さて、今回は .NET Core Lab プロジェクトで開発されている System.Text.Formatting について、主な使い方と、さらに深入りして使うには、ということで書いていきます。

普通の使い方を知りたいがために訪れた方は「基本的な使い方」まで読んでいただければと思います。その先は闇が深くなります。

注意

この記事の最終更新日は 2015/12/06 です。変化の多いリポジトリで、仕様も固まっていないことから、ここで説明する内容はすぐに古くなる可能性があります。

この記事の中で何回か出てくる ManagedBufferPool について別の記事に詳しく書きました。ご確認ください。

System.Text.Formatting の目的

System.Text.Formatting は従来 StringBuilder を使って行ってきた処理を、もっとメモリアロケーションを減らし、効率良く行うことが目的のライブラリです。いちいち ToString を呼び出したりせず、直接バッファに書き込む形をとることで、 GC の負担を減らすことができます。また .NET の内部では文字列は UTF-16 で処理されますが、 web などの用途では UTF-8 で出力することが求められることがあるので、可能な場合は直接 UTF-8 バイト列として書き込んだりします。

インストール

NuGet パッケージが MyGet でホスティングされています。 https://www.myget.org/gallery/dotnet-corefxlab よりフィード URL を取得して設定し、 System.Text.Formatting パッケージをインストールしてください。プロジェクトの種類によっては依存パッケージが自動的にインストールされないこともあるので、その場合は、System.Slices, System.Buffers, System.Text.Utf8 もインストールしてください。

また、現在コンソールアプリケーションから使用すると実行時に FileLoadException が発生することがあるみたいです。これを回避するにはプラットフォームターゲットを x64 にしてみてください。

Formatter の種類

IFormatter インターフェイスを実装することで、「書き込み先」になることができます。

System.Text.Formatting ではいくつかの既存の実装があります。ほとんどはこれらで事足りると思います。

StringFormatter

StringFormatter は名前の通り、 String 型をターゲットとしたフォーマッタです。内部で byte 配列を保持しており、 ToString メソッドを呼ぶことで、 String 型に変換されます。

(でも ToString の処理が Encoding.Unicode.GetString(_buffer, 0, _count); で、これだと裏でいろいろチェック走って遅そう。。)

コンストラクタは以下の 2 種類があります。

StringFormatter(System.Buffers.ManagedBufferPool<byte> pool)
StringFormatter(int capacity, ManagedBufferPool<byte> pool)

1つ目のコンストラクタは、2つ目のコンストラクタの capacity を 64 にしたものです。

capacity には最初にメモリを割り当てる「文字数」を指定します。文字数なので、実際に割り当てられるのはその 2 倍のバイト数です。

pool には適当な ManagedBufferPool<byte> を指定すればいいですが、 StringFormatter は pool から取得したバイト配列を解放する手段を持っていないことに注意してください。

BufferFormatter

BufferFormatter は StringFormatter に似ていますが、 ToString の実装がありません。

コンストラクタは次のとおりです。

BufferFormatter(int capacity, FormattingData formattingData, ManagedBufferPool<byte> pool = null)

formattingData には FormattingData.InvariantUtf16 または FormattingData.InvariantUtf8 を指定して、文字コードを決定します。

pool を省略した場合、内部で capacity を最大容量*1とする ManagedBufferPool<byte> が自動的に作成されます。

使用後は Buffer プロパティをコピーするなり、ストリームに書き込むなりしましょう。

var formatter = new BufferFormatter(64, FormattingData.InvariantUtf8);

/*
*  処理
*/

using (var fs = new FileStream("file"))
{
    // CommitedByteCount プロパティでどこまで書き込まれているかが取得できる
    fs.Write(formatter.Buffer, 0, formatter.CommitedByteCount);
}

StreamFormatter

(まさかの構造体!)

StreamFormatter は Stream をラップして、フォーマッタとして使えるようにするものです。動きとしては多少のバッファを用意しておき、 IFormatter.CommitBytes メソッドが呼ばれた時に Stream.Write します。

コンストラクタは以下の 2 種類があります。

StreamFormatter(Stream stream, ManagedBufferPool<byte> pool)
StreamFormatter(Stream stream, FormattingData formattingData, ManagedBufferPool<byte> pool, int bufferSize = 256)

1つ目のコンストラクタは2つ目のコンストラクタの formattingData に FormattingData.InvariantUtf16 を指定したものになります。

StreamFormatter は IDisposable を実装しています。 Dispose メソッドではバッファの解放が行われますが、 Stream が Dispose されることはありません。

つまり、 pool 引数に ManagedBufferPool<byte>.SharedByteBufferPool を指定しても正しく Dispose されれば問題ありません。

基本的な使い方

普通、 IFormatter で定義されているメンバーを直接使うことはなく、用意されている拡張メソッドを使用します。拡張メソッドのクラスは System.Text.Formatting にあるので、確実に using しておきましょう。

Append

Append 拡張メソッドは IFormatterExtensions クラスで定義されており、その名の通り、フォーマッタに文字列を追加します。

Append メソッドは次のように定義されています。

void Append<TFormatter>(this TFormatter formatter, 入力の型 value, Format.Parsed format = null) where TFormatter : IFormatter

入力は数値型や String の他、 DateTime, DateTimeOffset, Guid, TimeSpan に対応しています。また IBufferFormattable を実装した任意の型を受理します。

format には書式を指定することができます。 Format.Parsed は Format.Parse(string/char/Span<char>) メソッドを使って作成します。指定できるフォーマットは基本的には ToString(string) で指定できるものですが、今のところ対応は微妙で、 1. カスタム書式は使えない 2. 小数型は「G」のみ といったところです。

formatter.Append(7, Format.Parse("D3"));
// 007

Format

Format 拡張メソッドは CompositeFormattingExtensions クラスで定義されており、 String.Format のような使い方ができます。今のところ、指定できるパラメータは最大 4 つまでです。

String.Format のような書き方ができるとは言いましたが、例えば {0:D3} のようにフォーマット文字の後ろに桁数の数字をつけることはできません。そのうち対応するのかな?

formatter.Format("{0:G}\n{0:O}\n{0:R}", DateTime.Now);
/*
12/5/2015 12:14:04 AM
2015-12-05T00:14:04.-323992386429Z
Fri, 04 Dec 2015 15:14:04 GMT
*/

IBufferFormattable を実装する

IBufferFormattable を実装することで、そのクラスや構造体を Append や Format の引数として指定できるようになります。

このインターフェイスは次のメソッドを持っています。

bool TryFormat(Span<byte> buffer, Format.Parsed format, FormattingData formattingData, out int written)

TryFormat は ToString の直接バッファに書き込むバージョンといったところです。

実装の手順は

  1. buffer.Length を確認し、足りなさそうならば false を返す。
  2. buffer に書き込む。
  3. written に書き込んだバイト数を代入する。
  4. true を返す。

といった具合です。

formattingData を使って数字や記号をカルチャーや文字コードに合わせて高速に書き込むことができます。 TryWriteDigit メソッドでは 1 桁の数字を書き込むことができます。

formattingData.TryWriteDigit(1, buffer, out written);

TryWriteSymbol メソッドでは数値表記に使う記号を書き込むことができます。

formattingData.TryWriteSymbol(FormattingData.Symbol.Exponent, buffer, out written);
// E

(正直 TryWriteSymbol は用途が狭すぎる。あと FormattingData.IsUtf16, IsUtf8 を public にしてくれ。。)

実装例

using System;
using System.Buffers;
using System.Text.Formatting;

class Program
{
    static void Main(string[] args)
    {
        var formatter = new StringFormatter(new ManagedBufferPool<byte>());
        var value = new IntPair(114514, 810);
        formatter.Format("{0}\n{0:x}", value);
        Console.WriteLine(formatter.ToString());
        // 114514,810
        // 1bf52,32a
    }
}

struct IntPair : IBufferFormattable
{
    public IntPair(int x, int y)
    {
        this.X = x;
        this.Y = y;
    }

    public int X { get; set; }
    public int Y { get; set; }

    public override string ToString()
    {
        return this.X + "," + this.Y;
    }

    public bool TryFormat(Span<byte> buffer, Format.Parsed format, FormattingData formattingData, out int written)
    {
        written = 0;
        int tmpWritten;

        // X を書き込む
        if (!this.X.TryFormat(buffer, format, formattingData, out tmpWritten))
            goto FAIL;
        written += tmpWritten;

        // バッファのスタート地点を移動
        buffer = buffer.Slice(tmpWritten);

        // ',' を書き込む
        if (!formattingData.TryWriteSymbol(FormattingData.Symbol.GroupSeparator, buffer, out tmpWritten))
            goto FAIL;
        written += tmpWritten;

        buffer = buffer.Slice(tmpWritten);

        // Y を書き込む
        if (!this.Y.TryFormat(buffer, format, formattingData, out tmpWritten))
            goto FAIL;
        written += tmpWritten;

        return true;

        FAIL:
        written = 0;
        return false;
    }
}

IFormatter を実装する

上で既存実装だけで事足りると言いましたが、自分で IFormatter を作ることもあるかもしれません。僕は BufferFormatter で ManagedBufferPool を使うより 1 つのバイト配列だけで戦うやつも欲しいなぁと思い実装してみました(あとで実装例にコード載せます)。

IFormatter のメンバーは以下のとおりです。

FormattingData FormattingData { get; }
Span<byte> FreeBuffer { get; }

void CommitBytes(int bytes);
void ResizeBuffer();

FormattingData プロパティ

FormattingData は、何度か出てきているようにカルチャーと文字コードの情報を保持している、書き込み方の指示書です。 IFormatter の実装にあたっては、これを使用することはない(Append メソッドとかが勝手に使ってくれる)ので、コンストラクタで指定されたインスタンスを保持しておくだけでいいです。

public MyFormatter(FormattingData formattingData)
{
    this.FormattingData = formattingData;
}

public FormattingData FormattingData { get; }

FreeBuffer プロパティ

FreeBuffer はバッファの空き部分をスタートとする Span<byte> を返すプロパティです。 TryFormat でこのスライスの始めから書き込まれていきます。

FreeBuffer の実装方法はフォーマッタの種類によって異なります。例えばバイト配列を保持している場合は次のようになります。

private byte[] _buffer;

// _buffer に書き込まれているバイト数
private int _writtenBytes;

public Span<byte> FreeBuffer => new Span<byte>(
    this._buffer,
    this._writtenBytes, // スライスの始点 = 書き込まれているバイト数
    this._buffer.Length - this._writtenBytes
);

StreamFormatter のように CommitBytes 後はバッファの中身が不要になる場合は、単にバイト配列をそのまま返せばいいです。

private byte[] _buffer;

public Span<byte> FreeBuffer => new Span<byte>(this._buffer);

CommitBytes(int bytes) メソッド

CommitBytes は Append などが処理を終えた時に呼ばれます。 bytes 引数は、現在の FreeBuffer に書き込まれたバイト数で、 CommitBytes が呼ばれた後は、 FreeBuffer を bytes 分だけ進めなければいけません。といっても、 FreeBuffer の実装で見たように、 CommitBytes でバッファを他のところに投げつけて、あとは不要、みたいな使い方の場合は FreeBuffer はそのままで問題無いですね。

それを踏まえて、実装すると FreeBuffer の例と合わせてこのようになります。

private byte[] _buffer;
private int _writtenBytes;
public Span<byte> FreeBuffer => new Span<byte>(this._buffer, this._writtenBytes, this._buffer.Length - this._writtenBytes);

public void CommitBytes(int bytes)
    => this._writtenBytes += bytes;
// 出力先ストリーム
private Stream _output;

private byte[] _buffer;
public Span<byte> FreeBuffer => new Span<byte>(this._buffer);

public void CommitBytes(int bytes)
    => this._output.Write(this._buffer, 0, bytes);

ResizeBuffer メソッド

ResizeBuffer は Append などでバッファ容量が不足している時に呼ばれます。 System.Text.Formatting の既存実装では、バッファ容量を 2 倍にしています。

// ManagedBufferPool は配列を使いまわして GC 回数を減らすやつ
// コンストラクタで指定してもらったり
private ManagedBufferPool<byte> _pool;

private byte[] _buffer;

public void ResizeBuffer() =>
    this._pool.EnlargeBuffer(ref this._buffer, this._buffer.Length * 2);

実装例

class Utf8Formatter : IFormatter
{
    private byte[] _buffer;
    private int _count;

    public Utf8Formatter(int initialCapacity)
    {
        this._buffer = new byte[initialCapacity];
        this._count = 0;
    }

    public FormattingData FormattingData => FormattingData.InvariantUtf8;

    public Span<byte> FreeBuffer => new Span<byte>(
        this._buffer, this._count, this._buffer.Length - this._count);

    public void CommitBytes(int bytes)
    {
        this._count += bytes;
    }

    public void ResizeBuffer()
    {
        var oldBuffer = this._buffer;
        if (oldBuffer.Length == 0)
        {
            this._buffer = new byte[2];
            return;
        }
        var newBuffer = new byte[oldBuffer.Length * 2];
        Buffer.BlockCopy(oldBuffer, 0, newBuffer, 0, oldBuffer.Length);
        this._buffer = newBuffer;
    }

    public Utf8String ToUtf8String()
    {
        return new Utf8String(this._buffer, 0, this._count);
    }
}

JChopper/Utf8Formatter.cs at 543155f4ea19c3b0070f87f92d8ff88d100d70c0 · azyobuzin/JChopper · GitHub

IFormatter を直接触ってみる

IFormatter に触れるには Append や Format といった拡張メソッドを使ってきましたが、それらがどのように実装されているかを理解すれば多くの挙動を予測できるかと思います。しかし、ここにはひとつ重大な問題点があります。それは FormattingData.IsUtf16 および IsUtf8 が public なプロパティではないということです。これがわからなきゃ書き込む内容を区別しようがないね……。というわけで普通は拡張メソッドを使え、ということなのでしょう。

まずは Append のソースコードを見てみましょう。

public static void Append<TFormatter, T>(this TFormatter formatter, T value, Format.Parsed format = default(Format.Parsed)) where T : IBufferFormattable where TFormatter : IFormatter
{
    int bytesWritten;
    while (!value.TryFormat(formatter.FreeBuffer, format, formatter.FormattingData, out bytesWritten))
    {
        formatter.ResizeBuffer();
        bytesWritten = 0;
    }
    formatter.CommitBytes(bytesWritten);
}

corefxlab/IFormatterExtensions.cs at 48cd9fe091954b4bdefbb53aa62cf6f0260a7e7c · dotnet/corefxlab · GitHub

(フォーマッタがジェネリック引数となっているのは、 StreamFormatter が構造体だからでしょうか。)

流れとして、 TryFormat が成功するまで ResizeBuffer を繰り返しています。そして成功したら CommitBytes を呼び出しています。

つまりこのように使えば良いとわかります。

  1. FreeBuffer を取得
  2. バッファサイズが足りなければ ResizeBuffer を呼んで 1 へ
  3. FreeBuffer へ書き込み
  4. CommitBytes を呼び出す

FreeBuffer は空きバッファというより、自由に使っていい領域という意味なのでバッファサイズが足りないからといってもとに戻したりはしなくても大丈夫そうです。

また、 CommitBytes では StreamFormatter ならストリームへの書き込みが行われるなど、大きな作業を行う場合があります。そのため、極力 CommitBytes を我慢することで、効率よく出力することができると思います。そのためにこんなものを作ったりしていました。

private static void RequireBuffer(ref Span<byte> buffer, ref int bytesWritten, IFormatter formatter, int requiredBytes)
{
    if (buffer.Length >= requiredBytes)
        return;

    // Commit
    if (bytesWritten > 0)
    {
        formatter.CommitBytes(bytesWritten);
        bytesWritten = 0;
        buffer = formatter.FreeBuffer;
    }

    // Resize
    while (buffer.Length < requiredBytes)
    {
        formatter.ResizeBuffer();
        buffer = formatter.FreeBuffer;
    }
}

JChopper/SerializationHelper.cs at 543155f4ea19c3b0070f87f92d8ff88d100d70c0 · azyobuzin/JChopper · GitHub

そして気づいた。「これただのデータ書き込みで文字列関係なくなってきたし、 Stream でいいじゃん」

Exactly. 生 IFormatter を使う時なんてほぼ Stream でいいですね。ええ。

まとめ

UTF-8 で出力する機会は結構あって、毎回 string から変換するのって無駄が多いよなぁと思っていたところにこのようなライブラリが飛び込んできて大変うれしい限りです。パフォーマンスを確保するためにかなり変な構造をしていますが、拡張メソッドで一般人にも扱えるようにしてくれているので、うまく利用していきましょう。間違っても IFormatter は素手で触るものではありませんよ。

*1:最大容量より大きいバイト配列は再利用されない