Closed class hierarchies in C# 15
The release of C# 15 is getting closer and its new features started to show up in the .NET 11 previews. That's a good time for me to take a closer look at what to expect. I'm starting with closed class hierarchies.
Class hierarchies are referring to a base class and the classes derived from it. For example:
public abstract record OpenShape { }
public record OpenSquare(double Length) : OpenShape { }
public record OpenCircle(double Radius) : OpenShape { }
A switch expression can be used to handle each type differently:
var area = shape switch
{
OpenSquare square => Math.Pow(square.Length, 2),
OpenCircle circle => Math.PI * Math.Pow(circle.Radius, 2),
};
Although the code covers all the classes, currently derived from OpenShape, the code above still generates a compiler warning:
Warning CS8509 : The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern
_is not covered.
That's because the compiler cannot be sure that the code won't encounter additional classes derived from OpenShape at runtime. Such a class could be defined in a different assembly and passed into code from the original assembly which was compiled without the knowledge of this new class. Such a class hierarchy is called an open class hierarchy because it's open for adding new classes to it. To fix the warning, a catch-all discard pattern has to be added to the switch expression which will handle any classes that are not handled explicitly:
var area = shape switch
{
OpenSquare square => Math.Pow(square.Length, 2),
OpenCircle circle => Math.PI * Math.Pow(circle.Radius, 2),
_ => throw new NotSupportedException($"{shape.GetType()} is not supported."),
};
Even before C# 15, there is a way to prevent new classes from being derived from the base class in a different assembly, with access modifiers making the base class constructor inaccessible from other assemblies:
- Use
internal, if you want the constructor publicly accessible in the original assembly. - Use
private protected, if you want the constructor to be protected in the original assembly, i.e., only accessible from derived classes.
public abstract record PrivateShape
{
private protected PrivateShape() { }
}
public record PrivateSquare(double Length) : PrivateShape { }
public record PrivateCircle(double Radius) : PrivateShape { }
Trying to derive from PrivateShape in a different assembly will generate the following error:
Error CS7036 : There is no argument given that corresponds to the required parameter 'original' of 'PrivateShape.PrivateShape(PrivateShape)'
This is caused by the fact that there is now no parameterless constructor accessible. The constructor mentioned in the error message is the automatically generated copy constructor for a record.
Although this effectively ensures that no class can be derived from PrivateShape in a different assembly, the compiler will still generate a warning for a switch expression without the discard pattern.
However, C# 15 introduces a new keyword closed which can be used on a base class to disallow deriving from it in a different assembly:
public closed record ClosedShape { }
public record ClosedSquare(double Length) : ClosedShape { }
public record ClosedCircle(double Radius) : ClosedShape { }
When trying to do so, the following error will be generated:
Error CS9382 : 'ClosedRectangle': cannot use a closed type 'ClosedShape' from another assembly as a base type.
A class hierarchy with a closed class as its base is treated by the compiler as a closed class hierarchy, meaning that it is closed for adding new classes. A switch expression over such a class hierarchy doesn't require a discard catch-all pattern. The following block of code will compile without warnings:
var area = shape switch
{
ClosedSquare square => Math.Pow(square.Length, 2),
ClosedCircle circle => Math.PI * Math.Pow(circle.Radius, 2),
};
You might wonder what is the big deal about having or not having to add a discard pattern to a switch expression. After all, it's only a single line of code. But this line of code has an important impact on what will happen when you add another class to the hierarchy, e.g.:
public record ClosedRectangle(double Length, double Width) : ClosedShape { }
If there is a discard pattern in the switch expression, no new warnings will be generated when you add such a class. Only at run time will you notice that an exception is thrown because you forgot to handle the new class. However, without the discard pattern, a warning will be generated when you add a class to the hierarchy, notifying you immediately that you need to properly handle this new class:
Warning CS8509 : The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'ClosedClassHierarchies.CoreLib.ClosedRectangle' is not covered.
You might also argue that you can still extend a closed class hierarchy indirectly by deriving from a derived class instead of the base class:
public record ClosedColoredSquare(Color Color, double Length)
: ClosedSquare(Length) { }
Such a class doesn't affect the exhaustiveness of a switch expression, though. The new class will be handled by the existing ClosedSquare case.
As already mentioned, the feature is still in preview, so it can change before the final release. You can already try it out in its current state with .NET 11 preview 6. To enable it, you have to set the language version to preview in your project:
<PropertyGroup>
<LangVersion>preview</LangVersion>
</PropertyGroup>
You can find a small sample with all the code from this article and more in my GitHub repository. Feel free to clone it and use it as a basis for experimenting with this new feature yourself.
The closed class hierarchies with the closed keywords make the type-based switch expressions safer to use. As long as you don't ignore the warnings in your project you should never again forget to modify an existing switch expression when extending a class hierarchy with a new class.
