はじめに
えっ?C# の補間文字列ハンドラーで入力用の sscanf を作るって?出力用の sprintf のことじゃないの?
タイトルを見て、そう思った方も多いかもしれません。ですが、ここで実装するのは sprintf ではなく、まさに「sscanf」です。
補間文字列ハンドラー
C# には「文字列補間」という機能があり、$"abc{x}def" のように、自然な形で文字列中に変数の値を埋め込めます。これは従来の string.Format による書式化を置き換えるもので、あらかじめ書式文字列を渡してから引数を順に渡す必要がなく、とても便利です。
さらに一歩進んで、C# は「補間文字列ハンドラー」をサポートしており、補間の振る舞いをユーザーがカスタマイズできます。
たとえば、
[InterpolatedStringHandler]
struct Handler(int literalLength, int formattedCount)
{
public void AppendLiteral(string s)
{
Console.WriteLine($"Literal: '{s}'");
}
public void AppendFormatted<T>(T v)
{
Console.WriteLine($"Value: '{v}'");
}
}
使う側では、string を受け取っていた箇所をこの Handler 型に置き換えるだけで、補間文字列は C# コンパイラにより自動的に Handler の生成とメソッド呼び出しに変換され、渡されます。
void Foo(Handler handler) { }
var x = 42;
Foo($"abc{x}def");
このときの出力は次のようになります。
Literal: 'abc'
Value: '42'
Literal: 'def'
これにより各種の構造化ロギング フレームワークは、補間文字列をそのまま渡すだけで、カスタムの補間ロジックに基づいて構造化解析が行えます。手動で文字列を書式化する必要が完全になくなるわけです。
パラメーター付きの補間文字列ハンドラー
実は C# の補間文字列ハンドラーは追加の引数も受け取れます。
[InterpolatedStringHandler]
struct Handler(int literalLength, int formattedCount, int value)
{
public void AppendLiteral(string s)
{
Console.WriteLine($"Literal: '{s}'");
}
public void AppendFormatted<T>(T v)
{
Console.WriteLine($"Value: '{v}'");
}
}
void Foo(int value, [InterpolatedStringHandlerArgument("value")] Handler handler) { }
Foo(42, $"abc{x}def");
こうすることで、42 は handler の value パラメーターに渡されます。これは呼び出し元のコンテキストを捕捉するのに役立ちます。ロギングのシナリオでは、引数に応じて書式を変えるのはよくある話です。
sscanf?
ご存じのとおり、C/C++ にはよく使われる関数 sscanf があり、入力テキスト、書式文字列、書式に対応する変数の参照を受け取って、変数へ値を読み取れます。
const char* input = "test 123 test";
const char* template = "test %d test";
int v = 0;
sscanf(input, template, &v);
printf("%d\n", v); // 123
では、これを C# で再現できるでしょうか?
もちろん可能です。ただし少しだけ~魔法~が必要です。
C# で sscanf を実装する
まず、パラメーター付きの補間文字列ハンドラーを用意します。
[InterpolatedStringHandler]
ref struct TemplatedStringHandler(int literalLength, int formattedCount, ReadOnlySpan<char> input)
{
private ReadOnlySpan<char> _input = input;
public void AppendLiteral(ReadOnlySpan<char> s)
{
}
public void AppendFormatted<T>(T v) where T : ISpanParsable<T>
{
}
}
ここではすべての string を ReadOnlySpan<char> に置き換え、割り当てを減らしています。
sscanf の使い勝手に合わせるなら、次のような API を作りたくなります。
void sscanf(ReadOnlySpan<char> input, ReadOnlySpan<char> template, params object[] args);
ですが、実際に必要なのは (ref object)[] のような「参照の配列」です。外部の変数を書き換えるには参照を渡す必要があり、値を object として渡すだけでは不十分です。では、どうしましょう?
そこで、C# の補間文字列ハンドラーには、補間式の各「値」がすでに含まれていることに注目します。つまり、C/C++ のように %d のようなプレースホルダーを使う必要はありません。"test %d test" の代わりに、$"test {v} test" と書けるわけです。その上で、この v を参照として渡せばよいのです。
自然な発想として、AppendFormatted<T>(T v) を AppendFormatted<T>(ref T v) に変えればよさそうです。
しかし、実際にそうすると動きません。
[InterpolatedStringHandler]
ref struct TemplatedStringHandler(int literalLength, int formattedCount, ReadOnlySpan<char> input)
{
private ReadOnlySpan<char> _input = input;
public void AppendLiteral(ReadOnlySpan<char> s)
{
}
public void AppendFormatted<T>(ref T v) where T : ISpanParsable<T>
{
}
}
void sscanf(ReadOnlySpan<char> input, [InterpolatedStringHandlerArgument("input")] TemplatedStringHandler template);
この sscanf を呼び出そうとすると:
int v = 0;
sscanf("test 123 test", $"test {ref v} test"); // error CS1525: Invalid expression term 'ref'
エラーになります。補間式の値の部分に ref キーワードは書けません!
このエラーは C# コンパイラのパーサーによるものです。つまり、構文上の ref を取り除ければコンパイルは通るはずです。
ここで「in による読み取り専用参照渡し」が使えます。C# には in で読み取り専用参照を渡す仕組みがあり、言語が自動的に参照を作って渡してくれるため、呼び出し側の式に ref を明示する必要がありません。そこで、少し工夫して次のようにします。
[InterpolatedStringHandler]
ref struct TemplatedStringHandler(int literalLength, int formattedCount, ReadOnlySpan<char> input)
{
private ReadOnlySpan<char> _input = input;
public void AppendLiteral(ReadOnlySpan<char> s)
{
}
public void AppendFormatted<T>(in T v) where T : ISpanParsable<T>
{
}
}
すると、次のコードはコンパイルに成功します。
int v = 0;
sscanf("test 123 test", $"test {v} test");
残る最後の課題は、渡ってくるのが「読み取り専用参照」だという点です。変数の値を取り出すには、その参照先を書き換える必要があります。どうすればよいでしょうか?
ここで魔法です!幸い Unsafe.AsRef を使えば、読み取り専用参照を可変参照に変換できます。
これで最後の問題も解決できたので、実装を進めましょう。
[InterpolatedStringHandler]
ref struct TemplatedStringHandler(int literalLength, int formattedCount, ReadOnlySpan<char> input)
{
private int _index = 0;
private ReadOnlySpan<char> _input = input;
public void AppendLiteral(ReadOnlySpan<char> s)
{
var offset = Advance(0); // まず連続する空白文字をスキップする
_input = _input[offset..];
_index += offset;
if (_input.StartsWith(s)) // 入力テキストからテンプレートのリテラル部分を取り除く
{
_input = _input[s.Length..];
}
else throw new FormatException($"Cannot find '{s}' in the input string (at index: {_index}).");
_index += s.Length;
literalLength -= s.Length;
}
public void AppendFormatted<T>(in T v) where T : ISpanParsable<T>
{
var offset = Advance(0); // まず連続する空白文字をスキップする
_input = _input[offset..];
_index += offset;
var length = Scan(); // 次の空白文字までの長さを算出する
if (T.TryParse(_input[..length], null, out var result)) // パース!
{
Unsafe.AsRef(in v) = result; // 読み取り専用参照を可変参照に変換して値を更新する
_input = _input[length..];
_index += length;
formattedCount--;
}
else
{
throw new FormatException($"Cannot parse '{_input[..length]}' to '{typeof(T)}' (at index: {_index}).");
}
}
// 空白文字に遭遇するまで走査する
private int Scan()
{
var length = 0;
for (var i = 0; i < _input.Length; i++)
{
if (_input[i] is ' ' or '\t' or '\r' or '\n') break;
length++;
}
return length;
}
// すべての空白をスキップする
private int Advance(int start)
{
var length = start;
while (length < _input.Length && _input[length] is ' ' or '\t' or '\r' or '\n')
{
length++;
}
return length;
}
}
そして、この補間文字列ハンドラーを公開する薄いラッパーとして、次のような sscanf を用意します。
static void sscanf(ReadOnlySpan<char> input, [InterpolatedStringHandlerArgument("input")] TemplatedStringHandler template) { }
sscanf を使ってみよう
使用例:
int x = 0;
string y = "";
bool z = false;
DateTime d = default;
sscanf("test 123 hello false 2025/01/01T00:00:00 end", $"test{x}{y}{z}{d}end");
Console.WriteLine(x);
Console.WriteLine(y);
Console.WriteLine(z);
Console.WriteLine(d);
出力:
123
hello
False
2025年1月1日 0:00:00
これで sscanf がちゃんと動作しているとわかります。
なお、scanf は単に sscanf(Console.ReadLine(), template) の略記にすぎません。なので、ここでは sscanf があれば十分です。
まとめ
C# の補間文字列ハンドラーは非常に強力です。この機能を活用することで、C/C++ の sscanf よりも使いやすい文字列解析関数を実装できました。
書式プレースホルダーが不要なうえ、型を自動的に決定でき、引数で変数参照を一つずつ渡す必要すらありません。さらに、ReadOnlySpan<char> の活用で割り当てなしで動作できます。
読み取り専用参照を可変参照に変換する Unsafe.AsRef の使用が唯一の不安なところではありますが、無事に動作できているのでヨシとしましょう。