Table of Contents

ValueCollections

Introduction

This package provides immutable collections with value equality:

  • Immutability: Once constructed, the collections cannot be changed anymore. Efficient construction can be done using so called Builders.
  • Value equality (a.k.a. structural equality): Two collections are considered "equal" when they have the same type and the same content.

The combination of these two properties neatly complement C# record types and streamline the implementation of Value Objects (DDD).

In general, the performance and memory usage is equivalent to the regular System.Collections.Generic types. Converting a Builder to an immutable instance is an O(1) operation.

Preface: The examples in this document focus on ValueLists, but the same principles apply equally to ValueSets and ValueDictionaries.

Installation

# Main package:
dotnet add package Badeend.ValueCollections

# Optional JSON (de)serializers:
dotnet add package Badeend.ValueCollections.SystemTextJson
dotnet add package Badeend.ValueCollections.NewtonsoftJson

# Optional EF _Core_ interoperability:
dotnet add package Badeend.ValueCollections.EntityFrameworkCore
# Optional "classic" EF6 interoperability:
dotnet add package Badeend.ValueCollections.EntityFramework

Nuget packages:

  • NuGet Badeend.ValueCollections
  • NuGet Badeend.ValueCollections.SystemTextJson
  • NuGet Badeend.ValueCollections.NewtonsoftJson
  • NuGet Badeend.ValueCollections.EntityFrameworkCore
  • NuGet Badeend.ValueCollections.EntityFramework

Basic example

Standalone:

ValueList<int> a = [1, 2, 3]; // Supports C# 12 collection expressions (even on .NET Framework)
ValueList<int> b = [1, 2, 3];
ValueList<int> c = [1, 2, 3, 4];

Assert(a == b); // Two ValueLists are considered equal when their contents are the same.
Assert(a != c); // Not the same content

// a.Add(42); // Won't compile; ValueLists are immutable.

Within a record:

public record Blog(string Title, ValueList<string> Tags);

var a = new Blog("The Value of Values", ["ddd", "fp"]);
var b = new Blog("The Value of Values", ["ddd", "fp"]);

Assert(a == b); // This would fail if `Tags` was a regular List<T>, ImmutableList<T> or IReadOnlyList<T>.

Constructing new instances

For every immutable ValueCollection type there also exists an accompanying "Builder" type.

var builder = ValueList.CreateBuilder<int>();

foreach (var x in /* complex source */)
{
    if (/* complex logic */)
    {
        builder.Add(x);
    }
    else if (/* even more logic */)
    {
        builder.Insert(0, x);
    }
    else
    {
        builder.RemoveAll(x);
    }
}

var newList = builder.Build();

When constructing ValueCollections, it is generally recommended to use their Builders over e.g. .NET's regular List<T>s. The builders are able to take advantage of the immutability of its results and avoid unnecessary copying. Whereas calling .ToValueList() on a regular List<T> will always perform a full copy.

Building fluently

Many builder methods return this, allowing you to chain multiple operations in a single expression.

ValueList<int> existingList = ...;

var newList = existingList.ToBuilder()
    .Add(4)
    .Add(5)
    .Add(6)
    .Remove(4)
    .Build();

In the specific case of Remove: that also exists as TryRemove which returns a boolean instead of the Builder.

Boring interface

Being a 100% drop-in replacement for System.Collections.Generic is not a goal for this project. Nonetheless, the interface should still feel very familiar:

ValueList<int> a = ...;
ValueList<int>.Builder b = ...;

/* Reading: */
_ = a.Count;
_ = b.Count;
_ = a[2];
_ = b[2];
_ = a.Contains(42);
_ = b.Contains(42);
_ = a.IndexOf(42);
_ = b.IndexOf(42);
_ = a.LastIndexOf(42);
_ = b.LastIndexOf(42);
_ = a.ToArray();
_ = b.ToArray();
// etc...

/* Writing: */
b[2] = 42;
b.Add(42);
b.AddRange(...);
b.Clear();
b.Insert(42);
b.InsertRange(...);
b.Remove(42);
b.Reverse();
b.Sort();
// etc...

Full API reference

Other notable features & omissions

  • All immutable types are thread safe. (Pretty much by definition, but still.. :) )
  • First-class support for .NET Framework. Even in combination with functionalities not originally present in .NET Framework, such as:
    • Spans
    • C#8 Nullable reference types.
    • C#12 collection expressions.
  • Getting a ReadOnlySpan<T> from a ValueList<T> is a safe operation, whereas doing the same on a List<T> is unsafe.
  • Slicing a ValueList<T> using either the slice syntax (myList[3..4]) or the .Slice methods returns a stack-allocated view into the existing memory allocation. It does not allocate a new copy of the items.
  • The .Keys & .Values properties on ValueDictionary and its Builder return a stack-allocated enumerator, instead of a heap-allocated collection.
  • All methods that operate on a range of items and that have traditionally been designed as various overloads with offset & count parameters, take advantage of slices and spans wherever possible. Examples:
    • .IndexOf(T, int) -> list[start..].IndexOf(T)
    • .IndexOf(T, int, int) -> list[start..end].IndexOf(T)
    • .CopyTo(T[], int, int) -> .CopyTo(Span<T>)
  • Custom IComparer parameters are not supported. All operations use the type's Default(Equality)Comparer.
  • Passes the .NET Runtime's own testsuite wherever possible.

Enumeration order

System.Collections.Generic.HashSet<T> & Dictionary<TKey,TValue> usually preserve the order in which the elements were inserted. Except when they don't...

This behavior is documented, though it is just a tiny side note buried in a huge page:

The order in which the items are returned is undefined.

To raise developer awareness, the ValueSet & ValueDictionary types and their respective Builders deliberately randomize the enumeration order. If your code breaks because of this, feel free to buy me a coffee as I just helped you discover a bug in your code 🙃

Comparison with other collection types

In general, all types below use reference equality. The ValueCollections in this package use structural equality.

Versus System.Collections.Generic.*:

The Generic collections are not immutable (obviously). Aside from that, the ValueCollections should be pretty similar.

Versus IReadOnlyList<T>, IReadOnlySet<T>, IReadOnlyDictionary<TKey,TValue>:

These interfaces offer read-only access but do not guarantee immutability. The underlying data may still be mutable, allowing unintended modifications elsewhere in the system. IReadOnlyList<T> and friends can be upcast back to their mutable source type (e.g., List<T>), undermining expectations of bother consumers and producers. While methods like .AsReadOnly() mitigate this last aspect, they require strict discipline and involves additional heap allocation.

Versus System.Collections.Immutable.*:

.NET's immutable collections also provide "persistence", enabling structural sharing during modifications. This feature, however, introduces significant performance trade-offs, making them suitable mainly for specialized use cases benefiting from persistence.

Versus System.Collections.Frozen.*:

Frozen collections are immutable and optimized for frequent access after a single expensive creation. They are ideal for cases where the collection is created once, potentially at the startup of an application, and is used throughout the remainder of the life of the application. They should not be initialized with untrusted user input and are therefore not general-purpose collection types.