前回の記事では sealed をクラスに付けて「継承を禁止する」という設計戦略を取り上げました。しかし sealed はクラスだけのものではありません。virtual メソッドやプロパティの override に sealed を付けることで、「この振る舞いをここで確定させ、これ以上のオーバーライドを禁止する」 という、より細かい粒度の制御が可能です。
本記事では、クラスレベルの sealed と仮想メンバーレベルの sealed override の違いを整理し、C# 10 で追加された record の sealed ToString() も含めて、使い分けの判断基準を解説します。
sealed override は C# 1.0 からの機能
まず歴史的な事実を押さえておきます。仮想メンバーに対する sealed override は C# 1.0(2002 年)から使える機能 です。ECMA-334 仕様書の初版において、sealed 修飾子はメソッド・プロパティ・イベント・インデクサーの override 宣言にも適用できることが明記されています。
「C# 10 で sealed が仮想メンバーに使えるようになった」と誤解されることがありますが、C# 10(2021 年)で追加されたのは record 型の ToString() を sealed override できる機能 に限定されます。通常のクラスでの sealed override は初版から存在する基本機能です。
| バージョン | sealed に関する変更 |
|---|---|
| C# 1.0(2002 年) | クラスの sealed、メソッド・プロパティの sealed override |
| C# 9.0(2020 年) | record 型の導入(ToString() の sealed はまだ不可) |
| C# 10.0(2021 年) | record 型で ToString() を sealed override 可能に |
クラスの sealed と仮想メンバーの sealed override — 何が違うのか
クラスの sealed: 継承そのものを禁止する
public sealed class ConnectionString
{
public string Value { get; }
public ConnectionString(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
Value = value;
}
}
// コンパイルエラー: sealed 型からは派生できない
// public class SpecialConnectionString : ConnectionString { }
クラス全体を封印 するため、派生クラスは一切作れません。クラスが持つすべてのメソッド・プロパティの振る舞いが確定します。
仮想メンバーの sealed override: 継承は許可しつつ特定の振る舞いだけを確定させる
public abstract class Shape
{
public abstract double Area();
public virtual string Description => GetType().Name;
}
public class Circle : Shape
{
private readonly double _radius;
public Circle(double radius) => _radius = radius;
// Area() はここで確定。これ以上の override を禁止する
public sealed override double Area() => Math.PI * _radius * _radius;
// Description は sealed にしていないので、派生クラスで override 可能
}
public class LabeledCircle : Circle
{
public string Label { get; }
public LabeledCircle(double radius, string label) : base(radius)
{
Label = label;
}
// OK: Description は sealed されていない
public override string Description => $"{Label} (Circle)";
// コンパイルエラー CS0239: Area() は sealed されている
// public override double Area() => 0;
}
この設計では:
Circleを継承すること自体は許可されている- しかし
Area()の計算ロジックだけはCircleで確定しており、派生クラスが変更できない Descriptionは引き続きオーバーライド可能
「クラスの拡張は許すが、特定の振る舞いだけは固定したい」 という場面で威力を発揮します。
比較表
| 特性 | sealed class |
sealed override メンバー |
|---|---|---|
| 継承 | 不可 | 可能 |
| 制御の粒度 | クラス全体 | 個々のメソッド・プロパティ単位 |
| 派生クラスの追加メンバー | 不可能 | 可能 |
| 他の virtual メンバーの override | 不可能 | sealed でないメンバーは override 可能 |
| 主な目的 | クラスの完全な封印 | 特定の振る舞いの確定 |
| 構文 | sealed class Foo { } |
sealed override void Bar() { } |
sealed override の使いどころ
1. 不変条件を守るメソッドの確定
バリデーションロジックやビジネスルールなど、派生クラスによって変更されると不変条件が壊れるメソッド を確定させます。
public abstract class Account
{
public decimal Balance { get; protected set; }
// 出金ルールはサブクラスごとに異なる
public abstract bool CanWithdraw(decimal amount);
// 残高を変更する操作は全アカウント共通の不変条件を守る
public virtual void Withdraw(decimal amount)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount));
if (!CanWithdraw(amount))
throw new InvalidOperationException("Withdrawal not allowed.");
Balance -= amount;
}
}
public class SavingsAccount : Account
{
private const decimal MinimumBalance = 1000m;
public override bool CanWithdraw(decimal amount) =>
Balance - amount >= MinimumBalance;
// Withdraw() のバリデーションロジックを確定
// 派生クラスが残高チェックをスキップする実装を作れないようにする
public sealed override void Withdraw(decimal amount)
{
base.Withdraw(amount);
// 普通預金固有の処理(利息計算のリセットなど)
}
}
public class PremiumSavingsAccount : SavingsAccount
{
// CanWithdraw() は sealed されていないので override 可能
public override bool CanWithdraw(decimal amount) =>
Balance - amount >= 0; // プレミアム口座は残高ゼロまで引き出し可
// コンパイルエラー: Withdraw() は sealed されている
// public override void Withdraw(decimal amount) { Balance -= amount; }
}
SavingsAccount.Withdraw() を sealed にすることで、バリデーションをバイパスする派生クラスの作成を構造的に防止 しています。一方で CanWithdraw() は開放しているため、出金可能条件のカスタマイズは許容しています。
2. Template Method パターンでのフレームワークメソッドの保護
Template Method パターンでは、アルゴリズムの骨格を基底クラスで定義し、個々のステップを派生クラスに委譲します。骨格部分を sealed override することで、アルゴリズムの実行順序が改ざんされないことを保証 できます。
public abstract class DataImporter
{
// アルゴリズムの骨格を定義する Template Method
public virtual void Import()
{
var rawData = ReadSource();
var validated = Validate(rawData);
Save(validated);
}
protected abstract string ReadSource();
protected abstract string Validate(string data);
protected abstract void Save(string data);
}
public class CsvImporter : DataImporter
{
// Import() の実行順序を確定させる
// 派生クラスが Validate() を飛ばすような Import() を作れない
public sealed override void Import()
{
base.Import();
Console.WriteLine("CSV import completed.");
}
protected override string ReadSource() => /* CSV 読み込み */;
protected override string Validate(string data) => /* バリデーション */;
protected override void Save(string data) => /* DB 保存 */;
}
public class SpecialCsvImporter : CsvImporter
{
// 個々のステップは override 可能
protected override string Validate(string data) =>
/* 特殊なバリデーションルール */;
// コンパイルエラー: Import() は sealed されている
// public override void Import() { Save(ReadSource()); } // Validate() をスキップ!
}
3. イベントハンドラーの安全な確定
フレームワークやライブラリが提供する基底クラスで、イベントの発火ロジックを派生クラスが改ざんできないようにします。
public abstract class ObservableEntity
{
public event EventHandler? Changed;
protected virtual void OnChanged()
{
Changed?.Invoke(this, EventArgs.Empty);
}
}
public class AuditableEntity : ObservableEntity
{
public DateTime LastModified { get; private set; }
// OnChanged() を sealed にして、変更通知のロジックを確定
// 派生クラスが通知を握りつぶせないようにする
protected sealed override void OnChanged()
{
LastModified = DateTime.UtcNow;
base.OnChanged(); // イベントを確実に発火
}
}
public class Order : AuditableEntity
{
// OnChanged() は sealed なので override できない
// イベント発火と監査ログの記録が常に保証される
public string Status { get; private set; } = "New";
public void Approve()
{
Status = "Approved";
OnChanged(); // 必ず AuditableEntity.OnChanged() が呼ばれる
}
}
4. Equals / GetHashCode の確定
等価性の実装をクラス階層の途中で確定させ、派生クラスが不整合な等価性定義を持ち込むのを防ぎます。
public class Entity
{
public int Id { get; init; }
public override bool Equals(object? obj) =>
obj is Entity other && Id == other.Id;
public override int GetHashCode() => Id.GetHashCode();
}
public class User : Entity
{
public string Name { get; init; } = "";
// ID ベースの等価性をここで確定
// 派生クラスが Name を含めた等価性に変更して不整合を起こすのを防ぐ
public sealed override bool Equals(object? obj) =>
base.Equals(obj);
public sealed override int GetHashCode() =>
base.GetHashCode();
}
public class AdminUser : User
{
public string Role { get; init; } = "Admin";
// コンパイルエラー: Equals() は sealed されている
// public override bool Equals(object? obj) =>
// base.Equals(obj) && obj is AdminUser other && Role == other.Role;
}
C# 10 の record と sealed ToString()
record の ToString() が持つ特殊な性質
C# 9 で導入された record 型は、コンパイラが自動的に ToString() を生成します。この ToString() は、プロパティ名と値を { Name = value } 形式で表示する便利な機能です。
public record Person(string Name, int Age);
var p = new Person("Alice", 30);
Console.WriteLine(p); // Person { Name = Alice, Age = 30 }
しかし C# 9 では、record 型を継承した派生 record が 常に自分自身の ToString() を上書き してしまいます。基底 record でカスタム ToString() を定義しても、派生 record のコンパイラ生成コードによって上書きされます。
// C# 9: sealed ToString() が使えない
public record Person(string Name, int Age)
{
// カスタム ToString() を定義しても...
public override string ToString() => $"{Name} (age {Age})";
}
public record Employee(string Name, int Age, string Department)
: Person(Name, Age);
var emp = new Employee("Alice", 30, "Engineering");
Console.WriteLine(emp);
// 期待: Alice (age 30)
// 実際: Employee { Name = Alice, Age = 30, Department = Engineering }
// → 派生 record のコンパイラ生成 ToString() に上書きされてしまう
C# 10 で sealed ToString() が可能に
C# 10 では、record 型の ToString() に sealed を付けられるようになりました。これにより、派生 record がコンパイラ生成の ToString() で上書きすることを防止 できます。
// C# 10: sealed ToString() で表示形式を確定
public record Person(string Name, int Age)
{
public sealed override string ToString() => $"{Name} (age {Age})";
}
public record Employee(string Name, int Age, string Department)
: Person(Name, Age);
var emp = new Employee("Alice", 30, "Engineering");
Console.WriteLine(emp);
// 出力: Alice (age 30)
// → sealed により派生 record の自動生成 ToString() が抑制される
補足:
sealed ToString()を付けても、派生 record のPrintMembersメソッドはコンパイラによって引き続き生成されます。PrintMembersはToString()から呼ばれる内部メソッドですが、ToString()自体が sealed で上書きされているため、出力に影響しません。
どんな場面で record の sealed ToString() が有効か
ログ出力のフォーマット統一
public record LogEntry(DateTime Timestamp, string Level, string Message)
{
// ログのフォーマットは階層全体で統一したい
public sealed override string ToString() =>
$"[{Timestamp:yyyy-MM-dd HH:mm:ss}] [{Level}] {Message}";
}
public record DetailedLogEntry(
DateTime Timestamp, string Level, string Message, string Source)
: LogEntry(Timestamp, Level, Message);
var entry = new DetailedLogEntry(DateTime.Now, "ERROR", "Connection failed", "DbModule");
Console.WriteLine(entry);
// 出力: [2026-04-28 12:34:56] [ERROR] Connection failed
// → Source が含まれない統一フォーマット
シリアライズに影響する文字列表現の固定
public record ApiResponse(int StatusCode, string Body)
{
// API レスポンスの文字列表現を固定
// デバッグ時に Body 全体が出力されるのを防ぐ
public sealed override string ToString() =>
$"ApiResponse(Status={StatusCode}, BodyLength={Body.Length})";
}
public record PaginatedApiResponse(int StatusCode, string Body, int Page, int TotalPages)
: ApiResponse(StatusCode, Body);
var response = new PaginatedApiResponse(200, "{...very large json...}", 1, 10);
Console.WriteLine(response);
// 出力: ApiResponse(Status=200, BodyLength=23)
// → 巨大な Body がログに出力されない
クラスの sealed と sealed override — 判断フローチャート
設計時の判断基準を整理します。
このクラスを継承させる予定はあるか?
├── NO → sealed class にする(前回の記事を参照)
└── YES → クラスは sealed にしない
│
派生クラスが変更してはならない振る舞いがあるか?
├── NO → virtual / override のまま(sealed override 不要)
└── YES → 該当メソッドを sealed override にする
│
├── 不変条件を守るバリデーションメソッド
├── Template Method の骨格
├── イベント発火ロジック
├── 等価性の実装(Equals / GetHashCode)
└── record の ToString() フォーマット
パフォーマンスへの影響
sealed override はパフォーマンスにも好影響を与えます。JIT コンパイラは、sealed override されたメソッドが これ以上オーバーライドされないことを保証できる ため、仮想メソッドテーブル(vtable)を経由する間接呼び出しを直接呼び出しに変換(デバーチャライゼーション)できます。
public abstract class Formatter
{
public abstract string Format(string input);
}
public class UpperFormatter : Formatter
{
// sealed override により JIT が直接呼び出しに最適化しやすい
public sealed override string Format(string input) => input.ToUpperInvariant();
}
ホットパス上にある仮想メソッド呼び出しでは、この最適化が測定可能な差を生む場合があります。ただし sealed を付ける主な理由はあくまで 設計の意図の明示 であり、パフォーマンスは副次的な恩恵と捉えるべきです。
sealed override の注意点
sealed override は override と組み合わせる必要がある
sealed 単独ではメソッドに付けられません。必ず override と組み合わせて使います。新規に定義した非 virtual メソッドに sealed を付ける必要はありません(そもそもオーバーライドできないため)。
public class Foo
{
// コンパイルエラー: sealed は override と組み合わせる必要がある
// public sealed void Bar() { }
// OK: そもそも virtual でないのでオーバーライドできない
public void Bar() { }
}
sealed override はテスタビリティに影響する
sealed override されたメソッドは、モックフレームワークによるオーバーライドもできません。テスト時にメソッドの振る舞いを差し替える必要がある場合は、インターフェースを介した設計を検討してください。
// sealed override のメソッドはモック化できない
// → テスト用にはインターフェースで抽象化する
public interface IFormatter
{
string Format(string input);
}
public class UpperFormatter : Formatter, IFormatter
{
public sealed override string Format(string input) => input.ToUpperInvariant();
}
// テスト時は IFormatter をモック化する
まとめ
sealed にはクラスレベルと仮想メンバーレベルの 2 つの適用対象があり、それぞれ異なる設計上の役割を果たします。
| 適用対象 | 効果 | 主な用途 |
|---|---|---|
sealed class |
継承そのものを禁止 | 完全なカプセル化、値オブジェクト、DTO |
sealed override メソッド |
特定の振る舞いだけを確定 | 不変条件の保護、Template Method、イベントロジック |
sealed override ToString()(record) |
record の文字列表現を確定(C# 10) | ログフォーマット統一、デバッグ出力制御 |
- 仮想メンバーの
sealed overrideは C# 1.0 からの機能であり、recordのsealed ToString()は C# 10 で追加された - クラスを
sealedにするか、メンバーをsealed overrideにするかは、「継承自体を禁止したいのか、特定の振る舞いだけを確定させたいのか」 で判断する - 迷ったらまずクラスを
sealedにし、継承を許可する明確な理由ができたときにsealedを外して、確定すべきメソッドにsealed overrideを付ける
前回の記事の「迷ったら sealed にする」という方針と組み合わせることで、クラス階層の設計をより精密に制御 できるようになります。