次の方法で共有


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

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

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

必須コンポーネント

.NET を実行するようにマシンを設定します。 C# コンパイラは、 Visual Studio または .NET SDK から入手できます。

このチュートリアルでは、Visual Studio または Visual Studio Code、C# DevKit など、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 が適用されている。
  • intliteralLength という、2 つの formattedCount パラメーターを持つコンストラクター。 (さらに多くのパラメーターも使用できます。)
  • 次のシグネチャを持つパブリック AppendLiteral メソッド: public void AppendLiteral(string s)
  • 次のシグネチャを持つジェネリック パブリック AppendFormatted メソッド: public void AppendFormatted<T>(T t)

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

[InterpolatedStringHandler]
public 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");
    }

    public override string ToString() => builder.ToString();
}

挿入文字列式がコンパイル時定数 (つまり、プレースホルダーを持たない) の場合、コンパイラはカスタム補間文字列ハンドラーを呼び出す代わりにターゲット型 string を使用します。 この動作は、定数補間文字列がカスタム ハンドラーを完全にバイパスしたことを意味します。

これで、LogMessage クラスの Logger にオーバーロードを追加して、新しい補間された文字列ハンドラーを試すことができます。

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

元の 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 の呼び出しが追加されます。
  • コンパイラによって、LogMessage を引数として指定して CoreInterpolatedStringHandler メソッドが呼び出されます。

最後に、最後の警告では補間された文字列ハンドラーは呼び出されないことがわかります。 その引数は string です。そのため、その呼び出しでは文字列パラメーターを持つ他のオーバーロードが呼び出されます。

重要

挿入文字列ハンドラーには、絶対に必要な場合にのみ、ref struct を使用します。 ref struct 型には、スタックに格納する必要があるため、制限があります。 たとえば、挿入文字列ホールに await 式が含まれている場合、コンパイラが生成した IAsyncStateMachine 実装にハンドラーを格納する必要があるため、機能しません。

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

前のバージョンの補間された文字列ハンドラーでは、パターンが実装されています。 すべてのプレースホルダー式を処理しないようにするには、さらなる情報がハンドラーに必要です。 このセクションでは、構築された文字列がログに書き込まれない場合にハンドラーの作業が少なくなるようにハンドラーを改善します。 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.ToString());
}

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

InterpolatedStringHandlerArgumentAttributeコンストラクター引数リストが空の場合、動作は属性が完全に省略された場合と同じです。

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

        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 メソッドが呼び出されているのに、処理は行われていないことがわかります。 最後の文字列は、必要ないとハンドラーによって判断されたため、構築されません。 まだいくつかの改善点が残っています。

まず、AppendFormatted を実装する型に引数を制約する System.IFormattable のオーバーロードを追加できます。 このオーバーロードにより、呼び出し元でプレースホルダーに書式指定文字列を追加できます。 この変更を行う際に、他の 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");
}

public void AppendFormatted<T>(T t, int alignment, string format) where T : IFormattable
{
    Console.WriteLine($"\tAppendFormatted (IFormattable version) called: {t} with alignment {alignment} and format {{{format}}} is of type {typeof(T)},");
    var formatString =$"{alignment}:{format}";
    builder.Append(string.Format($"{{0,{formatString}}}", t));
    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 {index++}");
    numberOfIncrements++;
}
Console.WriteLine($"Value of index {index}, value of numberOfIncrements: {numberOfIncrements}");

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

Critical
Critical: Increment index 0
Error
Error: Increment index 1
Warning
Warning: Increment index 2
Information
Trace
Value of index 3, value of numberOfIncrements: 5

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