C#の基底クラスとは?継承・baseの使い方から抽象クラスとの違いまで初心者向けに解説

はじめに

C#で複数のクラスに共通するデータや処理をまとめたいときに役立つのが、基底クラスです。基底クラスを利用すると、共通部分を一度だけ実装し、クラスごとに異なる部分だけを追加・変更できます。

たとえば、犬や猫を表すクラスには「名前」「年齢」「鳴く」といった共通要素があります。これらを各クラスへ個別に実装するのではなく、Animalという基底クラスにまとめれば、コードの重複を減らせます。

この記事では、C#の基底クラスと派生クラスの関係、継承の基本構文、baseキーワード、メソッドのオーバーライド、アクセス修飾子について初心者向けに解説します。抽象クラスやインターフェイスとの違い、実践的なサンプルコード、よくあるエラーについても確認していきましょう。

1. C#の基底クラスとは

基底クラスとは、別のクラスにメンバーを引き継がせる側のクラスです。英語では「base class」と呼ばれます。

基底クラスを継承して作成されるクラスは、派生クラスと呼ばれます。派生クラスは、基底クラスに定義されているプロパティやメソッドなどを利用しながら、独自の機能を追加できます。

1-1. 基底クラスと派生クラスの関係

基底クラスと派生クラスの関係は、一般化された型と、より具体的な型の関係として考えると理解しやすくなります。

たとえば、犬は動物の一種です。この関係をC#のクラスで表す場合、Animalを基底クラス、Dogを派生クラスにできます。

C#
public class Animal
{
public string Name { get; set; } = string.Empty;

public void Eat()
{
Console.WriteLine($"{Name}が食事をしています。");
}
}

public class Dog : Animal
{
public void Bark()
{
Console.WriteLine($"{Name}がワンと鳴きました。");
}
}

Dogクラス自身にはNameプロパティやEatメソッドを定義していません。しかし、Animalクラスを継承しているため、Dogのインスタンスから利用できます。

C#
Dog dog = new Dog();

dog.Name = "ポチ";
dog.Eat();
dog.Bark();

実行結果は次のとおりです。

ポチが食事をしています。
ポチがワンと鳴きました。

このように、派生クラスは基底クラスの機能を受け継ぎつつ、独自の機能を追加できます。

1-2. 基底クラスを使う目的とメリット

基底クラスを使う主な目的は、複数のクラスに共通する処理やデータをまとめることです。

たとえば、DogCatBirdというクラスに同じNameプロパティを定義すると、同じコードが何度も登場します。基底クラスのAnimalにまとめれば、共通部分を一度だけ記述できます。

基底クラスには、次のようなメリットがあります。

  • 共通するコードの重複を減らせる

  • 共通処理の修正箇所を一つにまとめられる

  • 派生クラスを同じ基底クラス型として扱える

  • オーバーライドによってクラスごとに処理を変更できる

  • クラス同士の関係をコード上で明確に表現できる

ただし、共通するコードがあるという理由だけで継承するのは適切とは限りません。継承は、派生クラスが基底クラスの一種であるという関係が成り立つ場合に利用することが重要です。

1-3. C#のすべての型の基底となるobjectクラス

C#では、クラスの基底クラスを明示しなかった場合でも、クラスは最終的にSystem.Objectを基底とします。C#では通常、objectというキーワードで表します。

次の2つのクラス定義は、実質的に同じ意味です。

C#
public class Sample
{
}
C#
public class Sample : object
{
}

objectクラスには、すべてのオブジェクトで利用できる基本的なメソッドが定義されています。

代表的なメソッドは次のとおりです。

C#
Sample sample = new Sample();

Console.WriteLine(sample.ToString());
Console.WriteLine(sample.GetHashCode());
Console.WriteLine(sample.GetType());
Console.WriteLine(sample.Equals(new Sample()));

主なメソッドの役割は次のとおりです。

  • ToString:オブジェクトを表す文字列を返す

  • Equals:別のオブジェクトと等しいかを判定する

  • GetHashCode:ハッシュ値を返す

  • GetType:実行時の型情報を返す

値型である構造体や列挙型も、System.ValueTypeなどを経由してobjectの機能と関係しています。そのため、数値や構造体をobject型の変数へ代入することも可能です。このとき、値型からobject型への変換ではボックス化が発生します。

C#
int number = 10;
object value = number;

1-4. 基底クラス・親クラス・スーパークラスの呼び方の違い

基底クラス、親クラス、スーパークラスは、いずれも継承される側のクラスを表す言葉として使われます。

C#や.NETの公式な用語では、「基底クラス」という表現が一般的です。一方、「親クラス」は入門書や会話でよく使われ、「スーパークラス」はJavaなどを含むオブジェクト指向全般で使われます。

反対に、継承する側のクラスにも複数の呼び方があります。

  • 基底クラスに対する「派生クラス」

  • 親クラスに対する「子クラス」

  • スーパークラスに対する「サブクラス」

意味はほぼ同じですが、C#のコードや技術文書では「基底クラス」と「派生クラス」を使うと誤解が少なくなります。

2. C#で基底クラスを継承する方法

C#でクラスを継承するときは、派生クラス名の後ろにコロンを記述し、その後に基底クラス名を指定します。

2-1. コロンを使ったクラス継承の基本構文

クラス継承の基本構文は次のとおりです。

C#
public class 基底クラス
{
}

public class 派生クラス : 基底クラス
{
}

実際のクラス名を使うと、次のようになります。

C#
public class Vehicle
{
public void Start()
{
Console.WriteLine("乗り物が動き始めました。");
}
}

public class Car : Vehicle
{
}

CarクラスはVehicleクラスを継承しているため、Startメソッドを利用できます。

C#
Car car = new Car();
car.Start();

派生クラスを定義するときに、基底クラスのメンバーをもう一度記述する必要はありません。

2-2. 基底クラスのフィールド・プロパティ・メソッドを引き継ぐ例

基底クラスには、フィールド、プロパティ、メソッドなどを定義できます。

C#
public class Employee
{
protected int employeeId;

public string Name { get; set; } = string.Empty;

public void Work()
{
Console.WriteLine($"{Name}が働いています。");
}
}

public class Engineer : Employee
{
public void ShowEmployeeId()
{
Console.WriteLine($"社員ID: {employeeId}");
}
}

Engineerクラスでは、基底クラスのNameプロパティとWorkメソッドを利用できます。また、employeeIdフィールドはprotectedであるため、派生クラスの内部から参照できます。

C#
Engineer engineer = new Engineer
{
Name = "佐藤"
};

engineer.Work();
engineer.ShowEmployeeId();

ただし、基底クラスに定義されたすべてのメンバーを、派生クラスから自由に参照できるわけではありません。利用できる範囲はアクセス修飾子によって決まります。

2-3. 派生クラスに独自のメンバーを追加する方法

派生クラスには、基底クラスから引き継いだメンバーに加えて、独自のプロパティやメソッドを追加できます。

C#
public class Employee
{
public string Name { get; set; } = string.Empty;

public void Work()
{
Console.WriteLine($"{Name}が働いています。");
}
}

public class Engineer : Employee
{
public string ProgrammingLanguage { get; set; } = string.Empty;

public void WriteCode()
{
Console.WriteLine(
$"{Name}が{ProgrammingLanguage}でコードを書いています。"
);
}
}

利用例は次のとおりです。

C#
Engineer engineer = new Engineer
{
Name = "田中",
ProgrammingLanguage = "C#"
};

engineer.Work();
engineer.WriteCode();

Workは基底クラス由来のメソッドであり、WriteCodeは派生クラス独自のメソッドです。

2-4. 派生クラスのインスタンスを基底クラス型で扱う方法

派生クラスのインスタンスは、基底クラス型の変数に代入できます。

C#
Animal animal = new Dog();

これは、犬が動物の一種であるためです。このような変換をアップキャストと呼びます。

C#
public class Animal
{
public string Name { get; set; } = string.Empty;

public void Eat()
{
Console.WriteLine($"{Name}が食事をしています。");
}
}

public class Dog : Animal
{
public void Bark()
{
Console.WriteLine($"{Name}がワンと鳴きました。");
}
}

次のコードでは、変数の型がAnimalなので、コンパイル時に利用できるのはAnimal型から見えるメンバーです。

C#
Animal animal = new Dog
{
Name = "ポチ"
};

animal.Eat();

実体はDogですが、次の呼び出しはそのままではできません。

C#
// コンパイルエラー
// animal.Bark();

Dog固有のメソッドを呼び出すには、型を確認してダウンキャストします。

C#
if (animal is Dog dog)
{
dog.Bark();
}

基底クラス型で扱えることは、複数の派生クラスを同じコレクションに格納したり、共通の引数として受け取ったりするときに役立ちます。

2-5. C#では複数のクラスを継承できない

C#のクラスが直接継承できるクラスは、一つだけです。

次のように、複数のクラスを同時に継承することはできません。

C#
public class Printer
{
}

public class Scanner
{
}

// コンパイルエラー
// public class MultiFunctionDevice : Printer, Scanner
// {
// }

ただし、クラスを一つ継承しながら、複数のインターフェイスを実装することは可能です。

C#
public interface IPrintable
{
void Print();
}

public interface IScannable
{
void Scan();
}

public class Device
{
}

public class MultiFunctionDevice
: Device, IPrintable, IScannable
{
public void Print()
{
Console.WriteLine("印刷します。");
}

public void Scan()
{
Console.WriteLine("読み取ります。");
}
}

コロンの後ろには、基底クラスを最初に記述し、その後に実装するインターフェイスを記述します。

3. baseキーワードの使い方

baseキーワードは、派生クラスから基底クラスのメンバーやコンストラクターを明示的に参照するときに使います。

主な用途は次の2つです。

  • 基底クラスのメソッドやプロパティを呼び出す

  • 基底クラスのコンストラクターを呼び出す

3-1. baseで基底クラスのメンバーを呼び出す方法

派生クラスの内部では、base.メンバー名という形式で基底クラスのメンバーを呼び出せます。

C#
public class Person
{
protected string Name { get; set; } = string.Empty;

public void ShowName()
{
Console.WriteLine($"名前: {Name}");
}
}

public class Student : Person
{
public void Introduce()
{
base.ShowName();
Console.WriteLine("私は学生です。");
}
}

base.ShowName()は、基底クラスであるPersonShowNameメソッドを呼び出しています。

通常、名前が重複していなければbaseを付けずに呼び出すこともできます。しかし、基底クラスのメンバーを使っていることを明示したい場合や、派生クラス側で同名のメンバーを定義している場合にbaseが役立ちます。

3-2. baseでオーバーライド前のメソッドを実行する方法

派生クラスでメソッドをオーバーライドした後も、baseを使えば基底クラス側の元の処理を呼び出せます。

C#
public class Animal
{
public virtual void Speak()
{
Console.WriteLine("動物が鳴きます。");
}
}

public class Dog : Animal
{
public override void Speak()
{
base.Speak();
Console.WriteLine("ワン!");
}
}

呼び出し例は次のとおりです。

C#
Dog dog = new Dog();
dog.Speak();

実行結果は次のようになります。

動物が鳴きます。
ワン!

この方法は、基底クラスの共通処理を実行したうえで、派生クラス固有の処理を追加したい場合に便利です。

3-3. baseを使って基底クラスのコンストラクターを呼び出す方法

派生クラスのコンストラクターから基底クラスのコンストラクターを呼び出す場合は、コンストラクター宣言の後ろに: base(...)を記述します。

C#
public class Person
{
public string Name { get; }

public Person(string name)
{
Name = name;
Console.WriteLine("Personのコンストラクター");
}
}

public class Student : Person
{
public int Grade { get; }

public Student(string name, int grade)
: base(name)
{
Grade = grade;
Console.WriteLine("Studentのコンストラクター");
}
}

インスタンスを生成すると、基底クラスのコンストラクターが先に実行され、その後に派生クラスのコンストラクターが実行されます。

C#
Student student = new Student("山田", 2);

実行結果は次のとおりです。

Personのコンストラクター
Studentのコンストラクター

この順序により、基底クラス側の状態が初期化された後で、派生クラス側の初期化処理が行われます。

3-4. 引数付きコンストラクターを継承するときの注意点

コンストラクター自体は、通常のメソッドやプロパティのように派生クラスへ継承されません。

また、基底クラスに引数なしコンストラクターが存在しない場合、派生クラスのコンストラクターから呼び出す基底クラスのコンストラクターを明示する必要があります。

C#
public class Product
{
public string Name { get; }

public Product(string name)
{
Name = name;
}
}

public class Book : Product
{
public string Author { get; }

public Book(string name, string author)
: base(name)
{
Author = author;
}
}

: base(name)を省略すると、コンパイラーは基底クラスの引数なしコンストラクターを呼び出そうとします。しかし、Productには引数なしコンストラクターがないため、コンパイルエラーになります。

なお、C#ではusing宣言のような形で、基底クラスのコンストラクターを一括して派生クラスへ引き継ぐことはできません。必要なコンストラクターは派生クラス側にも定義します。

3-5. baseとthisの違い

basethisは、どちらもクラスのメンバーやコンストラクターを参照するときに使いますが、参照先が異なります。

  • this:現在のインスタンスを表す

  • base:現在のインスタンスに含まれる基底クラス部分を表す

次の例では、this.Nameが現在のオブジェクトのプロパティを指しています。

C#
public class Person
{
public string Name { get; }

public Person(string name)
{
this.Name = name;
}
}

コンストラクターから同じクラスの別のコンストラクターを呼ぶ場合は、: this(...)を使います。

C#
public class User
{
public string Name { get; }
public int Age { get; }

public User(string name)
: this(name, 0)
{
}

public User(string name, int age)
{
Name = name;
Age = age;
}
}

一方、基底クラスのコンストラクターを呼ぶ場合は: base(...)を使います。

C#
public class Member : User
{
public Member(string name, int age)
: base(name, age)
{
}
}

一つのコンストラクターで、: this(...): base(...)を同時に指定することはできません。: this(...)で別のコンストラクターを呼んだ場合は、最終的に呼び出されたコンストラクターから基底クラスのコンストラクターが実行されます。

4. 基底クラスのメソッドをオーバーライドする方法

オーバーライドとは、基底クラスに定義されたメソッドの処理を、派生クラスで置き換える仕組みです。

基底クラスではvirtualまたはabstractを指定し、派生クラスではoverrideを指定します。

4-1. virtualとoverrideの基本的な使い方

virtualを付けたメソッドは、派生クラスで必要に応じてオーバーライドできます。

C#
public class Animal
{
public virtual void Speak()
{
Console.WriteLine("動物が鳴きます。");
}
}

public class Dog : Animal
{
public override void Speak()
{
Console.WriteLine("ワン!");
}
}

public class Cat : Animal
{
public override void Speak()
{
Console.WriteLine("ニャー!");
}
}

利用例は次のとおりです。

C#
Dog dog = new Dog();
Cat cat = new Cat();

dog.Speak();
cat.Speak();

実行結果は次のようになります。

ワン!
ニャー!

virtualメソッドには、基底クラス側の標準的な実装があります。派生クラスは、その実装をそのまま利用することも、overrideで変更することも可能です。

4-2. 基底クラス型の変数で派生クラスの処理が呼ばれる仕組み

オーバーライドされたメソッドは、変数の型ではなく、実際に代入されているインスタンスの型に応じて呼び分けられます。

C#
Animal animal1 = new Dog();
Animal animal2 = new Cat();

animal1.Speak();
animal2.Speak();

実行結果は次のとおりです。

ワン!
ニャー!

変数の型はいずれもAnimalですが、実体はそれぞれDogCatです。そのため、各派生クラスでオーバーライドされたSpeakメソッドが実行されます。

この仕組みはポリモーフィズム、多態性、または多相性と呼ばれます。呼び出す側は具体的な派生クラスを意識せず、基底クラス型を通じて共通の操作を実行できます。

4-3. overrideとnewによるメソッドの隠蔽の違い

派生クラスで基底クラスと同名のメソッドを定義する方法には、overrideのほかにnewがあります。ただし、この2つの動作は異なります。

overrideは、基底クラスの仮想メソッドを上書きします。

C#
public class BaseClass
{
public virtual void Show()
{
Console.WriteLine("BaseClass.Show");
}
}

public class DerivedClass : BaseClass
{
public override void Show()
{
Console.WriteLine("DerivedClass.Show");
}
}

次のコードでは、変数が基底クラス型でも派生クラスのメソッドが呼ばれます。

C#
BaseClass value = new DerivedClass();
value.Show();

実行結果は次のとおりです。

DerivedClass.Show

一方、newは基底クラスのメソッドを上書きするのではなく、同名のメソッドを隠します。

C#
public class BaseClass
{
public void Show()
{
Console.WriteLine("BaseClass.Show");
}
}

public class DerivedClass : BaseClass
{
public new void Show()
{
Console.WriteLine("DerivedClass.Show");
}
}

呼び出されるメソッドは、変数の宣言上の型によって変わります。

C#
DerivedClass derived = new DerivedClass();
BaseClass based = derived;

derived.Show();
based.Show();

実行結果は次のとおりです。

DerivedClass.Show
BaseClass.Show

動的に処理を切り替えたい場合は、通常、virtualoverrideを使います。newによる隠蔽は意図が分かりにくくなりやすいため、明確な理由がある場合に限定するのが安全です。

4-4. abstractメソッドとvirtualメソッドの違い

virtualメソッドには、基底クラス側の実装があります。派生クラスによるオーバーライドは任意です。

C#
public class Animal
{
public virtual void Move()
{
Console.WriteLine("移動します。");
}
}

一方、abstractメソッドには実装がありません。具象派生クラスは、原則としてそのメソッドを実装しなければなりません。

C#
public abstract class Animal
{
public abstract void Speak();
}

public class Dog : Animal
{
public override void Speak()
{
Console.WriteLine("ワン!");
}
}

abstractメソッドを宣言できるのは、abstractクラスの中だけです。また、抽象メソッドにはメソッド本体を記述しません。

使い分けの目安は次のとおりです。

  • 標準実装を用意し、必要な派生クラスだけ変更する:virtual

  • 派生クラスごとの実装を必須にする:abstract

4-5. sealed overrideで再オーバーライドを禁止する方法

オーバーライドしたメソッドにsealedを付けると、さらに下位の派生クラスでの再オーバーライドを禁止できます。

C#
public class Animal
{
public virtual void Speak()
{
Console.WriteLine("動物が鳴きます。");
}
}

public class Dog : Animal
{
public sealed override void Speak()
{
Console.WriteLine("ワン!");
}
}

public class GuideDog : Dog
{
// コンパイルエラー
// public override void Speak()
// {
// }
}

sealedだけをメソッドに付けることはできません。再オーバーライドを禁止する場合は、sealed overrideという組み合わせで指定します。

処理の意味や契約をこれ以上変更させたくない場合に利用できますが、拡張性が下がるため、必要性を検討したうえで使用します。

5. 基底クラスのメンバーとアクセス修飾子

派生クラスが基底クラスのメンバーを利用できるかどうかは、アクセス修飾子によって決まります。

継承したからといって、基底クラスのすべてのメンバーを派生クラスから直接参照できるわけではありません。

5-1. publicメンバーを継承した場合

publicメンバーは、派生クラスの内部だけでなく、クラスの外部からも利用できます。

C#
public class Person
{
public string Name { get; set; } = string.Empty;

public void Introduce()
{
Console.WriteLine($"私は{Name}です。");
}
}

public class Student : Person
{
}

次のように、Studentのインスタンスから利用できます。

C#
Student student = new Student
{
Name = "鈴木"
};

student.Introduce();

publicは利用範囲が広いため、外部へ公開する必要があるメンバーにだけ指定します。

5-2. protectedメンバーを派生クラスから利用する方法

protectedメンバーは、定義したクラス自身と、その派生クラスから利用できます。ただし、通常はクラス外部から直接利用できません。

C#
public class Account
{
protected decimal balance;

public Account(decimal initialBalance)
{
balance = initialBalance;
}
}

public class SavingsAccount : Account
{
public SavingsAccount(decimal initialBalance)
: base(initialBalance)
{
}

public void AddInterest(decimal rate)
{
balance += balance * rate;
}

public decimal GetBalance()
{
return balance;
}
}

利用例は次のとおりです。

C#
SavingsAccount account = new SavingsAccount(10000m);
account.AddInterest(0.01m);

Console.WriteLine(account.GetBalance());

// protectedなので外部からは参照できない
// Console.WriteLine(account.balance);

派生クラスにだけ公開したい実装用のメンバーには、protectedを利用できます。

5-3. privateメンバーを派生クラスから直接参照できない理由

privateメンバーは、そのメンバーを宣言したクラスの内部からだけ参照できます。派生クラスも、基底クラスのprivateメンバーへ直接アクセスすることはできません。

C#
public class BankAccount
{
private decimal balance;

public BankAccount(decimal initialBalance)
{
balance = initialBalance;
}

public decimal GetBalance()
{
return balance;
}
}

public class PremiumAccount : BankAccount
{
public PremiumAccount(decimal initialBalance)
: base(initialBalance)
{
}

public void ShowBalance()
{
// コンパイルエラー
// Console.WriteLine(balance);

Console.WriteLine(GetBalance());
}
}

privateメンバーが派生クラスから直接見えないのは、基底クラス内部の実装を保護するためです。

基底クラスが内部構造を変更しても、派生クラスが公開されたメソッドやプロパティだけを利用していれば、影響を小さくできます。派生クラスに値を操作させたい場合でも、フィールド自体を公開するのではなく、検証処理を含むprotectedメソッドなどを用意する方法があります。

C#
public class BankAccount
{
private decimal balance;

protected void Deposit(decimal amount)
{
if (amount <= 0)
{
throw new ArgumentOutOfRangeException(nameof(amount));
}

balance += amount;
}

public decimal Balance => balance;
}

5-4. protected internalとprivate protectedの違い

protected internalprivate protectedは名前が似ていますが、公開範囲が異なります。

protected internalは、次のどちらかを満たすコードから利用できます。

  • 同じアセンブリ内にある

  • 別のアセンブリでも派生クラスである

つまり、「同じアセンブリ、または派生クラス」という比較的広い範囲です。

C#
public class BaseClass
{
protected internal void MethodA()
{
Console.WriteLine("protected internal");
}
}

一方、private protectedは、次の両方を満たすコードから利用できます。

  • 同じアセンブリ内にある

  • 派生クラスである

つまり、「同じアセンブリ内の派生クラス」に限定されます。

C#
public class BaseClass
{
private protected void MethodB()
{
Console.WriteLine("private protected");
}
}

違いをまとめると、次のようになります。

アクセス修飾子同じアセンブリの非派生クラス同じアセンブリの派生クラス別アセンブリの派生クラス
protected internal利用可能利用可能利用可能
private protected利用不可利用可能利用不可

ライブラリを設計するときは、別アセンブリの派生クラスにも公開する必要があるかどうかで使い分けます。

5-5. 基底クラスのメンバーとカプセル化の考え方

基底クラスを設計するときは、派生クラスから利用できる範囲を必要最小限にすることが重要です。

フィールドをprotectedにすると派生クラスから自由に変更できますが、基底クラスが状態を管理しにくくなります。

たとえば、残高を表すフィールドを直接変更できる設計では、負の値や不正な値が設定される可能性があります。

C#
public class Account
{
protected decimal balance;
}

より安全なのは、フィールドをprivateにし、必要な操作をメソッドとして公開する設計です。

C#
public class Account
{
private decimal balance;

public decimal Balance => balance;

protected void ChangeBalance(decimal amount)
{
decimal newBalance = balance + amount;

if (newBalance < 0)
{
throw new InvalidOperationException(
"残高を0未満にはできません。"
);
}

balance = newBalance;
}
}

この設計なら、派生クラスはChangeBalanceを通して残高を変更するため、基底クラス側でルールを一元管理できます。

継承では派生クラスとの結び付きが強くなるため、公開するメンバーの変更は広い範囲へ影響します。privateを基本とし、派生クラスに必要な機能だけをprotectedpublicで公開するとよいでしょう。

6. 基底クラスと抽象クラスの違い

抽象クラスも基底クラスとして利用できますが、通常のクラスとはインスタンス化できるかどうかが異なります。

なお、「基底クラス」は継承関係における役割を表す言葉です。一方、「抽象クラス」はabstractを指定したクラスの種類を表します。そのため、抽象クラスは基底クラスとして使えますが、基底クラスが必ず抽象クラスであるとは限りません。

6-1. 通常の基底クラスはインスタンス化できる

通常のクラスは、基底クラスとして利用されていてもインスタンス化できます。

C#
public class Animal
{
public void Eat()
{
Console.WriteLine("食事をします。");
}
}

次のように、newを使って直接インスタンスを作れます。

C#
Animal animal = new Animal();
animal.Eat();

通常の基底クラスは、そのクラス自体を具体的なオブジェクトとして扱っても不自然でない場合に利用できます。

6-2. abstractクラスはインスタンス化できない

abstractを付けたクラスは抽象クラスになり、直接インスタンス化できません。

C#
public abstract class Animal
{
public void Eat()
{
Console.WriteLine("食事をします。");
}
}

次のコードはコンパイルエラーになります。

C#
// コンパイルエラー
// Animal animal = new Animal();

抽象クラスを利用するには、抽象クラスを継承した具象クラスを作成します。

C#
public class Dog : Animal
{
}

Dog dog = new Dog();
dog.Eat();

「動物」という概念だけでは具体的なインスタンスを作るべきではなく、「犬」や「猫」のような具体的な型を通して利用させたい場合に抽象クラスが適しています。

6-3. 抽象クラスに実装済みメンバーを定義する方法

抽象クラスには、抽象メソッドだけでなく、実装済みのプロパティやメソッドも定義できます。

C#
public abstract class Animal
{
public string Name { get; }

protected Animal(string name)
{
Name = name;
}

public void Eat()
{
Console.WriteLine($"{Name}が食事をしています。");
}

public virtual void Sleep()
{
Console.WriteLine($"{Name}が眠っています。");
}
}

抽象クラスにはコンストラクターも定義できます。抽象クラスを直接インスタンス化することはできませんが、そのコンストラクターは派生クラスのインスタンス生成時に呼び出されます。

C#
public class Dog : Animal
{
public Dog(string name)
: base(name)
{
}
}

このように、抽象クラスは共通実装を提供しながら、派生クラスの土台として利用できます。

6-4. 抽象メソッドを派生クラスに実装させる方法

抽象メソッドを宣言すると、具象派生クラスに実装を強制できます。

C#
public abstract class Animal
{
public abstract void Speak();
}

このクラスを継承する具象クラスは、Speakメソッドをoverrideしなければなりません。

C#
public class Dog : Animal
{
public override void Speak()
{
Console.WriteLine("ワン!");
}
}

public class Cat : Animal
{
public override void Speak()
{
Console.WriteLine("ニャー!");
}
}

実装しなかった場合はコンパイルエラーになります。

ただし、派生クラス自身もabstractにする場合は、そこで抽象メソッドを実装せず、さらに下位の派生クラスへ実装を引き継がせることができます。

C#
public abstract class Mammal : Animal
{
}

6-5. 通常クラスと抽象クラスの使い分け

通常クラスと抽象クラスは、次のような基準で使い分けられます。

通常クラスが適しているのは、基底クラス自体にも具体的な意味があり、単独でインスタンス化して問題ない場合です。

C#
public class Employee
{
public virtual void Work()
{
Console.WriteLine("仕事をします。");
}
}

抽象クラスが適しているのは、基底クラスを共通設計としてだけ利用し、具体的な派生クラスを通して使わせたい場合です。

C#
public abstract class Shape
{
public abstract double GetArea();
}

特に、すべての派生クラスに必ず実装させたい処理がある場合は、抽象クラスと抽象メソッドが有効です。

7. 基底クラス・抽象クラス・インターフェイスの違い

基底クラス、抽象クラス、インターフェイスは、いずれも複数の型に共通する仕組みを表現できます。ただし、用途や制約が異なります。

7-1. 継承できるクラス数の違い

C#のクラスが直接継承できるクラスは一つだけです。これは、通常クラスでも抽象クラスでも同じです。

C#
public class Dog : Animal
{
}

一方、一つのクラスが複数のインターフェイスを実装することは可能です。

C#
public interface IRunnable
{
void Run();
}

public interface ISwimmable
{
void Swim();
}

public class Dog : Animal, IRunnable, ISwimmable
{
public void Run()
{
Console.WriteLine("走ります。");
}

public void Swim()
{
Console.WriteLine("泳ぎます。");
}
}

この違いにより、クラス継承は主となる型の階層を表すために使い、インターフェイスは複数の能力や役割を表すために使う設計が一般的です。

7-2. フィールドやコンストラクターを持てるかの違い

通常の基底クラスと抽象クラスは、インスタンスフィールドやコンストラクターを持てます。

C#
public abstract class Animal
{
private readonly string name;

protected Animal(string name)
{
this.name = name;
}

public string Name => name;
}

インターフェイスには、クラスのようなインスタンスフィールドやインスタンスコンストラクターを定義できません。

C#
public interface INamed
{
string Name { get; }
}

インターフェイスにはプロパティやメソッドなどの契約を定義できます。現在のC#では、一定の条件で実装を持つメンバーや静的メンバーも定義できますが、インスタンスごとの状態をクラスのフィールドとして保持する用途には向いていません。

オブジェクトの共通状態を管理したい場合は、基底クラスや抽象クラスが適しています。

7-3. 共通実装を再利用したい場合の選び方

複数のクラスで共通実装や共通状態を再利用したい場合は、通常の基底クラスまたは抽象クラスが候補になります。

C#
public abstract class FileProcessor
{
public void Execute(string path)
{
Validate(path);
Process(path);
}

private static void Validate(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException(
"パスを指定してください。",
nameof(path)
);
}
}

protected abstract void Process(string path);
}

Executeの処理はすべての派生クラスで共通利用し、ファイルごとに異なる処理だけをProcessとして実装できます。

C#
public class CsvProcessor : FileProcessor
{
protected override void Process(string path)
{
Console.WriteLine($"CSVを処理します: {path}");
}
}

共通実装が必要で、基底クラスを直接作成させたくない場合は、抽象クラスが適しています。

7-4. 複数の型に共通の機能を持たせたい場合の選び方

継承関係の異なる複数のクラスに共通の能力を持たせたい場合は、インターフェイスが適しています。

たとえば、人物と機械は同じ基底クラスにまとめにくいものの、どちらも作業できる可能性があります。

C#
public interface IWorker
{
void Work();
}

public class Person : IWorker
{
public void Work()
{
Console.WriteLine("人が作業します。");
}
}

public class Robot : IWorker
{
public void Work()
{
Console.WriteLine("ロボットが作業します。");
}
}

利用側は、具体的なクラスに依存せず、IWorkerとして扱えます。

C#
static void StartWork(IWorker worker)
{
worker.Work();
}

StartWork(new Person());
StartWork(new Robot());

「何であるか」という分類を表したい場合はクラス継承、「何ができるか」という能力を表したい場合はインターフェイスが候補になります。

7-5. 抽象クラスとインターフェイスを併用する例

抽象クラスとインターフェイスは、どちらか一方だけを選ぶ必要はありません。共通状態や共通実装を抽象クラスにまとめ、追加の能力をインターフェイスで表せます。

C#
public interface ITrainable
{
void Train();
}

public abstract class Animal
{
public string Name { get; }

protected Animal(string name)
{
Name = name;
}

public void Eat()
{
Console.WriteLine($"{Name}が食事をしています。");
}

public abstract void Speak();
}

public class Dog : Animal, ITrainable
{
public Dog(string name)
: base(name)
{
}

public override void Speak()
{
Console.WriteLine($"{Name}: ワン!");
}

public void Train()
{
Console.WriteLine($"{Name}が訓練を受けています。");
}
}

この例では、Animalが動物としての共通状態と処理を提供し、ITrainableが訓練できるという能力を表しています。

8. 基底クラスを使った実践的なサンプルコード

ここでは、動物を題材にして、基底クラス、コンストラクター、オーバーライド、基底クラス型のコレクションを段階的に実装します。

8-1. Animal基底クラスとDog派生クラスを作る

まず、名前と年齢を持つAnimal基底クラスを作成します。

C#
public class Animal
{
public string Name { get; set; } = string.Empty;
public int Age { get; set; }

public void Eat()
{
Console.WriteLine($"{Name}が食事をしています。");
}
}

次に、Animalを継承するDogクラスを作成します。

C#
public class Dog : Animal
{
public void Fetch()
{
Console.WriteLine($"{Name}がボールを取ってきました。");
}
}

利用例は次のとおりです。

C#
Dog dog = new Dog
{
Name = "ポチ",
Age = 3
};

dog.Eat();
dog.Fetch();

DogAnimalからNameAgeEatを引き継ぎ、独自にFetchを追加しています。

8-2. コンストラクターをbaseで連携させる

次に、プロパティをコンストラクターで初期化する形へ変更します。

C#
public class Animal
{
public string Name { get; }
public int Age { get; }

public Animal(string name, int age)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(
"名前を指定してください。",
nameof(name)
);
}

if (age < 0)
{
throw new ArgumentOutOfRangeException(nameof(age));
}

Name = name;
Age = age;
}

public void Eat()
{
Console.WriteLine($"{Name}が食事をしています。");
}
}

Dogクラスから、baseを使ってAnimalのコンストラクターを呼び出します。

C#
public class Dog : Animal
{
public string Breed { get; }

public Dog(string name, int age, string breed)
: base(name, age)
{
Breed = breed;
}

public void Fetch()
{
Console.WriteLine($"{Name}がボールを取ってきました。");
}
}

インスタンスの生成方法は次のとおりです。

C#
Dog dog = new Dog("ポチ", 3, "柴犬");

Console.WriteLine($"名前: {dog.Name}");
Console.WriteLine($"年齢: {dog.Age}");
Console.WriteLine($"犬種: {dog.Breed}");

名前と年齢の検証や初期化は基底クラスにまとめられるため、派生クラスごとに同じ処理を繰り返す必要がありません。

8-3. メソッドをoverrideして派生クラスごとに動作を変える

動物ごとに鳴き声を変えられるように、Speakメソッドをvirtualにします。

C#
public class Animal
{
public string Name { get; }
public int Age { get; }

public Animal(string name, int age)
{
Name = name;
Age = age;
}

public virtual void Speak()
{
Console.WriteLine($"{Name}が鳴きました。");
}
}

DogCatでそれぞれオーバーライドします。

C#
public class Dog : Animal
{
public Dog(string name, int age)
: base(name, age)
{
}

public override void Speak()
{
Console.WriteLine($"{Name}: ワン!");
}
}

public class Cat : Animal
{
public Cat(string name, int age)
: base(name, age)
{
}

public override void Speak()
{
Console.WriteLine($"{Name}: ニャー!");
}
}

利用例は次のとおりです。

C#
Dog dog = new Dog("ポチ", 3);
Cat cat = new Cat("タマ", 2);

dog.Speak();
cat.Speak();

派生クラスごとに異なる処理を定義しながら、呼び出し方をSpeakに統一できます。

8-4. 基底クラス型のコレクションで複数の派生クラスを扱う

DogCatはどちらもAnimalを継承しているため、List<Animal>へまとめて格納できます。

C#
List<Animal> animals = new List<Animal>
{
new Dog("ポチ", 3),
new Cat("タマ", 2),
new Dog("コタロウ", 5)
};

foreach (Animal animal in animals)
{
animal.Speak();
}

実行結果は次のとおりです。

ポチ: ワン!
タマ: ニャー!
コタロウ: ワン!

ループ内の変数はAnimal型ですが、実際のインスタンスに応じてオーバーライドされたメソッドが呼ばれます。

この仕組みにより、新しい派生クラスを追加しても、コレクションを処理する側のコードを大きく変更せずに済みます。

C#
public class Bird : Animal
{
public Bird(string name, int age)
: base(name, age)
{
}

public override void Speak()
{
Console.WriteLine($"{Name}: ピヨピヨ!");
}
}
C#
animals.Add(new Bird("ピーちゃん", 1));

8-5. 抽象基底クラスに書き換えて実装を強制する

すべての動物に固有の鳴き声を実装させたい場合は、Animalを抽象クラスにし、Speakを抽象メソッドにします。

C#
public abstract class Animal
{
public string Name { get; }
public int Age { get; }

protected Animal(string name, int age)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException(
"名前を指定してください。",
nameof(name)
);
}

if (age < 0)
{
throw new ArgumentOutOfRangeException(nameof(age));
}

Name = name;
Age = age;
}

public void Eat()
{
Console.WriteLine($"{Name}が食事をしています。");
}

public abstract void Speak();
}

派生クラスはSpeakを実装します。

C#
public sealed class Dog : Animal
{
public Dog(string name, int age)
: base(name, age)
{
}

public override void Speak()
{
Console.WriteLine($"{Name}: ワン!");
}
}

public sealed class Cat : Animal
{
public Cat(string name, int age)
: base(name, age)
{
}

public override void Speak()
{
Console.WriteLine($"{Name}: ニャー!");
}
}

全体の利用例は次のとおりです。

C#
List<Animal> animals = new()
{
new Dog("ポチ", 3),
new Cat("タマ", 2)
};

foreach (Animal animal in animals)
{
Console.WriteLine(
$"{animal.Name}は{animal.Age}歳です。"
);

animal.Eat();
animal.Speak();
}

抽象クラスにすることで、Animal自体のインスタンス化を禁止し、各具象派生クラスに鳴き方の実装を求められます。

9. C#の基底クラスでよくあるエラーと対処法

基底クラスや継承を利用すると、アクセス修飾子、コンストラクター、オーバーライドの指定などに関するエラーが発生することがあります。

エラーメッセージだけでなく、基底クラスと派生クラスのどちらに原因があるかを確認することが大切です。

9-1. アクセスできない保護レベルと表示される

基底クラスのprivateメンバーを派生クラスから参照すると、保護レベルに関するエラーが発生します。

C#
public class Person
{
private string name = string.Empty;
}

public class Student : Person
{
public void ShowName()
{
// エラー
// Console.WriteLine(name);
}
}

派生クラスから参照させる必要がある場合は、次のような対処方法があります。

  • protectedプロパティやメソッドを用意する

  • publicな読み取り専用プロパティを用意する

  • 基底クラス側に必要な操作を行うメソッドを用意する

たとえば、直接フィールドを公開せず、読み取り専用プロパティを使います。

C#
public class Person
{
private readonly string name;

public Person(string name)
{
this.name = name;
}

public string Name => name;
}

派生クラスでは次のように参照できます。

C#
public class Student : Person
{
public Student(string name)
: base(name)
{
}

public void ShowName()
{
Console.WriteLine(Name);
}
}

9-2. オーバーライドする適切なメソッドがないと表示される

派生クラスでoverrideを指定したのに、対応する仮想メソッドが基底クラスに存在しない場合、エラーになります。

C#
public class Animal
{
public void Speak()
{
Console.WriteLine("鳴きます。");
}
}

public class Dog : Animal
{
// エラー
// public override void Speak()
// {
// Console.WriteLine("ワン!");
// }
}

overrideするには、基底クラスのメソッドにvirtualabstract、またはoverrideが指定されている必要があります。

C#
public class Animal
{
public virtual void Speak()
{
Console.WriteLine("鳴きます。");
}
}

public class Dog : Animal
{
public override void Speak()
{
Console.WriteLine("ワン!");
}
}

また、メソッド名だけでなく、引数の型や数、戻り値なども対応している必要があります。

C#
public class Animal
{
public virtual void Speak(int count)
{
}
}

public class Dog : Animal
{
// 引数が一致しないためエラー
// public override void Speak()
// {
// }
}

9-3. 基底クラスに引数なしコンストラクターがなくエラーになる

基底クラスに引数付きコンストラクターしかない場合、派生クラスのコンストラクターで: base(...)を指定しないとエラーになります。

C#
public class Person
{
public Person(string name)
{
}
}

public class Student : Person
{
// エラー
// public Student()
// {
// }
}

派生クラスから、存在する基底クラスのコンストラクターを呼び出します。

C#
public class Student : Person
{
public Student(string name)
: base(name)
{
}
}

別の対処法として、設計上問題がなければ基底クラスに引数なしコンストラクターを追加することもできます。

C#
public class Person
{
public Person()
{
}

public Person(string name)
{
}
}

ただし、不完全な状態のインスタンスが作られる可能性がある場合は、安易に引数なしコンストラクターを追加せず、必要な値を派生クラスから渡す方が安全です。

9-4. sealedクラスを継承しようとしてエラーになる

sealedが指定されたクラスは継承できません。

C#
public sealed class Utility
{
}

// エラー
// public class CustomUtility : Utility
// {
// }

sealedは、そのクラスを継承によって拡張させたくない場合に使います。

自分で管理しているクラスなら、継承を許可して問題がないかを検討したうえでsealedを外す方法があります。ただし、外部ライブラリのsealedクラスは変更できません。

その場合は、継承ではなく、対象クラスのインスタンスをフィールドやプロパティとして保持するコンポジションを検討します。

C#
public sealed class Utility
{
public void Execute()
{
Console.WriteLine("処理を実行します。");
}
}

public class CustomUtility
{
private readonly Utility utility = new();

public void Execute()
{
Console.WriteLine("前処理");
utility.Execute();
Console.WriteLine("後処理");
}
}

9-5. 抽象メンバーを実装していないと表示される

抽象クラスを継承した具象クラスは、継承した抽象メンバーを実装する必要があります。

C#
public abstract class Animal
{
public abstract void Speak();
}

// エラー
// public class Dog : Animal
// {
// }

overrideを指定して実装します。

C#
public class Dog : Animal
{
public override void Speak()
{
Console.WriteLine("ワン!");
}
}

すぐに実装できず、さらに下位の派生クラスへ実装を任せたい場合は、派生クラス自身もabstractにします。

C#
public abstract class Mammal : Animal
{
}

また、抽象プロパティや抽象イベントが定義されている場合は、それらも実装対象です。エラーが解消しないときは、未実装メンバーがほかに残っていないか確認します。

9-6. baseと基底クラスのインスタンス生成を混同する

baseは、別の基底クラスインスタンスを生成するためのキーワードではありません。現在の派生クラスインスタンスに含まれる、基底クラス側のメンバーやコンストラクターを参照するためのキーワードです。

C#
public class Dog : Animal
{
public override void Speak()
{
base.Speak();
Console.WriteLine("ワン!");
}
}

このbase.Speak()が操作しているのは、Dogとは別に作られたAnimalインスタンスではありません。同じDogインスタンスに対して、基底クラスで定義された実装を呼び出しています。

基底クラスの独立したインスタンスが必要なら、通常のnewを使います。

C#
Animal animal = new Animal();

ただし、基底クラスが抽象クラスの場合は直接インスタンス化できません。

C#
public abstract class Animal
{
}

// エラー
// Animal animal = new Animal();

また、静的メソッドの中では、現在のインスタンスが存在しないためbasethisを使用できません。

10. 基底クラスを設計するときの注意点

基底クラスを使うとコードを再利用できますが、継承はクラス同士を強く結び付けます。将来の変更や拡張も考慮して設計する必要があります。

10-1. 継承は「is-a」の関係が成り立つ場合に使う

継承を使う基本的な判断基準は、派生クラスが基底クラスの一種であるかどうかです。この関係は「is-a」と呼ばれます。

たとえば、次の関係は自然です。

  • 犬は動物である

  • 自動車は乗り物である

  • 正方形は図形である

  • 管理者ユーザーはユーザーである

C#
public class Vehicle
{
}

public class Car : Vehicle
{
}

一方、「自動車はエンジンである」という関係は成り立ちません。自動車はエンジンを持っているのであり、エンジンの一種ではないためです。

C#
// 不自然な設計
// public class Car : Engine
// {
// }

この場合は、継承ではなくフィールドやプロパティとして保持します。

C#
public class Engine
{
public void Start()
{
Console.WriteLine("エンジンを始動します。");
}
}

public class Car
{
private readonly Engine engine = new();

public void Start()
{
engine.Start();
}
}

これは「has-a」の関係と呼ばれます。

10-2. コードの重複削減だけを目的に継承しない

似たコードがあるだけで継承関係を作ると、意味の異なるクラスが不自然に結び付くことがあります。

たとえば、画面への出力処理を再利用したいという理由だけで、関連性のないクラスを継承させるのは適切ではありません。

C#
// 「ログを出力したい」という理由だけで継承するのは不自然
public class Logger
{
public void Log(string message)
{
Console.WriteLine(message);
}
}

// public class OrderService : Logger
// {
// }

この場合、ロガーを依存オブジェクトとして受け取る方が役割を分離できます。

C#
public interface ILogger
{
void Log(string message);
}

public class ConsoleLogger : ILogger
{
public void Log(string message)
{
Console.WriteLine(message);
}
}

public class OrderService
{
private readonly ILogger logger;

public OrderService(ILogger logger)
{
this.logger = logger;
}

public void PlaceOrder()
{
logger.Log("注文を登録しました。");
}
}

継承を検討するときは、コードが共通しているかだけでなく、型として置き換え可能か、概念上の親子関係が自然かを確認します。

10-3. 継承階層を深くしすぎない

継承階層が深くなると、あるメソッドやプロパティがどのクラスで定義され、どのクラスで変更されたのかを追いにくくなります。

Entity
└─ LivingThing
└─ Animal
└─ Mammal
└─ Canine
└─ Dog
└─ GuideDog

階層が深いと、基底クラスの小さな変更が多数の派生クラスに影響する可能性があります。また、オーバーライドやprotectedメンバーが増えると、各クラスの振る舞いを単独で理解しにくくなります。

継承階層が深くなってきた場合は、次の点を見直します。

  • 本当にすべての階層が必要か

  • インターフェイスで役割を分離できないか

  • 共通処理を別クラスに移せないか

  • コンポジションへ変更できないか

基底クラスは、少ない階層で明確な共通概念を表すように設計すると保守しやすくなります。

10-4. 継承よりコンポジションが適しているケース

コンポジションとは、別のクラスを継承するのではなく、そのインスタンスを内部に持って機能を利用する設計です。

動作を実行時に切り替えたい場合や、複数の機能を柔軟に組み合わせたい場合は、継承よりコンポジションが適しています。

たとえば、通知方法を継承で表すと、通知方法ごとにサービスの派生クラスが必要になる可能性があります。代わりに、通知機能をインターフェイスとして分離します。

C#
public interface INotifier
{
void Notify(string message);
}

public class EmailNotifier : INotifier
{
public void Notify(string message)
{
Console.WriteLine($"メール通知: {message}");
}
}

public class ConsoleNotifier : INotifier
{
public void Notify(string message)
{
Console.WriteLine($"コンソール通知: {message}");
}
}

サービスは通知オブジェクトを内部に保持します。

C#
public class OrderService
{
private readonly INotifier notifier;

public OrderService(INotifier notifier)
{
this.notifier = notifier;
}

public void CompleteOrder()
{
Console.WriteLine("注文処理が完了しました。");
notifier.Notify("注文が完了しました。");
}
}

利用する通知方法は、インスタンス生成時に選べます。

C#
OrderService emailService =
new OrderService(new EmailNotifier());

OrderService consoleService =
new OrderService(new ConsoleNotifier());

emailService.CompleteOrder();
consoleService.CompleteOrder();

コンポジションには、次のような利点があります。

  • 利用する機能を交換しやすい

  • クラス同士の結び付きを弱めやすい

  • 複数の機能を組み合わせやすい

  • 単体テストで依存オブジェクトを差し替えやすい

派生クラスが基底クラスとして自然に扱えない場合や、機能を柔軟に交換したい場合はコンポジションを検討します。

10-5. 継承させないクラスにはsealedを検討する

継承による拡張を想定していないクラスには、sealedを指定できます。

C#
public sealed class ApplicationSettings
{
public string EnvironmentName { get; }

public ApplicationSettings(string environmentName)
{
EnvironmentName = environmentName;
}
}

sealedを付けると、このクラスを基底クラスとして利用できなくなります。

C#
// コンパイルエラー
// public class CustomSettings : ApplicationSettings
// {
// }

クラスを継承可能にすると、派生クラスが仮想メソッドを変更したり、基底クラスの想定とは異なる使い方をしたりする可能性があります。継承用として設計・検証していないクラスを明確に閉じるために、sealedは有効です。

ただし、将来の拡張方法を不必要に制限することもあります。公開ライブラリでは特に、継承を許可する責任と、禁止する影響の両方を考えて判断する必要があります。

まとめ

C#の基底クラスとは、派生クラスへ共通のプロパティやメソッドなどを引き継がせるクラスです。派生クラスは、クラス名の後ろにコロンと基底クラス名を記述して定義します。

C#
public class Dog : Animal
{
}

基底クラスのコンストラクターや、オーバーライド前のメソッドを呼び出すときは、baseキーワードを使います。

C#
public Dog(string name)
: base(name)
{
}
C#
public override void Speak()
{
base.Speak();
Console.WriteLine("ワン!");
}

メソッドの動作を派生クラスごとに変更する場合は、virtualoverrideを使用します。派生クラスに実装を強制したい場合は、抽象クラスとabstractメソッドが適しています。

基底クラスを設計するときは、アクセス修飾子によるカプセル化も重要です。フィールドを安易にprotectedへせず、必要な操作をプロパティやメソッドとして公開すると、基底クラスが状態を安全に管理できます。

また、継承は単なるコード再利用の仕組みではありません。「犬は動物である」のようなis-aの関係が成り立つ場合に利用します。機能を交換したい場合や「持っている」というhas-aの関係を表す場合は、継承よりコンポジションが適していることもあります。

基底クラス、抽象クラス、インターフェイスを目的に応じて使い分けることで、重複が少なく、拡張しやすいC#コードを設計できます。