C#のインターフェースは「この型は何ができるか」という約束だけを決めるもの、抽象クラスは「共通の状態と処理」を持たせた未完成のクラスです。 インターフェースはいくつでも実装できますが、基底クラスにできる抽象クラスは1つだけです。

この記事では、フィールド・コンストラクタ・アクセス修飾子・多重実装・既定の実装・静的メンバーの可否を項目別に並べた比較表を先に示し、同じ題材(備品一覧の出力)をインターフェース版と抽象クラス版の両方で書いて比べます。さらに、既定の実装がクラス型の変数からは呼べないという落とし穴をコンパイルエラーつきで再現し、どちらを選ぶかの判断フローと、両者を組み合わせる定番の形まで、すべて .NET 8(C# 12)で動かした結果を載せています。

インターフェースと抽象クラスの早見表

項目 インターフェース(interface) 抽象クラス(abstract class)
役割 「何ができるか」の約束 共通の状態と処理を持つ土台
1つの型が持てる数 いくつでも実装できる 基底クラスは1つだけ
インスタンスフィールド 書けない(CS0525) 書ける
インスタンスコンストラクタ 書けない(CS0526) 書ける(派生クラスから base(...) で呼ぶ)
new で直接作る できない(CS0144) できない(CS0144)
中身のないメンバー 書ける(実装する型で必ず書く) abstract を付けて書ける(派生クラスで必ず override)
中身のあるメンバー 書ける(既定の実装、C# 8 以降) 書ける(通常のメソッド、virtual メソッド)
中身のあるメンバーをクラス型の変数から呼ぶ 呼べない(インターフェース型に変換が必要) 呼べる
メンバーのアクセス修飾子 省略すると public。C# 8 以降は private・protected・internal なども指定できる 自由に指定できる(省略すると private)
実装する側の書き方 public で実装(または明示的な実装)。override は書かない override を付ける
静的メンバー 静的フィールド・静的メソッド・定数を書ける(C# 8 以降)。static abstract も書ける(C# 11 以降) 書ける(static abstract は書けない)
構造体(struct)での利用 実装できる 継承できない(CS0527)
向いている関係 「〜できる」(can-do) 「〜の一種である」(is-a)

やりたいこと別の選び方は次のとおりです。

やりたいこと 選ぶもの
関係のない複数のクラスに同じ操作を約束させたい インターフェース
テスト用の差し替えをしやすくしたい インターフェース
派生クラスに共通のフィールドやコンストラクタを持たせたい 抽象クラス
処理の流れは共通で、一部の手順だけ派生クラスに書かせたい 抽象クラス
既に別のクラスを継承している型に機能を足したい インターフェース
公開済みのインターフェースに、既存の実装を変えずメンバーを足したい 既定の実装
約束と共通処理の両方がほしい インターフェース + 抽象クラスの組み合わせ

迷ったらインターフェースから始め、共通のフィールドや処理の流れが見えてきたら抽象クラスを足す、という順番が進めやすい形です。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C# インターフェースと抽象クラスの違い|書き方と既定の実装」

この記事の確認環境とC#のバージョン

コードはすべて次の環境で動かしました。

項目 内容
.NET SDK 8.0.131
C# 12(.NET 8 の既定)
実行方法 dotnet new console で作ったプロジェクトの Program.cs を置き換えて dotnet run

コンパイルエラーのメッセージは dotnet build の出力(英語)を載せ、意味を日本語で添えています。長いフォルダ名と末尾のプロジェクトファイル名は省き、Program.cs(行,列): error 番号: メッセージ の部分だけにしています。Visual Studio の日本語環境では同じエラー番号で日本語のメッセージが表示されます。

この記事に出てくる機能と、使えるようになったバージョンの対応です。Microsoft Learn の「言語のバージョン管理」のページ(今回開いて確認しました)によると、既定の C# バージョンは .NET 8 が C# 12、.NET 9 が C# 13、.NET 10 が C# 14 で、.NET Framework はすべて C# 7.3 です。

機能 使えるバージョン この記事での確認
インターフェース・抽象クラスの基本 C# 1.0 から .NET 8 で実行
既定の実装(既定のインターフェースメンバー) C# 8 以降 .NET 8 で実行
インターフェース内の静的メンバー・アクセス修飾子 C# 8 以降 .NET 8 で実行
static abstract メンバー C# 11 以降 .NET 8 で実行
C# 13・C# 14(.NET 9・.NET 10) ― 未実行(この記事の内容は C# 12 で確認したもの)

.NET Framework 向けのコードでは既定の C# が 7.3 のため、既定の実装や static abstract は使えない前提で設計することになります。

インターフェースの書き方

インターフェースは、interface キーワードで宣言し、メンバーの名前・引数・戻り値だけを書きます。名前の先頭に I を付けるのが C# の慣習です。

次のコードは、備品の一覧を CSV と JSON で出力する例です。Program.cs にそのまま貼って動かせます。

// インターフェース版:「何ができるか」だけを決める
var items = new List<Item> { new("ノートPC", 3), new("モニター", 5) };

IExporter[] exporters = { new CsvExporter(), new JsonExporter() };
foreach (var ex in exporters)
{
    Console.WriteLine($"[{ex.Extension}]");
    Console.WriteLine(ex.Export(items));
}

public record Item(string Name, int Qty);

public interface IExporter
{
    string Extension { get; }                      // プロパティ
    string Export(IEnumerable<Item> items);        // メソッド(中身は書かない)
}

public class CsvExporter : IExporter
{
    public string Extension => "csv";
    public string Export(IEnumerable<Item> items) =>
        "name,qty\n" + string.Join("\n", items.Select(i => $"{i.Name},{i.Qty}"));
}

public class JsonExporter : IExporter
{
    public string Extension => "json";
    public string Export(IEnumerable<Item> items) =>
        "[" + string.Join(",", items.Select(i => $"{{\"name\":\"{i.Name}\",\"qty\":{i.Qty}}}")) + "]";
}

実行結果(.NET 8):

[csv]
name,qty
ノートPC,3
モニター,5
[json]
[{"name":"ノートPC","qty":3},{"name":"モニター","qty":5}]

書き方のポイントを表にまとめます。

ポイント 内容
宣言 public interface IExporter { ... }
メンバー 修飾子を書かなければ public。中身は書かずに ; で終える
実装 class CsvExporter : IExporter のように : のあとに書く
実装側の修飾子 public を付ける。override は書かない
使う側 IExporter 型の変数や配列に入れて、同じ呼び方で扱う

使う側の foreach は、相手が CSV なのか JSON なのかを知りません。 IExporter という約束だけを見ているので、あとから別の形式のクラスを足しても foreach の中は変わりません。これがインターフェースを使ういちばんの利点です。

複数のインターフェースを実装する

インターフェースは , で区切っていくつでも実装できます。2つのインターフェースに同じ名前のメンバーがあるときは、明示的な実装(インターフェース名.メンバー名 の形)で書き分けられます。

// 複数のインターフェースを実装する/同名メンバーを明示的に実装する
var doc = new Document("手順書");

IPrintable p = doc;
ISavable s = doc;
p.Output();
s.Output();
Console.WriteLine(doc is IPrintable);
Console.WriteLine(doc is IDisposable);

public interface IPrintable { void Output(); }
public interface ISavable { void Output(); }

public class Document : IPrintable, ISavable       // インターフェースはいくつでも
{
    private readonly string _name;
    public Document(string name) => _name = name;

    void IPrintable.Output() => Console.WriteLine($"{_name} を印刷しました");
    void ISavable.Output() => Console.WriteLine($"{_name} を保存しました");
}

実行結果:

手順書 を印刷しました
手順書 を保存しました
True
False

明示的に実装したメンバーは、doc.Output() のようにクラス型の変数からは呼べず、インターフェース型の変数から呼びます。Microsoft Learn の interface キーワードのページ(今回開いて確認しました)にも、明示的に実装されたメンバーはインターフェースのインスタンスを通したときだけアクセスできる、と書かれています。

抽象クラスの書き方

抽象クラスは、abstract class で宣言します。普通のクラスと同じようにフィールド・コンストラクタ・中身のあるメソッドを持てるうえに、abstract を付けた「中身のないメンバー」を混ぜられます。

同じ備品一覧の出力を、抽象クラスで書いた例です。

// 抽象クラス版:共通の流れと状態を基底クラスに持たせる
using System.Text;

var items = new List<Item> { new("ノートPC", 3), new("モニター", 5) };

ReportBase[] reports = { new CsvReport("備品一覧"), new TextReport("備品一覧") };
foreach (var r in reports)
{
    Console.WriteLine(r.Build(items));
    Console.WriteLine($"(出力回数: {r.BuildCount})");
}

public record Item(string Name, int Qty);

public abstract class ReportBase
{
    private int _buildCount;                 // フィールド(状態)を持てる
    protected string Title { get; }
    public int BuildCount => _buildCount;

    protected ReportBase(string title)       // コンストラクタを持てる
    {
        Title = title;
    }

    // 共通の流れは基底クラスに1回だけ書く
    public string Build(IEnumerable<Item> items)
    {
        _buildCount++;
        var sb = new StringBuilder();
        sb.AppendLine(Header());
        foreach (var i in items) sb.AppendLine(Line(i));
        sb.Append(Footer(items.Sum(i => i.Qty)));
        return sb.ToString();
    }

    protected abstract string Header();              // 派生クラスで必ず書く
    protected abstract string Line(Item item);       // 派生クラスで必ず書く
    protected virtual string Footer(int total) => $"合計: {total}";  // 書き換えてもよい
}

public class CsvReport : ReportBase
{
    public CsvReport(string title) : base(title) { }
    protected override string Header() => $"# {Title}\nname,qty";
    protected override string Line(Item item) => $"{item.Name},{item.Qty}";
}

public class TextReport : ReportBase
{
    public TextReport(string title) : base(title) { }
    protected override string Header() => $"■ {Title}";
    protected override string Line(Item item) => $"・{item.Name}:{item.Qty}個";
    protected override string Footer(int total) => $"(合計 {total} 個)";
}

実行結果(.NET 8):

# 備品一覧
name,qty
ノートPC,3
モニター,5
合計: 8
(出力回数: 1)
■ 備品一覧
・ノートPC:3個
・モニター:5個
(合計 8 個)
(出力回数: 1)
ポイント 内容
宣言 public abstract class ReportBase { ... }
抽象メンバー protected abstract string Header(); のように abstract を付け、中身は書かない
書き換え可能なメンバー virtual を付けて既定の中身を書く(Footer)
派生クラス class CsvReport : ReportBase。抽象メンバーは override を付けて必ず実装
コンストラクタ 派生クラスから : base(title) で呼ぶ

「ヘッダー → 各行 → フッター」という流れは Build に1回だけ書かれていて、派生クラスは違う部分だけを書いています。 TextReport は Footer も書き換え、CsvReport は基底クラスの Footer をそのまま使っています。出力回数を数える _buildCount のような状態を持てるのも、抽象クラスならではです。

同じ題材で比べてわかること

観点 インターフェース版(IExporter) 抽象クラス版(ReportBase)
共通の流れ 各クラスがそれぞれ書く 基底クラスに1回だけ書く
状態(出力回数など) 各クラスがそれぞれ持つ 基底クラスのフィールドで持つ
実装クラスの自由度 高い(中身は完全に自由) 流れは基底クラスが決める
他のクラスを継承している型 そのまま実装できる 基底クラスの枠を使ってしまう
向いている場面 形式ごとに作り方がまったく違う 手順が共通で、一部だけ違う

比較表の各項目をコンパイルエラーで確かめる

早見表の「書けない」「できない」が実際にどんなエラーになるかを確かめます。

newで直接作ろうとした場合

var r = new ReportBase();
IExporter e = new IExporter();

public abstract class ReportBase { }
public interface IExporter { }

dotnet build の結果:

Program.cs(1,9): error CS0144: Cannot create an instance of the abstract type or interface 'ReportBase'
Program.cs(2,15): error CS0144: Cannot create an instance of the abstract type or interface 'IExporter'

インターフェースも抽象クラスも、どちらも同じ CS0144(抽象型またはインターフェースのインスタンスは作成できません)になります。使うときは、実装クラス・派生クラスを new して、インターフェース型や抽象クラス型の変数に入れます。

フィールド・コンストラクタ・多重継承などをまとめて確認

public abstract class ReportBase
{
    protected abstract string Header();
}
public class EmptyReport : ReportBase { }           // abstract を override していない

public interface IExporter { string Export(); }
public class NoExport : IExporter { }               // メンバーを実装していない
public class Hidden : IExporter { string Export() => ""; }   // public が無い

public interface IWithField { int count; }          // インスタンスフィールド
public interface IWithCtor { IWithCtor() { } }      // コンストラクタ

public class A { }
public class B { }
public class C : A, B { }                           // 基底クラスを2つ指定

public interface IBase { void M() => Console.WriteLine("IBase"); }
public interface ILeft : IBase { void IBase.M() => Console.WriteLine("ILeft"); }
public interface IRight : IBase { void IBase.M() => Console.WriteLine("IRight"); }
public class Diamond : ILeft, IRight { }            // 既定の実装が2つあり決められない

public abstract class Sealed1 { public abstract void M() { } }   // abstract に本体

dotnet build の結果(重複する行をまとめ、文字列順に並べ替えて掲載しています):

Program.cs(11,35): error CS0525: Interfaces cannot contain instance fields
Program.cs(12,30): error CS0526: Interfaces cannot contain instance constructors
Program.cs(16,21): error CS1721: Class 'C' cannot have multiple base classes: 'A' and 'B'
Program.cs(21,24): error CS8705: Interface member 'IBase.M()' does not have a most specific implementation. Neither 'ILeft.IBase.M()', nor 'IRight.IBase.M()' are most specific.
Program.cs(23,54): error CS0500: 'Sealed1.M()' cannot declare a body because it is marked abstract
Program.cs(5,14): error CS0534: 'EmptyReport' does not implement inherited abstract member 'ReportBase.Header()'
Program.cs(8,25): error CS0535: 'NoExport' does not implement interface member 'IExporter.Export()'
Program.cs(9,23): error CS0737: 'Hidden' does not implement interface member 'IExporter.Export()'. 'Hidden.Export()' cannot implement an interface member because it is not public.
エラー番号 意味 直し方
CS0534 抽象メンバーを override していない 派生クラスで override を付けて実装する
CS0535 インターフェースのメンバーを実装していない 実装クラスにメンバーを書く
CS0737 実装したメンバーが public ではない public を付ける(または明示的な実装にする)
CS0525 インターフェースにインスタンスフィールドは書けない プロパティにする。状態が必要なら抽象クラスを使う
CS0526 インターフェースにインスタンスコンストラクタは書けない 初期化が必要なら抽象クラスを使う
CS1721 基底クラスを2つ指定している 片方をインターフェースにする
CS8705 既定の実装が2つあり、どちらを使うか決められない 実装クラスでそのメンバーを自分で実装する
CS0500 abstract を付けたメンバーに本体がある 本体を消すか、virtual にする

構造体が抽象クラスを継承しようとした場合も確認しました。public struct Point : ReportBase { } と書くと、次のエラーになります。

Program.cs(2,23): error CS0527: Type 'ReportBase' in interface list is not an interface

構造体はクラスを継承できないため、: のあとに書けるのはインターフェースだけです。構造体に共通の約束を持たせたいときは、インターフェース一択になります。

インターフェースのアクセス修飾子と構造体での実装

C# 8 以降は、インターフェースの中でも private や protected などの修飾子を書けます。中身のあるメンバーの補助メソッドを private にする、という使い方ができます。

// インターフェースのアクセス修飾子と、構造体での実装
ICounter c = new StepCounter();
c.Increment();
c.Increment();
Console.WriteLine(c.Describe());

Point pt = new(1, 2);
IDescribable d = pt;                    // 構造体もインターフェースを実装できる
Console.WriteLine(d.Describe());

public interface ICounter
{
    int Value { get; }
    void Increment();

    public string Describe() => Format(Value);          // public(既定)
    private string Format(int v) => $"現在の値は {v}";   // private:interface 内の補助
    protected void Reset() { }                           // protected も書ける
    internal void Dump() { }                             // internal も書ける
}

public class StepCounter : ICounter
{
    public int Value { get; private set; }
    public void Increment() => Value++;
}

public interface IDescribable { string Describe(); }

public readonly struct Point : IDescribable
{
    public int X { get; }
    public int Y { get; }
    public Point(int x, int y) { X = x; Y = y; }
    public string Describe() => $"({X}, {Y})";
}

実行結果(.NET 8):

現在の値は 2
(1, 2)

private・protected・internal を付けたメンバーも含めてコンパイルでき、構造体 Point がインターフェースを実装できることも確認できました。なお、抽象クラスのメンバーは修飾子を省略すると private になり、インターフェースのメンバーは省略すると public になる、という違いがあります。

既定の実装の使い方と落とし穴

既定の実装は、インターフェースのメンバーに中身を書ける機能です(C# 8 以降)。公開済みのインターフェースにメンバーを足しても、既存の実装クラスを書き換えずに済むようにするために用意されました。

既定の実装はクラス型の変数から呼べない

まず、つまずきやすい点を再現します。

var logger = new ConsoleLogger();
logger.Log("開始");
logger.LogError("クラス型の変数から既定の実装を呼ぶ");

public interface ILogger
{
    void Log(string message);
    void LogError(string message) => Log("[ERROR] " + message);
}

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

dotnet build の結果:

Program.cs(3,8): error CS1061: 'ConsoleLogger' does not contain a definition for 'LogError' and no accessible extension method 'LogError' accepting a first argument of type 'ConsoleLogger' could be found (are you missing a using directive or an assembly reference?)

CS1061('ConsoleLogger' に 'LogError' の定義が含まれていません)になりました。ConsoleLogger は ILogger を実装しているのに、です。

理由は、既定の実装はインターフェースのメンバーであって、実装クラスのメンバーにはならないからです。抽象クラスのメソッドは派生クラスに引き継がれますが、インターフェースの既定の実装は引き継がれません。ここが両者のいちばん大きな違いです。

インターフェース型に変換すれば呼べる

// 既定の実装(デフォルトインターフェースメソッド)
var logger = new ConsoleLogger();
logger.Log("開始");

// logger.LogError("x");   // ← これは CS1061 でコンパイルできない

((ILogger)logger).LogError("キャストすれば呼べる");

ILogger il = logger;
il.LogError("インターフェース型の変数なら呼べる");

Write(logger, "引数の型がインターフェースなら呼べる");

// クラス側で LogError を実装すると、そちらが使われる
ILogger custom = new PrefixLogger();
custom.LogError("クラス側で実装し直した");
new PrefixLogger().LogError("クラス型からも呼べる");

// 派生クラスで同名メソッドを足しただけでは、インターフェース経由の呼び出しは変わらない
ILogger derived = new DerivedLogger();
derived.LogError("ILogger 経由");
new DerivedLogger().LogError("DerivedLogger 型から");

static void Write(ILogger l, string message) => l.LogError(message);

public interface ILogger
{
    void Log(string message);
    void LogError(string message) => Log("[ERROR] " + message);   // 既定の実装
}

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

public class PrefixLogger : ILogger
{
    public void Log(string message) => Console.WriteLine(message);
    public void LogError(string message) => Log("!!! " + message);
}

public class DerivedLogger : ConsoleLogger
{
    public void LogError(string message) => Log("派生クラス: " + message);
}

実行結果(.NET 8):

開始
[ERROR] キャストすれば呼べる
[ERROR] インターフェース型の変数なら呼べる
[ERROR] 引数の型がインターフェースなら呼べる
!!! クラス側で実装し直した
!!! クラス型からも呼べる
[ERROR] ILogger 経由
派生クラス: DerivedLogger 型から

結果を整理します。

呼び方 結果
logger.LogError(...)(変数の型が ConsoleLogger) CS1061 でコンパイルできない
((ILogger)logger).LogError(...) 既定の実装が呼ばれる
ILogger il = logger; il.LogError(...) 既定の実装が呼ばれる
引数の型が ILogger のメソッドに渡す 既定の実装が呼ばれる
クラスが public で LogError を実装している(PrefixLogger) クラス側の実装が呼ばれる。クラス型からも呼べる
派生クラスで同名メソッドを足しただけ(DerivedLogger)を ILogger 経由で呼ぶ 既定の実装が呼ばれる

最後の行は見落としやすい動きです。DerivedLogger は ConsoleLogger を継承して LogError を足していますが、ILogger 経由で呼ぶと [ERROR] ILogger 経由 と、既定の実装のほうが動きました。DerivedLogger 自身が ILogger を実装し直していないため、インターフェースの LogError とは結び付いていないからです。派生クラスで差し替えたいときは、class DerivedLogger : ConsoleLogger, ILogger のようにインターフェースをもう一度書くか、最初から基底クラス側で実装しておきます。インターフェースをもう一度書いた形に変えて実行したところ、ILogger 経由でも次のように派生クラスの実装が呼ばれました。

public class DerivedLogger : ConsoleLogger, ILogger      // インターフェースをもう一度書く
{
    public void LogError(string message) => Log("派生クラス: " + message);
}
派生クラス: ILogger 経由

既定の実装を使うときのチェック表

確認すること 理由
変数・引数・フィールドの型をインターフェース型にしているか クラス型のままでは呼べない(CS1061)
var x = new ConsoleLogger(); と書いていないか var はクラス型になる。ILogger x = new ConsoleLogger(); と書く
状態(フィールド)が必要になっていないか インターフェースはインスタンスフィールドを持てない。必要なら抽象クラスへ
2つのインターフェースが同じメンバーに別々の既定の実装を書いていないか CS8705 になる。実装クラスで自分で実装する
対象が .NET Framework ではないか 既定の C# が 7.3 のため使えない

既定の実装は「あとからメンバーを足すための仕組み」と考え、共通処理をたくさん持たせたいときは抽象クラスを選ぶと、役割がはっきりします。

インターフェースの静的メンバーとstatic abstract

インターフェースには、静的フィールド・静的メソッド・定数も書けます(C# 8 以降)。さらに C# 11 からは、実装する型に「この静的メンバーを持つこと」を求める static abstract が使えます。

// インターフェースの静的メンバー
Console.WriteLine(IShape.Count);
IShape.Count++;
Console.WriteLine(IShape.Describe());

// static abstract:型引数 T の静的メソッドを呼べる
Console.WriteLine(Create<Meter>("12.5"));
Console.WriteLine(Create<Gram>("300"));

static T Create<T>(string s) where T : IUnit<T> => T.Parse(s);

public interface IShape
{
    static int Count = 0;                       // 静的フィールドは書ける
    const string Unit = "cm";                   // 定数も書ける
    static string Describe() => $"図形は {Count} 個、単位は {Unit}";
}

public interface IUnit<T> where T : IUnit<T>
{
    static abstract string Symbol { get; }      // 実装する型に静的メンバーを求める
    static abstract T Parse(string s);
}

public readonly record struct Meter(double Value) : IUnit<Meter>
{
    public static string Symbol => "m";
    public static Meter Parse(string s) => new(double.Parse(s));
    public override string ToString() => $"{Value}{Symbol}";
}

public readonly record struct Gram(double Value) : IUnit<Gram>
{
    public static string Symbol => "g";
    public static Gram Parse(string s) => new(double.Parse(s));
    public override string ToString() => $"{Value}{Symbol}";
}

実行結果(.NET 8):

0
図形は 1 個、単位は cm
12.5m
300g
書き方 意味 使えるバージョン
static int Count = 0; インターフェース自身が持つ静的フィールド C# 8 以降
const string Unit = "cm"; 定数 C# 8 以降
static string Describe() => ... 中身のある静的メソッド C# 8 以降
static abstract T Parse(string s); 実装する型に静的メソッドを求める C# 11 以降

Create<T> の中で T.Parse(s) と、型引数に対して静的メソッドを呼べている点が static abstract の特徴です。.NET の数値型が実装している INumber<T> などの汎用数値演算は、この仕組みの上に作られています。Microsoft Learn の interface キーワードのページによると、static abstract と static virtual の呼び出しはコンパイル時に解決され、実行時に切り替える仕組みは無い、と説明されています。

抽象クラスにも静的メンバーは書けますが、static abstract は書けません(抽象クラスの中に public static abstract void M(); と書くと、CS0112: A static member cannot be marked as 'abstract' になることを確認しました)。「型そのものに操作を約束させる」ことができるのはインターフェースだけです。

どちらを選ぶかの判断フロー

flowchart TD
    A[共通の型を作りたい] --> B{共通のフィールドやコンストラクタが必要?}
    B -- はい --> C{実装する型は別のクラスを継承している? または構造体?}
    C -- いいえ --> D[抽象クラス]
    C -- はい --> E[インターフェース + 共通処理は別クラスに切り出す]
    B -- いいえ --> F{処理の流れを共通化したい?}
    F -- はい --> G{使う側には約束だけ見せたい?}
    G -- はい --> H[インターフェース + 抽象クラス]
    G -- いいえ --> D
    F -- いいえ --> I[インターフェース]

判断に使う質問を表にすると、次のようになります。

質問 はい いいえ
「AはBの一種である」と言えるか 抽象クラス寄り インターフェース寄り
共通のフィールド・コンストラクタが必要か 抽象クラス インターフェース
実装する型が既に別のクラスを継承しているか インターフェース どちらも可
構造体や record struct にも実装させたいか インターフェース どちらも可
テストで別の実装に差し替えたいか インターフェース どちらも可
手順は同じで一部だけ違うか 抽象クラス インターフェース

複数の質問で答えが分かれたら、次の「組み合わせ」が候補になります。

インターフェースと抽象クラスを組み合わせる定番の形

よく使われるのが、「使う側にはインターフェースを見せ、共通処理は抽象クラスにまとめ、具体的な違いだけを具象クラスに書く」という3段の形です。

classDiagram
    class INotifier {
        <<interface>>
        +Notify(message)
    }
    class NotifierBase {
        <<abstract>>
        +Notify(message)
        #Channel
        #Send(text)
    }
    INotifier <|.. NotifierBase
    NotifierBase <|-- MailNotifier
    NotifierBase <|-- ChatNotifier
    INotifier <|.. SilentNotifier
// 組み合わせ:インターフェース(約束)+ 抽象クラス(共通処理)+ 具象クラス
INotifier[] notifiers = { new MailNotifier(), new ChatNotifier(), new SilentNotifier() };
foreach (var n in notifiers)
{
    n.Notify("在庫が残り3個です");
}

// 使う側はインターフェースだけを知っていればよい
public interface INotifier
{
    void Notify(string message);
}

// 共通の流れ(整形 → 送信 → 記録)は抽象クラスにまとめる
public abstract class NotifierBase : INotifier
{
    public void Notify(string message)
    {
        var text = $"[{Channel}] {message}";
        Send(text);
        Console.WriteLine($"  -> 送信ログ: {text.Length}文字");
    }

    protected abstract string Channel { get; }
    protected abstract void Send(string text);
}

public class MailNotifier : NotifierBase
{
    protected override string Channel => "mail";
    protected override void Send(string text) => Console.WriteLine("メール送信: " + text);
}

public class ChatNotifier : NotifierBase
{
    protected override string Channel => "chat";
    protected override void Send(string text) => Console.WriteLine("チャット投稿: " + text);
}

// 基底クラスを使わず、インターフェースだけを実装する型も混ぜられる
public class SilentNotifier : INotifier
{
    public void Notify(string message) => Console.WriteLine("(テスト用: 何も送らない)");
}

実行結果(.NET 8):

メール送信: [mail] 在庫が残り3個です
  -> 送信ログ: 16文字
チャット投稿: [chat] 在庫が残り3個です
  -> 送信ログ: 16文字
(テスト用: 何も送らない)
層 役割 この例での型
インターフェース 使う側との約束 INotifier
抽象クラス 共通の流れ(整形 → 送信 → 記録) NotifierBase
具象クラス 違う部分だけ MailNotifier、ChatNotifier
インターフェースだけを実装する型 基底クラスの流れが不要なもの SilentNotifier

MailNotifier と ChatNotifier は「送信ログ」の行を自分では書いていませんが、基底クラスの Notify を通るので必ず出力されます。一方 SilentNotifier は基底クラスを使わず、インターフェースだけを実装しています。使う側の foreach は INotifier しか知らないため、3つを同じように扱えます。

この形にしておくと、共通処理を抽象クラスに1回だけ書きつつ、テスト用の差し替えや、まったく別の作り方をする型の追加にも対応できます。

よくあるつまずきとエラー早見表

症状・エラー 原因 直し方
CS1061(既定の実装を呼べない) 変数の型が実装クラスになっている インターフェース型の変数に入れる、またはキャストする
CS0144 インターフェース・抽象クラスを new している 実装クラス・派生クラスを new する
CS0535 インターフェースのメンバーを実装していない メンバーを追加する
CS0737 実装メンバーに public が無い public を付ける
CS0534 抽象メンバーを override していない override で実装する
CS0525 / CS0526 インターフェースにフィールド・コンストラクタを書いた プロパティにする、または抽象クラスにする
CS1721 クラスを2つ継承しようとした 片方をインターフェースにする
CS0527 構造体でクラスを継承しようとした インターフェースにする
CS8705 既定の実装が競合している 実装クラスで自分で実装する
派生クラスで書いたメソッドが、インターフェース経由だと呼ばれない 派生クラスがインターフェースを実装し直していない 派生クラスにもインターフェースを書く、または基底クラスで virtual にする

よくある質問

Q. インターフェースと抽象クラスは、結局どちらを先に検討すればよいですか? A. まずインターフェースで「使う側に見せる約束」を決めるのがおすすめです。実装クラスを2つ3つ書いてみて、同じフィールドや同じ処理の流れが繰り返し出てきたら、その共通部分を抽象クラスにまとめます。この記事の最後の「組み合わせ」の形に自然に行き着きます。

Q. 既定の実装があれば、抽象クラスは不要になりますか? A. 役割が違うので、どちらも使いどころがあります。既定の実装はインスタンスフィールドやコンストラクタを持てず、クラス型の変数から呼べません。状態を持つ共通処理や、処理の流れの共通化には抽象クラスが向いています。既定の実装は、公開済みのインターフェースにメンバーを足すときに力を発揮します。

Q. インターフェースのメンバーに public と書く必要はありますか? A. インターフェースの中では省略できます(省略すると public)。一方、実装するクラス側では public を付ける必要があり、付けないと CS0737 になることを確認しました。

Q. 抽象クラスがインターフェースを実装するとき、すべてのメンバーを書く必要がありますか? A. 抽象クラス側でメンバーを実装するか、public abstract void Notify(string message); のように抽象メンバーとして宣言して派生クラスに任せる形が一般に使われます。どちらの形も .NET 8 で動かし、抽象メンバーとして宣言した場合は派生クラスで public override void Notify(...) と書けば呼び出せることを確認しました。

まとめ

  • インターフェースは「何ができるか」の約束、抽象クラスは「共通の状態と処理」を持つ土台
  • インターフェースはいくつでも実装でき、基底クラスは1つだけ(2つ書くと CS1721)
  • フィールドとコンストラクタが必要なら抽象クラス。インターフェースに書くと CS0525 / CS0526
  • 既定の実装はクラス型の変数から呼べず CS1061 になる。インターフェース型の変数に入れて呼ぶ
  • 静的メンバーはどちらにも書けるが、static abstract はインターフェースだけ(C# 11 以降)
  • 構造体に共通の約束を持たせられるのはインターフェースだけ
  • 約束と共通処理の両方がほしいときは、インターフェース + 抽象クラス + 具象クラスの3段にする

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C# インターフェースと抽象クラスの違い|書き方と既定の実装」

株式会社RJCでは、C#のインターフェースや抽象クラスを使った設計の考え方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?