Dictionary, ImmutableDictionary, FrozenDictionary : lequel choisir ?

Quand on a besoin d’une collection clé-valeur en C#, le réflexe est quasi universel : on instancie un Dictionary<TKey, TValue> et on passe à la suite. Ce choix est parfaitement légitime dans la grande majorité des cas. Mais il existe une catégorie de situations où ce réflexe nous fait passer à côté de quelque chose : les collections qui, une fois construites, ne changent plus jamais.

Dans ces cas-là, utiliser un Dictionary standard revient à laisser la porte ouverte à des mutations qui ne devraient pas pouvoir arriver, et à se priver de performances de lecture que le runtime pourrait tout à fait nous offrir si on lui indiquait clairement nos intentions.

Le namespace System.Collections.Frozen, introduit avec .NET 8, et le plus ancien System.Collections.Immutable répondent précisément à ce besoin. Mais ils ne répondent pas au même problème, et les confondre ou les utiliser sans discernement peut produire l’effet inverse de celui recherché.

Dans cet article, on laissera de côté l’inventaire exhaustif des APIs pour se concentrer sur ce qui guide vraiment la décision : comprendre quand sortir ces collections, quand s’en abstenir, et pourquoi le choix entre les deux familles a un impact mesurable sur les performances de votre application.

 

Point de départ : des données qui ne bougent plus

Pour rester ancré dans quelque chose de tangible, on va s’appuyer sur un scénario classique : une application qui charge des données de référence au démarrage et les consulte ensuite en permanence, sans jamais les modifier.

Ces données viennent d’une base de données ou d’un fichier de configuration, elles sont stables, et elles seront interrogées à chaque requête entrante.

// Chargement au démarrage, dans le pipeline d'initialisation
var characterPowers = new Dictionary<string, string>
{
    { "Elsa", "Ice manipulation" },
    { "Anna", "True love" },
    { "Olaf", "Snowman resilience" },
    { "Kristoff", "Ice harvesting" },
    { "Sven", "Reindeer strength" }
};

var validRoles = new HashSet<string> { "Admin", "User", "Moderator" };

Construction unique, lectures très fréquentes, aucune modification attendue après l’initialisation. C’est exactement le scénario pour lequel les frozen collections ont été conçues, et c’est sur cette base qu’on va évaluer les différentes options.

 

Panorama des types : ce qu’il faut vraiment retenir

Trois familles de types sont à connaître, et elles ne répondent pas au même besoin.

 

ReadOnlyDictionary<TKey, TValue>

Un simple wrapper autour d’un dictionnaire existant, qui en expose une vue en lecture seule. Contrairement à ce que son nom pourrait laisser penser, il n’offre aucune garantie d’immutabilité réelle. Attention toutefois : si quelqu’un conserve une référence au dictionnaire original, les modifications resteront visibles à travers le wrapper.

var source = new Dictionary<string, string>
{
    { "Elsa", "Ice manipulation" }
};

var readOnly = new ReadOnlyDictionary<string, string>(source);

// Ceci compile et s'exécute sans erreur
source["Elsa"] = "Something else";

// Affiche "Something else"
Console.WriteLine(readOnly["Elsa"]);

C’est utile pour protéger une collection à la frontière d’une API, pour signaler à un appelant qu’il ne doit pas modifier la collection. Mais ce n’est qu’une convention, pas une garantie. Si l’immutabilité est une contrainte réelle dans votre contexte, FrozenDictionary ou ImmutableDictionary sont les bons choix.

 

ImmutableDictionary<TKey, TValue> et ses cousins

Le namespace System.Collections.Immutable propose des versions immuables des collections standard : ImmutableListImmutableHashSetImmutableDictionary… Toute opération qui ressemble à une mutation retourne une nouvelle instance avec les changements appliqués, sans toucher à l’original.

C’est adapté aux scénarios où la collection évolue par étapes successives avec une contrainte de thread-safety. En revanche, les performances en lecture sont inférieures à celles d’un Dictionary standard, ce qui en fait un mauvais choix pour des lookups intensifs.

 

FrozenDictionary<TKey, TValue> et FrozenSet<T>

Introduits avec .NET 8, ces types adoptent une philosophie différente : on accepte un coût élevé à la construction en échange de performances de lecture maximales. Le runtime construit une structure de lookup optimisée une fois pour toutes à la création.

Sur notre exemple, la conversion est immédiate :

var frozenPowers = characterPowers.ToFrozenDictionary();
var frozenRoles = validRoles.ToFrozenSet();

À partir de là, toute tentative de modification lèvera une exception.

 

Le vrai trade-off : création vs. lecture

Quand on appelle ToFrozenDictionary(), le runtime analyse l’ensemble des clés et calcule la fonction de hachage la plus efficace possible pour cet ensemble précis. C’est ce qu’on appelle le perfect hashing : en éliminant les collisions à la construction, les lookups n’ont plus à les gérer au moment de l’exécution, ce qui accélère les recherches. Ce travail a un coût à la création, mais en contrepartie les lookups deviennent plus rapides qu’avec un Dictionary standard, à partir d’une certaine taille de collection.

ImmutableDictionary fonctionne différemment : sa structure interne est un arbre équilibré, ce qui facilite les modifications successives, mais pénalise la lecture sur les grandes collections.

 

Comparons l’accès d’une clé-valeur sur ImmutableDictionary et FrozenDictionary, et comparons avec un Dictionary. Le code du benchmark est disponible sur ce repo GitHub.

Type 10 entrées 100 entrées 1000 entrées
Dictionary 3.9 ns 8.8 ns 7.0 ns
ImmutableDictionary 6.6 ns 5.2 ns 10.1 ns
FrozenDictionary 5.0 ns 5.9 ns 5.5 ns

Ce qui ressort : FrozenDictionary a un comportement remarquablement stable quelle que soit la taille de la collection, là où Dictionary et ImmutableDictionary sont plus variables. Le gain de FrozenDictionary devient visible à partir d’une centaine d’entrées, ce qui correspond aux cas d’usage typiques : tables de configuration, mappings de référence, données chargées au démarrage.

 

La grille de décision

Voici comment trancher dans les cas courants.

La collection est chargée une fois au démarrage et ne change jamais : c’est le cas idéal pour FrozenDictionary ou FrozenSet. Configuration applicative, tables de référence, mappings statiques, feature flags : tous ces scénarios correspondent exactement à ce pour quoi ces types ont été conçus.

La collection évolue par modifications successives avec une contrainte de thread-safety : ImmutableDictionary est ici le bon choix. Chaque modification produit une nouvelle instance sans affecter les autres threads qui lisent l’ancienne. C’est particulièrement adapté aux pipelines fonctionnels ou aux structures partagées entre plusieurs threads qui évoluent au fil du temps.

On veut juste empêcher la modification depuis l’extérieur d’un composant : ReadOnlyDictionary suffit. Pas besoin d’aller chercher plus loin si la seule contrainte est d’exposer une collection en lecture seule à travers une interface.

Dans tous les autres cas : Dictionary standard. Ne pas sur-ingéniérer. Si la collection est locale, de courte durée de vie, ou modifiée régulièrement, aucun de ces types ne vous apportera quoi que ce soit.

Un tableau pour résumer :

Scénario Type recommandé
Données de référence, chargées une fois FrozenDictionary / FrozenSet
Collection partagée entre threads, qui évolue ImmutableDictionary
Exposition en lecture seule d’une collection existante ReadOnlyDictionary
Tout le reste Dictionary

Quand ne pas les utiliser

Les frozen collections ont un cas d’usage précis, et en sortir peut s’avérer contre-productif.

Le premier piège est la taille. Sur une poignée d’entrées, le surcoût de construction n’est pas compensé par le gain en lecture, comme nos benchmarks le montrent. En dessous d’une centaine d’entrées, Dictionary standard reste devant.

Le deuxième piège est la durée de vie. FrozenDictionary est fait pour des collections qui vivent longtemps. Si la collection est reconstruite fréquemment, le coût de création s’accumule et annule tout bénéfice.

Notre second benchmark mesure précisément ce coût : il compare le temps de construction d’un FrozenDictionary et d’un Dictionary standard sur 1000 entrées. Le code est disponible sur ce repo GitHub.

Opération Temps moyen Mémoire allouée
CreateDictionary 16 µs 30 KB
CreateFrozenDictionary 110 µs 96 KB

La construction est donc environ 7 fois plus coûteuse, pour une empreinte mémoire trois fois supérieure. Avec un gain de 1.5 ns par lookup à cette taille, il faut environ 62500 opérations de lecture pour que l’investissement soit rentabilisé.

Un autre point à garder en tête : les gains en lecture de FrozenDictionary sont surtout documentés pour des clés de type string. Le runtime dispose en effet d’implémentations internes spécialisées pour string et dans une moindre mesure pour int. Sur des clés de type Guid ou des types personnalisés, les performances en lecture restent comparables à celles d’un Dictionary standard, avec un surcoût de construction sans contrepartie mesurable.

Enfin, si la collection doit pouvoir être mise à jour sans redémarrage, ni FrozenDictionary ni ImmutableDictionary ne sont adaptés. Un ConcurrentDictionary ou une solution de cache avec invalidation sera plus approprié.

Conclusion : ce que ces types disent de votre code

FrozenDictionary et ses cousins ne sont pas des remplaçants universels du Dictionary standard. Ce sont des outils spécialisés, utiles dans un contexte bien défini : une collection construite une fois, consultée très souvent, et qui ne doit plus jamais changer.

Si vous reconnaissez ce pattern dans votre codebase, l’adoption est simple et la conversion tient en une ligne. Une bonne façon de commencer : parcourir les champs static readonly Dictionary de votre application. Ceux qui sont chargés au démarrage et ne sont jamais modifiés ensuite sont de bons candidats.

Ce qui est intéressant avec ces types, c’est ce qu’ils disent de la direction prise par Microsoft : proposer des types spécialisés selon l’intention du développeur, plutôt que d’optimiser un type généraliste pour tous les cas. FrozenDictionary en est un bon exemple, et cette tendance se retrouve dans d’autres ajouts récents du runtime, comme SearchValues<T> introduit lui aussi en .NET 8 pour optimiser les recherches dans des ensembles de caractères fixes.