.NET 中密封類的性能優勢,你知道幾個?
Intro
最近看到一篇文章 Performance benefits of sealed class in .NET,覺得寫得不錯,翻譯一下,分享給大家。
目前看到的一些類庫中其實很多并沒有考慮使用密封類,如果你的類型是不希望被繼承的,或者不需要被重寫的,那么就應該考慮聲明為密封類,尤其是對于類庫項目的作者來說,這其實是非常值得考慮的一件事情,很多優秀的類庫都會考慮這樣的問題,尤其是 .NET 框架里的一些代碼,大家看開源項目源碼的時候也可以留意一下。
Preface
默認情況下,類是不密封的。這意味著你可以從它們那里繼承。我認為這并不是正確的默認行為。事實上,除非一個類被設計成可以繼承,否則它應該被密封。如果有需要,你仍然可以在以后刪除 sealed 修飾符。除了不是最好的默認值之外,它還會影響性能。
事實上,當一個類被密封時,JIT可以進行一些優化,并稍微提升應用程序的性能。
在 .NET 7 中應該會有一個新的分析器來檢測可以被密封的類。在這篇文章中,我將展示這個 issue https://github.com/dotnet/runtime/issues/49944 中提到的密封類的一些性能優勢。
性能優勢
虛方法調用
當調用虛方法時,實際的方法是在運行時根據對象的實際類型找到的。每個類型都有一個虛擬方法表(vtable),其中包含所有虛擬方法的地址。這些指針在運行時被用來調用適當的方法實現(動態執行)。
如果JIT知道對象的實際類型,它可以跳過vtable,直接調用正確的方法以提高性能。使用密封類型有助于JIT,因為它知道不能有任何派生類。
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() { }
}
方法 | 算術平均值 | 誤差 | 方差 | 中位數 | 比率 | 代碼大小 |
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 |
請注意,當 JIT 可以確定實際類型時,即使類型沒有密封,它也可以使用直接調用。例如,以下兩個片段之間沒有區別:
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
}
對象類型轉換 (is / as)
當對象類型轉換時,CLR 必須在運行時檢查對象的類型。當轉換到一個非密封的類型時,運行時必須檢查層次結構中的所有類型。然而,當轉換到一個密封的類型時,運行時必須只檢查對象的類型,所以它的速度更快。
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 {}
方法 | 平均值 | 誤差 | 方差 | 中位數 |
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
.NET中的數組是支持協變的。這意味著,BaseType[] value = new DerivedType[1] 是有效的。而其他集合則不是這樣的。例如,List value = new List(); 是無效的。
協變會帶來性能上的損失。事實上,JIT在將一個項目分配到數組之前必須檢查對象的類型。當使用密封類型時,JIT可以取消檢查。你可以查看 Jon Skeet 的文章 https://codeblog.jonskeet.uk/2013/06/22/array-covariance-not-just-ugly-but-slow-too/ 來獲得更多關于性能損失的細節。
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 { }
方法平均值誤差方
方法 | 平均值 | 誤差 | 方差 | 中位數 | 比率 |
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 |
數組轉換成 Span
你可以將數組轉換為 Span 或 ReadOnlySpan。出于與前面部分相同的原因,JIT在將數組轉換為 Span 之前必須檢查對象的類型。當使用一個密封的類型時,可以避免檢查并稍微提高性能。
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 { }
方法 | 平均值 | 誤差 | 方差 | 中位數 | 比率 |
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 |
檢測不可達的代碼
當使用密封類型時,編譯器知道一些轉換是無效的。所以,它可以報告警告和錯誤。這可能會減少你的應用程序中的錯誤,同時也會刪除不可到達的代碼。
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 { }
尋找可以被密封的類型
Meziantou.Analyzer 包含一個規則,可以檢查可能被密封的類型。
dotnet add package Meziantou.Analyzer
它應該使用 MA0053 報告任何可以被密封的internal 類型:
你也可以通過編輯 .editorconfig文件指示分析器報告 public類型。
[*.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
你可以使用像 dotnet format 這樣的工具來解決這個問題。
dotnet format analyzers --severity info
注意:在.NET 7中,這應該是 CA1851 的標準靜態分析的一部分 https://github.com/dotnet/roslyn-analyzers/pull/5594
補充說明
所有的基準都是使用以下配置運行的:
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
其他資源
- Why Are So Many Of The Framework Classes Sealed?
- Analyzer Proposal: Seal internal/private types
More
從上面的解釋和基準測試中我們可以看到一些密封類為我們帶來的好處,我們在設計一個類型的時候就應該去考慮這個類型是不是允許被繼承,如果不允許被繼承,則應該考慮將其聲明為 sealed,如果你有嘗試過 Sonar Cloud 這樣的靜態代碼分析工具,你也會發現,有一些 private 的類型如果沒有聲明為 sealed 就會被報告為 Code Smell 一個代碼中的壞味道
除了性能上的好處,首先將一個類型聲明為 sealed 可以實現更好的 API 兼容性,如果從密封類變成一個非密封類不是一個破壞性的變更,但是從一個非密封類變成一個密封類是一個破壞性的變更
希望大家在自己的類庫項目中新建類型的時候會思考一下是否該將其聲明為 sealed,除此之外可以不 public 的類型可以聲明為 internal,不 public 不必要的類型,希望有越來越多更好更高質量的開源項目
原文地址:https://www.meziantou.net/performance-benefits-of-sealed-class.htm