次の方法で共有


チュートリアル: カスタム文字列補間ハンドラーを記述する

このチュートリアルでは、次の方法について説明します。

  • 文字列補間ハンドラー パターンを実装する
  • 文字列補間操作で受信側と対話します。
  • 文字列補間ハンドラーに引数を追加する
  • 文字列補間の新しいライブラリ機能について

前提 条件

.NET を実行するようにマシンを設定する必要があります。 C# コンパイラは、Visual Studio 2022 または .NET SDKで使用できます。

このチュートリアルでは、Visual Studio や .NET CLI など、C# と .NET について理解していることを前提としています。

カスタム 補間文字列ハンドラーを記述できます。 挿入文字列ハンドラーは、挿入文字列のプレースホルダー式を処理する型です。 カスタム ハンドラーがない場合、プレースホルダーは String.Formatと同様に処理されます。 各プレースホルダーはテキストとして書式設定され、コンポーネントが連結されて結果の文字列が形成されます。

結果の文字列に関する情報を使用する任意のシナリオのハンドラーを記述できます。 使用されますか? 形式にはどのような制約がありますか? いくつかの例を次に示します。

  • 80 文字など、結果として得られる文字列の数が一部の制限を超えないようにする必要がある場合があります。 補間された文字列を処理して固定長バッファーを埋め、バッファーの長さに達すると処理を停止できます。
  • 表形式の場合、各プレースホルダーは固定長である必要があります。 カスタム ハンドラーは、すべてのクライアント コードに強制的に準拠させるのではなく、そのことを強制できます。

このチュートリアルでは、コア パフォーマンス シナリオの 1 つであるログ ライブラリの文字列補間ハンドラーを作成します。 構成されたログ レベルによっては、ログ メッセージを作成する作業は必要ありません。 ログ記録がオフの場合、挿入文字列式から文字列を構築する作業は必要ありません。 メッセージは印刷されないため、任意の文字列連結をスキップできます。 さらに、スタック トレースの生成を含め、プレースホルダーで使用される式を実行する必要はありません。

挿入文字列ハンドラーは、書式設定された文字列が使用されるかどうかを判断し、必要な場合にのみ必要な処理を実行できます。

初期実装

さまざまなレベルをサポートする基本的な Logger クラスから始めましょう。

public enum LogLevel
{
    Off,
    Critical,
    Error,
    Warning,
    Information,
    Trace
}

public class Logger
{
    public LogLevel EnabledLevel { get; init; } = LogLevel.Error;

    public void LogMessage(LogLevel level, string msg)
    {
        if (EnabledLevel < level) return;
        Console.WriteLine(msg);
    }
}

この Logger では、6 つの異なるレベルがサポートされています。 メッセージがログ レベル フィルターに合格しない場合、出力はありません。 ロガーのパブリック API は、メッセージとして (完全に書式設定された) 文字列を受け入れます。 文字列を作成するための作業はすべて既に完了しています。

ハンドラー パターンを実装する

この手順では、現在の動作を再作成する 補間文字列ハンドラー を構築します。 挿入文字列ハンドラーは、次の特性を持つ必要がある型です。

  • 型に System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute が適用されている。
  • literalLengthformattedCountの 2 つの int パラメーターを持つコンストラクター。 (その他のパラメーターを使用できます)。
  • シグネチャを持つパブリック AppendLiteral メソッド: public void AppendLiteral(string s)
  • シグネチャを持つ汎用パブリック AppendFormatted メソッド: public void AppendFormatted<T>(T t)

内部的には、ビルダーは書式設定された文字列を作成し、クライアントがその文字列を取得するためのメンバーを提供します。 次のコードは、これらの要件を満たす LogInterpolatedStringHandler 型を示しています。

[InterpolatedStringHandler]
public ref struct LogInterpolatedStringHandler
{
    // Storage for the built-up string
    StringBuilder builder;

    public LogInterpolatedStringHandler(int literalLength, int formattedCount)
    {
        builder = new StringBuilder(literalLength);
        Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
    }

    public void AppendLiteral(string s)
    {
        Console.WriteLine($"\tAppendLiteral called: {{{s}}}");
        
        builder.Append(s);
        Console.WriteLine($"\tAppended the literal string");
    }

    public void AppendFormatted<T>(T t)
    {
        Console.WriteLine($"\tAppendFormatted called: {{{t}}} is of type {typeof(T)}");

        builder.Append(t?.ToString());
        Console.WriteLine($"\tAppended the formatted object");
    }

    internal string GetFormattedText() => builder.ToString();
}

Logger クラスの LogMessage にオーバーロードを追加して、新しい補間文字列ハンドラーを試すようになりました。

public void LogMessage(LogLevel level, LogInterpolatedStringHandler builder)
{
    if (EnabledLevel < level) return;
    Console.WriteLine(builder.GetFormattedText());
}

元の LogMessage メソッドを削除する必要はありません。コンパイラは、引数が補間文字列式である場合に、string パラメーターを持つメソッドよりも補間ハンドラー パラメーターを持つメソッドを優先します。

次のコードをメイン プログラムとして使用して、新しいハンドラーが呼び出されたことを確認できます。

var logger = new Logger() { EnabledLevel = LogLevel.Warning };
var time = DateTime.Now;

logger.LogMessage(LogLevel.Error, $"Error Level. CurrentTime: {time}. This is an error. It will be printed.");
logger.LogMessage(LogLevel.Trace, $"Trace Level. CurrentTime: {time}. This won't be printed.");
logger.LogMessage(LogLevel.Warning, "Warning Level. This warning is a string, not an interpolated string expression.");

アプリケーションを実行すると、次のような出力が生成されます。

        literal length: 65, formattedCount: 1
        AppendLiteral called: {Error Level. CurrentTime: }
        Appended the literal string
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: {. This is an error. It will be printed.}
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:19:10 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
        AppendLiteral called: {Trace Level. CurrentTime: }
        Appended the literal string
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: {. This won't be printed.}
        Appended the literal string
Warning Level. This warning is a string, not an interpolated string expression.

出力をトレースすると、コンパイラがハンドラーを呼び出して文字列をビルドするコードを追加する方法を確認できます。

  • コンパイラは、ハンドラーを構築する呼び出しを追加し、書式指定文字列内のリテラル テキストの合計長とプレースホルダーの数を渡します。
  • コンパイラは、リテラル文字列の各セクションとプレースホルダーごとに、AppendLiteralAppendFormatted の呼び出しを追加します。
  • コンパイラは、CoreInterpolatedStringHandler を引数として使用して、LogMessage メソッドを呼び出します。

最後に、最後の警告で補間文字列ハンドラーが呼び出されていないことに注意してください。 引数は stringであるため、呼び出しは文字列パラメーターを使用して他のオーバーロードを呼び出します。

重要

このセクションの Logger のバージョンは ref structです。 ref struct はスタックに格納する必要があるため、割り当てを最小限に抑えます。 ただし、ref struct 型は、一般にインターフェイスを実装できません。 これにより、実装に ref struct 型を使用する単体テスト フレームワークとモック型の互換性の問題が発生する可能性があります。

ハンドラーにさらに機能を追加する

補間された文字列ハンドラーの前のバージョンでは、パターンが実装されています。 すべてのプレースホルダー式を処理しないようにするには、ハンドラーに詳細情報が必要です。 このセクションでは、作成された文字列がログに書き込まれない場合に処理が少ないほどハンドラーを改善します。 System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute を使用して、パブリック API へのパラメーターとハンドラーのコンストラクターへのパラメーター間のマッピングを指定します。 これは、挿入文字列を評価する必要があるかどうかを判断するために必要な情報をハンドラーに提供します。

ハンドラーの変更から始めましょう。 まず、ハンドラーが有効になっているかどうかを追跡するフィールドを追加します。 コンストラクターに 2 つのパラメーターを追加します。1 つはこのメッセージのログ レベルを指定し、もう 1 つはログ オブジェクトへの参照を指定します。

private readonly bool enabled;

public LogInterpolatedStringHandler(int literalLength, int formattedCount, Logger logger, LogLevel logLevel)
{
    enabled = logger.EnabledLevel >= logLevel;
    builder = new StringBuilder(literalLength);
    Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
}

次に、最後の文字列が使用されるときにハンドラーがリテラルまたは書式設定されたオブジェクトのみを追加するように、フィールドを使用します。

public void AppendLiteral(string s)
{
    Console.WriteLine($"\tAppendLiteral called: {{{s}}}");
    if (!enabled) return;

    builder.Append(s);
    Console.WriteLine($"\tAppended the literal string");
}

public void AppendFormatted<T>(T t)
{
    Console.WriteLine($"\tAppendFormatted called: {{{t}}} is of type {typeof(T)}");
    if (!enabled) return;

    builder.Append(t?.ToString());
    Console.WriteLine($"\tAppended the formatted object");
}

次に、コンパイラがハンドラーのコンストラクターに追加のパラメーターを渡すことができるように、LogMessage 宣言を更新する必要があります。 これは、ハンドラー引数の System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute を使用して処理されます。

public void LogMessage(LogLevel level, [InterpolatedStringHandlerArgument("", "level")] LogInterpolatedStringHandler builder)
{
    if (EnabledLevel < level) return;
    Console.WriteLine(builder.GetFormattedText());
}

この属性は、必須の literalLength および formattedCount パラメーターに続くパラメーターにマップされる LogMessage の引数リストを指定します。 空の文字列 ("") は、受信側を指定します。 コンパイラは、ハンドラーのコンストラクターの次の引数の this で表される Logger オブジェクトの値を置き換えます。 コンパイラは、level の値を次の引数に置き換えます。 記述するハンドラーには、任意の数の引数を指定できます。 追加する引数は文字列引数です。

このバージョンは、同じテスト コードを使用して実行できます。 今回は、次の結果が表示されます。

        literal length: 65, formattedCount: 1
        AppendLiteral called: {Error Level. CurrentTime: }
        Appended the literal string
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: {. This is an error. It will be printed.}
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:19:10 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
        AppendLiteral called: {Trace Level. CurrentTime: }
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        AppendLiteral called: {. This won't be printed.}
Warning Level. This warning is a string, not an interpolated string expression.

AppendLiteral メソッドと AppendFormat メソッドが呼び出されていますが、何もしていないのがわかります。 ハンドラーは、最終的な文字列が必要ないことを判断したので、ハンドラーはそれをビルドしません。 まだいくつかの改善点があります。

まず、System.IFormattableを実装する型に引数を制約する AppendFormatted のオーバーロードを追加できます。 このオーバーロードにより、呼び出し元はプレースホルダーに書式指定文字列を追加できます。 この変更を行いながら、他の AppendFormatted メソッドと AppendLiteral メソッドの戻り値の型を void から bool に変更します (これらのメソッドの戻り値の型が異なる場合は、コンパイル エラーが発生します)。 この変更により、"ショート サーキット" が可能になります。 メソッドは、挿入文字列式の処理を停止する必要があることを示す false を返します。 true を返す場合は、続行する必要があることを示します。 この例では、結果の文字列が必要ない場合に処理を停止するために使用しています。 ショート サーキットでは、よりきめ細かなアクションがサポートされます。 固定長バッファーをサポートするために、一定の長さに達すると式の処理を停止できます。 または、一部の条件は、残りの要素が必要ないことを示している可能性があります。

public void AppendFormatted<T>(T t, string format) where T : IFormattable
{
    Console.WriteLine($"\tAppendFormatted (IFormattable version) called: {t} with format {{{format}}} is of type {typeof(T)},");

    builder.Append(t?.ToString(format, null));
    Console.WriteLine($"\tAppended the formatted object");
}

それを追加して、補間された文字列式で書式指定文字列を指定できます。

var time = DateTime.Now;

logger.LogMessage(LogLevel.Error, $"Error Level. CurrentTime: {time}. The time doesn't use formatting.");
logger.LogMessage(LogLevel.Error, $"Error Level. CurrentTime: {time:t}. This is an error. It will be printed.");
logger.LogMessage(LogLevel.Trace, $"Trace Level. CurrentTime: {time:t}. This won't be printed.");

最初のメッセージの :t は、現在の時刻の "短い時刻形式" を指定します。 前の例では、ハンドラー用に作成できる AppendFormatted メソッドのオーバーロードの 1 つを示しました。 書式設定するオブジェクトのジェネリック引数を指定する必要はありません。 作成した型を文字列に変換するより効率的な方法がある場合があります。 ジェネリック引数ではなく、これらの型を受け取る AppendFormatted のオーバーロードを記述できます。 コンパイラは、最適なオーバーロードを選択します。 ランタイムでは、この手法を使用して System.Span<T> を文字列出力に変換します。 整数パラメーターを追加して、IFormattableの有無にかかわらず、出力の 配置 を指定できます。 .NET 6 に付属する System.Runtime.CompilerServices.DefaultInterpolatedStringHandler には、さまざまな用途で 9 つの AppendFormatted のオーバーロードが含まれています。 目的に合わせてハンドラーを構築するときに参照として使用できます。

ここでサンプルを実行すると、Trace メッセージに対して最初の AppendLiteral のみが呼び出されることがわかります。

        literal length: 60, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted called: 10/20/2021 12:18:29 PM is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: . The time doesn't use formatting.
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:18:29 PM. The time doesn't use formatting.
        literal length: 65, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted (IFormattable version) called: 10/20/2021 12:18:29 PM with format {t} is of type System.DateTime,
        Appended the formatted object
        AppendLiteral called: . This is an error. It will be printed.
        Appended the literal string
Error Level. CurrentTime: 12:18 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
        AppendLiteral called: Trace Level. CurrentTime:
Warning Level. This warning is a string, not an interpolated string expression.

効率を向上させるハンドラーのコンストラクターに対して最後の更新を 1 つ行うことができます。 ハンドラーは、最終的な out bool パラメーターを追加できます。 そのパラメーターを false に設定すると、挿入文字列式を処理するためにハンドラーをまったく呼び出すべきではありません。

public LogInterpolatedStringHandler(int literalLength, int formattedCount, Logger logger, LogLevel level, out bool isEnabled)
{
    isEnabled = logger.EnabledLevel >= level;
    Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
    builder = isEnabled ? new StringBuilder(literalLength) : default!;
}

この変更は、enabled フィールドを削除できることを意味します。 次に、AppendLiteralAppendFormatted の戻り値の型を voidに変更できます。 ここで、サンプルを実行すると、次の出力が表示されます。

        literal length: 60, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted called: 10/20/2021 12:19:10 PM is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: . The time doesn't use formatting.
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:19:10 PM. The time doesn't use formatting.
        literal length: 65, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted (IFormattable version) called: 10/20/2021 12:19:10 PM with format {t} is of type System.DateTime,
        Appended the formatted object
        AppendLiteral called: . This is an error. It will be printed.
        Appended the literal string
Error Level. CurrentTime: 12:19 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
Warning Level. This warning is a string, not an interpolated string expression.

LogLevel.Trace が指定された場合の唯一の出力は、コンストラクターからの出力です。 ハンドラーは有効になっていないことを示したので、Append メソッドは呼び出されませんでした。

この例では、特にログ ライブラリを使用する場合に、挿入文字列ハンドラーの重要なポイントを示します。 プレースホルダーの副作用は発生しない可能性があります。 メイン プログラムに次のコードを追加し、この動作が動作することを確認します。

int index = 0;
int numberOfIncrements = 0;
for (var level = LogLevel.Critical; level <= LogLevel.Trace; level++)
{
    Console.WriteLine(level);
    logger.LogMessage(level, $"{level}: Increment index a few times {index++}, {index++}, {index++}, {index++}, {index++}");
    numberOfIncrements += 5;
}
Console.WriteLine($"Value of index {index}, value of numberOfIncrements: {numberOfIncrements}");

index 変数がループの反復ごとに 5 回インクリメントされていることがわかります。 プレースホルダーは、InformationTraceではなく、CriticalError、および Warning レベルに対してのみ評価されるため、index の最終的な値は期待と一致しません。

Critical
Critical: Increment index a few times 0, 1, 2, 3, 4
Error
Error: Increment index a few times 5, 6, 7, 8, 9
Warning
Warning: Increment index a few times 10, 11, 12, 13, 14
Information
Trace
Value of index 15, value of numberOfIncrements: 25

挿入文字列ハンドラーを使用すると、挿入文字列式を文字列に変換する方法をより詳細に制御できます。 .NET ランタイム チームは、この機能を使用して、いくつかの領域でパフォーマンスを向上しました。 独自のライブラリで同じ機能を使用できます。 さらに詳しく調べるには、System.Runtime.CompilerServices.DefaultInterpolatedStringHandlerを参照してください。 ここで構築したよりも完全な実装が提供されます。 多くの追加のオーバーロードがAppendメソッドに対して可能であることがわかります。