共用方式為


改善插值字串

注意

本文是功能規格。 規格可作為功能的設計檔。 其中包含建議的規格變更,以及功能設計和開發期間所需的資訊。 這些文章會發佈,直到提議的規格變更完成並併併入目前的ECMA規格為止。

功能規格與已完成實作之間可能有一些差異。 這些差異是在 的相關語言設計會議(LDM)記錄中擷取的。

您可以參閱有關 規範的文章,深入瞭解將功能規範納入 C# 語言標準的過程。

冠軍問題:https://github.com/dotnet/csharplang/issues/4487

總結

我們引入了一種新的模式,用於創建和使用插值字串表達式,使其能有效格式化並用於一般 string 場景和較為專門的場景(如記錄框架),同時避免因在框架中格式化字串而引發不必要的資源配置。

動機

目前,字串插補主要轉化為呼叫 string.Format。 這雖然是一般用途,但因為許多原因而效率不佳:

  1. 它會框起任何結構自變數,除非運行時間碰巧引進多載的 string.Format,而該多載會以正確的順序完全採用正確的自變數類型。
    • 這種排序是為什麼執行環境對引入泛型版本的方法感到猶豫,因為這會導致泛型實例化一個非常常見方法的組合爆炸。
  2. 在大部分情況下,它必須分配一個陣列來存放參數。
  3. 如果不需要實例,就沒有機會避免具現化實例。 例如,記錄架構會建議避免字串內插補,因為它會根據應用程式的目前記錄層級,實現可能不需要的字串。
  4. 它永遠不能使用 Span 或其他 ref 結構類型,因為不允許 ref 結構做為泛型類型參數,這表示如果使用者想要避免複製到必須手動格式化字串的中繼位置。

在內部,執行時有一種類型稱為 ValueStringBuilder,協助處理最初的兩種情況。 他們會將堆疊分配的緩衝區傳遞至產生器,並不斷呼叫包含每個部分的 AppendFormat,然後獲得最終的字串。如果產生的字串超過堆疊緩衝區的界限,它們就可以移至堆中的陣列。 不過,這種類型如果直接公開是很危險的,因為不正確的使用方式可能會導致租用的陣列被重複釋放,這會造成程式出現各種未定義行為,因為兩個位置誤認為它們擁有租用陣列的唯一存取權。 此提案提供了一種方法,只需撰寫插值字串常表示式,即可在原生 C# 程式碼中安全使用此類型,從而改進使用者撰寫的每個插值字串,同時保持程式碼不變。 它也會擴充這個模式,以允許將插入字串作為參數傳遞給其他方法,使用由方法接收者定義的處理模式,這樣可以讓像是記錄框架的系統避免配置那些永遠不會被需要的字串,並讓 C# 使用者能夠使用熟悉且方便的插值語法。

詳細設計

處理程式模式

我們引進了新的處理程式模式,此模式可以表示作為方法參數傳遞的插值字串。 模式的簡單英文如下所示:

interpolated_string_expression 作為參數傳遞至方法時,我們會查看參數的類型。 如果參數類型具有一個可以用兩個 int 參數(literalLengthformattedCount)來叫用的建構函式,並且可選擇性地採用由原始參數屬性指定的其他參數,且選擇性地具有布爾值的尾端參數,而原始參數的類型具有可以針對插入字串的每個部分來叫用的實例方法 AppendLiteralAppendFormatted,那麼我們會藉由使用這種方式來降低插補,而不是使用傳統的方式來叫用 string.Format(formatStr, args)。 更具體的範例有助於理解這一點。

// The handler that will actually "build" the interpolated string"
[InterpolatedStringHandler]
public ref struct TraceLoggerParamsInterpolatedStringHandler
{
    // Storage for the built-up string

    private bool _logLevelEnabled;

    public TraceLoggerParamsInterpolatedStringHandler(int literalLength, int formattedCount, Logger logger, out bool handlerIsValid)
    {
        if (!logger._logLevelEnabled)
        {
            handlerIsValid = false;
            return;
        }

        handlerIsValid = true;
        _logLevelEnabled = logger.EnabledLevel;
    }

    public void AppendLiteral(string s)
    {
        // Store and format part as required
    }

    public void AppendFormatted<T>(T t)
    {
        // Store and format part as required
    }
}

// The logger class. The user has an instance of this, accesses it via static state, or some other access
// mechanism
public class Logger
{
    // Initialization code omitted
    public LogLevel EnabledLevel;

    public void LogTrace([InterpolatedStringHandlerArguments("")]TraceLoggerParamsInterpolatedStringHandler handler)
    {
        // Impl of logging
    }
}

Logger logger = GetLogger(LogLevel.Info);

// Given the above definitions, usage looks like this:
var name = "Fred Silberberg";
logger.LogTrace($"{name} will never be printed because info is < trace!");

// This is converted to:
var name = "Fred Silberberg";
var receiverTemp = logger;
var handler = new TraceLoggerParamsInterpolatedStringHandler(literalLength: 47, formattedCount: 1, receiverTemp, out var handlerIsValid);
if (handlerIsValid)
{
    handler.AppendFormatted(name);
    handler.AppendLiteral(" will never be printed because info is < trace!");
}
receiverTemp.LogTrace(handler);

在這裡,由於 TraceLoggerParamsInterpolatedStringHandler 具有正確參數的建構函式,因此假設插補字串具有該參數的隱含處理程序轉換,而且會降低至上述模式。 這項需求的規格比較複雜,詳細內容如下。

接下來的提案部分,當皆適用時將使用 Append... 指代 AppendLiteralAppendFormatted

新屬性

編譯程式會辨識 System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute

using System;
namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
    public sealed class InterpolatedStringHandlerAttribute : Attribute
    {
        public InterpolatedStringHandlerAttribute()
        {
        }
    }
}

編譯程式會使用這個屬性來判斷類型是否為有效的插補字串處理程序類型。

編譯程式也會辨識 System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
    public sealed class InterpolatedStringHandlerArgumentAttribute : Attribute
    {
        public InterpolatedHandlerArgumentAttribute(string argument);
        public InterpolatedHandlerArgumentAttribute(params string[] arguments);

        public string[] Arguments { get; }
    }
}

這個屬性用於參數,以通知編譯程式如何降低參數位置中使用的插補字串處理程式模式。

插入字串處理程序轉換

據說,若類型 T 是以 System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute屬性標註,則為 applicable_interpolated_string_handler_type。 有隱含的 interpolated_string_handler_conversion 可以從 interpolated_string_expressionT,或從完全由_interpolated_string_expression_s組成的 additive_expressionT,並且僅使用 + 運算符。

為了簡化此說明中的其餘部分,interpolated_string_expression 是指簡單的 interpolated_string_expression,以及由_interpolated_string_expression_s 完全組成並且僅使用 + 運算符的 additive_expression

請注意,此轉換始終存在,不論稍後實際嘗試使用處理程式模式降低插補時是否會發生錯誤。 這樣做是為了協助確保有可預測且有用的錯誤,而且運行時間行為不會根據插入字串的內容而變更。

適用的函式成員調整

我們調整適用函數成員演算法的措辭(§12.6.4.2),調整內容如下:(每個部分新增了粗體字的新子項目符號):

可以被稱為 適用的函式成員, 需滿足以下所有條件:A 參數列表。

  • A 中的每個引數都對應到函式成員宣告中的參數,如對應參數(§12.6.2.2)所述,而任何沒有對應引數的參數都是選擇性參數。
  • 針對 A中的每個自變數,自變數的參數傳遞模式(例如值、refout)與對應參數的參數傳遞模式相同,以及
    • 若為值參數或參數陣列,則存在從引數到對應參數類型的隱含轉換(§10.2),或
    • 針對類型為結構體類型的 ref 參數 ,隱含存在從自變數 到對應參數類型的 interpolated_string_handler_conversion,或
    • 對於 refout 參數,自變數的類型與對應參數的類型相同。 畢竟,refout 參數是傳遞自變數的別名。

對於包含參數陣列的函式成員,如果函式成員適用於上述規則,則表示其 一般形式適用。 如果包含參數陣列的函式成員不適用於其一般形式,則該函式成員可能適用於其 擴展形式

  • 展開的表單是藉由將函式成員宣告中的參數陣列取代為參數陣元素型別的零或多個值參數,使自變數清單中的自變數數目 A 符合參數總數。 如果 A 自變數比函數成員宣告中的固定參數數目少,則無法建構函式成員的展開形式,因此不適用。
  • 否則,若在 A 中的每個引數的參數傳遞模式都與對應參數的參數傳遞模式相同,則展開格式就適用。
    • 對於固定值參數或由擴充創建的值參數,存在從引數類型到相應參數類型的隱含轉換(§10.2),或
    • 針對類型為結構類型的 ref 參數 ,存在從自變數到對應參數類型的隱式 interpolated_string_handler_conversion,或
    • 對於 refout 參數,自變數的類型與對應參數的類型相同。

重要注意事項:這表示如果有 2 個其他相等的多載,則只有 applicable_interpolated_string_handler_type的類型不同,這些多載會被視為模棱兩可。 此外,由於我們無法透過明確類型轉換來查看,因此可能會出現無法解決的情況,其中兩個適用的多載都使用 InterpolatedStringHandlerArguments,並且在不手動執行處理程序降級模式的情況下完全無法調用。 如果我們選擇,我們可能會變更更好的函式成員演算法來解決此問題,但此案例不太可能發生,而且不是解決的優先順序。

表達式調整帶來更好的轉換

我們將表達式(§12.6.4.5)區段的轉換改善為以下內容:

假設有一個隱含轉換 C1,將表達式 E 轉換為類型 T1,還有一個隱含轉換 C2,將表達式 E 轉換為類型 T2,若以下條件成立,則 C1 是一個 C2 更好的轉換

  1. E 是一個非常數 interpolated_string_expressionC1 是一個 implicit_string_handler_conversionT1 是一個 applicable_interpolated_string_handler_type,而 C2 不是一個 implicit_string_handler_conversion,或者
  2. ET2 不完全相符,且至少保留下列其中一項:

確實意味著存在一些潛在的非明顯的超載解析規則,主要決定於所指的插補字串是否為常數表達式。 例如:

void Log(string s) { ... }
void Log(TraceLoggerParamsInterpolatedStringHandler p) { ... }

Log($""); // Calls Log(string s), because $"" is a constant expression
Log($"{"test"}"); // Calls Log(string s), because $"{"test"}" is a constant expression
Log($"{1}"); // Calls Log(TraceLoggerParamsInterpolatedStringHandler p), because $"{1}" is not a constant expression

這樣做是為了讓可以簡單作為常數發出的東西做到這一點,並且不會帶來任何負擔,而無法作為常數的則使用處理程式模式。

InterpolatedStringHandler 和使用方式

我們在 System.Runtime.CompilerServices中引進了新的類型:DefaultInterpolatedStringHandler。 這是 ref 結構,其語意與 ValueStringBuilder相同,適用於 C# 編譯程式直接使用。 此結構看起來大致如下:

// API Proposal issue: https://github.com/dotnet/runtime/issues/50601
namespace System.Runtime.CompilerServices
{
    [InterpolatedStringHandler]
    public ref struct DefaultInterpolatedStringHandler
    {
        public DefaultInterpolatedStringHandler(int literalLength, int formattedCount);
        public string ToStringAndClear();

        public void AppendLiteral(string value);

        public void AppendFormatted<T>(T value);
        public void AppendFormatted<T>(T value, string? format);
        public void AppendFormatted<T>(T value, int alignment);
        public void AppendFormatted<T>(T value, int alignment, string? format);

        public void AppendFormatted(ReadOnlySpan<char> value);
        public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null);

        public void AppendFormatted(string? value);
        public void AppendFormatted(string? value, int alignment = 0, string? format = null);

        public void AppendFormatted(object? value, int alignment = 0, string? format = null);
    }
}

我們對 插值字符串表達式第12.8.3條)的意義規則稍作改變:

如果插入字串的類型是 string 且類型 System.Runtime.CompilerServices.DefaultInterpolatedStringHandler 存在,而且目前的內容支援使用該類型,則字串會使用處理程式模式降低。最後一個 string 值接著會在處理程式類型上呼叫 ToStringAndClear() 來取得。否則,如果 插入字串的類型 System.IFormattableSystem.FormattableString [其餘的未變更]

「和目前的上下文支援使用該類型」規則有意設計得較為模糊,讓編譯器在優化此模式的使用時有更多自由。 處理程式類型可能是 ref 結構類型,而 ref 結構類型通常不允許在異步方法中。 在此情況下,如果沒有任何插補漏洞包含 await 表達式,則允許編譯程式使用 處理程式,因為我們可以靜態判斷處理程式類型是安全地使用,而不需要額外的複雜分析,因為處理程式會在評估插補字串表達式之後捨棄。

開啟 問題

我們想要改為讓編譯程式知道 DefaultInterpolatedStringHandler,並完全略過 string.Format 呼叫嗎? 這可讓我們隱藏一個不一定想讓人們注意到的方法,當他們手動呼叫 string.Format時。

回答:是的。

開啟 問題

我們也想要有 System.IFormattableSystem.FormattableString 的處理程式嗎?

回答:否。

處理器模式代碼生成

在本節中,方法調用的解析是指 §12.8.10.2中列出的步驟。

建構函式解析

假設有 applicable_interpolated_string_handler_typeTinterpolated_string_expressioni,則會對 T 上的有效建構函式進行方法調用解析和驗證,如下所示:

  1. 實例建構函式的成員查閱會在 T上執行。 產生的方法群組稱為 M
  2. 自變數清單 A 建構如下:
    1. 前兩個參數是整數常數,分別代表 i的常值長度,以及 i插值組件的 數目。
    2. 如果在方法 M1中使用 i 作為某些參數 pi 的自變數,且參數 pi 具備 System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute屬性,則編譯器會將該屬性的 Arguments 陣列中的每個名稱 Argx 與具有相同名稱的參數 px 進行匹配。 空字串會與 M1的接收者相符。
      • 如果任何 Argx 無法比對到 M1的參數,或者 Argx 請求 M1 的接收者,而 M1 是靜態方法,則會產生錯誤,並且不會執行任何後續步驟。
      • 否則,每個已解析 px 的類型都會以 Arguments 陣列所指定的順序新增至自變數清單。 每個 px 都會以與 M1中指定的相同 ref 語意傳遞。
    3. 最後一個自變數是 bool,傳遞為 out 參數。
  3. 傳統方法調用解析是使用方法群組 M 和自變數清單 A來執行。 針對方法調用最終驗證的目的,將 M 的上下文視為在類型 T中透過 的成員訪問
    • 如果找到單一最佳建構函式 F,多載解析的結果為 F
    • 如果找不到適用的建構函式,則會重試步驟 3,從 A移除最終的 bool 參數。 如果此重試也找不到適用的成員,則會產生錯誤,而且不會採取進一步的步驟。
    • 如果找不到單一最佳方法,多載解析的結果模棱兩可,就會產生錯誤,而且不會採取任何進一步的步驟。
  4. 執行 F 的最終驗證。
    • 如果 A 的任何元素在 i之後在語彙上出現,就會產生錯誤,而且不會有任何進一步的動作。
    • 如果有任何 A 是對 F的接收者有所要求,並且 F 是在 的 member_initializer中作為 initializer_target 使用的索引器,則會產生錯誤訊息,並且不會執行任何進一步的步驟。

注意:此處的解析度會刻意 不要 使用作為 Argx 元素的其他自變數傳遞的實際表達式。 我們只考慮轉換后的類型。 這可確保在傳遞至 M1 時,Lambda 系結至一個委派類型,並在傳遞至 M時系結至不同的委派類型時,不會發生雙重轉換問題或非預期的情況。

注意:我們會報告使用索引器做為成員初始化的錯誤,因為巢狀成員初始化的評估順序。 請考慮此代碼段:


var x1 = new C1 { C2 = { [GetString()] = { A = 2, B = 4 } } };

/* Lowering:
__c1 = new C1();
string argTemp = GetString();
__c1.C2[argTemp][1] = 2;
__c1.C2[argTemp][3] = 4;

Prints:
GetString
get_C2
get_C2
*/

string GetString()
{
    Console.WriteLine("GetString");
    return "";
}

class C1
{
    private C2 c2 = new C2();
    public C2 C2 { get { Console.WriteLine("get_C2"); return c2; } set { } }
}

class C2
{
    public C3 this[string s]
    {
        get => new C3();
        set { }
    }
}

class C3
{
    public int A
    {
        get => 0;
        set { }
    }
    public int B
    {
        get => 0;
        set { }
    }
}

在索引器接收者 之前,要傳遞給 __c1.C2[] 的自變數會先進行 評估。 雖然我們可以想出適用於此情境的降階方案(無論是通過為 __c1.C2 建立暫存並在兩次索引器調用中共用,還是僅在第一次索引器調用中使用它並在兩次調用中共用參數),但我們認為任何這種降階在我們認為是病理性情境下都會令人感到困惑。 因此,我們完全禁止該情境。

公開問題

如果我們使用建構函式,而不是 Create,我們會改善執行時期的程式碼生成,但會稍微縮小模式。

回應:我們現在將限製為建構函式。 如果情況需要,我們可以稍後重新考慮增加一般 Create 方法。

Append... 方法多載解析

假設有一個 applicable_interpolated_string_handler_typeT 和一個 interpolated_string_expressioni,則會在 T 上對一組有效的 Append... 方法執行多載解析,如下所示:

  1. 如果 i中有任何 interpolated_regular_string_character 組件:
    1. T 上執行名為 AppendLiteral 的成員查詢。 產生的方法群組稱為 Ml
    2. 自變數清單 Al 是使用類型為 string的一個值參數來建構。
    3. 傳統方法調用解析是使用方法群組 Ml 和自變數清單 Al來執行。 針對方法調用最終驗證的目的,Ml 的內容會透過 T實例視為 member_access
      • 如果找到單一最佳方法 Fi 且不會產生任何錯誤,則方法調用解析的結果會 Fi
      • 否則會報告錯誤。
  2. 針對 i的每個 插補ix 元件:
    1. 執行對於 T 的名稱 AppendFormatted 的成員查閱。 產生的方法群組稱為 Mf
    2. 建構自變數清單 Af
      1. 第一個參數是 ixexpression,並以傳值方式傳遞。
      2. 如果 ix 直接包含 constant_expression 元件,則會新增整數值參數,並指定名稱 alignment
      3. 如果 ix 後面緊接著 interpolation_format,則會新增字串值參數,並指定名稱 format
    3. 傳統方法調用解析是使用方法群組 Mf 和自變數清單 Af來執行。 針對方法調用最終驗證的目的,Mf 的內容會透過 T實例視為 member_access
      • 如果找到單一最佳方法 Fi,則會得到方法調用解析的結果 Fi
      • 否則會報告錯誤。
  3. 最後,針對步驟 1 和 2 中探索到的每個 Fi,都會執行最終驗證:
    • 如果任何 Fi 未依值或 void傳回 bool,則會報告錯誤。
    • 如果所有 Fi 都未傳回相同的類型,則會報告錯誤。

請注意,這些規則不允許 Append... 呼叫的擴充方法。 我們可以考慮在需要的時候啟用這個功能,但這類似於列舉器模式,其中我們允許 GetEnumerator 作為擴充方法,但不允許 CurrentMoveNext()

這些規則 允許 Append... 呼叫的預設參數,這些參數適用於 CallerLineNumberCallerArgumentExpression 等專案(語言支援時)。

我們針對基底元素與插值孔擁有個別的多載查找規則,因為某些處理器會希望能夠理解插入元件和屬於基底字串的元件之間的差異。

開啟 問題

某些情境,例如結構化記錄,希望能夠為插補元素提供名稱。 例如,今天記錄呼叫看起來可能會像 Log("{name} bought {itemCount} items", name, items.Count);{} 內的名稱會為記錄器提供重要的結構資訊,以協助確保輸出一致且統一。 在某些情況下,可能會將插補孔的 :format 元件重複用於此目的,但許多記錄器已經了解格式規範,並基於這些資訊具有現有的輸出格式行為。 是否有一些語法可用來啟用將這些具名規範放入?

在某些情況下,如果 C# 10 確實提供支持,就可以使用 CallerArgumentExpression。 但對於叫用方法/屬性的情況,可能是不夠的。

回應

雖然我們可以在正交語言功能中探索的範本字串有一些有趣的部分,但我們並不認為這裡的特定語法對使用元組等解決方案有很大好處:$"{("StructuredCategory", myExpression)}"

執行轉換

假設存在一個 applicable_interpolated_string_handler_typeT 和一個 interpolated_string_expressioni,其具有一個已解決的有效建構函式 FcAppend... 方法 Fa,則會按如下方式對 i 進行降低:

  1. 在所有語法上位於 i 之前的 Fc 自變數都會依照語法順序被評估並儲存為暫存變數。 為了保留語彙順序,如果 i 作為較大表達式 e的一部分發生,則會以語匯順序再次評估 i 之前發生 e 的任何元件。
  2. Fc 會使用插補字串常值元件的長度、插補 洞的數目、任何先前評估的自變數,以及 bool out 自變數(如果 Fc 解析為最後一個參數)。 結果會儲存到暫存值 ib
    1. 常值元件的長度會在以單一 {取代任何 open_brace_escape_sequence 之後計算,而任何 close_brace_escape_sequence 則以單一 }來計算。
  3. 如果 Fcbool out 參數結束,則會產生對該 bool 值的檢查。 如果為 true,則會呼叫 Fa 中的方法。 否則,將不會呼叫它們。
  4. 針對 Fa中的每個 Fax,將在 ib 上呼叫 Fax,並根據需要使用目前的常值元件或 插補 表達式。 如果 Fax 傳回 bool,則結果會以邏輯方式和上述所有 Fax 呼叫一起。
    1. 如果 Fax 是對 AppendLiteral的呼叫,則字面值元件會以單一 {取代任何 open_brace_escape_sequence,並以單一 }取代任何 close_brace_escape_sequence
  5. 轉換的結果 ib

同樣地,請注意傳遞至 Fc 的參數和傳遞至 e 的參數是相同的暫存。轉換可能會在暫存上進行,以轉成 Fc 需要的格式,但例如 lambda 表達式無法在 Fce之間繫結至不同的委派類型。

開啟 問題

這個降低表示在傳回 false 傳回 Append... 呼叫之後,插入字串的後續部分不會進行評估。 這可能非常令人困惑,特別是如果格式漏洞會產生副作用。 我們可以改為先評估所有格式問題,然後根據結果重複呼叫 Append...,如果結果為 false,就會停止。 這可確保所有表達式都會按預期進行評估,但我們會盡量只呼叫必要的方法。 雖然部分評估對於一些更進階的案例可能很理想,但對於一般案例來說,這也許並不直覺。

如果我們想要一律評估所有格式洞,另一個替代方法是移除 API 的 Append... 版本,並只執行重複 Format 呼叫。 處理程式可以追蹤它是否應該只卸除自變數,並立即傳回此版本。

回答:我們將有條件地評估漏洞。

開啟 問題

我們是否需要釋放可釋放的處理程序類型,並用 try/finally 包覆呼叫來確保執行 Dispose? 例如,bcl 中的內插字串處理器可能包含一個租用的陣列,如果某一個內插點在評估期間拋出例外,未處置的租用陣列可能會導致記憶體洩漏。

回答:否。處理程式可以指派給局部變數(例如 MyHandler handler = $"{MyCode()};),而且這類處理程式的存留期尚不清楚。 不同於 foreach 列舉器,其存活期是明顯的,且不會為列舉器建立任何使用者定義的區域變數。

對可空參考型別的影響

為了降低實作的複雜性,我們對作為方法或索引器參數的插值字串處理器建構函式執行可空性分析時有一些限制。 特別是,我們不會將建構函式的信息傳回原始上下文的參數或引數位置,我們也不會利用建構函式的參數類型來決定包含方法中類型參數的泛型類型推斷。 其中可能會影響的范例如下:

string s = "";
C c = new C();
c.M(s, $"", c.ToString(), s.ToString()); // No warnings on c.ToString() or s.ToString(), as the `MaybeNull` does not flow back.

public class C
{
    public void M(string s1, [InterpolatedStringHandlerArgument("", "s1")] CustomHandler c1, string s2, string s3) { }
}

[InterpolatedStringHandler]
public partial struct CustomHandler
{
    public CustomHandler(int literalLength, int formattedCount, [MaybeNull] C c, [MaybeNull] string s) : this()
    {
    }
}
string? s = null;
M(s, $""); // Infers `string` for `T` because of the `T?` parameter, not `string?`, as flow analysis does not consider the unannotated `T` parameter of the constructor

void M<T>(T? t, [InterpolatedStringHandlerArgument("s1")] CustomHandler<T> c) { }

[InterpolatedStringHandler]
public partial struct CustomHandler<T>
{
    public CustomHandler(int literalLength, int formattedCount, T t) : this()
    {
    }
}

其他考慮

允許 string 類型也可以轉換成處理程式

為了在設計類型時的簡化,我們可以考慮允許將類型 string 的運算式隱含轉換為 applicable_interpolated_string_handler_types的可能性。 按照今天的建議,作者可能需要在該處理程式類型和一般 string 類型上多載入,以便使用者不必瞭解其中的差異。 這可能是令人惱火且不明顯的額外開銷,因為 string 表示式可以視為內插運算,有著 expression.Length 的預填長度,並且完全沒有需要填補的部分。

這可讓新的 API 只公開處理程式,而不需要公開 string接受多載。 不過,它不會因應變更的需求,以便從表達式進行更好的轉換,因此,雖然其運作可能會造成不必要的額外負荷。

回應

我們認為這最終可能會造成混淆,但對於自定義處理程式類型來說,有一個簡單的解決方法:新增一個從字串進行的使用者定義轉換。

合併無堆積字串的範圍

目前的 ValueStringBuilder 有兩個建構函式:一個接受計數並在堆中主動分配,另一個接受 Span<char>。 該 Span<char> 通常在執行期間程式碼庫中具有固定大小,平均大約 250 個元素。 若要真正取代該類型,我們應考慮進行擴展,在此過程中去辨識採用 Span<char>GetInterpolatedString 方法,而不只是計數版本。 不過,我們看到一些潛在的棘手案例可以在這裡解決:

  • 我們不想在熱迴圈中重複使用 stackalloc。 如果我們要對功能執行此延伸模組,我們很可能會想要在迴圈反覆項目之間共用 stackalloc'd 範圍。 我們知道這是安全的,因為 Span<T> 是一個無法儲存在堆積上的 ref 結構,而且使用者必須相當狡猾地設法擷取該 Span 的參考(例如建立接受這類處理程式的方法,然後刻意從處理程式擷取 Span,並將其傳回給呼叫者)。 不過,事先配置會產生其他問題:
    • 我們應該急切地使用 stackalloc 分配嗎? 如果迴圈從未輸入,或在需要空間之前結束,該怎麼辦?
    • 如果我們不急切地使用 stackalloc,這是否表示我們在每個迴圈中引入隱藏分支? 大部分的迴圈可能不太會關心這個問題,但它可能會影響一些性能要求高的迴圈,這些迴圈不想承擔這個成本。
  • 有些字串可能相當大,而 stackalloc 的適當數量取決於許多因素,包括運行時間因素。 我們並不真的想要 C# 編譯程式和規格事先判斷這一點,因此我們想要解析 https://github.com/dotnet/runtime/issues/25423 並新增 API,讓編譯程式在這些情況下呼叫。 它還會將更多的優缺點加入到上一個迴圈討論的點中,因為我們不希望在不需要時就多次在堆中分配大型陣列,也不希望在需要之前就進行分配。

回應

這不在 C# 10 的範疇內。 當我們查看較一般的 params Span<T> 功能時,我們可以從一個更一般的角度來看待這一點。

非試用版本的 API

為了簡單起見,此規格目前只建議識別 Append... 方法,總是會成功的項目(例如 InterpolatedStringHandler)將從該方法中返回 true。 這麼做是為了支援部分格式化的情境,使得使用者在發生錯誤或不需要格式設定時可以停止,例如在記錄的情況。然而,這可能會在標準插值字串的使用中引入許多不必要的分支。 我們可以考慮加入一個附錄,在沒有任何 Append... 方法存在的情況下,只使用 FormatX 方法。然而,如果 Append...FormatX 呼叫混合在一起,我們就會面臨該如何處理的問題。

回應

我們想要非試用版本的 API。 提案已更新,以反映這一點。

將先前的自變數傳遞至處理程式

提案目前缺乏對稱性,不幸的是:以縮減形式叫用擴充方法會產生與以一般形式叫用擴充方法不同的語意。 這與語言中的大多數其他位置不同,其中減少的形式只是糖。 我們建議在系結方法時將 屬性新增至架構,以通知編譯程式應該將特定參數傳遞至處理程式上的建構函式。 使用方式如下所示:

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
    public sealed class InterpolatedStringHandlerArgumentAttribute : Attribute
    {
        public InterpolatedStringHandlerArgumentAttribute(string argument);
        public InterpolatedStringHandlerArgumentAttribute(params string[] arguments);

        public string[] Arguments { get; }
    }
}

接著會使用此方式:

namespace System
{
    public sealed class String
    {
        public static string Format(IFormatProvider? provider, [InterpolatedStringHandlerArgument("provider")] ref DefaultInterpolatedStringHandler handler);
        …
    }
}

namespace System.Runtime.CompilerServices
{
    public ref struct DefaultInterpolatedStringHandler
    {
        public DefaultInterpolatedStringHandler(int baseLength, int holeCount, IFormatProvider? provider); // additional factory
        …
    }
}

var formatted = string.Format(CultureInfo.InvariantCulture, $"{X} = {Y}");

// Is lowered to

var tmp1 = CultureInfo.InvariantCulture;
var handler = new DefaultInterpolatedStringHandler(3, 2, tmp1);
handler.AppendFormatted(X);
handler.AppendLiteral(" = ");
handler.AppendFormatted(Y);
var formatted = string.Format(tmp1, handler);

我們需要回答的問題:

  1. 我們一般喜歡這種模式嗎?
  2. 我們想要允許這些自變數來自處理程序參數之後嗎? BCL 中的某些現有模式,例如 Utf8Formatter,會先將值格式化 ,然後在需要格式化的項目中使用。 為了符合這些模式,我們可能會想要允許這種情況,但我們需要決定這種非按順序的評估是否可行。

回應

我們想要支援這一點。 此規格已更新以反映這一點。 引數必須在呼叫位置以語彙順序指定,如果在插值字串常值之後指定 create 方法所需的引數,就會產生錯誤。

await 在插補洞中的應用

因為 $"{await A()}" 是現今有效的表達式,因此我們需要使用 await 來合理化插值漏洞。 我們可以使用一些規則來解決此問題:

  1. 如果插補字串用作 stringIFormattableFormattableString 且在插補洞中有 await,則恢復使用舊式格式化器。
  2. 如果插補字串受限於 implicit_string_handler_conversion,且 applicable_interpolated_string_handler_typeref struct,則不允許在格式洞中使用 await

基本上,只要我們保證 ref struct 不需要儲存到堆積中,那麼這種語法糖解構就可以在異步方法中使用 ref 結構。我們禁止在插值空位中使用 await,應該就能達成這個目的。

或者,我們可以只將所有處理程式類型設為非 ref 結構,包括插入字串的架構處理程式。 不過,這將使我們無法在未來辨識出一個完全不需要配置任何臨時空間的 Span 版本。

回應

我們會將內插字串處理程式視為與任何其他類型相同:這表示如果處理程式類型是 ref 結構,而且目前的內容不允許使用 ref 結構,則在這裡使用處理程式是非法的。 為了降低做為字串的字串常值,其規格是刻意模糊的,可讓編譯程式決定它認為適當的規則,但針對自定義處理程式類型,它們必須遵循與其餘語言相同的規則。

處理程式作為參考參數

某些處理程式可能會想要以 ref 參數的形式傳遞(inref)。 我們應該允許嗎? 如果是的話,ref 處理程式看起來會是什麼樣子? ref $"" 令人困惑,因為您實際上不是透過 ref 傳遞字串,因此您會傳遞從 ref by ref 建立的處理程式,而且異步方法有類似的潛在問題。

回應

我們想要支援這一點。 此規格已更新以反映這一點。 規則應該反映套用至實值型別擴充方法的相同規則。

透過二元運算和轉換進行字串插值

由於此提案使內插字串具上下文敏感性,我們希望允許編譯器將完全由內插字串組成的二元表達式,或經過類型轉換的內插字串,視為多載解析時的內插字串常值。 例如,採用下列案例:

struct Handler1
{
    public Handler1(int literalLength, int formattedCount, C c) => ...;
    // AppendX... methods as necessary
}
struct Handler2
{
    public Handler2(int literalLength, int formattedCount, C c) => ...;
    // AppendX... methods as necessary
}

class C
{
    void M(Handler1 handler) => ...;
    void M(Handler2 handler) => ...;
}

c.M($"{X}"); // Ambiguous between the M overloads

這會模棱兩可,必須轉換至 Handler1Handler2 才能解決。 不過,在進行該類型轉換時,我們可能會丟失來自方法接收者的上下文資訊,這意味著轉換會失敗,因為沒有任何內容可以填寫 c的資訊。 字串的二進位串連也出現了類似的問題:使用者可能想要將常值格式化成數行以避免換行,但無法,因為這將不再是可轉換成處理程式類型的插補字元串常值。

若要解決這些情況,我們會進行下列變更:

  • additive_expression 完全由 interpolated_string_expressions 組成,並且只使用 + 運算符時,針對轉換和多載解析會被視為 interpolated_string_literal。 最後一個插補字串是由邏輯上串連所有個別 interpolated_string_expression 元件,由左至右建立。
  • cast_expression 或帶有運算符 asrelational_expression,其操作數為 interpolated_string_expressions,在進行轉換和多載解析時被視為 interpolated_string_expressions

公開問題

我們要這麼做嗎? 例如,我們不會針對 System.FormattableString執行這項操作,但可以分成不同的行,而這可能會與內容相關,因此無法分成不同的行。 FormattableStringIFormattable也沒有任何多載解決考慮。

回應

我們認為這是加法表達式的有效使用案例,但改編版本目前尚不夠吸引人。 我們稍後可以視需要新增它。 此規格已更新以反映此決定。

其他使用案例

如需使用此模式的建議處理程式 API 範例,請參閱 https://github.com/dotnet/runtime/issues/50635