C# 10:撰寫字串插補處理器來提升效能

摘要:示範如何撰寫 C# 10 的字串插補處理器,並展示效能測試的結果。


上一篇文章提到了 C# 10 的字串插補語法背後負責組合字串的是一個叫做 DefaultInterpolatedStringHandler 的結構。每當編譯器碰到字串插補語法時,便會使用這個預設的字串插補處理器來建構字串。這便是 C# 10 的字串插補效能優於 C# 9 的主要原因。

在某些特殊場合,我們甚至可以自行設計特定用途的字串插補處理器,以減少一些非必要的字串連接操作,進一步提升應用程式的執行效能。一個常見的例子是輸出 log 訊息的場合。比如說,應用程式在許多地方呼叫了 logging API 來記錄程式的執行過程與錯誤訊息,並且在不需要記錄的時候,藉由修改組態檔來關閉記錄功能。然而,程式裡面有許多地方在呼叫 logging API 的時候使用了字串插補語法來組合字串,即便把記錄功能關閉了,那些傳遞給 logging API 的字串還是會在程式執行的時候經由 DefaultInterpolatedStringHandler 來進行字串的組合。請看以下範例:



第 3 行呼叫 Log 方法時,記錄器的功能是啟用的,這裡沒有問題。接下來,第 5 行把記錄功能關閉,故第 6 行呼叫 Log 方法時,儘管該方法在內部會直接返回、不輸出任何 log,但呼叫 Log 方法時的字串插補語法卻還是會透過 DefaultInterpolatedStringHandler 來完成字串的組合,而這些組合字串的操作便等於白做工了。

剛才舉的例子,如果你是那個 logging API 的設計者,便可以特別為它撰寫一個字串插補處理器,來避免無謂的效能損耗。以下範例仿自微軟文件:Improved Interpolated Strings



字串插補處理器的設計要點如下:

  • 宣告型別的時候必須套用 InterpolatedStringHandler 特徵項(第 3 行)。
  • 宣告型別的時候加上 ref struct,表示這個字串插補處理器是個結構,而且必須是配置於堆疊中的結構(即不可配置於堆積;參見〈C# 7:只能放在堆疊的結構:ref struct〉)。
  • 建構式至少要有兩個 int 參數:literalLength 和 formattedCount(第 8~10 行)。前者代表常數字元的字數,後者則為需要插補(格式化)的數量。比如說,$"Hi, {name}" 這個字串樣板的 literalLength 是 4,而 formattedCount 是 1。
  • 建構式還可以視需要加入兩個額外參數:一個是來源物件(第 10 行的 logger 參數),另一個是布林型別的輸出參數,代表字串處理器是否可用((第 10 行的 handlerIsValid 參數)。
  • 必須提供 AppendLiteral 和 AppendFormatted 方法。在建立字串的過程中會呼叫這兩個方法。
  • 必須提供 ToStringAndClear 方法,以傳回最終組合完成的字串。

相較於 DefaultInterpolatedStringHandler,這裡示範的字串插補處理器只有一個比較特別的地方,即建構式會根據來源物件 logger 的 Enabled 屬性(是否啟用記錄)來決定是否需要進行字串插補:

  • 如果不需要記錄,則不建立字串插補處理器,並將輸出參數 handlerIsValid 設為 false。(第 12~17 行)
  • 如果需要記錄(第 19~20 行),則建立一個 DefaultInterpolatedStringHandler 物件,而且往後的字串組合操作都是轉交給它處理;這些操作包括:AppendLiteralAppendFormatted、和 ToStringAndClear 方法。


接著來看記錄器(logger)類別:



你可以看到,Log 方法的 handler 參數的型別並非單純的 string,而是我們設計的的字串處理器 MyLoggerInterpolatedStringHandler

此外,handler 參數前面套用的特徵項 [InterpolatedStringHandlerArgument("")],是用來指定欲傳遞給 MyLoggerInterpolatedStringHandler 建構式的引數。由於此範例並沒有需要把當前引數列當中的某個引數傳遞給字串插補處理器的建構式,故傳入空字串,表示要傳入當前呼叫此方法的物件(亦即 this)。如果你覺得剛才的解釋不好理解,不妨對照一下我們的字串插補處理器的建構函式:

[InterpolatedStringHandler]
public ref struct MyLoggerInterpolatedStringHandler
{
    public MyLoggerInterpolatedStringHandler(
        int literalLength, int formattedCount,
        MyLogger logger, out bool handlerIsValid)
    { ...... }
}

這裡要關注的是建構式的第三個參數:logger。當我們撰寫類似底下的程式碼:

var name = "Michael";
var aLogger = new MyLogger();
aLogger.Log($"Hello, {name}");

編譯器看到傳入 Log 方法的參數是字串插補的語法,就會知道要先建立一個 MyLoggerInterpolatedStringHandler 型別的字串插補處理器,並由該物件來負責組合字串。建立該物件時,便會將當時的 aLogger 物件傳入至 MyLoggerInterpolatedStringHandler 建構式的第三個參數。這便是稍早說的,套用 [InterpolatedStringHandlerArgument("")] 特徵項時傳入空字串的作用。那麼,什麼情況會需要傳入某個引數的名稱呢?其中一個常見的場合是擴充方法,例如:



第 5 行的意思是:請把這次呼叫的參數列中名為 logger 的物件傳入至 MyLoggerInterpolatedStringHandler 建構式的第三個參數。

原始碼:CustomInterpolatedStringHandler.sln 裡面的 CustomInterpolStringHandler.csproj 專案。

OK,我們的字串插補處理器已經寫好了,回頭看本節開頭的程式碼:



現在,我們已達成以下目的:

  • 第 2~3 行:在記錄功能開啟的情況下,呼叫 Log 方法並傳入需要插補的字串時,會建立 MyLoggerInterpolatedStringHandler 物件,並由它來負責完成字串組合的工作。
  • 第 5~6 行:在記錄功能關閉的情況下,呼叫 Log 方法並傳入需要插補的字串時,不會建立 MyLoggerInterpolatedStringHandler 物件,所以也不會產生任何字串組合所需的效能損耗——更快、更省記憶體。

至於上述兩種情形的效能損耗差異,我另外寫了一個小程式來觀察,並且錄製成影片。效能測試程式的原始碼以及影片的連結如下:

👉Youtube 影片:Performance Test with Custom String Interpolation Handler

👉

效能測試程式:CustomInterpolatedStringHandler.sln 裡面的 InterpolStringHandlerBenchmark 專案。

 

順帶一提:在使用 Serilog 這類支援結構化記錄的類別庫時,最好使用它本身提供的字串樣板,亦即由 logging API 來處理 log 訊息字串的組合,而不要使用字串插補語法來把預先兜好的 log 字串丟給 logging API。這是因為,一旦我們把 log 訊息預先組成一個字串才丟給 logging API,那些結構化 logging API 也就失去了「結構化資訊」,導致日後在查詢和篩選 log 訊息時的麻煩。


Happy coding!

參考資料

沒有留言:

技術提供:Blogger.
回頂端⬆️