NDepend Blog

Improve your .NET code quality with NDepend

Readonly, Immutable, and Frozen Collections in .NET

July 1, 2024 4 minutes read

Readonly Immutable and Frozen Collections in .NET

In modern software development, immutability is a powerful concept. The .NET Base Class Library (BCL) offers readonly, immutable, and frozen collections. This terminology might be confusing, but each term addresses a different scenario where immutability plays a central role:

  • Readonly: Provides a readonly view of a mutable collection.
  • Immutable: Allow thread-safe modifications by creating a new instance for each change.
  • Frozen: Rely on the fact that a set is immutable to offer faster read and search operations.

Readonly, Immutable and Frozen through Example

Let’s illustrate these concepts with this program:

From this program we can infer that:

  • A ReadOnlyList is a readonly view of the original collection it was created from. Therefore, if you update the original list, the ReadOnlyList reflects those changes. However, given a ReadOnlyList reference we cannot mutate the internal state of the list itself.
  • Unlike a readonly list, an immutable list is a new collection itself, not just a view of another collection. Once you hold a reference to a  ImmutableList<T> you know it won’t change once created. However, this class presents modification methods like Add(), Remove(), Replace(), Clear()… that results in a new collection instance.
  • A frozen collection cannot be a list since only FrozenSet<T> and FrozenDictionary<TKey,TValue> are available in the namespace System.Collections.Frozen. Let’s see what is so special with frozen sets.

Why Frozen Sets?

FrozenSet<T> and FrozenDictionary<TKey,TValue> were introduced in .NET 8 to provide faster read access to sets and dictionaries. Indeed, ImmutableSet<T> and ImmutableDictionary<TKey,TValue> internally share memory through AVL trees between instances. This avoids memory waste and speeds up modifications (such as with the Add() method). Compared to good old HashSet<T> and Dictionary<TKey,TValue>, this design makes them faster for change operations but slower for read operations.

This is where frozen sets come into play. They are optimized for scenarios where you are creating an immutable collection optimized for reads and may be willing to spend more time creating the collection. This trade-off is worthwhile because creation happens infrequently, ensuring that read operations are as fast as possible. Frozen sets are suitable for create once, use a lot scenarios.

Frozen sets are faster to enumerate and read

Let’s run this benchmark:

Here are the results:

Method Mean Ratio
DictionaryRead 46.68 us 1.81
ImmutableDictionaryRead 388.04 us 15.09
FrozenDictionaryRead 25.72 us 1.00

Frozen sets optimization for certain keys

If we examine the source code of the ToFrozenSet() method, we see that it analyzes the input collection to select one of several optimized implementations of a frozen set. For example, when using strings as keys with Ordinal or OrdinalIgnoreCase, specific optimizations are applied:

  1. Length-based Hashing: Hashing is based on string length when appropriate.
  2. Key Analysis on Construction: During construction, keys are analyzed to determine if a substring provides sufficient variance. For instance, keys like Item1, Item2Item7 are hashed only on the last character, speeding up the process compared to hashing the entire string.
  3. Deterministic Hashing: Faster hashing methods are used where determinism is safe.
  4. ASCII Optimizations: For case-insensitive keys, faster ASCII-based hashing is sometimes used.
  5. Safe Case Sensitivity: In some cases, even a case-insensitive dictionary can use case-sensitive comparisons if deemed safe. For example, a case-insensitive dictionary with keys ["678", "139", ..., "801"] can safely use case-sensitive hashing.

Here is a benchmark on a set with various strings with different lengths:

Here are the results:

Method Mean Ratio
HashSet_Contains 8.315 ns 1.00
FrozenSet_Contains 6.641 ns 0.80

Immutable set’s thread-safe modifications are faster than on the regular sets

Finally, let’s benchmark that thread-safe modifications on ImmutableSet<T> are indeed faster than recreating a set for each change through the regular HashSet<T> class:

Here are the results:

Method Mean Ratio
HashSetAdds 94.080 ms 1.00
ImmutableHashSetAdds 4.067 ms 0.04

Conclusion

Readonly, immutable, and frozen collections in .NET harness immutability to handle various scenarios.

  • Readonly collections provide an immutable view of a mutable collection. This is useful for example, to ensure that a method won’t modify the collection passed to it.
  • Immutable collections are unmodifiable but allow new instances upon changes. This is similar to string.Replace(x, y), where changes create a new string returned by this method. They are useful for thread-safe modifications.
  • Frozen sets are optimized for create once – use often scenarios. They are usually faster than HashSet<T> and Dictionary<TKey,TValue> in these situations.