教學課程:撰寫自訂字串插補處理常式

在本教學課程中,您將了解如何:

  • 實作字串插補處理常式模式
  • 在字串插補作業中與接收者互動。
  • 將引數新增至字串插補處理常式
  • 了解字串插補的新程式庫功能

必要條件

您需要設定機器來執行 .NET 6 (包括 C# 10 編譯器)。 從 Visual Studio 2022.NET 6 SDK 開始,C# 10 編譯器可供使用。

本教學課程假設您已熟悉 C# 和 .NET,包括 Visual Studio 或 .NET CLI。

新增大綱

C# 10 新增對自訂插補字串處理常式的支援。 插補字串處理常式是處理插補字串中預留位置運算式的型別。 如果沒有自訂處理常式,預留位置的處理方式與 String.Format 類似。 每個預留位置都會格式化為文字,然後串連元件以形成產生的字串。

您可以針對使用所產生字串相關資訊的任何情節撰寫處理常式。 是否會使用此項目? 格式的條件約束為何? 這些範例包含:

  • 您可能會要求產生的任何字串大於某些限制,例如 80 個字元。 您可以處理插補字串以填滿固定長度緩衝區,並在達到該緩衝區長度後停止處理。
  • 您可能有表格式格式,而且每個預留位置都必須有固定長度。 自訂處理常式可以強制執行,而不是強制所有用戶端程式碼符合要求。

在本教學課程中,您將針對其中一個核心效能情節建立字串插補處理常式:記錄程式庫。 根據設定的記錄層級,不需要進行建構記錄訊息的工作。 如果記錄是關閉的,則不需要進行從插補字串運算式建構字串的工作。 訊息永遠不會列印,因此可以略過任何字串串連。 此外,不需要完成預留位置中使用的任何運算式,包括產生的堆疊追蹤。

插補字串處理常式可以判斷是否要使用格式化字串,並只視需要執行必要的工作。

初始實作

讓我們從支援不同層級的基本 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 支援六個不同的層級。 當訊息沒有通過記錄層級篩選條件時,就沒有輸出。 記錄器的公用 API 會接受 (完整格式化) 字串作為訊息。 建立字串的所有工作都已經完成。

實作處理常式模式

此步驟會建置插補字串處理常式,以重新建立目前的行為。 插補字串處理常式是必須具有下列特性的型別:

  • System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute 會套用至型別。
  • 具有兩個 int 參數 (literalLengthformattedCount) 的建構函式。 (允許多個參數)。
  • 具有簽章的公用 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 之間的對應,以及參數與處理常式建構函式之間的對應。 這可提供處理常式,並提供判斷是否應該評估插補字串所需的資訊。

讓我們從處理常式的變更開始。 首先,新增欄位以追蹤是否已啟用處理常式。 將兩個參數新增至建構函式:一個用來指定此訊息的記錄層級,另一個是記錄物件的參考:

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 引數清單,其對應至必要 literalLengthformattedCount 參數之後的參數。 空字串 ("") 指定接收者。 編譯器會以 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.

您可以看到正在呼叫的是 AppendLiteralAppendFormat 方法,但其不會執行任何工作。 處理常式已判斷不需要最終字串,因此處理程式不會進行建置。 仍有一些可改進的地方。

首先,您可以新增 AppendFormatted 的多載,將引數限制為實作 System.IFormattable 的型別。 此多載可讓呼叫端在預留位置中新增格式字串。 進行這項變更時,也讓我們將其他 AppendFormattedAppendLiteral 方法的傳回型別從 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 方法的其中一個多載。 您不需要為正在格式化的物件指定泛型引數。 您可能有將建立之型別轉換為字串的效率更高方式。 您可以撰寫 AppendFormatted 的多載,該多載會採用這些型別,而不是泛型引數。 編譯器會挑選最適合的多載。 執行階段會使用這項技術來將 System.Span<T> 轉換成字串輸出。 您可以新增整數參數,以指定在包含或不含 IFormattable 的情況下輸出的對齊方式。 隨附 .NET 6 的 System.Runtime.CompilerServices.DefaultInterpolatedStringHandler 針對不同的用途包含 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.

您可以對處理常式的建構函式進行最後一個更新,以改善效率。 處理常式可以新增最終 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 變數在迴圈每次反覆運算時遞增五次。 因為只會針對 CriticalErrorWarning 層級對預留位置進行評估,而不是針對 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 方法可能會有更多多載。