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

このチュートリアルで学習する内容は次のとおりです。

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

前提条件

C# 10 コンパイラを含め、.NET 6 が実行されるようにコンピューターを設定する必要があります。 C# 10 コンパイラは、Visual Studio 2022 以降または .NET 6 SDK 以降で使用できます。

このチュートリアルでは、C# と .NET (Visual Studio または .NET CLI のいずれかを含む) に精通していることを前提としています。

新しいアウトライン

C# 10 では、カスタム "補間文字列ハンドラー" のサポートが追加されています。 補間された文字列ハンドラーは、補間された文字列内のプレースホルダー式を処理する型です。 カスタム ハンドラーを使用しない場合、プレースホルダーは 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 です。そのため、その呼び出しでは文字列パラメーターを持つ他のオーバーロードが呼び出されます。

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

前のバージョンの補間された文字列ハンドラーでは、パターンが実装されています。 すべてのプレースホルダー式を処理しないようにするには、さらなる情報がハンドラーに必要です。 このセクションでは、ハンドラーを改善して、作成された文字列がログに書き込まれない場合の処理が減るようにします。 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 には、さまざまな用途に向けた AppendFormatted のオーバーロードが 9 つ含まれています。 ご自分の目的に合ったハンドラーを構築する際に、これを参照として使用できます。

ここでサンプルを実行すると、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 回インクリメントされているのを確認できます。 プレースホルダーは CriticalError、および Warning レベルに対してだけ評価され、InformationTrace に対しては評価されないため、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 メソッドに対して可能なさらに多くのオーバーロードを確認できます。