はじめに
C#の新しいバージョンでの記法や.NET Coreおよび.NET5以降で追加された機能をあまり使ってこなかったので、学習も兼ねてこれらを使用したライブラリを作成しようと思ったのがきっかけになり、高速かつメモリアロケーションを抑えたTOMLパーサー/シリアライザーライブラリCsTomlを作成しました。
本記事では、(基本的な内容も含めて)CsToml
の実装時に使用した機能やテクニックの一部を紹介します。
TOMLとは
TOMLは以下のようなものを目指した設定ファイル形式になります。
詳細仕様は引用先のTOML公式サイトに記載されているので、こちらを確認してもらえばと思います。
TOML は、明白なセマンティクスによって読みやすい最小限の設定ファイルフォーマットとなることを目的につくられました。TOML は、ハッシュテーブルに一義的に対応するように設計されていて、さまざまな言語のデータ構造に展開できます。
TOML v1.0.0(日本語)より引用
CsToml(TOMLパーサー/シリアライザー ライブラリ)
TOML v1.0.0をサポートしているTOMLパーサー/シリアライザー ライブラリになります。
.NET 8 および C# 12で実装しており、またSystem.Buffersで定義されたAPIなどを用いていることで、高速かつメモリアロケーションをなるべく減らしていることで、高パフォーマンスを実現しています。
またパース/デシリアライズ時には、string
ではなくReadOnlySpan<byte>
やReadOnlySequence<byte>
を入力値に採用しています。
ReadOnlySpan<byte> tomlText = @"
Key = ""value""
Number = 123
Array = [1, 2, 3]
[Table]
Key = ""value""
Number = 123
"u8;
// UTF8(ReadOnlySpan<byte>)から解析する
var document = CsTomlSerializer.Deserialize<TomlDocument>(tomlText);
UTF-8 の文字列リテラル
C# 11からu8
サフィックスをつけることでUTF8文字列として作成することが可能になりました。
TOMLは、文字コードがUTF8と決まっているため、string
で読み込むよりもReadOnlySpan<byte>
として読み込み後にパースできたほうが効率的ではないかと思いましたが、string
と同じように作成できないので、利便性が悪くなると思い、開発当初はどうするか悩んでいました。
この機能によって、string
をEncoding.UTF8.GetString
等でデコードすることなく、C#で簡単にUTF8文字列が使用できるようになったため、CsToml
ではReadOnlySpan<byte>
やReadOnlySequence<byte>
を入力値として採用しました。
※ この機能を見つけたときにTOMLパーサー/シリアライザーを作成しようと決意しました。
// u8をつけることでUTF8(ReadOnlySpan<byte>)として作成可能
ReadOnlySpan<byte> tomlText = @"
Key = ""value""
Number = 123
Array = [1, 2, 3]
[Table]
Key = ""value""
Number = 123
"u8;
ArrayPool<T>.Shared
.NETで用意されている配列を再利用できるバッファリソースプールになります。
CsToml
では、書き込み用の作業バッファ管理クラスであるArrayPoolBufferWriter
で使用しています。
TOMLフォーマットの文字列(UTF8文字列)からキーや値をパースする際、基本的には切り出したReadOnlySpan<byte>
を直接パースすることでメモリアロケーションを減らしていますが、いくつかのケースでは難しい時がありました。
例えば、TOMLの基本文字列では以下のようなエスケープシーケンスが使用できます。切り出したReadOnlySpan<byte>
にこれらのエスケープシーケンスが含まれている場合、そのままパースすることができないため、変換する必要がありました。
# TOMLの文字列のエスケープシーケンス
\b - backspace (U+0008)
\t - tab (U+0009)
\n - linefeed (U+000A)
\f - form feed (U+000C)
\r - carriage return (U+000D)
\" - quote (U+0022)
\\ - backslash (U+005C)
\uXXXX - unicode (U+XXXX)
\UXXXXXXXX - unicode (U+XXXXXXXX)
TOML v1.0.0 文字列より引用
他にもTOMLフォーマットの文字列をReadOnlySpan<byte>
ではなくbyte
シーケンス(SequenceReader<byte>
とほぼ同等のもの)として保持しているため、ReadOnlySpan<byte>
として切り出せない場合がありました。
その場合には、書き込み用バッファに1byteずつ書き込みした後に、バッファ経由で値にパースするといった形でなるべく新規配列を作成しないようにすることで、メモリアロケーションを減らすようにしています。
// CsTomlReader.ReadBool:ブール値のパース処理
internal TomlBoolean ReadBool(bool predictedValue)
{
var length = predictedValue ? 4 : 5;
TomlBoolean value = default!;
// ReadOnlySpan<byte>として切り出す。
if (sequenceReader.TryFullSpan(length, out var bytes))
{
value = TomlBoolean.Parse(bytes);
}
else
{
// 切り出せない場合には、RecycleArrayPoolBufferWriter<byte>.Rentから得られた
// ArrayPoolBufferWriterに1byteずつ書き込み、書き込みしたバッファ経由でパースする。
var bufferWriter = RecycleArrayPoolBufferWriter<byte>.Rent();
try
{
if (sequenceReader.TryGetbytes(length, bufferWriter))
{
value = TomlBoolean.Parse(bufferWriter.WrittenSpan);
}
else
{
ExceptionHelper.ThrowEndOfFileReached();
}
}
finally
{
RecycleArrayPoolBufferWriter<byte>.Return(bufferWriter);
}
}
...
}
IUtf8SpanParsable<T>.TryParse
.NET8にてReadOnlySpan<byte>
から値への変換を可能にしたIUtf8SpanParsable<T>
インタフェースが追加されました。
CsToml
では、TomlFloat.Parse
等でdouble
にパースする際に使用しています。
double
にもこのインターフェイスは実装されているため、自前でロジックを組みことなくdouble
に変換することができて、さらに直接変換することによりメモリアロケーションも減らすことができました。
public static TomlFloat Parse(ReadOnlySpan<byte> bytes)
{
...
// UTF8から直接doubleに変換を試みます。
if (double.TryParse(bytes, out var value))
{
return new TomlFloat(value);
}
ExceptionHelper.ThrowIncorrectTomlFloatFormat();
return default!;
}
整数の2進数、8進数、16進数の判別方法
TOMLではプレフィックスにそれぞれ0b
、0o
、0x
がついている場合、2進数、8進数、16進数の整数として表現できます。
ReadOnlySpan<byte>.SequenceEqual
の同値比較でも問題ないですが、プレフィックスがそれぞれ決まっているので、ReadOnlySpan<byte>
から直接short
に変換し、数値比較で判定するようにしています。
// TomlInteger.Parse:ReadOnlySpan<byte>からTomlIntegerへパースする処理
public static TomlInteger Parse(ReadOnlySpan<byte> bytes)
{
// hexadecimal, octal, or binary
if (bytes.Length > 2)
{
var prefix = Unsafe.ReadUnaligned<short>(ref MemoryMarshal.GetReference<byte>(bytes));
switch (prefix)
{
case 25136: //0b:binary
return TomlInteger.Create(ParseBinary(bytes[2..]));
case 28464: //0o:octal
return TomlInteger.Create(ParseOctal(bytes[2..]));
case 30768: //0x:hexadecimal
return TomlInteger.Create(ParseHex(bytes[2..]));
}
}
// decimal
}
この判定手法は、R3やMemoryPackなどのハイパフォーマンスなライブラリを開発されているneuecc
さんがCEDEC 2023 モダンハイパフォーマンスC# 2023 Editionで紹介された方法を流用しています。
こちらを含めてneuecc
さんが発表されている内容は興味深く勉強になるので、ぜひ確認することをお勧めします!
一部TOML値のキャッシュ
CsToml
では、TomlValue
クラスを継承したクラス(TomlInteger
やTomlBoolean
)に値を保持しています。
基本的にReadOnlySpan<byte>
から値にパースした後、これらのクラスにラップする形で作成していますが、ブール値や一部の整数(-1から8)については、新規作成ではなくキャッシュされたTomlInteger
やTomlBoolean
を割り当てることで、インスタンス作成のコストなどを回避しています。
// TomlIntegerのキャッシュ処理
internal sealed partial class TomlInteger : TomlValue
{
internal static readonly TomlInteger[] cache = CreateCacheValue();
private static TomlInteger[] CreateCacheValue()
{
// -1から8のTomlIntegerを作成する
var intCacheValues = new TomlInteger[10];
for (int i = 0; i < intCacheValues.Length; i++)
{
intCacheValues[i] = new TomlInteger(i - 1);
}
return intCacheValues;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TomlInteger Create(long value)
{
// -1から8の場合には、キャッシュを返却する
if ((ulong)(value + 1) < (ulong)cache.Length)
{
return cache[value + 1];
}
return new TomlInteger(value);
}
...
}
一部の値のみキャッシュを返す手法は、.NET6から変更されたTask.FromResult<TResult>
の破壊的変更を知ったときに、インスタンス作成のコスト削減に活用できると思い、CsToml
でも同じ手法を使用しています。
あとがき
最新機能ではない機能も紹介していますが、私自身はCsToml
を実装するまであまり使用したことがないもしくは知らなかった機能がほとんどでした。
上記以外にも色々な機能等を使用していますが、今回はこれくらいで終わろうと思います。
何か間違いなどあれば、コメントで情報提供よろしくお願いします。
CsToml
についても、興味があればぜひ使用していただければと思います。