Изменения в поведении при сравнении строк в .NET 5+

.NET 5 представляет изменение поведения среды выполнения, в котором API глобализации используют ICU по умолчанию на всех поддерживаемых платформах. Это отход от более ранних версий .NET Core и от .NET Framework, которые используют функциональные возможности многоязыковой поддержки (NLS) операционной системы при работе в Windows. Дополнительные сведения об этих изменениях, включая параметры совместимости, которые могут отменять изменение поведения, см. в статье Глобализация .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 глобализации, которые используют библиотеки ICU в Windows.

Предотвращение непредвиденного поведения

В этом разделе представлено два варианта решения непредвиденных изменений поведения в .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

Чтобы отменить изменения приложения .NET 5 и более ранних версий NLS при работе в Windows, выполните действия, описанные в разделе глобализации .NET и ICU. Этот переключатель совместимости следует установить на уровне приложения. Индивидуальные библиотеки не могут перенимать это поведение или отказываться от него.

Совет

Настоятельно рекомендуем включить правила анализа кода CA1307, CA1309 и CA1310 для очистки кода и обнаружения скрытых ошибок. Дополнительные сведения см. в разделе Включение анализаторов кода.

Затронутые API

Большинство приложений .NET не будут сталкиваться с непредвиденным поведением из-за изменений в .NET 5. Однако из-за числа затронутых API и того, как базовые эти API относятся к более широкой экосистеме .NET, вы должны знать о потенциале .NET 5 для внедрения нежелательных действий или предоставления скрытых ошибок, которые уже существуют в приложении.

В число затронутых API входят:

Примечание.

Это неполный список затронутых API.

Вышеперечисленные API применяют лингвистический поиск и сравнение строк, используя по умолчанию текущие язык и региональные параметры потока. Различия между лингвистическим и порядковым поиском и сравнением описаны в разделе Противопоставление методов порядкового и лингвистического поиска и сравнения.

Так как ICU реализует лингвистические сравнения строк по-разному от NLS, приложения на основе Windows, которые обновляются до .NET 5 из более ранней версии .NET Core или платформа .NET Framework, и что вызов одного из затронутых API может заметить, что API начинают демонстрировать другое поведение.

Исключения

  • Если API принимает явный параметр StringComparison или CultureInfo, этот параметр переопределяет поведение API по умолчанию.
  • Элементы System.String, в которых параметром является тип char (например, String.IndexOf(Char)), используют порядковый поиск, если вызывающий объект не передает явный аргумент StringComparison, указывающий на CurrentCulture[IgnoreCase] или InvariantCulture[IgnoreCase].

Более подробный анализ стандартного поведения для каждого API String см. в разделе Стандартные типы поиска и сравнения.

Противопоставление методов порядкового и лингвистического поиска и сравнения

Порядковый (или не лингвистический) поиск и сравнение разбивает строку на отдельные элементы char и выполняет поиск или сравнение по каждому типу char. Например, строки "dog" и "dog" в функции сравнения Ordinal определяются как равные, поскольку обе состоят из одной и той же последовательности символов char. Однако сравнение строк "dog" и "Dog" приводит к значению не равно в функции сравнения Ordinal, поскольку последовательности символов char в этих строках отличаются. Это значит, что кодовая точка U+0044 прописной 'D' появляется до кодовой точки U+0064 нижнего регистра 'd', что приводит к сортировке "Dog" перед "dog".

Функция сравнения OrdinalIgnoreCase по-прежнему работает с каждым символом char, однако устраняет различия в регистрах при выполнении операции. В функции сравнения OrdinalIgnoreCase пары символов char 'd' и 'D' будут определены как равные, как и пары символов char 'á' и 'Á'. Однако символ char без диакритического знака 'a' и символ char с диакритическим знаком 'á' при сравнении определяются как не равно.

Некоторые примеры приведены в следующей таблице.

Строка 1 Строка 2 Сравнение Ordinal Сравнение OrdinalIgnoreCase
"dog" "dog" равно равно
"dog" "Dog" не равно равно
"resume" "résumé" не равно не равно

В Юникоде строки также могут иметь несколько различных представлений в памяти. Например, e с акутом (é) можно представить двумя способами:

  • Одиночный литерал 'é' (также пишется как '\u00E9').
  • Литеральный неуправляемый 'e' символ, за которым следует объединение символа модификатора '\u0301'акцента.

Это означает, что следующие четыре строки отображаются как "résumé", даже если их составляющие части отличаются. В строках используется сочетание литералов 'é' или литералов без диакритических знаков 'e' с модификатором объединения диакритических знаков '\u0301'.

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

При использовании порядковой функции сравнения ни одна из этих строк не будет представлена как равная другой. Это связано с тем, что все они содержат разные базовые последовательности символов char, даже если при отображении на экране выглядят одинаково.

При выполнении операции 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é" и ее четыре различные интерпретации еще раз. В следующей таблице показаны все интерпретации, разделенные на элементы параметров сортировки.

Строка Как элементы параметров сортировки
"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"

Элемент параметров сортировки частично соответствует тому, что читатели представляют, думая об одном символе или кластере символов. Он концептуально похож на кластер графем, однако охватывает более крупные группировки.

В лингвистической функции сравнения точные совпадения не требуются. Элементы параметров сортировки сравниваются на основе их семантического значения. Например, лингвистическое сравнение обрабатывает подстроки "\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> в строке венгерский компаратор с учетом языка и региональных параметров обрабатывает его как один элемент сортировки.

Строка Как элементы параметров сортировки Замечания
"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 может изменяться по мере увеличения Юникода для охвата большего количества наборов символов и исправления пропусков в существующих данных регистра.
  • Использование: компраторы и StringComparison.InvariantCultureIgnoreCase являются лингвистическими StringComparison.InvariantCulture сравнениями, не поддерживающими язык и региональные параметры. Это значит, что эти функции сравнения настроены так, что диакритический знак é может иметь несколько возможных базовых интерпретаций и все эти интерпретации должны обрабатываться одинаково. Но неявные лингвистические сравнения не будут содержать специальную обработку для <dz> , отличающейся от <d> или <z>, как показано выше. Они также не поддерживают специальные символы, такие как немецкий Eszett (ß).

.NET также предлагает инвариантный режим глобализации. Этот режим отключает пути кода, которые работают с лингвистическими подпрограммами поиска и сравнения. В этом режиме все операции используют поведение Ordinal или OrdinalIgnoreCase, независимо от того, какой из аргументов, CultureInfo или StringComparison, предоставляется вызывающим объектом. Дополнительные сведения см. в разделе "Параметры конфигурации среды выполнения" для глобализации и инвариантного режима глобализации .NET Core.

Дополнительные сведения см. в статье Рекомендации по сравнению строк в .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 string и string-like. Если вызывающий объект предоставляет явный параметр 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 Порядковый

В отличие от API string, все API MemoryExtensions по умолчанию выполняют порядковый поиск и сравнение со следующими исключениями.

API Поведение по умолчанию Замечания
MemoryExtensions.ToLower CurrentCulture (при передаче нулевого аргумента CultureInfo)
MemoryExtensions.ToLowerInvariant InvariantCulture
MemoryExtensions.ToUpper CurrentCulture (при передаче нулевого аргумента 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

См. также