C# の sealed キーワードは「このクラスを継承してはならない」という制約を明示するものです。一見すると機能を制限するだけのネガティブな修飾子に思えますが、実際には クラス設計における積極的な意思表示 であり、保守性・安全性・パフォーマンスに直結する重要な判断です。
本記事では sealed の言語仕様から始め、設計実務における判断基準 と sealed にしないことで生じるリスク を掘り下げます。
sealed の基本
クラスに対する sealed
sealed を付けたクラスは、他のクラスの基底クラスになれません。
public sealed class ConnectionString
{
public string Value { get; }
public ConnectionString(string value)
{
// バリデーションして安全な値だけを保持する
ArgumentException.ThrowIfNullOrWhiteSpace(value);
Value = value;
}
}
// コンパイルエラー: 'SpecialConnectionString': cannot derive from sealed type 'ConnectionString'
// public class SpecialConnectionString : ConnectionString { }
sealed と abstract は共存できません。abstract は「継承して実装を与えることを前提とするクラス」であり、sealed の「継承を禁止する」という意味と矛盾するためです。
メソッド・プロパティに対する sealed
sealed はクラスだけでなく、override したメソッドやプロパティにも付けられます。これにより「このオーバーライドから先は、これ以上のオーバーライドを許さない」と宣言できます。
public class Shape
{
public virtual double Area() => 0;
}
public class Circle : Shape
{
private readonly double _radius;
public Circle(double radius) => _radius = radius;
// ここでオーバーライドチェーンを止める
public sealed override double Area() => Math.PI * _radius * _radius;
}
public class SpecialCircle : Circle
{
// コンパイルエラー: 'SpecialCircle.Area()': cannot override inherited member
// 'Circle.Area()' because it is sealed
// public override double Area() => 0;
}
クラス全体は継承を許しつつ、特定のメソッドだけ振る舞いを確定させたい 場合に使います。
struct は暗黙的に sealed
C# の struct は暗黙的に sealed です。構造体は継承できないため、明示的に sealed を付ける必要はありません(付けるとコンパイルエラーになります)。
public struct Point
{
public double X { get; init; }
public double Y { get; init; }
}
// コンパイルエラー: struct は継承できない
// public struct Point3D : Point { }
なぜ sealed が必要なのか — 設計の視点
言語仕様だけを見ると「継承を禁止する」というシンプルな機能ですが、設計の観点からは多くの意味を持ちます。
1. クラスの契約を守る
クラスが外部に公開する振る舞い(契約)は、継承によって容易に破壊されます。
public class PositiveAmount
{
public decimal Value { get; }
public PositiveAmount(decimal value)
{
if (value <= 0)
throw new ArgumentOutOfRangeException(nameof(value), "Amount must be positive.");
Value = value;
}
}
このクラスは「Value は常に正の値である」という不変条件(invariant)を持ちます。しかし sealed でなければ、次のような派生クラスが作られる可能性があります。
public class HackedAmount : PositiveAmount
{
// new で基底クラスのプロパティを隠す
public new decimal Value { get; set; }
public HackedAmount() : base(1) // バリデーションを通すためにダミー値を渡す
{
Value = -100; // 不変条件を破壊!
}
}
sealed を付けていれば、この攻撃経路は存在しません。「このクラスの不変条件は絶対に壊されない」 と保証できます。
2. 継承のための設計コストを避ける
クラスを「安全に継承可能」にするには大きなコストがかかります。
virtualメソッドの 出力契約(override した側が守るべきルール)を文書化する必要がある- 内部でのメソッド呼び出し順序が、派生クラスから見て安定している必要がある
protectedメンバーは事実上の公開 API になる
前回の記事でも触れた Anders Hejlsberg の Artima インタビューでは、virtual メソッドには「入力契約(呼び出す側のルール)」と「出力契約(override する側のルール)」の 2 面があり、後者の文書化が非常に難しいと語られています。
継承を想定しないクラスに sealed を付けないのは、「ドアに鍵をかけずに開けっ放しにしておく」ようなものです。 意図的に継承を許可する設計をしていないのなら、sealed で閉じるのが安全です。
3. 脆い基底クラス問題の防止
基底クラスにメンバーを追加すると、派生クラスとの間で名前の衝突やシャドウイングが発生するリスクがあります。sealed にしておけば、派生クラスが存在しないため、基底クラスの変更が他のクラスを壊す心配がなくなります。
// ライブラリ v1.0
public class Logger
{
public void Log(string message) { /* ... */ }
}
// ユーザーコード
public class CustomLogger : Logger
{
// v1.0 にはなかった Format() を独自に追加
public string Format(string message) => $"[Custom] {message}";
}
// ライブラリ v2.0 — Format() を追加!
public class Logger
{
public void Log(string message) { /* ... */ }
public virtual string Format(string message) => $"[Default] {message}"; // 衝突!
}
Logger が sealed であれば、この問題は原理的に発生しません。
4. パフォーマンスの向上
sealed クラスのメソッド呼び出しは、JIT コンパイラが デバーチャライゼーション(devirtualization) を適用しやすくなります。
通常、virtual メソッドの呼び出しは vtable(仮想メソッドテーブル)を経由する間接呼び出しになります。しかし sealed クラスは派生クラスが存在しないことが保証されるため、JIT は間接呼び出しを直接呼び出しに変換でき、さらにインライン化の対象にもなります。
.NET のコード分析ルール CA1852: Seal internal types は、まさにこの理由から「アセンブリ外部に公開されておらず、派生クラスもない internal 型は sealed にすべき」と推奨しています。
// CA1852 が推奨する書き方
internal sealed class CacheManager
{
// ...
}
5. テスタビリティの明確化
sealed クラスはモック化できないため、テストの境界が明確になります。「このクラスはモック化する必要がない」「依存する場合はインターフェースを介して注入する」という設計意図を型レベルで伝えられます。
// インターフェースで抽象化し、テスト時にモック可能にする
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body);
}
// 実装クラスは sealed — 継承ではなくインターフェースで差し替える
public sealed class SmtpEmailSender : IEmailSender
{
public async Task SendAsync(string to, string subject, string body)
{
// SMTP で実際に送信する
}
}
sealed にしないことで生まれるリスク
逆の視点から、sealed を付けないことで埋め込まれるリスクを整理します。
リスク 1: 不変条件の破壊
前述の PositiveAmount の例のように、派生クラスが new によるシャドウイングやフィールドの直接操作で、基底クラスの不変条件を壊すことができます。
リスク 2: Liskov Substitution Principle(LSP)違反
派生クラスが基底クラスの振る舞いを予期しない方法で変更すると、「基底クラスの参照を通じて使っても正しく動く」という LSP の前提が崩れます。
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area => Width * Height;
}
public class Square : Rectangle
{
public override int Width
{
get => base.Width;
set { base.Width = value; base.Height = value; }
}
public override int Height
{
get => base.Height;
set { base.Width = value; base.Height = value; }
}
}
Rectangle を期待するコードに Square を渡すと、Width を変更しただけで Height も変わるという予期しない副作用が発生します。Rectangle を sealed にするか、そもそも継承関係を見直す(コンポジションにする)かの判断が必要です。
リスク 3: API の進化を妨げる
一度外部に公開したクラスを継承された状態で、基底クラスに変更を加えるのは非常に困難です。sealed でないクラスは、暗黙的に 「このクラスの全 protected メンバーと virtual メソッドの振る舞いを永続的に保証する」 と宣言しているのと同じです。
リスク 4: セキュリティホール
セキュリティに関わるクラス(認証、暗号化、バリデーションなど)を継承可能にすると、派生クラスがセキュリティチェックをバイパスする実装を提供できてしまいます。
// sealed でないと...
public class PasswordValidator
{
public virtual bool IsValid(string password)
{
return password.Length >= 12 && HasSpecialChar(password);
}
private static bool HasSpecialChar(string s) => /* ... */;
}
// 攻撃者が作る派生クラス
public class WeakValidator : PasswordValidator
{
public override bool IsValid(string password) => true; // 常に OK!
}
sealed を使うべき場面の判断基準
sealed にすべきケース
| 場面 | 理由 |
|---|---|
| 不変条件を持つ値オブジェクト | 派生クラスによる不変条件破壊を防止 |
internal クラスで派生クラスがない |
パフォーマンス向上(CA1852) |
| セキュリティに関わるクラス | バリデーションのバイパスを防止 |
| DTO / POCO | 継承を想定していないデータ転送オブジェクト |
static メンバーのみのユーティリティクラス |
そもそも static class にすべき(CA1052) |
| インターフェースの具象実装 | 差し替えはインターフェース経由で行う設計 |
sealed にしないケース
| 場面 | 理由 |
|---|---|
| フレームワークの拡張ポイント | 派生による拡張を意図的に許可している |
abstract クラス |
定義上、継承されることが前提 |
virtual メソッドを持つ設計済みの基底クラス |
出力契約を文書化し、テスト済みで継承を許容 |
判断に迷ったら
迷ったら sealed にする のが安全なデフォルトです。理由は単純で、sealed を後から外すのは非破壊的変更ですが、sealed を後から付けるのは既存の派生クラスを壊す破壊的変更だからです。
.NET のコード分析ルールとの関係
.NET の Roslyn アナライザーには sealed に関連するルールが用意されています。
| ルール | 内容 |
|---|---|
| CA1852 | アセンブリ内で派生されていない internal 型は sealed にすべき(パフォーマンス) |
| CA1052 | 静的メンバーのみの型は static にすべき(継承を防止) |
特に CA1852 は .NET 7 で導入されたルールで、「internal で、かつ派生クラスが存在しないクラスは sealed にすべき」と明確に推奨しています。public クラスについては自動判定が難しいため対象外ですが、同じ考え方を手動で適用することが推奨されます。
.editorconfig で有効化する例:
[*.cs]
dotnet_diagnostic.CA1852.severity = warning
record と sealed
C# 9.0 で導入された record は、デフォルトでは sealed ではありません。record も継承が可能です。
public record Person(string Name, int Age);
public record Student(string Name, int Age, string StudentId) : Person(Name, Age);
しかし、継承を想定しない record には sealed を付けるべきです。record の値の等価性(equality)は継承階層に影響されるため、意図しない派生クラスが作られると等価性の挙動が変わる可能性があります。
// 継承を想定しない record は sealed に
public sealed record EmailAddress(string Value);
まとめ
sealed は「機能を制限する」ためのキーワードではなく、クラスの設計意図を明示し、契約を守るための積極的なツール です。
- 不変条件の保護: 派生クラスによる契約破壊を防ぐ
- API の安定性: 基底クラスの変更が派生クラスを壊すリスクを排除
- パフォーマンス: JIT のデバーチャライゼーションが効きやすくなる
- 設計意図の伝達: 「このクラスは継承するな」というメッセージを型レベルで表現
「継承のために設計し、文書化し、テストしたクラスだけを継承可能にする。そうでなければ sealed にする」— これが C# における安全なクラス設計の基本方針です。迷ったら sealed を付ける。外す判断は、継承を許可する明確な理由ができたときに行えばよいのです。