Skip to content

Proposal: Immutable Types #2543

@stephentoub

Description

@stephentoub

Problem

One of the uses of 'readonly' fields is in defining immutable types, types that once constructed cannot be visibly changed in any way. Such types often require significant diligence to implement, both from the developer and from code reviewers, because beyond 'readonly' there’s little assistance provided by the compiler in ensuring that a type is actually immutable. Additionally, there’s no way in the language (other than in type naming) for a developer to convey that a type was meant to be immutable, which can significantly impact how it’s consumed, e.g. whether a developer can freely share an instance of the type between multiple threads without concern for race conditions.

Consider this type:

public class Person
{
    public Person(string firstName, string lastName, DateTimeOffset birthDay)
    {
        FirstName = firstName;
        LastName = lastName;
        BirthDay = birthDay;
    }

    public string FirstName { get; }
    public string LastName { get; }
    public DateTime BirthDay { get; }

    public string FullName => $"{FirstName} {LastName}";
    public TimeSpan Age => DateTime.UtcNow – BirthDay;
}

Writing this type requires relatively minimal boilerplate. It also happens to be immutable: there are no exposed fields, there are no setters on any properties (the get-only auto-props will be backed by 'readonly' fields), all of the fields are of immutable types, etc. However, there is no way for the developer to actually express the intent to the compiler that an immutable type was desired here and thus get compiler checking to enforce this. At some point in the future, a developer could add a setter not realizing this type was meant to be immutable, and all of a sudden consumers of this type that were expecting full immutability (e.g. they'd avoiding making defensive copies) will now be very surprised:

public class Person
{
    public Person(string firstName, string lastName, DateTimeOffset birthDay)
    {
        FirstName = firstName;
        LastName = lastName;
        BirthDay = birthDay;
    }

    public string FirstName { get; }
    public string LastName { get; }
    public DateTime BirthDay { get; set; } // Oops!

    public string FullName => $"{FirstName} {LastName}";
    public TimeSpan Age => DateTime.UtcNow – BirthDay;
}

Similarly, the class could be augmented with an additional 'readonly' property but of a non-immutable type:

public class Person
{
    public Person(string firstName, string lastName, DateTimeOffset birthDay, Person[] ancestors)
    {
        FirstName = firstName;
        LastName = lastName;
        BirthDay = birthDay;
        Ancestors = ancestors;
    }

    public string FirstName { get; }
    public string LastName { get; }
    public DateTime BirthDay { get; }
    public Person[] Ancestors { get; }; // Oops!

    public string FullName => $"{FirstName} {LastName}";
    public TimeSpan Age => DateTime.UtcNow – BirthDay;
}

And so on. The developer has tried to design an immutable type, but without a way to declare that fact, and without compiler verification of that declaration, it is easy for bugs to slip in.

Solution: Immutable Types

We can introduce the notion of immutable types to C#. A type, either a class or a struct, can be annotated as "immutable":

public immutable class Person
{
    public Person(string firstName, string lastName, DateTimeOffset birthDay)
    {
        FirstName = firstName;
        LastName = lastName;
        BirthDay = birthDay; 
    }

    public string FirstName { get; }
    public string LastName { get; }
    public DateTime BirthDay { get; }

    public string FullName => $"{FirstName} {LastName}";
    public TimeSpan Age => DateTime.UtcNow – BirthDay;
}

When such an annotation is applied, the compiler validates that the type is indeed immutable. All fields are made implicitly readonly (though it’s ok for a developer to explicitly state the ‘readonly’ keyword if desired) and be of immutable types (all of the core types like Int32, Double, TimeSpan, String, and so on in the .NET Framework would be annotated as immutable). Additionally, the constructor of the type would be restricted in what it can do with the 'this' reference, limited only to directly reading and writing fields on the instance, e.g. it can’t call methods on 'this' (which could read the state of the immutable object before it was fully constructed and thus later perceive the immutable type as having changed), and it can’t pass 'this' out to other code (which could similar perceive the object changing). This includes being prohibited from capturing 'this' into an anonymous method in the ctor. A type being 'immutable' doesn't mean that its operations are pure, just that the state within the object can't observably change; an immutable type would still be able to access statics, could still mutate mutable objects passed into its methods, etc.

The 'immutable' keyword would also work as an annotation on generic types.

public immutable struct Tuple<T1, T2>
{
    public Tuple(T1 item1, T2 item2) { Item1 = item1; Item2 = item2; }
    public T1 Item1; // Implicitly readonly
    public T2 Item2; // Implicitly readonly
}

Applying 'immutable' to a type with generic parameters would enforce all of the aforementioned rules, except that the generic type parameters wouldn't be enforced to be immutable: after all, without constraints on the generic type parameters, there’d be no way for the implementation of the open generic to validate that the type parameters are immutable. As such, a generic type annotated as 'immutable' can be used to create both mutable and immutable instances: a generic instantiation is only considered to be immutable if it’s constructed with known immutable types:

void Usage<U>()
{
    Tuple<string, string>         local1; // considered immutable
    Tuple<int, string>            local2; // considered immutable
    Tuple<int, IC>                local3; // considered immutable
    Tupe<Tuple<int, string>, int> local4; // considered immutable
    Tuple<string, U>              local5; // considered mutable
    Tuple<C, C>                   local6; // considered mutable
    Tuple<Tuple<int, C>, int>     local6; // considered mutable
}
immutable class IC { }
class C { }

Such concrete instantiations could be used as fields of other immutable types iff they're immutable. But whether a generic instantiation is considered to be immutable or not has other effects on consumers of the type, for example being able to know that an instance is immutable and thus can be shared between threads freely without concern for race conditions. As such, the IDE should do the leg work for the developer and highlight whether a given generic instantiation is considered to be immutable or mutable (or unknown, in the case of open generics).

However, the immutability question also affects other places where the compiler needs to confirm that a type is in fact immutable. One such place would be with a new immutable generic constraint added to the language (there are conceivably additional places in the future that the language could depend on the immutability of a type). Consider this variation on the tuple type previously shown:

public immutable struct ImmutableTuple<T1, T2>(T1 item1, T2 item2) 
    where T1 : immutable
    where T2 : immutable
{
    public ImmutableTuple(T1 item1, T2 item2) { Item1 = item1; Item2 = item2; }
    public T1 Item1;
    public T2 Item2;
}

The only difference from the previous version (other than a name change for clarity) is that we’ve constrained both generic type parameters to be 'immutable'. With that, the compiler would enforce that all types used in generic instantiations of this type are 'immutable' and satisfy all of the aforementioned constraints.

void Usage<U>()
{
    ImmutableTuple<string, string>         local1; // Ok
    ImmutableTuple<int, string>            local2; // Ok
    ImmutableTuple<int, IC>                local3; // Ok
    ImmutableTupe<Tuple<int, string>, int> local4; // Ok
    ImmutableTuple<string, U>              local5; // Error: ‘U’ is not immutable
    ImmutableTuple<C, C>                   local6; // Error: ‘C’ is not immutable
    ImmutableTuple<Tuple<int, C>, int>     local6; // Error: ‘Tuple<int,C>’ is not immutable
}
immutable class IC { }
class C { }

With such constraints, it’s possible to create deeply immutable types, both non-generic and generic, and to have the compiler help fully validate the immutability.

However, there are times when you may want to cheat, where you want to be able to use the type to satisfy immutable constraints, and potentially have some of the type’s implementation checked for the rules of immutability, but where you need to break the rules in the implementation in a way that’s still observably immutable but not physically so. For example, consider building an ImmutableArray type that wraps an underlying array. As arrays are themselves mutable (code can freely write to an array’s elements), it’s not normally possible to store an array as a field of an immutable type:

public immutable class ImmutableArray<T>
{
    readonly T[] m_array; // Error: The types of fields in immutable types must be immutable}

To work around this, we can resort to unsafe code. Marking an immutable type as 'unsafe' would disable the rule checking for immutability in the entire type and put the onus back on the developer to ensure that the type really is observably immutable, while still allowing the type to be used in places that require immutable types, namely generic immutable constraints. Marking a field as unsafe would disable the rule checking only related to that field, and marking a method as unsafe would disable the rule checking only related to that method. A type that uses unsafe needs to ensure not only that it still puts forth an immutable facade, but that its internal implementation is safe to be used concurrently.

public immutable unsafe struct ImmutableArray<T>
{
    readonly T[] m_array; // Ok, but we’re now responsible again for ensuring immutability

    private ImmutableArray<T>(T[] array) { m_array = array; }

    private ImmutableArray<T>(T[] array, T nextItem) : this(new T[array.Length + 1])
    {
        Array.Copy(array, m_array, array.Length);
        m_array[array.Length] = nextItem;
    }

    public ImmutableArray<T> Add(T item) => new ImmutableArray<T>(m_array, item);

    public T this[int index] => m_array[index];
    public int Length => m_array.Length;
    ...
}

Delegates could also be marked as immutable, and a set of ImmutableAction and ImmutableFunc types would be included in the framework. As with other immutable types, all of the objects reachable from an immutable delegate instance would need to be immutable, which means that an immutable delegate could only bind to methods on immutable types. That in turn means that, when an anonymous method binds to an immutable delegate type, that anonymous method may only capture immutable state. Further, any locals captured into the lambda must either be from a 'readonly' value (#115) or must be captured by value (#117). This ensures that the fields of the display class can be 'readonly' and that the method which created the lambda can’t reassign the captured values after creating the lambda.

public void Run()
{
    readonly int local1 =;
    int local2 =;
    C local3 =;

    ImmutableAction action1 = () => {
        Console.WriteLine(local1.ToString()); // Ok, captured readonly immutable
        Console.WriteLine(local2.ToString()); // Error: ‘local2’ must be captured by value
        Console.WriteLine(local3.ToString()); // Error: ‘local3’ is mutable
    };

    ImmutableAction action2 = [val local2]() => {
        Console.WriteLine(local2.ToString()); // Ok, captured non-readonly immutable by value
        local2 = 0;                           // Error: ‘local2’ is readonly
    };
}

Alternatives

The 'immutable' attribution would be deep, meaning that an instance of an immutable type and all of the types it recursively references in its state would be immutable. In contrast, we could consider a shallow version, with a 'readonly' attribute that could be applied to types. As with 'immutable', this would enforce that all fields were readonly. Unlike 'immutable', it would place no constraints on the types of those fields also being immutable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions