4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C# の補間文字列ハンドラーで `sscanf` を実装してみた

Posted at

はじめに

えっ?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");

こうすることで、42handlervalue パラメーターに渡されます。これは呼び出し元のコンテキストを捕捉するのに役立ちます。ロギングのシナリオでは、引数に応じて書式を変えるのはよくある話です。

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>
    {
    }
}

ここではすべての stringReadOnlySpan<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 の使用が唯一の不安なところではありますが、無事に動作できているのでヨシとしましょう。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?