Il confronto tra stringhe è una delle operazioni più comuni in qualsiasi applicazione .NET, eppure è anche una delle fonti più insidiose di bug difficili da riprodurre — specialmente quando l’applicazione viene eseguita in ambienti con culture diverse o in pipeline CI/CD con impostazioni locali variabili. In questo articolo vediamo come funzionano correttamente string.Equals(), OrdinalIgnoreCase, StringComparer e come evitare le trappole più comuni legate alla cultura.
L’operatore == e il confronto ordinale
L’operatore == su stringhe esegue un confronto ordinale case-sensitive, basato sui valori Unicode dei caratteri, senza alcuna considerazione culturale:
var a = "Hello";
var b = "hello";
Console.WriteLine(a == b); // False
Console.WriteLine(a == "Hello"); // True
Questo è corretto e prevedibile per confronti interni al codice (chiavi di dizionari, nomi di variabili, costanti). Il problema nasce quando si vuole un confronto case-insensitive, o quando si confrontano stringhe con caratteri soggetti a regole culturali.
string.Equals() con StringComparison
La regola d’oro è: passare sempre esplicitamente un parametro StringComparison. Senza di esso, alcuni overload usano CurrentCulture, creando comportamenti potenzialmente incoerenti tra ambienti diversi.
// Confronto case-insensitive, senza dipendenze culturali
bool uguale = string.Equals("admin", input, StringComparison.OrdinalIgnoreCase);
// Equivalente con metodo d'istanza
bool ancheUguale = "admin".Equals(input, StringComparison.OrdinalIgnoreCase);
Panoramica dei valori di StringComparison
| Valore | Case | Cultura | Uso consigliato |
|---|---|---|---|
Ordinal |
Sensibile | Nessuna | File, chiavi, dati binari |
OrdinalIgnoreCase |
Insensibile | Nessuna | Comandi, URL, identificatori |
InvariantCulture |
Sensibile | Invariante | Testo serializzato/persistito |
InvariantCultureIgnoreCase |
Insensibile | Invariante | Testo serializzato, case-indipendente |
CurrentCulture |
Sensibile | Locale utente | Testo mostrato all’utente |
CurrentCultureIgnoreCase |
Insensibile | Locale utente | Ricerca/filtro lato UI |
La trappola di ToLower() e ToUpper()
Uno degli antipattern più diffusi è usare ToLower() per normalizzare le stringhe prima del confronto:
// Antipattern: alloca una nuova stringa inutilmente
if (input.ToLower() == "admin") { }
if (input.ToLowerInvariant() == "admin") { }
// Corretto: nessuna allocazione, semantica esplicita
if (string.Equals(input, "admin", StringComparison.OrdinalIgnoreCase)) { }
Il problema non è solo di prestazioni (allocazione di una stringa temporanea), ma di correttezza semantica. La versione con ToLower() dipende dalla cultura corrente del thread, mentre OrdinalIgnoreCase è culturalmente neutro e deterministico.
Il problema della “i” Turca
Questo è forse il bug più famoso legato alla cultura nelle stringhe. In turco esistono quattro varianti della lettera i: la “i” minuscola con punto diventa “İ” maiuscola con punto (non “I” come in italiano), e la “ı” minuscola senza punto diventa “I” maiuscola. Il risultato:
var culture = new System.Globalization.CultureInfo("tr-TR");
// Bug su locale turco!
bool sbagliato = "file".ToUpper(culture) == "FILE"; // False! "file" diventa "FİLE" in turco
// OrdinalIgnoreCase usa regole invarianti
bool corretto = string.Equals("file", "FILE", StringComparison.OrdinalIgnoreCase); // True
Questo bug si manifesta tipicamente in applicazioni multi-tenant o globali dove il server ha una cultura diversa dall’ambiente di sviluppo. La soluzione è sempre usare OrdinalIgnoreCase per confronti tecnici (nomi di file, comandi, URL, header HTTP) e riservare CurrentCulture solo al testo destinato all’utente finale.
StringComparer per collezioni
StringComparer implementa sia IComparer<string> che IEqualityComparer<string>, rendendolo ideale per strutture dati come Dictionary, HashSet e SortedSet:
Dizionario case-insensitive per gli header HTTP
// "Content-Type" e "content-type" devono essere equivalenti
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
headers["Content-Type"] = "application/json";
Console.WriteLine(headers["content-type"]); // application/json
Console.WriteLine(headers["CONTENT-TYPE"]); // application/json
SortedSet case-insensitive
var comandi = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
comandi.Add("Start");
comandi.Add("stop");
bool haStart = comandi.Contains("START"); // True
// I duplicati vengono rilevati correttamente
comandi.Add("START"); // Non aggiunge, esiste già come "Start"
Console.WriteLine(comandi.Count); // 2
Confronto ad alte prestazioni con Span<char>
Per scenari con requisiti di performance elevati (parsing di protocolli, hot paths), .NET offre confronti allocation-free tramite ReadOnlySpan<char>:
var riga = "Content-Type: application/json";
// Confronto senza allocare nuove stringhe
bool isContentType = riga.AsSpan(0, 12).Equals(
"Content-Type".AsSpan(),
StringComparison.OrdinalIgnoreCase);
// StartsWith su Span
bool isHttps = url.AsSpan().StartsWith(
"https://".AsSpan(),
StringComparison.OrdinalIgnoreCase);
Questo approccio è particolarmente utile in middleware HTTP, parser di configurazione e codice che elabora grandi volumi di testo.
Pattern Matching con Switch
Il pattern matching di C# non supporta nativamente il confronto case-insensitive negli switch, ma esistono due approcci corretti:
// Approccio 1: Guard clause con Equals
var risultato = comando switch
{
_ when string.Equals(comando, "start", StringComparison.OrdinalIgnoreCase) => "Avvio...",
_ when string.Equals(comando, "stop", StringComparison.OrdinalIgnoreCase) => "Arresto...",
_ => "Comando non riconosciuto"
};
// Approccio 2: Normalizzazione con ToUpperInvariant (accettabile per switch)
var risultato2 = comando.ToUpperInvariant() switch
{
"START" => "Avvio...",
"STOP" => "Arresto...",
_ => "Comando non riconosciuto"
};
Esempio completo: parser di configurazione
public sealed class ConfigParser
{
private readonly FrozenDictionary<string, string> _impostazioni;
public ConfigParser(IEnumerable<KeyValuePair<string, string>> rawSettings)
{
// FrozenDictionary è ottimizzato per letture frequenti (immutabile dopo la creazione)
_impostazioni = rawSettings.ToFrozenDictionary(
kvp => kvp.Key,
kvp => kvp.Value,
StringComparer.OrdinalIgnoreCase);
}
public string? Get(string chiave) =>
_impostazioni.TryGetValue(chiave, out var valore) ? valore : null;
}
// Utilizzo
var config = new ConfigParser(new[]
{
new KeyValuePair<string, string>("DatabaseUrl", "Server=..."),
new KeyValuePair<string, string>("MaxConnections", "100"),
});
Console.WriteLine(config.Get("databaseurl")); // "Server=..."
Console.WriteLine(config.Get("MAXCONNECTIONS")); // "100"
Riepilogo: regole pratiche
- Usa
OrdinalIgnoreCaseper chiavi, identificatori, URL, nomi di file, comandi, header HTTP. - Usa
CurrentCulturesolo per testo mostrato all’utente, quando le regole locali sono rilevanti. - Non usare
ToLower()per confronti: alloca inutilmente e dipende dalla cultura. - Specifica sempre
StringComparisonesplicitamente nelle chiamate aEqualseCompare. - Usa
StringComparerquando passi logica di confronto a strutture dati. - Usa
MemoryExtensionssuSpan<char>per hot path ad alta frequenza e senza allocazioni.
Seguendo queste linee guida, si eliminano intere classi di bug difficili da riprodurre e si ottiene codice più robusto, portabile e performante.
Fonte: DevLeader — C# String Comparison: Equals, OrdinalIgnoreCase, StringComparer, and Culture Pitfalls