Changements de comportement lors de la comparaison de chaînes sur .NET 5 et les versions plus récentes

.NET 5 introduit un changement de comportement de runtime où les API de globalisation utilisent l’ICU par défaut sur toutes les plateformes prises en charge. Il s’agit d’une différence par rapport aux versions antérieures de .NET Core et de .NET Framework, qui utilisent la fonctionnalité NLS (National Language Support) du système d’exploitation dans un contexte d’exécution sur Windows. Pour plus d’informations sur ces changements, notamment sur les commutateurs de compatibilité qui peuvent rétablir le comportement précédent, consultez Globalisation et ICU dans .NET.

Raison de la modification

Ce changement a été introduit pour unifier le comportement de globalisation de .NET sur tous les systèmes d’exploitation pris en charge. Il permet également aux applications de grouper leurs propres bibliothèques de globalisation plutôt que de dépendre des bibliothèques intégrées du système d’exploitation. Pour plus d’informations, consultez la notification du changement cassant.

Différences de comportement

Si vous utilisez des fonctions comme string.IndexOf(string) sans appeler la surcharge qui prend un argument StringComparison, vous avez certainement l’intention d’effectuer une recherche ordinale, mais au lieu de cela, vous utilisez par inadvertance une dépendance à un comportement spécifique à une culture. Étant donné que NLS et ICU implémentent une logique différente dans leurs comparateurs linguistiques, les résultats de méthodes comme string.IndexOf(string) peuvent retourner des valeurs inattendues.

Cela peut se produire même dans des endroits où vous ne vous attendez pas toujours à ce que des fonctionnalités de globalisation soient actives. Par exemple, le code suivant peut produire une réponse différente en fonction du runtime actuel.

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)

Pour plus d’informations, consultez Les API de globalisation utilisent des bibliothèques ICU sur Windows.

Se protéger d’un comportement inattendu

Cette section fournit deux options pour gérer les changements de comportement inattendus dans .NET 5.

Activer les analyseurs de code

Les analyseurs de code peuvent détecter les sites d’appel potentiellement bogués. Pour éviter la survenue de comportements surprenants, nous vous recommandons d’activer les analyseurs de plateforme de compilateur (Roslyn) .NET dans votre projet. Les analyseurs permettent de signaler le code susceptible d’utiliser par inadvertance un comparateur linguistique alors qu’un comparateur ordinal était certainement prévu. Les règles suivantes devraient vous aider à signaler ces problèmes :

Ces règles spécifiques ne sont pas activées par défaut. Pour les activer et afficher les violations sous forme d’erreurs de build, définissez les propriétés suivantes dans votre fichier projet :

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

L’extrait de code suivant montre des exemples de code qui génère les avertissements ou erreurs pertinents de l’analyseur de code.

//
// 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);

De même, lors de l’instanciation d’une collection triée de chaînes ou lors du tri d’une collection existante basée sur des chaînes, spécifiez un comparateur explicite.

//
// 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);

Revenir aux comportements NLS

Pour rétablir les anciens comportements NLS dans les applications .NET 5 et versions ultérieures qui sont exécutées sur Windows, suivez les étapes décrites dans Globalisation et ICU dans .NET. Ce commutateur de compatibilité à l’échelle de l’application doit être défini au niveau de l’application. Les bibliothèques individuelles ne peuvent pas accepter ou refuser ce comportement.

Conseil

Nous vous recommandons vivement d’activer les règles d’analyse du code CA1307, CA1309 et CA1310 pour améliorer l’hygiène du code et découvrir tout bogue latent existant. Pour plus d’informations, consultez Activer les analyseurs de code.

API affectées

La plupart des applications .NET ne rencontrent pas de comportements inattendus en raison des changements apportés à .NET 5. Toutefois, face au nombre d’API concernées et à l’importance de ces API dans l’écosystème .NET plus large, vous devez savoir que .NET 5 est susceptible d’introduire des comportements indésirables ou d’exposer des bogues latents qui existent déjà dans votre application.

Les API affectées incluent :

Notes

Cette liste des API affectées n’est pas exhaustive.

Toutes les API ci-dessus utilisent la recherche et la comparaison de chaînes linguistiques à l’aide de la culture actuelle du thread, par défaut. Les différences entre la recherche et la comparaison linguistiques et ordinales sont mises en lumière dans Recherche et comparaison ordinales ou linguistiques.

Étant donné que l’ICU implémente les comparaisons de chaînes linguistiques différemment de NLS, les applications Windows qui sont mises à niveau vers .NET 5 à partir d’une version antérieure de .NET Core ou .NET Framework et qui appellent l’une des API concernées peuvent remarquer que les API commencent à présenter des comportements différents.

Exceptions

  • Si une API accepte un paramètre StringComparison ou CultureInfo explicite, ce paramètre remplace le comportement par défaut de l’API.
  • Les membres System.String où le premier paramètre est de type char (par exemple, String.IndexOf(Char)) utilisent la recherche ordinale, sauf si l’appelant passe un argument StringComparison explicite qui spécifie CurrentCulture[IgnoreCase] ou InvariantCulture[IgnoreCase].

Pour une analyse plus détaillée du comportement par défaut de chaque API String, consultez la section Types de recherche et de comparaison par défaut.

Recherche et comparaison ordinales ou linguistiques

La recherche et la comparaison ordinales (également appelées non linguistiques) décomposent une chaîne en éléments char individuels et effectuent une recherche ou une comparaison caractère par caractère. Par exemple, les chaînes "dog" et "dog" sont comparées et considérées égales sous un comparateur Ordinal, car les deux chaînes se composent exactement de la même séquence de caractères. Toutefois, "dog" et "Dog" sont comparées et considérées différentes sous un comparateur Ordinal parce qu’elles ne se composent pas de la même séquence de caractères. Autrement dit, le code de caractère 'D' en majuscule U+0044 arrive avant le code de caractère 'd' en minuscule U+0064, ce qui entraîne le tri de "Dog" avant "dog".

Un comparateur OrdinalIgnoreCase fonctionne toujours caractère par caractère, mais il élimine les différences de casse lors de l’exécution de l’opération. Sous un comparateur OrdinalIgnoreCase, les paires de caractères 'd' et 'D' sont comparées et considérées égales, tout comme les paires de caractères 'á' et 'Á'. Mais le caractère 'a' non accentué est comparé et considéré différent du caractère accentué 'á'.

Le tableau suivant fournit quelques exemples :

Chaîne 1 Chaîne 2 Comparaison Ordinal Comparaison OrdinalIgnoreCase
"dog" "dog" equal equal
"dog" "Dog" différent de equal
"resume" "résumé" différent de différent de

Unicode permet également aux chaînes d’avoir plusieurs représentations différentes en mémoire. Par exemple, un « e » accent aigu (é) peut être représenté de deux manières possibles :

  • Un seul caractère littéral 'é' (également écrit sous la forme '\u00E9').
  • Un caractère littéral 'e' non accentué suivi d’un modificateur de diacritique '\u0301'.

Cela signifie que les quatre chaînes suivantes s’affichent toutes sous la forme "résumé", même si leurs éléments constitutifs sont différents. Les chaînes utilisent une combinaison de caractères littéraux 'é' ou de caractères littéraux 'e' non accentués, plus le modificateur de diacritique '\u0301'.

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

Sous un comparateur ordinal, aucune de ces chaînes n’est égale à une autre. C’est parce qu’elles contiennent toutes des séquences de caractères sous-jacentes différentes, même si lorsqu’elles sont affichées à l’écran, elles sont toutes identiques.

Lors de l’exécution d’une opération string.IndexOf(..., StringComparison.Ordinal), le runtime recherche une correspondance exacte de sous-chaîne. Les résultats sont les suivants.

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'

Les routines de recherche et de comparaison ordinales ne sont jamais affectées par le paramètre de culture actuelle du thread.

Les routines de recherche et de comparaison linguistiques décomposent une chaîne en éléments de classement et effectuent des recherches ou des comparaisons sur ces éléments. Il n’existe pas nécessairement de correspondance 1 à 1 entre les caractères d’une chaîne et ses éléments de classement constitutifs. Par exemple, une chaîne de longueur 2 peut se composer d’un seul élément de classement. Lorsque deux chaînes sont comparées de manière linguistique, le comparateur vérifie si les éléments de classement des deux chaînes ont la même signification sémantique, même si les caractères littéraux de la chaîne sont différents.

Regardez à nouveau la chaîne "résumé" et ses quatre représentations différentes. Le tableau suivant montre chaque représentation décomposée en éléments de classement.

String En tant qu’éléments de classement
"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"

Un élément de classement correspond vaguement à la façon dont les lecteurs se l’imagineraient, à savoir un seul caractère ou un cluster de caractères. D’un point de vue conceptuel, il s’apparente à un cluster de graphèmes, mais englobe un peu plus de choses.

Sous un comparateur linguistique, les correspondances exactes ne sont pas nécessaires. Les éléments de classement sont plutôt comparés en fonction de leur signification sémantique. Par exemple, un comparateur linguistique traite les sous-chaînes "\u00E9" et "e\u0301" comme étant égales, car elles signifient toutes deux sémantiquement « un e minuscule avec un modificateur d’accent aigu ». Cela permet à la méthode IndexOf de mettre en correspondance la sous-chaîne "e\u0301" dans une chaîne plus grande qui contient la sous-chaîne sémantiquement équivalente "\u00E9", comme indiqué dans l’exemple de code suivant.

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'

Par conséquent, deux chaînes de longueurs différentes peuvent être comparées et considérées égales si une comparaison linguistique est utilisée. Les appelants doivent veiller à ne pas utiliser de logique à casse spéciale qui traite de la longueur de chaîne dans de tels scénarios.

Les routines de recherche et de comparaison qui tiennent compte de la culture sont une forme spéciale de routines de recherche et de comparaison linguistiques. Sous un comparateur tenant compte de la culture, le concept d’élément de classement est étendu pour inclure des informations spécifiques à la culture spécifiée.

Par exemple, dans l’alphabet hongrois, lorsque les deux caractères <dz> apparaissent collés, ils sont considérés comme une lettre unique à part entière, distincte de <d> ou de <z>. Cela signifie que lorsque <dz> est vu dans une chaîne, un comparateur hongrois tenant compte de la culture le traite comme un élément de classement unique.

String En tant qu’éléments de classement Notes
"endz" "e" + "n" + "d" + "z" (avec un comparateur linguistique standard)
"endz" "e" + "n" + "dz" (avec un comparateur hongrois tenant compte de la culture)

Lorsque vous utilisez un comparateur hongrois qui tient compte de la culture, cela signifie que la chaîne "endz"ne se termine pas par la sous-chaîne "z", car <dz> et <z> sont considérés comme des éléments de classement avec une signification sémantique différente.

// 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'

Notes

  • Comportement : Les comparateurs linguistiques et qui tiennent compte de la culture peuvent subir des ajustements comportementaux de temps à autre. L’ICU et l’ancienne fonctionnalité NLS de Windows sont mises à jour pour prendre en compte l’évolution des langues du monde entier. Pour plus d’informations, consultez le billet de blog Locale (culture) data churn. Le comportement du comparateur ordinal ne change jamais, car il effectue une recherche et une comparaison exactes au niveau du bit. Toutefois, le comportement du comparateur OrdinalIgnoreCase peut changer à mesure qu’Unicode se développe pour englober davantage de jeux de caractères et qu’il corrige les omissions dans les données de casse existantes.
  • Utilisation : Les comparateurs StringComparison.InvariantCulture et StringComparison.InvariantCultureIgnoreCase sont des comparateurs linguistiques qui ne tiennent pas compte de la culture. Autrement dit, ces comparateurs comprennent les concepts, tels que le caractère accentué « é » qui a plusieurs représentations sous-jacentes possibles, et que toutes ces représentations doivent être traitées comme étant égales. Toutefois, les comparateurs linguistiques qui ne tiennent pas compte de la culture ne contiennent pas de gestion spéciale pour <dz> comme étant différent de <d> ou <z>, comme illustré ci-dessus. Ils n’ont rien non plus pour les caractères spéciaux comme l’Eszett (ß) allemand.

.NET offre également le mode invariant de globalisation. Ce mode d’adhésion désactive les chemins de code qui traitent des routines de recherche et de comparaison linguistiques. Dans ce mode, toutes les opérations utilisent des comportements Ordinal ou OrdinalIgnoreCase, quel que soit l’argument CultureInfo ou StringComparison que l’appelant fournit. Pour plus d’informations, consultez Options de configuration du runtime pour la globalisation et Mode invariant de globalisation .NET Core.

Pour plus d’informations, consultez Bonnes pratiques pour la comparaison de chaînes dans .NET.

Implications en matière de sécurité

Si votre application utilise une API affectée pour le filtrage, nous vous recommandons d’activer les règles d’analyse de code CA1307 et CA1309 qui vous aideront à localiser où une recherche linguistique a pu être utilisée par inadvertance à la place d’une recherche ordinale. Des modèles de code comme les suivants peuvent faire l’objet d’attaques de sécurité.

//
// 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;
}

Étant donné que la méthode string.IndexOf(string) utilise une recherche linguistique par défaut, il est possible qu’une chaîne contienne un caractère littéral '<' ou '&' et que la routine string.IndexOf(string) retourne -1, ce qui indique que la sous-chaîne de recherche est introuvable. Les règles d’analyse de code CA1307 et CA1309 signalent ces sites d’appel et alertent le développeur qu’il y a un problème potentiel.

Types de recherche et de comparaison par défaut

Le tableau suivant liste les types de recherche et de comparaison par défaut pour diverses API de chaîne ou de type chaîne. Si l’appelant fournit un paramètre CultureInfo ou StringComparison explicite, ce paramètre est honoré à la place de tout paramètre par défaut.

API Comportement par défaut Notes
string.Compare CurrentCulture
string.CompareTo CurrentCulture
string.Contains Ordinal
string.EndsWith Ordinal (quand le premier paramètre est un char)
string.EndsWith CurrentCulture (quand le premier paramètre est un string)
string.Equals Ordinal
string.GetHashCode Ordinal
string.IndexOf Ordinal (quand le premier paramètre est un char)
string.IndexOf CurrentCulture (quand le premier paramètre est un string)
string.IndexOfAny Ordinal
string.LastIndexOf Ordinal (quand le premier paramètre est un char)
string.LastIndexOf CurrentCulture (quand le premier paramètre est un string)
string.LastIndexOfAny Ordinal
string.Replace Ordinal
string.Split Ordinal
string.StartsWith Ordinal (quand le premier paramètre est un char)
string.StartsWith CurrentCulture (quand le premier paramètre est un string)
string.ToLower CurrentCulture
string.ToLowerInvariant InvariantCulture
string.ToUpper CurrentCulture
string.ToUpperInvariant InvariantCulture
string.Trim Ordinal
string.TrimEnd Ordinal
string.TrimStart Ordinal
string == string Ordinal
string != string Ordinal

Contrairement aux API string, toutes les API MemoryExtensions effectuent des recherches et des comparaisons ordinales par défaut, avec les exceptions suivantes.

API Comportement par défaut Notes
MemoryExtensions.ToLower CurrentCulture (quand un argument CultureInfo null est passé)
MemoryExtensions.ToLowerInvariant InvariantCulture
MemoryExtensions.ToUpper CurrentCulture (quand un argument CultureInfo null est passé)
MemoryExtensions.ToUpperInvariant InvariantCulture

L’une des conséquences est que lorsque le code est converti en passant de la consommation de string à la consommation de ReadOnlySpan<char>, des changements de comportement peuvent être introduits par inadvertance. En voici un exemple.

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

La méthode recommandée pour résoudre ce problème consiste à passer un paramètre StringComparison explicite à ces API. Les règles d’analyse du code CA1307 et CA1309 peuvent vous y aider.

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

Voir aussi