【C#】抽象クラスと抽象メソッドをやさしく理解するための基礎と実践ポイント
C#の抽象クラスは共通の振る舞いとプロパティを定義しつつ直接インスタンス化できない型です。
抽象メソッドを含めることで派生クラスに実装を強制し、ポリモーフィズムを安全に実現できます。
設計段階でインターフェースよりも基底実装を共有したい時に有効で、abstract
とoverride
キーワードだけ覚えれば導入は容易です。
継承階層で共通ロジックを一度だけ書けるため、保守性やテスト性も向上します。
抽象クラスはフィールドやコンストラクタも持てる点でinterface
と差別化され、状況に応じて使い分けが重要です。
抽象クラスの基本
C#における抽象クラスは、オブジェクト指向プログラミングの設計で非常に重要な役割を果たします。
ここでは、抽象クラスの基本的な特徴や使い方についてわかりやすく解説いたします。
インスタンス化不可の理由
抽象クラスはabstract
キーワードを使って宣言されるクラスで、直接インスタンス化できません。
これは抽象クラスが「未完成の設計図」のような役割を持っているためです。
具体的な動作が定義されていない抽象メソッドを含むことが多く、これらのメソッドは派生クラスで必ず実装しなければなりません。
たとえば、以下のような抽象クラスShape
を考えてみましょう。
public abstract class Shape
{
public abstract double GetArea(); // 面積を計算する抽象メソッド
}
このShape
クラスは面積を計算するメソッドのシグネチャだけを持ち、具体的な計算方法は定義していません。
したがって、Shape
クラス自体をインスタンス化しようとするとコンパイルエラーになります。
// コンパイルエラー: 抽象クラスはインスタンス化できません
Shape shape = new Shape();
抽象クラスはあくまで共通のインターフェースや基本的な機能を定義し、具体的な処理は派生クラスに任せるため、直接インスタンス化できない設計になっています。
抽象クラスが持てるメンバー種別
抽象クラスは、普通のクラスと同様にさまざまなメンバーを持つことができます。
具体的には以下のようなメンバーが定義可能です。
メンバー種別 | 説明 |
---|---|
抽象メソッド | 実装を持たず、派生クラスで必ずオーバーライドが必要なメソッド |
通常のメソッド | 実装を持つメソッド。派生クラスでオーバーライドも可能 |
抽象プロパティ | 実装を持たず、getter/setterの実装を派生クラスに強制するプロパティ |
通常のプロパティ | 実装を持つプロパティ。派生クラスでオーバーライド可能 |
フィールド | データを保持する変数。アクセス修飾子を付けて定義可能 |
コンストラクタ | 抽象クラス自身の初期化処理を記述可能です。派生クラスのコンストラクタから呼び出せる |
イベント | イベントの宣言や実装が可能です。派生クラスでオーバーライドも可能 |
静的メンバー | 静的メソッドや静的フィールドも定義可能です。抽象クラスのインスタンス化とは無関係に利用可能 |
たとえば、抽象クラスに通常のメソッドを定義して共通の処理をまとめつつ、抽象メソッドで派生クラスに固有の処理を強制する設計がよく使われます。
public abstract class Vehicle
{
public void StartEngine()
{
Console.WriteLine("エンジンを始動します。");
}
public abstract void Drive(); // 派生クラスで具体的に実装
}
このように抽象クラスは柔軟にメンバーを持てるため、共通の機能と拡張ポイントをうまく分けて設計できます。
典型的な用途
抽象クラスは、共通の機能をまとめつつ、派生クラスに特定の実装を強制したい場合に使います。
以下のようなシナリオが典型的です。
- 共通の基本機能を提供しつつ、詳細な動作は派生クラスに任せる場合
たとえば、動物を表すAnimal
クラスで「鳴く」動作を抽象メソッドにし、Dog
やCat
で具体的な鳴き声を実装するケースです。
- テンプレートメソッドパターンの実装
抽象クラスで処理の骨組み(テンプレート)を定義し、一部の処理を抽象メソッドとして派生クラスに実装させるパターンです。
これにより処理の流れは共通化しつつ、細部の動作をカスタマイズできます。
- 共通の状態やデータを保持しつつ、動作を派生クラスで差し替えたい場合
抽象クラスにフィールドやプロパティを持たせて状態管理を行い、動作は抽象メソッドで実装を強制します。
- インターフェースよりも多くの共通実装を持たせたい場合
インターフェースはメソッドのシグネチャだけを定義しますが、抽象クラスは共通の実装を持てるため、コードの重複を減らせます。
以下は典型的な抽象クラスの例です。
public abstract class Animal
{
public void Breathe()
{
Console.WriteLine("呼吸しています。");
}
public abstract void MakeSound(); // 派生クラスで必須実装
}
public class Dog : Animal
{
public override void MakeSound()
{
Console.WriteLine("ワンワン");
}
}
public class Cat : Animal
{
public override void MakeSound()
{
Console.WriteLine("ニャーニャー");
}
}
class Program
{
static void Main()
{
Animal dog = new Dog();
dog.Breathe(); // 呼吸しています。
dog.MakeSound(); // ワンワン
Animal cat = new Cat();
cat.Breathe(); // 呼吸しています。
cat.MakeSound(); // ニャーニャー
}
}
呼吸しています。
ワンワン
呼吸しています。
ニャーニャー
この例では、Animal
抽象クラスが共通の呼吸動作を持ちつつ、鳴き声は抽象メソッドで派生クラスに実装を強制しています。
これにより、共通の機能をまとめつつ、動物ごとの固有の動作を柔軟に実装できます。
抽象クラスはこのように、共通の設計をまとめてコードの再利用性を高めると同時に、派生クラスでの実装を強制することで堅牢な設計を実現します。
抽象メソッドの基本
シグネチャと実装の分離
抽象メソッドは、メソッドのシグネチャ(戻り値の型、メソッド名、引数の型や数)だけを定義し、実装は持ちません。
これにより、基底の抽象クラスは「どのようなメソッドが存在するか」を示すだけで、具体的な処理内容は派生クラスに任せることができます。
たとえば、以下のAnimal
クラスのMakeSound
メソッドは抽象メソッドです。
public abstract class Animal
{
public abstract void MakeSound(); // シグネチャのみ定義、実装なし
}
このMakeSound
メソッドは戻り値がvoid
で引数もありませんが、実装は書かれていません。
派生クラスはこのメソッドを必ずオーバーライドして、具体的な鳴き声を実装しなければなりません。
public class Dog : Animal
{
public override void MakeSound()
{
Console.WriteLine("ワンワン");
}
}
このように、抽象メソッドは「何をするか(メソッドの名前や引数)」だけを決めて、「どうやってするか(実装)」は派生クラスに任せる設計を可能にします。
override強制の仕組み
抽象メソッドは派生クラスで必ずoverride
キーワードを使って実装しなければなりません。
これがC#のコンパイラによって強制されるため、抽象メソッドを持つ抽象クラスを継承したクラスは、必ずそのメソッドの具体的な処理を提供しなければコンパイルエラーになります。
public abstract class Animal
{
public abstract void MakeSound();
}
public class Cat : Animal
{
// overrideしないとコンパイルエラーになる
public override void MakeSound()
{
Console.WriteLine("ニャーニャー");
}
}
もし派生クラスで抽象メソッドをオーバーライドしない場合、その派生クラス自体もabstract
として宣言しなければなりません。
つまり、抽象メソッドの実装は必須であり、これにより「未実装のまま使われる」ことを防ぎます。
この仕組みは、基底クラスが「このメソッドは必ず実装してください」という契約を派生クラスに課す役割を果たします。
コンパイラがチェックしてくれるため、実装漏れを防止できるのが大きなメリットです。
ポリモーフィズムとの関係
抽象メソッドはポリモーフィズム(多態性)を実現する重要な要素です。
ポリモーフィズムとは、同じメソッド呼び出しが異なるクラスのオブジェクトで異なる動作をすることを指します。
たとえば、Animal
型の変数にDog
やCat
のインスタンスを代入し、MakeSound
メソッドを呼び出すと、それぞれのクラスで実装された異なる鳴き声が実行されます。
public abstract class Animal
{
public abstract void MakeSound();
}
public class Dog : Animal
{
public override void MakeSound()
{
Console.WriteLine("ワンワン");
}
}
public class Cat : Animal
{
public override void MakeSound()
{
Console.WriteLine("ニャーニャー");
}
}
class Program
{
static void Main()
{
Animal animal1 = new Dog();
Animal animal2 = new Cat();
animal1.MakeSound(); // ワンワン
animal2.MakeSound(); // ニャーニャー
}
}
ワンワン
ニャーニャー
この例では、Animal
型の変数を使っているにもかかわらず、実際に呼び出されるMakeSound
の実装はDog
やCat
のクラスごとに異なります。
これがポリモーフィズムの典型的な動作です。
抽象メソッドは、基底クラスで「このメソッドは必ず派生クラスで実装される」と宣言することで、ポリモーフィズムを安全かつ明確に実現します。
これにより、コードの柔軟性や拡張性が大幅に向上します。
抽象クラスの宣言構文
abstractキーワードの配置
C#で抽象クラスを宣言する際は、クラス名の前にabstract
キーワードを付けます。
abstract
はアクセス修飾子の直後に記述するのが一般的です。
これにより、そのクラスが抽象クラスであることをコンパイラに示します。
基本的な構文は以下の通りです。
[アクセス修飾子] abstract class クラス名
{
// メンバー定義
}
たとえば、Animal
という抽象クラスを宣言する場合は次のようになります。
public abstract class Animal
{
public abstract void MakeSound();
}
ここでpublic
はアクセス修飾子で、abstract
は抽象クラスであることを示しています。
abstract
は必ずclass
キーワードの前に置きます。
逆にclass abstract
のように書くとコンパイルエラーになります。
アクセス修飾子との組み合わせ
抽象クラスは通常のクラスと同様にアクセス修飾子を指定できます。
代表的なアクセス修飾子は以下の通りです。
アクセス修飾子 | 説明 |
---|---|
public | どこからでもアクセス可能 |
internal | 同一アセンブリ内からのみアクセス可能 |
protected | 抽象クラス自体には使えない(クラスに対しては不可) |
private | クラス宣言には使えない |
抽象クラスの宣言においては、public
かinternal
が主に使われます。
protected
やprivate
はクラス宣言には適用できません。
たとえば、internal
修飾子を使うと同一アセンブリ内でのみ利用可能な抽象クラスを作れます。
internal abstract class BaseClass
{
public abstract void DoWork();
}
このようにアクセス修飾子とabstract
は組み合わせて使い、クラスの可視性を制御します。
sealedとの併用制限
sealed
キーワードはクラスの継承を禁止するために使います。
一方、abstract
は継承を前提とした未完成のクラスを示します。
このため、abstract
とsealed
は同時に指定できません。
// コンパイルエラー: abstractクラスはsealedにできない
public abstract sealed class MyClass
{
}
このように、abstract sealed
の組み合わせは矛盾しているためコンパイルエラーになります。
ただし、C# 9.0以降ではrecord
型においてabstract sealed
を使う特殊なケース(静的クラスのような使い方)が存在しますが、通常のクラス宣言では許されません。
まとめると、
abstract
は継承を前提とするため、sealed
と同時に使えないsealed
は継承禁止のため、abstract
と矛盾する
このルールは言語仕様として厳格に守られています。
抽象クラスの宣言では、abstract
キーワードをアクセス修飾子の後に置き、sealed
とは併用しないことが基本です。
これにより、抽象クラスの役割や継承のルールが明確になります。
抽象メソッドの宣言パターン
戻り値型がvoidの場合
戻り値がvoid
の抽象メソッドは、処理を実行するだけで値を返さないメソッドを定義するときに使います。
抽象メソッドは実装を持たないため、シグネチャだけを宣言します。
以下は戻り値がvoid
の抽象メソッドの例です。
public abstract class Logger
{
// ログを出力する抽象メソッド(戻り値なし)
public abstract void Log(string message);
}
public class ConsoleLogger : Logger
{
public override void Log(string message)
{
Console.WriteLine($"ログ: {message}");
}
}
class Program
{
static void Main()
{
Logger logger = new ConsoleLogger();
logger.Log("システムが起動しました。");
}
}
ログ: システムが起動しました。
この例では、Logger
抽象クラスのLog
メソッドは戻り値がvoid
で、派生クラスConsoleLogger
が具体的なログ出力処理を実装しています。
戻り値がないため、単に処理を実行する用途に適しています。
戻り値型が値型・参照型の場合
抽象メソッドは戻り値の型に制限はなく、値型(int
やdouble
など)や参照型(クラスや配列など)を返すことも可能です。
戻り値がある場合は、派生クラスで必ずその型に合った値を返す実装を行います。
以下は戻り値が値型の例です。
public abstract class Shape
{
// 面積を計算して返す抽象メソッド(戻り値はdouble)
public abstract double GetArea();
}
public class Circle : Shape
{
public double Radius { get; set; }
public Circle(double radius)
{
Radius = radius;
}
public override double GetArea()
{
return Math.PI * Radius * Radius;
}
}
class Program
{
static void Main()
{
Shape circle = new Circle(5);
Console.WriteLine($"円の面積: {circle.GetArea():F2}");
}
}
円の面積: 78.54
次に、戻り値が参照型の例です。
public abstract class DataProvider
{
// データを文字列配列で返す抽象メソッド
public abstract string[] GetData();
}
public class SampleDataProvider : DataProvider
{
public override string[] GetData()
{
return new string[] { "データ1", "データ2", "データ3" };
}
}
class Program
{
static void Main()
{
DataProvider provider = new SampleDataProvider();
foreach (var item in provider.GetData())
{
Console.WriteLine(item);
}
}
}
データ1
データ2
データ3
このように、戻り値の型に応じて抽象メソッドを定義し、派生クラスで適切な値を返す実装を行います。
ジェネリックメソッドとして定義する場合
抽象メソッドはジェネリックメソッドとしても定義可能です。
ジェネリックメソッドは型パラメータを持ち、呼び出し時に具体的な型を指定して使います。
これにより、型に依存しない柔軟なメソッド設計が可能です。
以下はジェネリックな抽象メソッドの例です。
public abstract class Repository
{
// ジェネリックな抽象メソッド。型Tのデータを取得する
public abstract T GetById<T>(int id);
}
public class SampleRepository : Repository
{
public override T GetById<T>(int id)
{
// ここでは簡単にデフォルト値を返す例
Console.WriteLine($"ID {id} のデータを取得します。型: {typeof(T).Name}");
return default(T);
}
}
class Program
{
static void Main()
{
Repository repo = new SampleRepository();
var data1 = repo.GetById<string>(1);
var data2 = repo.GetById<int>(2);
}
}
ID 1 のデータを取得します。型: String
ID 2 のデータを取得します。型: Int32
この例では、GetById<T>
がジェネリックな抽象メソッドとして定義され、派生クラスで具体的な処理を実装しています。
呼び出し時に型を指定できるため、さまざまな型のデータ取得に対応可能です。
型制約を追加するケース
ジェネリック抽象メソッドに型制約を付けることで、型パラメータに対して特定の条件を課すことができます。
これにより、型の安全性や利用可能なメンバーを制限できます。
たとえば、T
がclass
(参照型)であることを制約する例です。
public abstract class Repository
{
// Tは参照型でなければならない
public abstract T GetById<T>(int id) where T : class;
}
public class SampleRepository : Repository
{
public override T GetById<T>(int id)
{
Console.WriteLine($"ID {id} の参照型データを取得します。型: {typeof(T).Name}");
return null; // 参照型なのでnullを返せる
}
}
class Program
{
static void Main()
{
Repository repo = new SampleRepository();
var data = repo.GetById<string>(1); // OK
// var value = repo.GetById<int>(2); // コンパイルエラー: intは値型なので制約違反
}
}
ID 1 の参照型データを取得します。型: String
このように、where T : class
の型制約を付けることで、値型のint
などは使えなくなり、参照型のみが許可されます。
その他にも以下のような制約が使えます。
制約キーワード | 説明 |
---|---|
where T : struct | 値型(構造体)でなければならない |
where T : new() | 引数なしコンストラクタを持つ型 |
where T : 基底クラス | 指定したクラスを継承している型 |
where T : インターフェース | 指定したインターフェースを実装している型 |
型制約を活用することで、抽象メソッドのジェネリック設計に安全性や柔軟性を持たせられます。
抽象プロパティ・イベント・インデクサ
抽象プロパティのgetterとsetter
抽象プロパティは、プロパティのシグネチャだけを抽象クラスで定義し、具体的なget
やset
の実装を派生クラスに強制する仕組みです。
abstract
キーワードを使い、get
やset
のどちらか、または両方を抽象メンバーとして宣言できます。
以下は、読み書き可能な抽象プロパティの例です。
public abstract class Person
{
// 名前プロパティのgetterとsetterを抽象メソッドとして定義
public abstract string Name { get; set; }
}
public class Employee : Person
{
private string name;
public override string Name
{
get { return name; }
set { name = value; }
}
}
class Program
{
static void Main()
{
Person employee = new Employee();
employee.Name = "山田太郎";
Console.WriteLine($"名前: {employee.Name}");
}
}
名前: 山田太郎
この例では、Person
抽象クラスでName
プロパティのget
とset
を抽象として宣言し、Employee
クラスで具体的に実装しています。
get
だけやset
だけを抽象にすることも可能です。
public abstract class ReadOnlyData
{
public abstract int Id { get; } // 読み取り専用プロパティ
}
このように抽象プロパティは、プロパティのアクセス方法を柔軟に制御しつつ、派生クラスで必ず実装させることができます。
抽象イベントのサブスクライブ実装
抽象イベントは、イベントの宣言だけを抽象クラスで行い、イベントの登録add
や解除remove
の処理を派生クラスに実装させるものです。
イベントはデリゲートを使って通知機能を実装するため、抽象イベントを使うことでイベントの発火方法や管理方法を派生クラスに任せられます。
以下は抽象イベントの例です。
public abstract class ButtonBase
{
// クリックイベントを抽象イベントとして宣言
public abstract event EventHandler Clicked;
}
public class Button : ButtonBase
{
private event EventHandler clicked;
public override event EventHandler Clicked
{
add { clicked += value; }
remove { clicked -= value; }
}
public void SimulateClick()
{
clicked?.Invoke(this, EventArgs.Empty);
}
}
class Program
{
static void Main()
{
Button button = new Button();
button.Clicked += (sender, e) => Console.WriteLine("ボタンがクリックされました。");
button.SimulateClick();
}
}
ボタンがクリックされました。
この例では、ButtonBase
でClicked
イベントを抽象イベントとして宣言し、Button
クラスでイベントの登録・解除処理を実装しています。
SimulateClick
メソッドでイベントを発火し、登録されたハンドラが呼び出されます。
抽象イベントを使うことで、イベントの管理方法を派生クラスに柔軟に任せられます。
抽象インデクサの利用例
抽象インデクサは、配列やコレクションのようにインデックスでアクセスする機能を抽象クラスで定義し、派生クラスで具体的なアクセス方法を実装させるものです。
abstract
キーワードを使い、get
やset
の実装を強制できます。
以下は抽象インデクサの例です。
public abstract class DataCollection
{
// インデクサのgetterとsetterを抽象メンバーとして定義
public abstract string this[int index] { get; set; }
}
public class StringList : DataCollection
{
private List<string> items = new List<string>();
public override string this[int index]
{
get
{
if (index < 0 || index >= items.Count)
throw new IndexOutOfRangeException();
return items[index];
}
set
{
if (index < 0 || index >= items.Count)
throw new IndexOutOfRangeException();
items[index] = value;
}
}
public void Add(string item)
{
items.Add(item);
}
}
class Program
{
static void Main()
{
StringList list = new StringList();
list.Add("りんご");
list.Add("みかん");
list.Add("バナナ");
Console.WriteLine(list[1]); // みかん
list[1] = "オレンジ";
Console.WriteLine(list[1]); // オレンジ
}
}
みかん
オレンジ
この例では、DataCollection
抽象クラスでインデクサを抽象メンバーとして宣言し、StringList
クラスでリストのインデックスアクセスを具体的に実装しています。
インデクサを使うことで、配列のようにlist[1]
の形式で要素の取得・設定が可能になります。
抽象インデクサは、コレクションの共通インターフェースを抽象クラスで定義し、派生クラスで具体的なデータ構造に合わせたアクセス方法を実装する際に便利です。
コンストラクタとフィールドの扱い
ベースコンストラクタ呼び出し
抽象クラスも通常のクラスと同様にコンストラクタを持つことができます。
抽象クラスのコンストラクタは直接インスタンス化されることはありませんが、派生クラスのコンストラクタから呼び出されて、基底クラスの初期化処理を行います。
派生クラスのコンストラクタで基底クラスのコンストラクタを呼び出すには、base
キーワードを使います。
これにより、抽象クラス側で共通の初期化処理をまとめることが可能です。
以下は抽象クラスのコンストラクタ呼び出しの例です。
public abstract class Vehicle
{
public string Brand { get; }
// 抽象クラスのコンストラクタ
protected Vehicle(string brand)
{
Brand = brand;
Console.WriteLine($"Vehicleコンストラクタ: {brand}を初期化");
}
}
public class Car : Vehicle
{
public string Model { get; }
// 派生クラスのコンストラクタでbaseを使って基底クラスのコンストラクタを呼び出す
public Car(string brand, string model) : base(brand)
{
Model = model;
Console.WriteLine($"Carコンストラクタ: {model}を初期化");
}
}
class Program
{
static void Main()
{
Car car = new Car("トヨタ", "カローラ");
Console.WriteLine($"ブランド: {car.Brand}, モデル: {car.Model}");
}
}
Vehicleコンストラクタ: トヨタを初期化
Carコンストラクタ: カローラを初期化
ブランド: トヨタ, モデル: カローラ
この例では、Vehicle
抽象クラスのコンストラクタがbrand
を受け取り、Car
クラスのコンストラクタからbase(brand)
で呼び出されています。
これにより、共通の初期化処理を抽象クラス側でまとめられます。
フィールドの初期化パターン
抽象クラスはフィールドを持つことができ、フィールドの初期化は以下のようなパターンで行います。
- フィールド宣言時の初期化
フィールド宣言と同時に初期値を設定します。
- コンストラクタ内での初期化
コンストラクタの中で値を設定します。
- プロパティの初期化子を使う(C# 6.0以降)
自動実装プロパティに初期値を設定できます。
以下にそれぞれの例を示します。
public abstract class Device
{
// 1. フィールド宣言時の初期化
protected int version = 1;
// 3. プロパティの初期化子
public string Manufacturer { get; protected set; } = "未設定";
protected Device()
{
// 2. コンストラクタ内での初期化
version = 2;
Manufacturer = "Generic Corp";
Console.WriteLine($"Deviceコンストラクタ: version={version}, Manufacturer={Manufacturer}");
}
}
public class Smartphone : Device
{
public Smartphone()
{
Console.WriteLine("Smartphoneコンストラクタ");
}
}
class Program
{
static void Main()
{
Smartphone phone = new Smartphone();
}
}
Deviceコンストラクタ: version=2, Manufacturer=Generic Corp
Smartphoneコンストラクタ
この例では、version
フィールドは宣言時に1
で初期化され、コンストラクタ内で2
に上書きされています。
Manufacturer
プロパティは初期化子で"未設定"
を設定し、コンストラクタで"Generic Corp"
に変更しています。
このように、抽象クラスでもフィールドやプロパティの初期化は柔軟に行えます。
静的コンストラクタとの相性
抽象クラスは静的コンストラクタも持つことができます。
静的コンストラクタはクラスが初めてアクセスされたときに一度だけ実行され、静的フィールドの初期化や一度だけ行いたい処理に使います。
静的コンストラクタは引数を持たず、アクセス修飾子も指定しません。
抽象クラスの静的コンストラクタは、派生クラスのインスタンス生成や静的メンバーアクセス時に呼び出されます。
以下は静的コンストラクタの例です。
public abstract class Config
{
public static readonly string AppName;
// 静的コンストラクタ
static Config()
{
AppName = "MyApplication";
Console.WriteLine("Configの静的コンストラクタが呼ばれました。");
}
}
public class AppConfig : Config
{
public void ShowAppName()
{
Console.WriteLine($"アプリ名: {AppName}");
}
}
class Program
{
static void Main()
{
AppConfig config = new AppConfig();
config.ShowAppName();
}
}
Configの静的コンストラクタが呼ばれました。
アプリ名: MyApplication
この例では、Config
抽象クラスの静的コンストラクタが最初に呼ばれ、静的フィールドAppName
を初期化しています。
AppConfig
のインスタンス生成時に静的コンストラクタが一度だけ実行されることが確認できます。
静的コンストラクタは抽象クラスの設計において、共通の静的データや設定を初期化するのに便利です。
派生クラスの動作に影響を与えず、クラス単位での初期化処理をまとめられます。
派生クラスでの実装パターン
メソッドのoverride手順
抽象クラスの抽象メソッドは、派生クラスで必ずoverride
キーワードを使って実装しなければなりません。
override
を付けることで、基底クラスの抽象メソッドを具体的に実装することを明示します。
手順は以下の通りです。
- 派生クラスで基底の抽象クラスを継承します。
- 抽象メソッドと同じシグネチャでメソッドを定義します。
- メソッド宣言に
override
キーワードを付けます。 - メソッド本体に具体的な処理を記述します。
public abstract class Animal
{
public abstract void MakeSound();
}
public class Dog : Animal
{
// overrideキーワードを付けて抽象メソッドを実装
public override void MakeSound()
{
Console.WriteLine("ワンワン");
}
}
class Program
{
static void Main()
{
Animal dog = new Dog();
dog.MakeSound(); // ワンワン
}
}
ワンワン
override
を付け忘れるとコンパイルエラーになります。
また、シグネチャが異なるとオーバーライドとは認識されず、エラーや意図しない動作になるため注意が必要です。
baseキーワードの活用
base
キーワードは、派生クラスから基底クラスのメンバー(メソッドやプロパティ、コンストラクタなど)を呼び出すために使います。
オーバーライドしたメソッド内で基底クラスの実装を呼び出したい場合に便利です。
たとえば、基底クラスに共通処理があり、派生クラスでそれに加えて独自処理を行いたい場合にbase
を使います。
public abstract class Animal
{
public virtual void MakeSound()
{
Console.WriteLine("動物の鳴き声");
}
}
public class Dog : Animal
{
public override void MakeSound()
{
base.MakeSound(); // 基底クラスの処理を呼び出す
Console.WriteLine("ワンワン");
}
}
class Program
{
static void Main()
{
Animal dog = new Dog();
dog.MakeSound();
}
}
動物の鳴き声
ワンワン
この例では、Dog
クラスのMakeSound
メソッド内でbase.MakeSound()
を呼び出し、基底クラスの処理を実行した後に独自の鳴き声を出しています。
抽象メソッドは実装を持たないためbase
で呼び出せませんが、virtual
メソッドをオーバーライドする場合はbase
を活用して共通処理を再利用できます。
抽象メソッドにおける例外スロー
抽象メソッドは派生クラスで必ず実装しなければなりませんが、場合によっては「まだ実装していない」や「このメソッドは使えない」という状態を明示的に示すために、例外をスローする実装を行うことがあります。
たとえば、派生クラスの一部で特定の抽象メソッドが意味を持たない場合、NotImplementedException
をスローして未実装であることを明示できます。
public abstract class Animal
{
public abstract void MakeSound();
public abstract void Fly();
}
public class Dog : Animal
{
public override void MakeSound()
{
Console.WriteLine("ワンワン");
}
public override void Fly()
{
// 犬は飛べないので例外をスロー
throw new NotImplementedException("犬は飛べません。");
}
}
class Program
{
static void Main()
{
Animal dog = new Dog();
dog.MakeSound();
try
{
dog.Fly();
}
catch (NotImplementedException ex)
{
Console.WriteLine($"例外: {ex.Message}");
}
}
}
ワンワン
例外: 犬は飛べません。
このように例外をスローすることで、メソッドが意図的に未実装であることや利用不可であることを明確にできます。
ただし、抽象メソッドの本来の目的は「必ず実装させる」ことなので、例外スローは設計上の妥協や特別なケースとして使うのが望ましいです。
また、例外をスローする場合はドキュメントやコメントで理由を明示し、利用者に誤解を与えないように注意しましょう。
インターフェースとの違い
デフォルト実装の有無
従来、C#のインターフェースはメソッドのシグネチャのみを定義し、実装を持つことができませんでした。
一方、抽象クラスはメソッドの実装を持つことができ、共通の処理をまとめるのに適しています。
しかし、C# 8.0以降ではインターフェースにも「デフォルト実装」が導入され、インターフェース内でメソッドの具体的な実装を記述できるようになりました。
これにより、インターフェースと抽象クラスの境界がやや曖昧になっています。
以下はデフォルト実装を持つインターフェースの例です。
public interface ILogger
{
void Log(string message);
// デフォルト実装
void LogWarning(string message)
{
Log($"警告: {message}");
}
}
public class ConsoleLogger : ILogger
{
public void Log(string message)
{
Console.WriteLine(message);
}
}
class Program
{
static void Main()
{
ILogger logger = new ConsoleLogger();
logger.Log("通常のログ");
logger.LogWarning("これは警告です");
}
}
通常のログ
警告: これは警告です
このように、インターフェースでも共通の処理を持てるようになりましたが、抽象クラスはフィールドやコンストラクタを持てる点で依然として差別化されています。
抽象クラスは状態を持つことができるため、より複雑な共通機能の実装に向いています。
多重継承との関係
C#ではクラスの多重継承はサポートされていません。
つまり、1つのクラスは複数のクラスを継承できません。
一方で、インターフェースは多重継承が可能で、複数のインターフェースを同時に実装できます。
抽象クラスはあくまで単一継承の枠組みの中で使われるため、複数の抽象クラスを同時に継承することはできません。
// コンパイルエラー: 多重継承は不可
public class MultiDerived : AbstractClass1, AbstractClass2
{
}
一方、インターフェースは複数実装可能です。
public interface IFirst
{
void MethodA();
}
public interface ISecond
{
void MethodB();
}
public class MultiImplementer : IFirst, ISecond
{
public void MethodA() { Console.WriteLine("MethodA"); }
public void MethodB() { Console.WriteLine("MethodB"); }
}
この違いにより、抽象クラスは「共通の基本機能をまとめるための単一の基底クラス」として使い、インターフェースは「複数の役割や契約を同時に実装するための仕組み」として使い分けられます。
バージョニングの考慮点
ソフトウェアの進化に伴い、既存の抽象クラスやインターフェースに新しいメンバーを追加する必要が出てきます。
このとき、バージョニング(後方互換性)を考慮することが重要です。
- 抽象クラスの場合
抽象クラスに新しい抽象メソッドを追加すると、既存の派生クラスはそのメソッドを実装していないためコンパイルエラーになります。
つまり、抽象クラスの拡張は破壊的変更になりやすいです。
- インターフェースの場合
伝統的には、インターフェースに新しいメンバーを追加すると、既存の実装クラスはそのメンバーを実装していないためコンパイルエラーになります。
しかし、C# 8.0以降のデフォルト実装により、インターフェースに新しいメソッドを追加しても既存の実装を壊さずに済む場合があります。
このため、バージョニングの観点では以下のような特徴があります。
項目 | 抽象クラス | インターフェース |
---|---|---|
新メンバー追加時の影響 | 既存派生クラスは必ず修正が必要 | デフォルト実装があれば影響を抑えられる |
状態(フィールド)保持 | 可能 | 不可能 |
拡張性 | 制限されやすい | 柔軟(特にデフォルト実装で) |
バージョニングを考慮すると、将来的に拡張が必要な場合はインターフェースのデフォルト実装を活用するか、抽象クラスの設計を慎重に行うことが推奨されます。
抽象クラスと仮想メソッドの比較
virtualとabstractの使い分け
virtual
メソッドとabstract
メソッドは、どちらも派生クラスでオーバーライド可能なメソッドを定義しますが、使い方や目的に違いがあります。
- virtualメソッド
基底クラスで既に実装があり、必要に応じて派生クラスで上書き(オーバーライド)できるメソッドです。
基底クラスの実装が「既定の動作」として提供されているため、派生クラスでオーバーライドしなくても動作します。
- abstractメソッド
基底クラスで実装を持たず、派生クラスで必ずオーバーライドして具体的な実装を提供しなければならないメソッドです。
基底クラスは抽象クラスとして宣言され、抽象メソッドを含む場合は必ずabstract
修飾子が必要です。
使い分けのポイントは「基底クラスで既定の実装があるかどうか」です。
public abstract class Animal
{
// abstract: 派生クラスで必須実装
public abstract void MakeSound();
// virtual: 基底クラスで既定実装あり、必要に応じてオーバーライド可能
public virtual void Sleep()
{
Console.WriteLine("動物は眠ります。");
}
}
public class Dog : Animal
{
public override void MakeSound()
{
Console.WriteLine("ワンワン");
}
public override void Sleep()
{
Console.WriteLine("犬は丸くなって眠ります。");
}
}
この例では、MakeSound
は抽象メソッドで必ずDog
で実装が必要ですが、Sleep
は仮想メソッドで基底クラスに既定の動作があり、Dog
でオーバーライドしてもよいし、そのまま使ってもよい形です。
既定実装が必要な時
基底クラスで「共通の動作」を提供したい場合はvirtual
メソッドを使います。
これにより、派生クラスは必要に応じて動作を変更できますが、オーバーライドしなくても基底クラスの実装が使われます。
たとえば、ログ出力の共通処理を基底クラスで実装し、派生クラスで追加処理を行うケースです。
public abstract class Logger
{
public virtual void Log(string message)
{
Console.WriteLine($"ログ: {message}");
}
}
public class FileLogger : Logger
{
public override void Log(string message)
{
base.Log(message); // 基底クラスのログ出力を呼び出す
Console.WriteLine("ファイルにログを書き込みました。");
}
}
ログ: システム起動
ファイルにログを書き込みました。
このように、既定の処理を基底クラスにまとめておくことでコードの重複を減らし、拡張性を高められます。
overrideのオプショナル化
virtual
メソッドは派生クラスでのoverride
が任意です。
つまり、オーバーライドしなくても基底クラスの実装が使われます。
一方、abstract
メソッドは必須でオーバーライドしなければコンパイルエラーになります。
この違いにより、設計上の柔軟性が変わります。
- virtualメソッド
派生クラスでのオーバーライドはオプション。
基底クラスの動作をそのまま使うこともできます。
- abstractメソッド
派生クラスで必ずオーバーライドしなければならない。
未実装のまま使うことはできない。
たとえば、以下のコードはvirtual
メソッドをオーバーライドしない場合でも動作します。
public class Cat : Animal
{
public override void MakeSound()
{
Console.WriteLine("ニャーニャー");
}
// Sleepはオーバーライドしないので基底クラスの実装が使われる
}
ニャーニャー
動物は眠ります。
このように、virtual
はオーバーライドの有無を柔軟に選べるため、基底クラスの既定動作を活かしつつ必要に応じて拡張したい場合に適しています。
まとめると、abstract
は「必ず実装させる」ために使い、virtual
は「既定実装を提供しつつ必要に応じて上書き可能」にしたい場合に使います。
設計の目的に応じて使い分けることが重要です。
デザインパターンでの応用
Template Methodパターン
Template Methodパターンは、処理の骨組み(アルゴリズムの流れ)を抽象クラスで定義し、具体的な処理の一部を抽象メソッドとして派生クラスに実装させるデザインパターンです。
これにより、処理の共通部分は基底クラスで管理し、変化する部分だけを派生クラスでカスタマイズできます。
以下はTemplate Methodパターンの例です。
料理の手順を抽象クラスで定義し、具体的な料理ごとに異なる調理方法を実装します。
public abstract class Cooking
{
// テンプレートメソッド(処理の流れを定義)
public void Prepare()
{
PrepareIngredients();
Cook();
Serve();
}
protected abstract void PrepareIngredients(); // 材料の準備(派生クラスで実装)
protected abstract void Cook(); // 調理(派生クラスで実装)
protected virtual void Serve()
{
Console.WriteLine("料理を皿に盛り付けます。");
}
}
public class Sushi : Cooking
{
protected override void PrepareIngredients()
{
Console.WriteLine("米を炊き、魚を切ります。");
}
protected override void Cook()
{
Console.WriteLine("酢飯を作り、ネタをのせます。");
}
}
public class Steak : Cooking
{
protected override void PrepareIngredients()
{
Console.WriteLine("牛肉を室温に戻します。");
}
protected override void Cook()
{
Console.WriteLine("フライパンで焼きます。");
}
protected override void Serve()
{
Console.WriteLine("ステーキソースをかけて盛り付けます。");
}
}
class Program
{
static void Main()
{
Cooking sushi = new Sushi();
sushi.Prepare();
Console.WriteLine();
Cooking steak = new Steak();
steak.Prepare();
}
}
米を炊き、魚を切ります。
酢飯を作り、ネタをのせます。
料理を皿に盛り付けます。
牛肉を室温に戻します。
フライパンで焼きます。
ステーキソースをかけて盛り付けます。
この例では、Prepare
メソッドがテンプレートメソッドとして処理の流れを定義し、PrepareIngredients
やCook
は抽象メソッドで派生クラスに実装を強制しています。
Serve
は仮想メソッドで、必要に応じて派生クラスでオーバーライド可能です。
Factory Methodパターン
Factory Methodパターンは、オブジェクトの生成をサブクラスに委譲するデザインパターンです。
抽象クラスに「オブジェクトを生成するメソッド(ファクトリーメソッド)」を抽象メソッドとして定義し、派生クラスで具体的な生成処理を実装します。
以下はFactory Methodパターンの例です。
動物のインスタンスを生成するファクトリーメソッドを抽象クラスで定義し、派生クラスで具体的な動物を生成します。
public abstract class AnimalFactory
{
// ファクトリーメソッド(抽象メソッド)
public abstract Animal CreateAnimal();
public void ShowAnimalSound()
{
Animal animal = CreateAnimal();
animal.MakeSound();
}
}
public abstract class Animal
{
public abstract void MakeSound();
}
public class Dog : Animal
{
public override void MakeSound()
{
Console.WriteLine("ワンワン");
}
}
public class Cat : Animal
{
public override void MakeSound()
{
Console.WriteLine("ニャーニャー");
}
}
public class DogFactory : AnimalFactory
{
public override Animal CreateAnimal()
{
return new Dog();
}
}
public class CatFactory : AnimalFactory
{
public override Animal CreateAnimal()
{
return new Cat();
}
}
class Program
{
static void Main()
{
AnimalFactory dogFactory = new DogFactory();
dogFactory.ShowAnimalSound();
AnimalFactory catFactory = new CatFactory();
catFactory.ShowAnimalSound();
}
}
ワンワン
ニャーニャー
この例では、AnimalFactory
抽象クラスのCreateAnimal
がファクトリーメソッドとして抽象メソッドになっており、DogFactory
やCatFactory
が具体的な動物を生成しています。
これにより、生成するオブジェクトの種類を柔軟に切り替えられます。
Strategyパターンとの組み合わせ
Strategyパターンは、アルゴリズムや処理の切り替えをオブジェクトとしてカプセル化し、実行時に動的に切り替えられるようにするデザインパターンです。
抽象クラスやインターフェースで共通の処理を定義し、具体的な戦略を派生クラスで実装します。
抽象クラスの抽象メソッドを使って戦略の共通インターフェースを定義し、クライアントは戦略オブジェクトを切り替えて動作を変えられます。
以下はStrategyパターンの例です。
異なる計算方法を戦略として切り替えます。
public abstract class CalculationStrategy
{
public abstract int Calculate(int a, int b);
}
public class AddStrategy : CalculationStrategy
{
public override int Calculate(int a, int b)
{
return a + b;
}
}
public class MultiplyStrategy : CalculationStrategy
{
public override int Calculate(int a, int b)
{
return a * b;
}
}
public class Calculator
{
private CalculationStrategy strategy;
public Calculator(CalculationStrategy strategy)
{
this.strategy = strategy;
}
public void SetStrategy(CalculationStrategy strategy)
{
this.strategy = strategy;
}
public int Execute(int a, int b)
{
return strategy.Calculate(a, b);
}
}
class Program
{
static void Main()
{
Calculator calculator = new Calculator(new AddStrategy());
Console.WriteLine($"加算結果: {calculator.Execute(3, 5)}");
calculator.SetStrategy(new MultiplyStrategy());
Console.WriteLine($"乗算結果: {calculator.Execute(3, 5)}");
}
}
加算結果: 8
乗算結果: 15
この例では、CalculationStrategy
抽象クラスが計算の共通インターフェースを定義し、AddStrategy
とMultiplyStrategy
が具体的な計算方法を実装しています。
Calculator
クラスは戦略オブジェクトを保持し、動的に切り替えて計算を実行しています。
抽象クラスの抽象メソッドを活用することで、Strategyパターンの柔軟な設計が可能になります。
Generic抽象クラスの実践
ジェネリック型引数を持つ抽象クラス
ジェネリック型引数を持つ抽象クラスは、型に依存しない柔軟な設計を可能にします。
抽象クラス自体が型パラメータを受け取り、その型に対して共通の処理やインターフェースを定義できます。
これにより、異なる型に対して同じロジックを再利用しつつ、型安全性を保てます。
以下はジェネリック型引数を持つ抽象クラスの例です。
public abstract class Repository<T>
{
// 抽象メソッドで型Tのデータを取得する
public abstract T GetById(int id);
// 仮想メソッドでデータを保存する共通処理
public virtual void Save(T item)
{
Console.WriteLine($"{typeof(T).Name}を保存しました。");
}
}
public class User
{
public int Id { get; set; }
public string Name { get; set; }
}
public class UserRepository : Repository<User>
{
public override User GetById(int id)
{
// ここでは簡単にダミーデータを返す例
return new User { Id = id, Name = "山田太郎" };
}
}
class Program
{
static void Main()
{
Repository<User> userRepo = new UserRepository();
User user = userRepo.GetById(1);
Console.WriteLine($"ユーザーID: {user.Id}, 名前: {user.Name}");
userRepo.Save(user);
}
}
ユーザーID: 1, 名前: 山田太郎
Userを保存しました。
この例では、Repository<T>
がジェネリック抽象クラスとして定義され、UserRepository
がUser
型を指定して具体的な実装を行っています。
型パラメータT
を使うことで、さまざまな型に対応可能な共通のリポジトリ設計ができます。
コントラバリアンスと抽象クラス
コントラバリアンス(contravariance)は、ジェネリック型パラメータの型変換ルールの一つで、主にデリゲートやインターフェースで使われます。
C#では、in
キーワードを使って入力パラメータの型パラメータにコントラバリアンスを指定できます。
しかし、抽象クラスのジェネリック型パラメータにはコントラバリアンスや共変性(covariance)を直接指定できません。
これは抽象クラスがクラスであり、型安全性を保つために制限されているためです。
たとえば、以下のようなインターフェースではコントラバリアンスが可能です。
public interface IProcessor<in T>
{
void Process(T item);
}
一方、抽象クラスでは以下のように指定できません。
// コンパイルエラー: 抽象クラスの型パラメータにin/outは使えない
public abstract class Processor<in T>
{
public abstract void Process(T item);
}
この制限により、抽象クラスのジェネリック型パラメータは共変性や反変性を持たず、型の安全性が保たれています。
コントラバリアンスが必要な場合は、インターフェースを使う設計が推奨されます。
制約付きジェネリックによる安全性向上
ジェネリック型パラメータに制約を付けることで、型の安全性や利用可能なメンバーを限定できます。
抽象クラスのジェネリック型引数にも制約を付けることができ、特定の条件を満たす型だけを受け入れる設計が可能です。
代表的な制約には以下があります。
制約キーワード | 説明 |
---|---|
where T : class | 参照型でなければならない |
where T : struct | 値型(構造体)でなければならない |
where T : new() | 引数なしコンストラクタを持つ型 |
where T : 基底クラス | 指定したクラスを継承している型 |
where T : インターフェース | 指定したインターフェースを実装している型 |
以下は制約付きジェネリック抽象クラスの例です。
public abstract class EntityRepository<T> where T : IEntity, new()
{
public abstract T GetById(int id);
public virtual void AddNew()
{
T entity = new T();
Console.WriteLine($"{typeof(T).Name}の新規エンティティを追加しました。");
}
}
public interface IEntity
{
int Id { get; set; }
}
public class Product : IEntity
{
public int Id { get; set; }
public string Name { get; set; }
}
public class ProductRepository : EntityRepository<Product>
{
public override Product GetById(int id)
{
return new Product { Id = id, Name = "サンプル商品" };
}
}
class Program
{
static void Main()
{
EntityRepository<Product> repo = new ProductRepository();
Product product = repo.GetById(10);
Console.WriteLine($"商品ID: {product.Id}, 名前: {product.Name}");
repo.AddNew();
}
}
商品ID: 10, 名前: サンプル商品
Productの新規エンティティを追加しました。
この例では、EntityRepository<T>
にwhere T : IEntity, new()
という制約を付けています。
これにより、T
はIEntity
を実装し、引数なしコンストラクタを持つ型に限定されます。
AddNew
メソッドでnew T()
が安全に使えるのはこの制約のおかげです。
制約を活用することで、抽象クラスのジェネリック設計における型安全性や柔軟性を高められます。
抽象クラスの継承階層設計
単一責任原則との適合
抽象クラスの設計において重要な指針の一つが「単一責任原則(Single Responsibility Principle, SRP)」です。
これは「クラスは単一の責任だけを持つべき」という考え方で、抽象クラスも例外ではありません。
抽象クラスに複数の役割や機能を詰め込みすぎると、継承階層が複雑になり、保守性や拡張性が低下します。
抽象クラスは共通の責務を明確にし、その責務に関連する機能だけを持つように設計することが望ましいです。
例えば、以下のように抽象クラスを分割することで単一責任原則に適合させられます。
public abstract class Logger
{
public abstract void Log(string message);
}
public abstract class ErrorLogger : Logger
{
public abstract void LogError(string errorMessage);
}
この例では、Logger
はログ出力の基本機能を持ち、ErrorLogger
はエラーログに特化した責務を持つ抽象クラスとして分けています。
責務を分割することで、派生クラスは必要な機能だけを継承しやすくなります。
深い継承のリスク
抽象クラスの継承階層が深くなりすぎると、以下のようなリスクが生じます。
- 理解の困難さ
継承元の抽象クラスが多層にわたると、どのクラスでどの機能が実装されているか把握しづらくなります。
- 変更の波及
基底クラスの変更が派生クラスに予期せぬ影響を与え、バグの原因になることがあります。
- 柔軟性の低下
深い継承はクラスの結合度を高め、設計の柔軟性や再利用性を損なうことがあります。
例えば、以下のような多層継承は避けるべきです。
public abstract class A { /* 基本機能 */ }
public abstract class B : A { /* 追加機能 */ }
public abstract class C : B { /* さらに追加 */ }
public abstract class D : C { /* さらに追加 */ }
public class Concrete : D { /* 実装 */ }
このような階層は保守が難しく、設計の見直しが必要になることが多いです。
拡張性を高める分割手法
継承階層の深さを抑えつつ拡張性を高めるためには、以下のような分割手法が有効です。
- インターフェースとの併用
抽象クラスで共通の基本機能を提供し、インターフェースで役割ごとの契約を分けることで柔軟な設計が可能です。
- コンポジションの活用
継承ではなく、必要な機能を持つオブジェクトをメンバーとして持つことで、機能の組み合わせを柔軟に変更できます。
- 機能ごとに抽象クラスを分割
大きな抽象クラスを複数の小さな抽象クラスに分割し、必要な機能だけを継承させる設計にします。
以下はインターフェースと抽象クラスを組み合わせた例です。
public interface ILogger
{
void Log(string message);
}
public interface IErrorLogger
{
void LogError(string errorMessage);
}
public abstract class BaseLogger : ILogger
{
public virtual void Log(string message)
{
Console.WriteLine($"ログ: {message}");
}
}
public class FileLogger : BaseLogger, IErrorLogger
{
public void LogError(string errorMessage)
{
Console.WriteLine($"エラーログ: {errorMessage}");
}
}
この設計では、BaseLogger
が基本的なログ機能を提供し、IErrorLogger
インターフェースでエラーログの契約を分離しています。
FileLogger
は必要な機能だけを実装し、柔軟に拡張できます。
このように、抽象クラスの継承階層設計では単一責任原則を守り、深い継承を避けて分割やコンポジションを活用することで、保守性と拡張性の高い設計を実現できます。
抽象クラスと依存性注入
IoCコンテナ登録のポイント
依存性注入(Dependency Injection, DI)を利用する際、抽象クラスはインターフェースと同様に依存先の抽象化として活用できます。
IoC(Inversion of Control)コンテナに抽象クラスとその具象クラスを登録することで、実行時に適切な具象クラスのインスタンスが注入されます。
IoCコンテナに抽象クラスを登録する際のポイントは以下の通りです。
- 抽象クラスをサービスの型として登録する
抽象クラスをキー(サービス型)として登録し、具象クラスを実装として指定します。
これにより、抽象クラス型の依存性を解決できます。
- 具象クラスは抽象クラスを継承している必要がある
登録時に指定する具象クラスは、必ず抽象クラスを継承している必要があります。
そうでないと解決時に例外が発生します。
- ライフタイム管理に注意する
シングルトンやスコープ付きなど、ライフタイムの設定は具象クラスの用途に合わせて適切に行います。
以下は、Microsoft.Extensions.DependencyInjectionを使った登録例です。
public abstract class PaymentProcessor
{
public abstract void ProcessPayment(decimal amount);
}
public class CreditCardProcessor : PaymentProcessor
{
public override void ProcessPayment(decimal amount)
{
Console.WriteLine($"クレジットカードで{amount}円を処理しました。");
}
}
class Program
{
static void Main()
{
var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
// 抽象クラスをサービス型として登録し、具象クラスを指定
services.AddTransient<PaymentProcessor, CreditCardProcessor>();
var provider = services.BuildServiceProvider();
// PaymentProcessor型で解決され、CreditCardProcessorのインスタンスが返る
var processor = provider.GetService<PaymentProcessor>();
processor.ProcessPayment(1000);
}
}
クレジットカードで1000円を処理しました。
このように、抽象クラスをキーに登録することで、依存性注入の恩恵を受けられます。
インターフェースと同様に使えますが、抽象クラスは状態や共通実装を持てる点が特徴です。
モック実装作成時の注意
単体テストなどで依存性注入を利用する場合、抽象クラスをモック(テスト用の代替実装)として扱うことがあります。
モック作成時の注意点は以下の通りです。
- モックフレームワークの対応状況を確認する
一部のモックフレームワークは抽象クラスのモック作成に対応していますが、対応していないものもあります。
代表的なフレームワーク(Moq、NSubstituteなど)は抽象クラスのモックをサポートしています。
- 抽象クラスのコンストラクタに注意
抽象クラスに引数付きコンストラクタがある場合、モック作成時に適切な引数を渡す必要があります。
引数なしコンストラクタがないとモック作成が難しくなることがあります。
- 共通実装の影響を理解する
抽象クラスは共通の実装を持つことが多いため、モックがその実装を利用するかどうかを意識する必要があります。
モックが基底クラスの実装を呼び出す場合、テストの挙動に影響を与えることがあります。
以下はMoqを使った抽象クラスのモック例です。
public abstract class DataFetcher
{
public abstract string FetchData(int id);
public virtual string FetchDefault()
{
return "デフォルトデータ";
}
}
class Program
{
static void Main()
{
var mock = new Moq.Mock<DataFetcher>();
// 抽象メソッドの戻り値を設定
mock.Setup(m => m.FetchData(It.IsAny<int>())).Returns("モックデータ");
var fetcher = mock.Object;
Console.WriteLine(fetcher.FetchData(10)); // モックデータ
Console.WriteLine(fetcher.FetchDefault()); // デフォルトデータ(基底クラスの実装が呼ばれる)
}
}
モックデータ
デフォルトデータ
この例では、抽象メソッドFetchData
をモックで設定し、仮想メソッドFetchDefault
は基底クラスの実装が呼ばれています。
必要に応じて仮想メソッドもモックで上書き可能です。
モック作成時は抽象クラスの設計やコンストラクタ、共通実装の影響を理解し、テストの目的に合ったモックを作成することが重要です。
依存性注入と組み合わせることで、柔軟で保守性の高いテスト設計が可能になります。
抽象クラスにおける例外設計
例外のベースクラスを抽象化する
例外処理はソフトウェアの堅牢性を高める重要な要素です。
抽象クラスを設計する際、例外の種類や階層構造も考慮すると、例外処理の一貫性や拡張性が向上します。
特に、独自例外を設計する場合は、共通のベース例外クラスを抽象クラスとして定義することが有効です。
これにより、例外の種類を体系的に管理でき、キャッチ時に共通の基底クラスでまとめて処理したり、特定の派生例外だけを個別に処理したりすることが容易になります。
以下は例外のベースクラスを抽象クラスとして定義する例です。
// 独自例外のベース抽象クラス
public abstract class ApplicationExceptionBase : Exception
{
protected ApplicationExceptionBase(string message) : base(message)
{
}
protected ApplicationExceptionBase(string message, Exception innerException) : base(message, innerException)
{
}
// 共通のログ出力メソッドなどを追加可能
public void LogError()
{
Console.WriteLine($"エラー発生: {Message}");
}
}
// 具体的な例外クラス
public class DataNotFoundException : ApplicationExceptionBase
{
public DataNotFoundException(string message) : base(message)
{
}
}
public class ValidationException : ApplicationExceptionBase
{
public ValidationException(string message) : base(message)
{
}
}
この設計により、例外処理の際にApplicationExceptionBase
でまとめてキャッチできるため、共通処理を一元化しやすくなります。
try
{
throw new DataNotFoundException("データが見つかりません。");
}
catch (ApplicationExceptionBase ex)
{
ex.LogError();
// 共通の例外処理
}
抽象クラスとしてベース例外を設計することで、例外の拡張や管理が体系的に行え、保守性が向上します。
未実装メソッドの例外設計
抽象クラスの抽象メソッドは派生クラスで必ず実装されるべきですが、場合によっては「未実装」や「サポートしていない」ことを明示的に示すために例外をスローすることがあります。
特に、抽象クラスに仮想メソッドvirtual
として実装を提供しつつ、派生クラスでオーバーライドしない場合に未実装例外をスローするパターンが見られます。
これは、基底クラスの既定実装が「未対応」であることを明示し、誤って呼び出された場合に早期に問題を検出するためです。
以下は未実装メソッドで例外をスローする例です。
public abstract class ReportGenerator
{
// 仮想メソッドで既定実装は未対応として例外をスロー
public virtual void GeneratePdfReport()
{
throw new NotImplementedException("PDFレポート生成は未実装です。");
}
// 抽象メソッドは必ず実装が必要
public abstract void GenerateHtmlReport();
}
public class HtmlReportGenerator : ReportGenerator
{
public override void GenerateHtmlReport()
{
Console.WriteLine("HTMLレポートを生成しました。");
}
// GeneratePdfReportはオーバーライドしないため例外がスローされる
}
class Program
{
static void Main()
{
ReportGenerator generator = new HtmlReportGenerator();
generator.GenerateHtmlReport();
try
{
generator.GeneratePdfReport();
}
catch (NotImplementedException ex)
{
Console.WriteLine($"例外: {ex.Message}");
}
}
}
HTMLレポートを生成しました。
例外: PDFレポート生成は未実装です。
この設計は、将来的にPDFレポート生成機能を追加する可能性がある場合や、すべての派生クラスで必須ではない機能を示す際に有効です。
ただし、抽象メソッドであれば未実装のまま放置できないため、例外スローは仮想メソッドの既定実装に限定して使うのが一般的です。
抽象クラスにおける例外設計は、例外の階層構造を抽象化して管理しやすくすることと、未実装や非対応のメソッドに対して適切に例外をスローして誤用を防ぐことがポイントです。
これにより、堅牢で拡張性の高い設計が実現できます。
抽象クラスにありがちな落とし穴
アクセシビリティの不整合
抽象クラスを設計する際に注意したいのが、メンバーのアクセシビリティ(アクセス修飾子)の不整合です。
抽象クラスのメンバーはpublic
、protected
、internal
など様々なアクセスレベルを指定できますが、これが適切に設計されていないと、派生クラスや外部からの利用に支障をきたすことがあります。
例えば、抽象クラスの抽象メソッドがprotected
で宣言されている場合、派生クラス内でのみオーバーライド可能ですが、外部からは呼び出せません。
逆にpublic
にすべきところをinternal
にしてしまうと、別アセンブリからの利用が制限されてしまいます。
public abstract class BaseClass
{
// protected抽象メソッドは派生クラス内でのみ実装・呼び出し可能
protected abstract void DoWork();
}
public class DerivedClass : BaseClass
{
public override void DoWork()
{
Console.WriteLine("処理を実行");
}
public void Execute()
{
DoWork(); // OK
}
}
class Program
{
static void Main()
{
DerivedClass obj = new DerivedClass();
// obj.DoWork(); // コンパイルエラー: protectedなので外部から呼べない
obj.Execute(); // これを通じてDoWorkが呼ばれる
}
}
このように、アクセシビリティの設定が不適切だと、意図した範囲でメソッドが使えなかったり、逆に不必要に公開されてしまったりします。
抽象クラスの設計時は、どの範囲でメンバーを利用させたいかを明確にし、適切なアクセス修飾子を設定することが重要です。
不要な抽象化による複雑化
抽象クラスは強力な設計手法ですが、過剰に使いすぎるとコードが複雑化し、保守性が低下するリスクがあります。
特に、抽象クラスを安易に増やしたり、抽象メソッドを多用して細かく分割しすぎると、継承階層が深くなりすぎて理解しづらくなります。
例えば、単純な処理に対しても抽象クラスを作成し、派生クラスを大量に用意すると、どのクラスがどの機能を持っているのか把握が難しくなります。
また、抽象クラスの変更が派生クラスに波及しやすく、バグの温床になることもあります。
// 過剰な抽象化の例(イメージ)
public abstract class BaseA { public abstract void MethodA(); }
public abstract class BaseB : BaseA { public abstract void MethodB(); }
public abstract class BaseC : BaseB { public abstract void MethodC(); }
// さらに派生クラスが続く...
// これにより設計が複雑化し、保守が困難に
不要な抽象化を避けるためには、以下のポイントを意識しましょう。
- 本当に共通化すべき機能かを見極める
- 単一責任原則に従い、クラスの責務を明確にする
- インターフェースやコンポジションも検討する
性能への影響
抽象クラスや抽象メソッドの利用は、設計上のメリットが大きい一方で、性能面での影響も考慮する必要があります。
特に抽象メソッドは仮想呼び出し(virtual dispatch)を伴うため、直接呼び出すメソッドに比べて若干のオーバーヘッドが発生します。
このオーバーヘッドは通常のアプリケーションではほとんど無視できるレベルですが、パフォーマンスが極めて重要なリアルタイム処理や大量の呼び出しが発生する場面では影響が出ることがあります。
public abstract class Base
{
public abstract void DoWork();
}
public class Derived : Base
{
public override void DoWork()
{
// 処理内容
}
}
仮想メソッド呼び出しは、実行時にメソッドテーブル(vtable)を参照して呼び出すため、非仮想メソッドに比べて呼び出しコストが高くなります。
性能を最適化したい場合は、以下の点に注意してください。
- 頻繁に呼び出されるメソッドは非仮想メソッドにする
- 抽象クラスの設計を見直し、必要最低限の仮想化にとどめる
- JITコンパイラの最適化やインライン展開の影響を理解する
ただし、現代のJITコンパイラは仮想呼び出しの最適化も進んでおり、多くのケースで性能差は微小です。
設計の柔軟性と性能のバランスを考慮して使い分けることが重要です。
抽象クラスの設計では、アクセシビリティの適切な設定、過剰な抽象化の回避、そして性能面の影響を理解しながらバランスよく活用することが求められます。
これらの落とし穴を意識することで、堅牢で保守性の高い設計が実現できます。
C#言語バージョンと抽象機能の進化
C# 8.0以前の違い
C# 8.0以前のバージョンでは、抽象クラスと抽象メソッドの基本的な機能は現在とほぼ同様でしたが、インターフェースに関する抽象化の扱いに大きな違いがありました。
- インターフェースの実装はシグネチャのみ
C# 8.0以前のインターフェースは、メソッドのシグネチャだけを定義し、実装を持つことができませんでした。
そのため、共通の実装を持たせたい場合は抽象クラスを使う必要がありました。
- 抽象クラスの役割がより重要
共通の実装や状態を持たせるために抽象クラスが多用され、設計上の中心的な役割を担っていました。
- デフォルト実装の不在
インターフェースにデフォルト実装がなかったため、機能拡張の際に既存のインターフェースを変更すると、すべての実装クラスに影響が及びやすく、バージョニングが難しいという課題がありました。
抽象クラスはこの時代において、共通のコードをまとめるための主要な手段として使われていました。
C# 9.0以降のrecordとの比較
C# 9.0で導入されたrecord
は、主に不変データの表現に特化した参照型で、値の比較やコピーが簡単にできる特徴を持ちます。
record
はクラスと似ていますが、以下の点で抽象クラスと異なります。
項目 | 抽象クラス | record |
---|---|---|
目的 | 共通の機能やインターフェースの定義 | 不変データの表現と値の比較 |
インスタンス化 | 抽象クラスは直接インスタンス化不可 | recordは直接インスタンス化可能 |
継承 | 単一継承可能 | 単一継承可能 |
メンバーの実装 | 抽象メソッドや通常メソッドを持てる | プロパティ中心でメソッドは限定的 |
イミュータブル性 | 任意 | デフォルトでイミュータブル |
record
はデータキャリアとしての役割が強く、抽象クラスのように共通の振る舞いを強制したり、抽象メソッドで実装を強制する用途には向いていません。
ただし、record
も抽象record
として宣言でき、継承や抽象メソッドの定義が可能です。
以下は抽象record
の例です。
public abstract record Shape
{
public abstract double GetArea();
}
public record Circle(double Radius) : Shape
{
public override double GetArea() => Math.PI * Radius * Radius;
}
このように、record
は抽象クラスの機能を一部取り込みつつ、データの不変性や値の比較を強化した新しい型として位置づけられています。
.NETのライブラリ実装例
.NETの標準ライブラリでも抽象クラスは多用されており、共通の機能をまとめたり、拡張ポイントを提供したりするために設計されています。
代表的な例をいくつか紹介します。
System.IO.Stream
抽象クラスとして定義され、ファイルやメモリ、ネットワークなど様々なストリームの共通インターフェースと基本機能を提供します。
派生クラスは読み書きの具体的な実装を行います。
System.Collections.ObjectModel.Collection<T>
抽象クラスではありませんが、拡張可能なコレクションの基底クラスとして機能し、必要に応じてメソッドをオーバーライドして動作をカスタマイズできます。
System.Threading.Tasks.Task
抽象クラスではありませんが、非同期処理の基盤として多くの派生クラスやラッパーが存在し、抽象クラス的な役割を果たす設計がなされています。
System.Net.Http.HttpMessageHandler
抽象クラスで、HTTPメッセージの送受信処理の共通基盤を提供し、派生クラスで具体的な送信処理を実装します。
これらの例からもわかるように、.NETのライブラリ設計では抽象クラスが共通機能の集約や拡張ポイントの提供に欠かせない役割を担っています。
抽象クラスの設計は、ライブラリの柔軟性や拡張性を高めるための重要な要素です。
まとめ
この記事では、C#の抽象クラスと抽象メソッドの基本から応用、設計上の注意点まで幅広く解説しました。
抽象クラスは共通機能の集約と派生クラスへの実装強制に役立ち、仮想メソッドやインターフェースとの違いも理解できます。
デザインパターンやジェネリックとの組み合わせ、依存性注入や例外設計のポイントも押さえ、堅牢で拡張性の高い設計を目指せます。
適切な継承階層設計や性能面の考慮も重要です。