在 .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 中的步驟。 這是會影響到整個應用程式的相容性參數,而必須在應用層級設定。 個別程式庫無法選擇加入或退出此行為。
受影響的 API
由於 .NET 5 中的變更,大部分的 .NET 應用程式都不會遇到任何非預期的行為。 不過,基於受影響的 API 數目,以及這些 API 對更大範圍的 .NET 生態系統在根本上的重要性,務必要意識到 .NET 5 可能會導入不必要的行為,或讓已存在於應用程式中的潛在 Bug 浮現。
受影響的 API 包括:
- System.String.Compare
- System.String.EndsWith
- System.String.IndexOf
- System.String.StartsWith
- System.String.ToLower
- System.String.ToLowerInvariant
- System.String.ToUpper
- System.String.ToUpperInvariant
- System.Globalization.TextInfo (大部分的成員)
- System.Globalization.CompareInfo (大部分的成員)
- System.Array.Sort (排序字串陣列時)
- System.Collections.Generic.List<T>.Sort() (清單項目是字串)
- System.Collections.Generic.SortedDictionary<TKey,TValue> (索引鍵是字串)
- System.Collections.Generic.SortedList<TKey,TValue> (索引鍵是字串)
- System.Collections.Generic.SortedSet<T> (集合包含字串)
注意
這不是受影響 API 的完整清單。
上述所有 API 所用的「語言」字串搜尋和比較都是預設使用該執行緒的目前文化特性。 序數與語言搜尋和比較一文中,會指出語言和序數搜尋和比較之間的差異。
因為 ICU 的語言字串比較實作與 NLS 不同,若 Windows 型應用程式從舊版 .NET Core 或 .NET Framework 升級至 .NET 5,還有當應用程式呼叫其中一個受影響的 API 時,這些 API 可能會開始表現出不同的行為。
例外狀況
- 如果 API 接受明確的
StringComparison
或CultureInfo
參數,該參數會覆寫 API 的預設行為。 - 第一個參數所在的型別
char
的System.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.InvariantCulture
和StringComparison.InvariantCultureIgnoreCase
是不感知文化特性的語言比較子。 也就是說,這些比較子可瞭解某些概念,例如重音字元 é 具有多種可能的基礎表示法,而且應當將所有這類表示法視為相等。 但是,非文化特性感知的語言比較子不會對異於 <d> 或 <z> 的 <dz> 進行特殊處理,如上所示。 也不會處理特殊大小寫字元,例如德文 Eszett (ß)。
.NET 也提供全球化不區分模式。 這是可供選擇加入的模式,會停用處理語言搜尋和比較常式的程式碼路徑。 在此模式中,所有作業都會使用 Ordinal 或 OrdinalIgnoreCase 行為,不論呼叫端提供何種 CultureInfo
或 StringComparison
引數。 如需詳細資訊,請參閱全球化執行階段組態選項和 .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 的預設搜尋和比較型別。 如果呼叫端提供明確的 CultureInfo
或 StringComparison
參數,則會優先接受該參數並凌駕任何預設值。
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