インターフェースは、クラスが必ず備えるべきメンバーの集合(契約)を表します。
どのメソッドやプロパティを持つかを先に約束しておくことで、実装の差し替えやテストが楽になります。
本記事では、C#のinterfaceの基本、書き方、実装方法、注意点まで順を追って解説します。
C#のインターフェース(interface)とは
役割とメリット(契約の考え方)
インターフェースは「何ができるか」を定義する契約です。
メソッドやプロパティ、イベントのシグネチャ(名前、引数、戻り値など)だけを宣言し、具体的な処理は実装側のクラスに任せます。
この分離によって、呼び出し側は実装の詳細を知らなくても同じ使い方ができるため、疎結合でテストしやすいコードになります。
たとえば、IStorage
というデータ保存の契約を用意すれば、メモリ保存でもファイル保存でも、どちらのクラスも同じSave
やLoad
を持ち、呼び出し側はIStorage
型として扱うだけで切り替えできます。
これはポリモーフィズム(多態性)の典型的な利用です。
できること/できないこと
インターフェースが提供するものと、制約を整理します。
できること | できないこと |
---|---|
メソッド、プロパティ、イベントの宣言 | フィールド(状態)の保持 |
複数のインターフェースを1つのクラスで同時に実装 | コンストラクタの定義 |
別のインターフェースを継承(多重継承) | メンバーにアクセス修飾子を付ける(基本的に全てpublic) |
既定実装(default、C# 8.0+) | 具象処理を持つ通常のメソッド定義(C# 7.x以前) |
ジェネリックインターフェースの定義 |
注意点として、インターフェースはデータを保持しません。
状態が必要なら実装クラスでフィールドや自動実装プロパティを用意します。
使う場面の例
アプリケーション全体を通じて「差し替え」に強くしたい箇所で役立ちます。
ストレージの切り替え、ロガーの切り替え、支払い処理の切り替えなどが典型です。
ユニットテストでは、本番実装の代わりにテスト用のダミーやモック(インターフェースを実装した置き換えクラス)を差し込めるため、テスト容易性が向上します。
interfaceの書き方と定義
基本構文と命名規則
C#では、インターフェース名は先頭にI
を付けるのが.NETの慣習です。
パスカルケースで分かりやすい動詞や名詞を用います。
// インターフェースの基本形
public interface IStorage
{
// メンバー宣言のみ(実装は書かない)
void Save(string key, string value);
string? Load(string key);
}
命名の指針
- 先頭に
I
(例:ILogger
,IRepository<T>
) - 役割が分かる名前(「できること」を表す)
- メソッド名やプロパティ名は用途が明確になるようにする
メンバーの宣言(メソッド/プロパティ/イベント)
インターフェースでは、メソッド、プロパティ、イベントを宣言できます。
メンバーは暗黙的にpublicで、基本的にアクセス修飾子は付けません。
using System;
// イベント引数の例
public sealed class SavedEventArgs : EventArgs
{
public string Key { get; }
public string Value { get; }
public SavedEventArgs(string key, string value)
{
Key = key;
Value = value;
}
}
public interface IStorage
{
// 読み取り専用プロパティ
string Name { get; }
// メソッド
void Save(string key, string value);
string? Load(string key);
// イベント
event EventHandler<SavedEventArgs>? Saved;
}
プロパティはget
/set
の有無を宣言できます。
イベントはevent
キーワードを用いて宣言し、実装側で発火(Invoke)します。
継承するinterfaceの定義
インターフェースは他のインターフェースを継承できます。
複数継承も可能です。
public interface IReadOnlyStorage
{
string? Load(string key);
}
public interface IWritableStorage
{
void Save(string key, string value);
}
// 複数のインターフェースを継承
public interface IFullStorage : IReadOnlyStorage, IWritableStorage
{
string Name { get; }
event EventHandler<SavedEventArgs>? Saved;
}
このように分割しておくと、読み取り専用が必要な場面ではIReadOnlyStorage
だけを要求でき、責務を細かく指定できます。
クラスへ実装する方法(implements)
実装の基本(メソッド/プロパティ)
クラスはコロン:
の後にインターフェース名を並べて実装を表明します。
宣言したメンバーはすべて実装しなければコンパイルエラーになります。
using System;
using System.Collections.Generic;
public class MemoryStorage : IStorage
{
private readonly Dictionary<string, string> _store = new();
public string Name => "Memory";
// イベントの実装
public event EventHandler<SavedEventArgs>? Saved;
public void Save(string key, string value)
{
_store[key] = value;
// イベントを発火
Saved?.Invoke(this, new SavedEventArgs(key, value));
}
public string? Load(string key)
{
return _store.TryGetValue(key, out var value) ? value : null;
}
}
サンプルコードで理解
インターフェースを介して実装を差し替えられること、イベントが発火することをひとつのプログラムで確かめます。
ファイル保存風の実装は説明用に簡略化し、実際のファイルI/Oは行いません。
using System;
using System.Collections.Generic;
// 例に使うインターフェースとイベント引数
public sealed class SavedEventArgs : EventArgs
{
public string Key { get; }
public string Value { get; }
public SavedEventArgs(string key, string value) { Key = key; Value = value; }
}
public interface IStorage
{
string Name { get; }
void Save(string key, string value);
string? Load(string key);
event EventHandler<SavedEventArgs>? Saved;
}
// メモリ上のストレージ実装
public class MemoryStorage : IStorage
{
private readonly Dictionary<string, string> _store = new();
public string Name => "Memory";
public event EventHandler<SavedEventArgs>? Saved;
public void Save(string key, string value)
{
_store[key] = value;
Saved?.Invoke(this, new SavedEventArgs(key, value));
}
public string? Load(string key) => _store.TryGetValue(key, out var v) ? v : null;
}
// 説明用の疑似ファイルストレージ実装(実ファイルI/Oはしない)
public class PseudoFileStorage : IStorage
{
private readonly Dictionary<string, string> _fileLike = new();
public string Name => "PseudoFile";
public event EventHandler<SavedEventArgs>? Saved;
public void Save(string key, string value)
{
// 実際にはファイル書き込みになる箇所
_fileLike[key] = value;
Saved?.Invoke(this, new SavedEventArgs(key, value));
}
public string? Load(string key) => _fileLike.TryGetValue(key, out var v) ? v : null;
}
public static class Program
{
public static void Main()
{
// IStorage型で受ければ、実装を入れ替えても同じ使い方ができる
IStorage storage = CreateStorage(useMemory: true);
storage.Saved += (sender, e) =>
{
Console.WriteLine($"[{((IStorage)sender!).Name}] Saved: {e.Key}={e.Value}");
};
storage.Save("greeting", "Hello");
Console.WriteLine(storage.Load("greeting"));
// 実装を差し替え
storage = CreateStorage(useMemory: false);
storage.Saved += (s, e) => Console.WriteLine($"[{((IStorage)s!).Name}] Saved: {e.Key}={e.Value}");
storage.Save("greeting", "こんにちは");
Console.WriteLine(storage.Load("greeting"));
// コレクションでもIStorage型でまとめて扱える
var storages = new List<IStorage>
{
new MemoryStorage(),
new PseudoFileStorage()
};
foreach (var s in storages)
{
s.Saved += (sender, e) => Console.WriteLine($"[{s.Name}] Saved: {e.Key}={e.Value}");
s.Save("lang", s is MemoryStorage ? "EN" : "JP");
}
}
// インターフェース型を返すファクトリ
private static IStorage CreateStorage(bool useMemory)
=> useMemory ? new MemoryStorage() : new PseudoFileStorage();
}
[Memory] Saved: greeting=Hello
Hello
[PseudoFile] Saved: greeting=こんにちは
こんにちは
[Memory] Saved: lang=EN
[PseudoFile] Saved: lang=JP
このサンプルでは、IStorage
型を介してMemoryStorage
とPseudoFileStorage
を入れ替えても、呼び出しコードは同じままです。
イベントも両実装から同一の形で通知され、ポリモーフィズムの利点が見て取れます。
インターフェース型で受ける/返す
引数や戻り値でインターフェース型を使うと、利用側の柔軟性が上がります。
呼び出し側は具体型に依存せずに済み、テスト時にはモック実装に差し替えやすくなります。
// 引数で受ける例
public static void SaveUserName(IStorage storage, string userName)
{
storage.Save("userName", userName);
}
// 戻り値で返す例(前述のCreateStorageと同様)
public static IStorage CreateStorageFor(string purpose)
{
return purpose == "cache" ? new MemoryStorage() : new PseudoFileStorage();
}
インターフェースの使い方と注意点
コレクションや引数での活用
List<IService>
やIEnumerable<IHandler>
のように、インターフェース型のコレクションを使うと、さまざまな実装を同一視して一括処理できます。
DI(依存性の注入)コンテナでも、インターフェースをキーにして実装を登録・解決するのが一般的です。
明示的インターフェース実装
複数のインターフェースで同名シグネチャのメソッドがある場合や、インターフェース経由でのみ呼べるようにしたい場合は、明示的に実装します。
明示的実装はクラスのパブリックAPIには現れず、インターフェースにキャストして呼び出します。
using System;
public interface IFoo { void Do(); }
public interface IBar { void Do(); }
public class TaskRunner : IFoo, IBar
{
// クラス独自のDo
public void Do() => Console.WriteLine("TaskRunner.Do");
// 明示的インターフェース実装(アクセス修飾子は付けない)
void IFoo.Do() => Console.WriteLine("IFoo.Do");
void IBar.Do() => Console.WriteLine("IBar.Do");
}
public static class Program
{
public static void Main()
{
var r = new TaskRunner();
r.Do(); // クラスのDo
((IFoo)r).Do(); // IFoo.Do
((IBar)r).Do(); // IBar.Do
}
}
TaskRunner.Do
IFoo.Do
IBar.Do
明示的実装は、APIの衝突回避や、意図しない呼び出しを防ぐために有効です。
既定実装(default)の基礎(C# 8.0+)
C# 8.0以降は、インターフェースのメンバーに既定実装(デフォルト実装)を書くことができます。
新しいメソッドを既存のインターフェースに追加するとき、すべての実装クラスを書き換えなくても暫定的に動作させられます。
ただし、呼び出しはインターフェース経由で行う必要がある点に注意してください。
using System;
public interface IGreeter
{
// 既定実装(インターフェースに本体を書く)
void Greet(string name)
{
Console.WriteLine($"Hello, {name}!");
}
}
public class BasicGreeter : IGreeter
{
// 何も書かなくても、IGreeterの既定実装で動く
}
public class CustomGreeter : IGreeter
{
// 上書き(クラス側で独自実装)
public void Greet(string name) => Console.WriteLine($"こんにちは、{name}!");
}
public static class Program
{
public static void Main()
{
IGreeter g1 = new BasicGreeter();
g1.Greet("Taro"); // 既定実装が呼ばれる
IGreeter g2 = new CustomGreeter();
g2.Greet("Hanako"); // クラスの実装が呼ばれる
var basic = new BasicGreeter();
// basic.Greet("Jiro"); // コンパイルエラー: クラス型経由では見えないため、キャストが必要
((IGreeter)basic).Greet("Jiro");
}
}
Hello, Taro!
こんにちは、Hanako!
Hello, Jiro!
- 既定実装は、.NET Core 3.0以降や.NET 5+などのランタイムが必要です。古い.NET Frameworkでは使えません。
- 設計が複雑になりやすいため、使いどころは慎重に検討します。
基本は「契約のみ」を守るようにし、インターフェースの仕様変更での暫定的な対応に留めると良いでしょう。
よく使う.NETのinterface例(IComparable/IEnumerable/IDisposable)
.NETで頻出するインターフェースを、短い例とともに紹介します。
IComparable<T>: ソート可能にする
List<T>.Sort()
などで自然な並び替えを提供します。
using System;
using System.Collections.Generic;
public class Person : IComparable<Person>
{
public string Name { get; }
public int Age { get; }
public Person(string name, int age) { Name = name; Age = age; }
// 年齢で昇順ソート
public int CompareTo(Person? other)
{
if (other is null) return 1;
return Age.CompareTo(other.Age);
}
public override string ToString() => $"{Name}({Age})";
}
public static class Program
{
public static void Main()
{
var people = new List<Person>
{
new("Taro", 28),
new("Hanako", 22),
new("Ken", 35),
};
people.Sort(); // IComparable<Person>により比較可能
people.ForEach(p => Console.WriteLine(p));
}
}
Hanako(22)
Taro(28)
Ken(35)
IEnumerable<T>: foreachで列挙できる
IEnumerable<T>
を返すAPIは、配列、リスト、LINQの結果などを一様に扱えます。
using System;
using System.Collections.Generic;
using System.Linq;
public static class SumUtil
{
public static int SumEven(IEnumerable<int> numbers)
{
// 引数が配列でもListでもOK
return numbers.Where(n => n % 2 == 0).Sum();
}
}
public static class Program
{
public static void Main()
{
Console.WriteLine(SumUtil.SumEven(new[] { 1, 2, 3, 4 }));
Console.WriteLine(SumUtil.SumEven(new List<int> { 10, 11, 12 }));
}
}
6
22
IDisposable: 後始末のための契約
using
ステートメントと組み合わせ、確実にリソースを解放します。
using System;
// 簡易的なIDisposableの実装例(実リソースは持たない)
public sealed class DemoResource : IDisposable
{
private bool _disposed;
public void Work() => Console.WriteLine("Working...");
public void Dispose()
{
if (_disposed) return;
Console.WriteLine("Disposed.");
_disposed = true;
}
}
public static class Program
{
public static void Main()
{
// usingにより、スコープ終了時にDisposeが自動呼び出し
using (var r = new DemoResource())
{
r.Work();
}
}
}
Working...
Disposed.
よくあるエラーと対処
インターフェース周りで初心者が遭遇しやすいコンパイルエラーと対処法です。
- エラー: クラスがインターフェースメンバーを実装していない
メッセージ例:'MyClass' does not implement interface member 'IFoo.Bar()'
対処: 宣言したすべてのメンバーを実装します。シグネチャ(引数、戻り値、名前)が完全一致しているか確認します。
public interface IFoo { void Bar(int x); }
public class MyClass : IFoo
{
// 誤り: 引数型が違う
// public void Bar(string x) { }
// 正: シグネチャを一致させる
public void Bar(int x) { /*...*/ }
}
- エラー: 明示的インターフェース実装にアクセス修飾子を付けた
メッセージ例:'public' modifier not allowed on explicitly implemented member
対処: 明示的実装ではpublic
を外し、IFoo.Bar
のように完全名で実装します。
public interface IFoo { void Bar(); }
public class C : IFoo
{
// 誤り: publicは付けない
// public void IFoo.Bar() { }
// 正
void IFoo.Bar() { }
}
- エラー: インターフェースにフィールドを定義した
メッセージ例:Interfaces cannot contain fields
対処: フィールドは不可です。必要ならプロパティを定義し、実装クラスでフィールドを持ちます。
public interface IConfig
{
// 誤り
// int retryCount;
// 正: プロパティで宣言
int RetryCount { get; }
}
- 参照の整合性(アクセスレベルの不一致)
Inconsistent accessibility
対処:
public
なクラスがinternal
なインターフェースを公開APIで使っているなど、可視性の整合を取ります。必要に応じてアクセス修飾子をそろえます。- 既定実装が使えない
Feature 'default interface members' is not available in C# 7.3
対処: プロジェクトのLangVersionを8.0以上にし、.NET 5+や.NET Core 3+など対応ランタイムで実行します。古いターゲットでは既定実装を避け、抽象基底クラスなどで代替します。
まとめ
インターフェースは「何を提供するか」を宣言する契約であり、実装の差し替え、テスト容易性、拡張性に大きく貢献します。
基本構文では命名規則やメンバー(メソッド/プロパティ/イベント)の宣言に注意し、クラスでの実装では必須メンバーを漏れなく実装します。
引数・戻り値・コレクションでインターフェース型を用いることで、呼び出し側は具体実装に依存せず柔軟になります。
明示的インターフェース実装は衝突回避に有用で、C# 8.0以降の既定実装はバージョニングの助けになりますが、過度な依存は避けるのが無難です。
最後に、IComparable<T>
, IEnumerable<T>
, IDisposable
のようなよく使う契約に親しみ、エラーの原因と対処を把握しておくと、安定した設計ができるようになります。