Performance benefits of sealed class in .NET
By default, classes are not sealed
. This means that you can inherit from them. I think this is not the right default. Indeed, unless a class is designed to be inherited from, it should be sealed
. You can still remove the sealed
modifier later if there is a need. In addition to not be the best default, it has performance implications. Indeed, when a class is sealed
the JIT can apply optimizations and slightly improve the performance of the application.
A new analyzer should be available in .NET 7 to detect classes that can be sealed
. In this post, I'll show some performance benefits of sealed classes mentioned in this issue.
#Performance benefits
##Calling virtual methods
When calling virtual methods, the actual method is found at runtime based on the actual type of the object. Each type has a Virtual Method Table (vtable) which contains the address of all the virtual methods. These pointers are used at runtime to invoke the appropriate method implementations (dynamic dispatch).
If the JIT knows the actual type of the object, it can skip the vtable and call the right method directly to improve performance. Using sealed
types helps the JIT as it knows there cannot be any derived class.
public class SealedBenchmark
{
readonly NonSealedType nonSealedType = new();
readonly SealedType sealedType = new();
[Benchmark(Baseline = true)]
public void NonSealed()
{
// The JIT cannot know the actual type of nonSealedType. Indeed,
// it could have been set to a derived class by another method.
// So, it must use a virtual call to be safe.
nonSealedType.Method();
}
[Benchmark]
public void Sealed()
{
// The JIT is sure sealedType is a SealedType. As the class is sealed,
// it cannot be an instance from a derived type.
// So it can use a direct call which is faster.
sealedType.Method();
}
}
internal class BaseType
{
public virtual void Method() { }
}
internal class NonSealedType : BaseType
{
public override void Method() { }
}
internal sealed class SealedType : BaseType
{
public override void Method() { }
}
Method | Mean | Error | StdDev | Median | Ratio | Code Size |
---|---|---|---|---|---|---|
NonSealed | 0.4465 ns | 0.0276 ns | 0.0258 ns | 0.4437 ns | 1.00 | 18 B |
Sealed | 0.0107 ns | 0.0160 ns | 0.0150 ns | 0.0000 ns | 0.02 | 7 B |
Note that when the JIT can determine the actual type, it can use a direct call even if the type is not sealed
. For instance, there is no difference between the following two snippets:
void NonSealed()
{
var instance = new NonSealedType();
instance.Method(); // The JIT knows `instance` is NonSealedType because it is set
// in the method and never modified, so it uses a direct call
}
void Sealed()
{
var instance = new SealedType();
instance.Method(); // The JIT knows instance is SealedType, so it uses a direct call
}
##Casting objects (is
/ as
)
When casting objects, the runtime must check the type of the object at runtime. When casting to a non-sealed type, the runtime must check for all types in the hierarchy. However, when casting to a sealed type, the runtime must only check the type of the object, so it is faster.
public class SealedBenchmark
{
readonly BaseType baseType = new();
[Benchmark(Baseline = true)]
public bool Is_Sealed() => baseType is SealedType;
[Benchmark]
public bool Is_NonSealed() => baseType is NonSealedType;
}
internal class BaseType {}
internal class NonSealedType : BaseType {}
internal sealed class SealedType : BaseType {}
Method | Mean | Error | StdDev | Ratio |
---|---|---|---|---|
Is_NonSealed | 1.6560 ns | 0.0223 ns | 0.0208 ns | 1.00 |
Is_Sealed | 0.1505 ns | 0.0221 ns | 0.0207 ns | 0.09 |
##Arrays
Arrays in .NET are covariant. That means that BaseType[] value = new DerivedType[1]
is valid. This is not the case for other collections. For instance, List<BaseType> value = new List<DerivedType>();
is not valid.
The covariance comes with performance penalties. Indeed, the JIT must check the type of the object before assigning an item into an array. When using sealed types, the JIT can remove the check. You can check the post from Jon Skeet to get more details about the performance penalties.
public class SealedBenchmark
{
SealedType[] sealedTypeArray = new SealedType[100];
NonSealedType[] nonSealedTypeArray = new NonSealedType[100];
[Benchmark(Baseline = true)]
public void NonSealed()
{
nonSealedTypeArray[0] = new NonSealedType();
}
[Benchmark]
public void Sealed()
{
sealedTypeArray[0] = new SealedType();
}
}
internal class BaseType { }
internal class NonSealedType : BaseType { }
internal sealed class SealedType : BaseType { }
Method | Mean | Error | StdDev | Ratio | Code Size |
---|---|---|---|---|---|
NonSealed | 3.420 ns | 0.0897 ns | 0.0881 ns | 1.00 | 44 B |
Sealed | 2.951 ns | 0.0781 ns | 0.0802 ns | 0.86 | 58 B |
##Converting arrays to Span<T>
You can convert arrays to Span<T>
or ReadOnlySpan<T>
. For the same reasons as the previous section, the JIT must check the type of the object before converting the array to a Span<T>
. When using a sealed type, it can avoid the check and slightly improve the performance.
public class SealedBenchmark
{
SealedType[] sealedTypeArray = new SealedType[100];
NonSealedType[] nonSealedTypeArray = new NonSealedType[100];
[Benchmark(Baseline = true)]
public Span<NonSealedType> NonSealed() => nonSealedTypeArray;
[Benchmark]
public Span<SealedType> Sealed() => sealedTypeArray;
}
public class BaseType {}
public class NonSealedType : BaseType { }
public sealed class SealedType : BaseType { }
Method | Mean | Error | StdDev | Ratio | Code Size |
---|---|---|---|---|---|
NonSealed | 0.0668 ns | 0.0156 ns | 0.0138 ns | 1.00 | 64 B |
Sealed | 0.0307 ns | 0.0209 ns | 0.0185 ns | 0.50 | 35 B |
##Detecting unreachable code
When using a sealed
type, the compiler knows some conversions are not valid. So, it can report warnings and errors. This may reduce errors in your application and also remove unreachable code.
class Sample
{
public void Foo(NonSealedType obj)
{
_ = obj as IMyInterface; // ok because a derived class can implement the interface
}
public void Foo(SealedType obj)
{
_ = obj is IMyInterface; // ⚠️ Warning CS0184
_ = obj as IMyInterface; // ❌ Error CS0039
}
}
public class NonSealedType { }
public sealed class SealedType { }
public interface IMyInterface { }
#Finding types that could be sealed
Meziantou.Analyzer contains a rule that checks for types that could be sealed.
dotnet add package Meziantou.Analyzer
It should report any internal class that could be sealed using MA0053:
You can also instruct the analyzer to report public
classes by editing the .editorconfig
file:
[*.cs]
dotnet_diagnostic.MA0053.severity = suggestion
# Report public classes without inheritors (default: false)
MA0053.public_class_should_be_sealed = true
# Report class without inheritors even if there is virtual members (default: false)
MA0053.class_with_virtual_member_shoud_be_sealed = true
You can use a tool such as dotnet format
to fix the solution:
dotnet format analyzers --severity info
#Additional notes
All benchmarks were run using the following configuration:
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000
AMD Ryzen 7 5800X, 1 CPU, 16 logical and 8 physical cores
.NET SDK=7.0.100-preview.2.22153.17
[Host] : .NET 6.0.3 (6.0.322.12309), X64 RyuJIT
DefaultJob : .NET 6.0.3 (6.0.322.12309), X64 RyuJIT
#Additional resources
Do you have a question or a suggestion about this post? Contact me!