チュートリアル: カスタム文字列補間ハンドラーを記述する
このチュートリアルでは、次の作業を行う方法について説明します。
- 文字列補間ハンドラー パターンを実装する
- 文字列補間操作でレシーバーと対話する。
- 文字列補間ハンドラーに引数を追加する
- 文字列補間用の新しいライブラリ機能について理解する
必須コンポーネント
お使いのマシンで、.NET を実行するように設定する必要があります。 C# コンパイラは、Visual Studio 2022 または .NET SDK で使用できます。
このチュートリアルでは、C# と .NET (Visual Studio または .NET CLI のいずれかを含む) に精通していることを前提としています。
カスタムの補間文字列ハンドラーを作成できます。 補間された文字列ハンドラーは、補間された文字列内のプレースホルダー式を処理する型です。 カスタム ハンドラーを使用しない場合、プレースホルダーは 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 が適用されている。
literalLength
とformattedCount
という、2 つのint
パラメーターを持つコンストラクター。 (さらに多くのパラメーターも使用できます。)- 次のシグネチャを持つパブリック
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");
}
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.
出力をトレースすると、ハンドラーを呼び出して文字列を作成するコードが、コンパイラによってどのように追加されているかを確認できます。
- コンパイラによって、ハンドラーを構築するための呼び出しが追加され、書式設定文字列のリテラル テキストの合計長と、プレースホルダーの数が渡されます。
- コンパイラにより、リテラル文字列の各セクションと各プレースホルダーごとに
AppendLiteral
とAppendFormatted
の呼び出しが追加されます。 - コンパイラによって、
CoreInterpolatedStringHandler
を引数として指定してLogMessage
メソッドが呼び出されます。
最後に、最後の警告では補間された文字列ハンドラーは呼び出されないことがわかります。 その引数は 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.GetFormattedText());
}
この属性は、必須の LogMessage
および literalLength
パラメーターに続くパラメーターにマップされる formattedCount
の引数リストを指定します。 空の文字列 ("") では、レシーバーが指定されます。 コンパイラによって、ハンドラーのコンストラクターの次の引数が、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
フィールドを削除できるという意味です。 その後、AppendLiteral
と AppendFormatted
の戻り値の型を 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 回インクリメントされているのを確認できます。 プレースホルダーは Critical
、Error
、および Warning
レベルに対してだけ評価され、Information
と Trace
に対しては評価されないため、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
メソッドに対して可能であることがわかります。
.NET