在 .NET 5+ 上比較字串時的行為變更

.NET 5 導入了執行階段行為變更,其中,全球化 API 會在所有支援的平台上預設使用 ICU。 舊版 .NET Core 和 .NET Framework 在 Windows 上執行時,則是利用作業系統的國家語言支援 (NLS) 功能,因此有所不同。 如需這些變更的詳細資訊,包括可將此行為變更還原的相容性參數,請參閱 .NET 全球化和 ICU

變更原因

引進這項變更是為了統一 .NET 在所有受支援作業系統中的全球化行為。 應用程式也因此得以一同加入自身的全球化程式庫,而不用依賴作業系統的內建程式庫。 如需詳細資訊,請參閱重大變更通知

行為的差異

如果使用 string.IndexOf(string) 之類的函式,而不呼叫採用 StringComparison 引數的多載,這種情況可能是想執行「序數」搜尋,卻在無意間採取依賴文化特性的特有行為。 由於 NLS 和 ICU 在語言比較子中所採取的邏輯不同,因此 string.IndexOf(string) 等方法的結果可能會傳回非預期的值。

即使在並不預期全球化功能會生效的地方,也有可能會顯露效果。 例如,下列程式碼可能會根據目前的執行階段不同的答案。

const string greeting = "Hel\0lo";
Console.WriteLine($"{greeting.IndexOf("\0")}");

// The snippet prints:
//
// '3' when running on .NET Core 2.x - 3.x (Windows)
// '0' when running on .NET 5 or later (Windows)
// '0' when running on .NET Core 2.x - 3.x or .NET 5 (non-Windows)
// '3' when running on .NET Core 2.x or .NET 5+ (in invariant mode)

string s = "Hello\r\nworld!";
int idx = s.IndexOf("\n");
Console.WriteLine(idx);

// The snippet prints:
//
// '6' when running on .NET Core 3.1
// '-1' when running on .NET 5 or .NET Core 3.1 (non-Windows OS)
// '-1' when running on .NET 5 (Windows 10 May 2019 Update or later)
// '6' when running on .NET 6+ (all Windows and non-Windows OSs)

如需詳細資訊,請參閱全球化 API 在 Windows 上使用 ICU 程式庫

防範非預期的行為

本節提供兩個選項來處理 .NET 5 中的非預期行為變更。

啟用程式碼分析器

程式碼分析器可偵測可能出現的呼叫位置。 為了協助防範任何意外的行為,建議在專案中啟用 .NET 編譯器平台 (Roslyn) 分析器。 當較可能是有意使用序數比較子時,分析器可協助標示出可能不小心使用到語言比較子的程式碼。 下列規則有助於標示這些問題:

預設不會啟用這些特定規則。 若要啟用,並將任何違規情況顯示為建置錯誤,請在專案檔中設定下列屬性:

<PropertyGroup>
  <AnalysisMode>All</AnalysisMode>
  <WarningsAsErrors>$(WarningsAsErrors);CA1307;CA1309;CA1310</WarningsAsErrors>
</PropertyGroup>

下列程式碼片段提供的範例,是會產生相關程式碼分析器警告或錯誤的程式碼。

//
// Potentially incorrect code - answer might vary based on locale.
//
string s = GetString();
// Produces analyzer warning CA1310 for string; CA1307 matches on char ','
int idx = s.IndexOf(",");
Console.WriteLine(idx);

//
// Corrected code - matches the literal substring ",".
//
string s = GetString();
int idx = s.IndexOf(",", StringComparison.Ordinal);
Console.WriteLine(idx);

//
// Corrected code (alternative) - searches for the literal ',' character.
//
string s = GetString();
int idx = s.IndexOf(',');
Console.WriteLine(idx);

同理,將已排序的字串集合具現化,或是排序現有的字串型集合時,請指定明確的比較子。

//
// Potentially incorrect code - behavior might vary based on locale.
//
SortedSet<string> mySet = new SortedSet<string>();
List<string> list = GetListOfStrings();
list.Sort();

//
// Corrected code - uses ordinal sorting; doesn't vary by locale.
//
SortedSet<string> mySet = new SortedSet<string>(StringComparer.Ordinal);
List<string> list = GetListOfStrings();
list.Sort(StringComparer.Ordinal);

還原為 NLS 行為

在 Windows 上執行時,若要將 .NET 5+ 應用程式還原為舊版 NLS 行為,請遵循 .NET 全球化和 ICU 中的步驟。 這是會影響到整個應用程式的相容性參數,而必須在應用層級設定。 個別程式庫無法選擇加入或退出此行為。

提示

強烈建議啟用 CA1307CA1309CA1310 程式碼分析規則,以協助改善程式碼安檢,並探索任何現有的潛在 Bug。 如需詳細資訊,請參閱啟用程式碼分析器

受影響的 API

由於 .NET 5 中的變更,大部分的 .NET 應用程式都不會遇到任何非預期的行為。 不過,基於受影響的 API 數目,以及這些 API 對更大範圍的 .NET 生態系統在根本上的重要性,務必要意識到 .NET 5 可能會導入不必要的行為,或讓已存在於應用程式中的潛在 Bug 浮現。

受影響的 API 包括:

注意

這不是受影響 API 的完整清單。

上述所有 API 所用的「語言」字串搜尋和比較都是預設使用該執行緒的目前文化特性序數與語言搜尋和比較一文中,會指出語言和序數搜尋和比較之間的差異。

因為 ICU 的語言字串比較實作與 NLS 不同,若 Windows 型應用程式從舊版 .NET Core 或 .NET Framework 升級至 .NET 5,還有當應用程式呼叫其中一個受影響的 API 時,這些 API 可能會開始表現出不同的行為。

例外狀況

  • 如果 API 接受明確的 StringComparisonCultureInfo 參數,該參數會覆寫 API 的預設行為。
  • 第一個參數所在的型別 charSystem.String 成員 (例如 String.IndexOf(Char)) 使用序數搜尋,除非呼叫端傳遞指定 CurrentCulture[IgnoreCase]InvariantCulture[IgnoreCase] 的明確 StringComparison 引數。

如需每個 String API 預設行為的詳細分析,請參閱預設搜尋和比較型別一節。

序數與語言搜尋和比較

序數 (也稱為「非語言」) 搜尋和比較會將字串分解成其個別的 char 元素,並執行逐字元 (char-by-char) 搜尋或比較。 例如,在 Ordinal 比較子下,會將字串 "dog""dog" 比對為「相等」,因為兩個字串是由完全相同的字元序列所組成。 不過,在 Ordinal 比較子下,會將字串 "dog""Dog" 比對為「不相等」,因為雙方並非由完全相同的字元序列組成。 也就是說,大寫 'D' 的碼位 U+0044 出現在小寫 'd' 的碼位 U+0064 之前,導致 "Dog" 排序在 "dog" 之前。

OrdinalIgnoreCase 比較子仍會逐字元運作,但會在執行作業時排除大小寫差異。 在 OrdinalIgnoreCase 比較子下,會將字元組合 'd''D' 比對為「相等」,如同字元組合 'á''Á'。 但會將無重音字元 'a' 比對為「不等於」重音字元 'á'

下表提供此情況的一些範例:

字串 1 字串 2 Ordinal 比較 OrdinalIgnoreCase 比較
"dog" "dog" 等於 等於
"dog" "Dog" 不等於 等於
"resume" "résumé" 不等於 不等於

Unicode 也允許字串具有數個不同的記憶體內部表示法。 例如,e-acute (é) 可透過兩種可能的方式表示:

  • 單一常值 'é' 字元 (也寫成 '\u00E9')。
  • 常值無重音 'e' 字元,後面接著結合重音修飾詞字元 '\u0301'

這表示後續的四個字串全都顯示為 "résumé",即使其組成片段不同也一樣。 字串會使用常值 'é' 字元或常值無重音 'e' 字元,加上結合重音修飾詞 '\u0301' 的組合。

  • "r\u00E9sum\u00E9"
  • "r\u00E9sume\u0301"
  • "re\u0301sum\u00E9"
  • "re\u0301sume\u0301"

在序數比較子下,都不會將這些字串比對為彼此相等。 這是因為它們全都包含不同的基礎字元序列,即使呈現在螢幕畫面上時看起來都一樣。

執行 string.IndexOf(..., StringComparison.Ordinal) 作業時,執行階段會尋找確切的子字串相符項目。 結果如下所示。

Console.WriteLine("resume".IndexOf("e", StringComparison.Ordinal)); // prints '1'
Console.WriteLine("r\u00E9sum\u00E9".IndexOf("e", StringComparison.Ordinal)); // prints '-1'
Console.WriteLine("r\u00E9sume\u0301".IndexOf("e", StringComparison.Ordinal)); // prints '5'
Console.WriteLine("re\u0301sum\u00E9".IndexOf("e", StringComparison.Ordinal)); // prints '1'
Console.WriteLine("re\u0301sume\u0301".IndexOf("e", StringComparison.Ordinal)); // prints '1'
Console.WriteLine("resume".IndexOf("E", StringComparison.OrdinalIgnoreCase)); // prints '1'
Console.WriteLine("r\u00E9sum\u00E9".IndexOf("E", StringComparison.OrdinalIgnoreCase)); // prints '-1'
Console.WriteLine("r\u00E9sume\u0301".IndexOf("E", StringComparison.OrdinalIgnoreCase)); // prints '5'
Console.WriteLine("re\u0301sum\u00E9".IndexOf("E", StringComparison.OrdinalIgnoreCase)); // prints '1'
Console.WriteLine("re\u0301sume\u0301".IndexOf("E", StringComparison.OrdinalIgnoreCase)); // prints '1'

目前執行緒的文化特性設定永遠不會影響序數搜尋和比較常式。

語言搜尋和比較常式會將字串分解成「定序元素」,並對這些元素執行搜尋或比較。 字串的字元與其組成定序元素之間不一定是 1:1 對應。 例如,長度為 2 的字串可能只包含單一個定序元素。 當兩個字串以語言感知方式進行比較時,比較子會檢查兩個字串的定序元素是否具有相同的語意意義 (即使字串常值字元不同)。

請再思考一下字串 "résumé" 及其四個不同的表示法, 下表顯示的是分解成定序元素的各種表示法。

String 定序元素
"r\u00E9sum\u00E9" "r" + "\u00E9" + "s" + "u" + "m" + "\u00E9"
"r\u00E9sume\u0301" "r" + "\u00E9" + "s" + "u" + "m" + "e\u0301"
"re\u0301sum\u00E9" "r" + "e\u0301" + "s" + "u" + "m" + "\u00E9"
"re\u0301sume\u0301" "r" + "e\u0301" + "s" + "u" + "m" + "e\u0301"

定序元素會大致對應至讀取器會認為是單一字元或字元叢集的內容。 概念上類似於 grapheme 叢集,但涵蓋的總體範圍較廣。

在語言比較子下,並不需要完全相符。 定序元素會改為根據語意上的意義加以比較。 例如,語言比較子會將子字串 "\u00E9""e\u0301" 視為相等,因為兩者在語意上都意指「具有尖重音修飾符的小寫 e」。如此,IndexOf 方法若要比對子字串 "e\u0301",便能在包含語意對等子字串 "\u00E9" 的較大字串內比對出來,如下列程式碼範例所示。

Console.WriteLine("r\u00E9sum\u00E9".IndexOf("e")); // prints '-1' (not found)
Console.WriteLine("r\u00E9sum\u00E9".IndexOf("\u00E9")); // prints '1'
Console.WriteLine("\u00E9".IndexOf("e\u0301")); // prints '0'

因此如果使用語言比較,可能會將不同長度的兩個字串比對為相等。 呼叫端應該小心處理這類案例中需應對字串長度的特殊案例邏輯。

「文化特性感知」搜尋和比較常式是語言搜尋和比較常式的特殊形式。 在文化特性感知比較子下,定序元素的概念會擴大到納入指定文化特性特有的資訊。

例如,在匈牙利文字母中,當兩個字元 <dz> 接續出現時,會將之視為自身專屬的字母,與 <d> 或 <z> 不同。 這表示在字串中看到 <dz> 時,匈牙利文化特性感知的比較子會將其視為單一定序元素。

String 定序元素 備註
"endz" "e" + "n" + "d" + "z" (使用標準語言比較子)
"endz" "e" + "n" + "dz" (使用匈牙利文化特性感知比較子)

使用匈牙利文文化特性感知比較子時,這表示字串 "endz" 不會以子字串 "z" 結尾,因為會將 <dz> 和 <z> 視為具有不同語意含義的定序元素。

// Set thread culture to Hungarian
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("hu-HU");
Console.WriteLine("endz".EndsWith("z")); // Prints 'False'

// Set thread culture to invariant culture
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine("endz".EndsWith("z")); // Prints 'True'

注意

  • 行為:語言和文化特性感知比較子可以隨時調整行為。 ICU 和舊版 Windows NLS 功能都會更新,以將各種世界語言的演變納入考量。 如需詳細資訊,請參閱部落格文章地區設定 (文化特性) 資料變換 (英文)。 序數比較子的行為永遠不會改變,因為它會執行準確的位元搜尋和比較。 不過,OrdinalIgnoreCase 比較子的行為可能會隨著 Unicode 擴增而變更,以納入更多字元集,並更正現有大小寫資料中的缺漏。
  • 使用方式:比較子 StringComparison.InvariantCultureStringComparison.InvariantCultureIgnoreCase 是不感知文化特性的語言比較子。 也就是說,這些比較子可瞭解某些概念,例如重音字元 é 具有多種可能的基礎表示法,而且應當將所有這類表示法視為相等。 但是,非文化特性感知的語言比較子不會對異於 <d> 或 <z> 的 <dz> 進行特殊處理,如上所示。 也不會處理特殊大小寫字元,例如德文 Eszett (ß)。

.NET 也提供全球化不區分模式。 這是可供選擇加入的模式,會停用處理語言搜尋和比較常式的程式碼路徑。 在此模式中,所有作業都會使用 Ordinal 或 OrdinalIgnoreCase 行為,不論呼叫端提供何種 CultureInfoStringComparison 引數。 如需詳細資訊,請參閱全球化執行階段組態選項.NET 核心全球化不區分模式

如需詳細資訊,請參閱在 .NET 中比較字串的最佳做法

安全性隱含意義

如果您的應用程式使用受影響的 API 進行篩選,建議啟用 CA1307 和 CA1309 程式碼分析規則,以協助找出可能會不小心使用到語言搜尋 (而不是使用序數搜尋) 的位置。 類似下列的程式碼模式可能容易遭受安全性惡意探索。

//
// THIS SAMPLE CODE IS INCORRECT.
// DO NOT USE IT IN PRODUCTION.
//
public bool ContainsHtmlSensitiveCharacters(string input)
{
    if (input.IndexOf("<") >= 0) { return true; }
    if (input.IndexOf("&") >= 0) { return true; }
    return false;
}

由於 string.IndexOf(string) 方法預設會使用語言搜尋,所以字串可能包含常值 '<''&' 字元,以及 string.IndexOf(string) 常式會傳回 -1 以表示找不到搜尋子字串。 程式碼分析規則 CA1307 和 CA1309 會標示這類的呼叫位置,並警示開發人員有潛在問題。

預設搜尋和比較型別

下表列出各種字串和字串型 API 的預設搜尋和比較型別。 如果呼叫端提供明確的 CultureInfoStringComparison 參數,則會優先接受該參數並凌駕任何預設值。

API 預設行為 備註
string.Compare CurrentCulture
string.CompareTo CurrentCulture
string.Contains 序數
string.EndsWith 序數 (第一個參數是 char 時)
string.EndsWith CurrentCulture (第一個參數是 string 時)
string.Equals 序數
string.GetHashCode 序數
string.IndexOf 序數 (第一個參數是 char 時)
string.IndexOf CurrentCulture (第一個參數是 string 時)
string.IndexOfAny 序數
string.LastIndexOf 序數 (第一個參數是 char 時)
string.LastIndexOf CurrentCulture (第一個參數是 string 時)
string.LastIndexOfAny 序數
string.Replace 序數
string.Split 序數
string.StartsWith 序數 (第一個參數是 char 時)
string.StartsWith CurrentCulture (第一個參數是 string 時)
string.ToLower CurrentCulture
string.ToLowerInvariant InvariantCulture
string.ToUpper CurrentCulture
string.ToUpperInvariant InvariantCulture
string.Trim 序數
string.TrimEnd 序數
string.TrimStart 序數
string == string 序數
string != string 序數

有別於 string API,所有 MemoryExtensions API 預設都會執行序數搜尋和比較,但有下列例外狀況。

API 預設行為 備註
MemoryExtensions.ToLower CurrentCulture (傳遞 null CultureInfo 引數時)
MemoryExtensions.ToLowerInvariant InvariantCulture
MemoryExtensions.ToUpper CurrentCulture (傳遞 null CultureInfo 引數時)
MemoryExtensions.ToUpperInvariant InvariantCulture

因此,將程式碼從取用 string 轉換成取用 ReadOnlySpan<char> 時,可能會不小心導入行為變更。 此情況的範例如下:

string str = GetString();
if (str.StartsWith("Hello")) { /* do something */ } // this is a CULTURE-AWARE (linguistic) comparison

ReadOnlySpan<char> span = s.AsSpan();
if (span.StartsWith("Hello")) { /* do something */ } // this is an ORDINAL (non-linguistic) comparison

因應此問題的建議方式,是將明確的 StringComparison 參數傳遞至這些 API。 程式碼分析規則 CA1307 和 CA1309 可協助進行這項作業。

string str = GetString();
if (str.StartsWith("Hello", StringComparison.Ordinal)) { /* do something */ } // ordinal comparison

ReadOnlySpan<char> span = s.AsSpan();
if (span.StartsWith("Hello", StringComparison.Ordinal)) { /* do something */ } // ordinal comparison

另請參閱