前回の記事では、インターフェースの定義ルールと基本的な派生パターンを整理しました。今回はその続きとして、実際のコードで頻繁に登場する活用パターンを取り上げます。
オブジェクトレベルでのインターフェースメンバ呼び出し
インターフェースを実装したオブジェクトは、具象型の変数経由でもインターフェースのメンバを呼び出せます。ただし呼び出し方によって挙動が変わる点を押さえておきましょう。
public interface IDrivable
{
void Drive();
int MaxSpeed { get; }
}
public class Car : IDrivable
{
public void Drive() => Console.WriteLine("車が走る");
public int MaxSpeed => 180;
}
具象型の変数から呼び出すと、コンパイラは Car のメンバを直接解決します。
Car car = new Car();
car.Drive(); // Car.Drive() が呼ばれる
Console.WriteLine(car.MaxSpeed); // 180
インターフェース型の変数に代入して呼び出しても結果は同じですが、コンパイラが参照できるのはインターフェースで定義されたメンバのみになります。
IDrivable d = car;
d.Drive(); // 同じく Car.Drive() が呼ばれる
// d.SomeCarSpecificMethod(); // コンパイルエラー
仮想ディスパッチとの関係
インターフェースのメンバ呼び出しは常に仮想ディスパッチ(実行時に実際の型を確認する方式)で行われます。そのため、インターフェース変数越しでも派生クラスのオーバーライドが正しく呼び出されます。
public class ElectricCar : Car
{
public override void Drive() => Console.WriteLine("電気自動車が走る");
}
IDrivable d = new ElectricCar();
d.Drive(); // "電気自動車が走る"
インターフェース参照の取得:as キーワード
as 演算子は、オブジェクトを指定した型にキャストし、失敗時は例外を投げず null を返します。インターフェース参照の取得に非常によく使われます。
public interface ISavable
{
void Save();
}
public class Document : ISavable
{
public void Save() => Console.WriteLine("ドキュメントを保存");
}
public class Image { } // ISavable を実装しない
object obj1 = new Document();
object obj2 = new Image();
ISavable? s1 = obj1 as ISavable;
ISavable? s2 = obj2 as ISavable;
s1?.Save(); // "ドキュメントを保存"
Console.WriteLine(s2 is null); // True
as が有効なのは参照型とインターフェースに限られます。値型には使えません(Nullable<T> 経由は可)。取得した参照が null かどうかを確認してから使うのが基本パターンです。
// 典型的な使い方
if (obj as ISavable is { } savable)
{
savable.Save();
}
インターフェース参照の取得:is キーワード
is 演算子は、オブジェクトが指定した型を実装しているかを bool で返します。C# 7 以降はパターンマッチングと組み合わせて、型チェックと変数宣言を同時に行えます。
object obj = new Document();
// 古典的な書き方(C# 6 以前)
if (obj is ISavable)
{
((ISavable)obj).Save();
}
// C# 7 以降のパターンマッチング(推奨)
if (obj is ISavable savable)
{
savable.Save(); // savable は ISavable 型として使える
}
as vs is の使い分け
as |
is + パターンマッチング |
|
|---|---|---|
| 失敗時 | null を返す |
false を返す |
| 変数宣言 | 別途必要 | ブロック内で同時に宣言可 |
| 値型 | 使用不可 | 使用可 |
| 可読性 | やや低い | 高い(C# 7 以降) |
現代の C# では is によるパターンマッチングが主流です。as は null 参照をそのまま後続処理に渡す必要がある場合など、特定の文脈で使います。
switch 式でのパターンマッチング
void Process(object obj)
{
switch (obj)
{
case ISavable s:
s.Save();
break;
case IDrivable d:
d.Drive();
break;
default:
Console.WriteLine("未対応");
break;
}
}
インターフェースをパラメータとして使う
メソッドのパラメータをインターフェース型にすることで、具体的な実装クラスに依存しない疎結合な設計が実現できます。
public interface ILogger
{
void Log(string message);
}
public class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine($"[LOG] {message}");
}
public class FileLogger : ILogger
{
public void Log(string message) => File.AppendAllText("log.txt", message + "\n");
}
// ILogger を受け取るので、ConsoleLogger でも FileLogger でも渡せる
public class OrderService
{
private readonly ILogger _logger;
public OrderService(ILogger logger)
{
_logger = logger;
}
public void PlaceOrder(string item)
{
_logger.Log($"注文: {item}");
}
}
OrderService service1 = new OrderService(new ConsoleLogger());
OrderService service2 = new OrderService(new FileLogger());
service1.PlaceOrder("本"); // コンソールに出力
service2.PlaceOrder("ペン"); // ファイルに記録
この設計は依存性の逆転原則(DIP)の典型例であり、テスト時にモック(偽の実装)を渡すことも容易です。
// テスト用のモック
public class MockLogger : ILogger
{
public List<string> Messages { get; } = new();
public void Log(string message) => Messages.Add(message);
}
var mock = new MockLogger();
var service = new OrderService(mock);
service.PlaceOrder("傘");
Console.WriteLine(mock.Messages[0]); // "注文: 傘"
インターフェースを戻り値として使う
メソッドの戻り値型にインターフェースを指定すると、呼び出し側に実装の詳細を隠蔽できます。
public interface IShape
{
double Area();
}
public class Circle : IShape
{
private readonly double _radius;
public Circle(double radius) => _radius = radius;
public double Area() => Math.PI * _radius * _radius;
}
public class Rectangle : IShape
{
private readonly double _width, _height;
public Rectangle(double w, double h) => (_width, _height) = (w, h);
public double Area() => _width * _height;
}
// 戻り値は IShape — 具体的な型を呼び出し側に知らせない
public static IShape CreateShape(string kind)
{
return kind switch
{
"circle" => new Circle(5.0),
"rectangle" => new Rectangle(3.0, 4.0),
_ => throw new ArgumentException($"未知の図形: {kind}")
};
}
IShape shape = CreateShape("circle");
Console.WriteLine(shape.Area()); // 78.53...
呼び出し側は IShape のメンバしか見えないため、内部実装が変わっても呼び出し側のコードを変える必要がありません。これはファクトリメソッドや DI コンテナのよくある戻り値パターンです。
インターフェース型の配列
インターフェース型を要素型とする配列を作成することで、異なる実装クラスのオブジェクトを同一のコレクションで扱えます。
public interface IAnimal
{
void Speak();
}
public class Dog : IAnimal { public void Speak() => Console.WriteLine("ワン"); }
public class Cat : IAnimal { public void Speak() => Console.WriteLine("ニャン"); }
public class Bird : IAnimal { public void Speak() => Console.WriteLine("チュン"); }
IAnimal[] animals = { new Dog(), new Cat(), new Bird() };
foreach (IAnimal animal in animals)
{
animal.Speak();
}
// ワン
// ニャン
// チュン
配列の要素型がインターフェースであるため、どの具象型でも格納できます。List<IAnimal> や IEnumerable<IAnimal> も同様のパターンで使えます。
List<IAnimal> zoo = new List<IAnimal> { new Dog(), new Cat() };
zoo.Add(new Bird());
int count = zoo.Count(a => a is Cat); // 1
Array.Sort や LINQ との組み合わせ
IComparable<T> や IComparer<T> を実装したオブジェクトの配列は、組み込みのソートや LINQ 操作と直接組み合わせられます。
public interface IPriority
{
int Priority { get; }
}
public class Task : IPriority
{
public string Name { get; init; } = "";
public int Priority { get; init; }
}
IPriority[] tasks =
{
new Task { Name = "A", Priority = 3 },
new Task { Name = "B", Priority = 1 },
new Task { Name = "C", Priority = 2 },
};
var sorted = tasks.OrderBy(t => t.Priority);
foreach (var t in sorted)
Console.WriteLine(((Task)t).Name); // B, C, A
既定実装(C# 8.0)
C# 8.0 からインターフェースのメソッドに**既定実装(default interface implementation)**を書けるようになりました。実装クラスが上書きしなければ、この既定実装が呼ばれます。
public interface INotifier
{
void Notify(string message);
// 既定実装 — 実装クラスで上書きしなくてもよい
void NotifyAll(IEnumerable<string> messages)
{
foreach (var m in messages)
Notify(m);
}
}
public class EmailNotifier : INotifier
{
// NotifyAll は上書きしない → 既定実装が使われる
public void Notify(string message) => Console.WriteLine($"メール送信: {message}");
}
INotifier notifier = new EmailNotifier();
notifier.NotifyAll(new[] { "件名A", "件名B" });
// メール送信: 件名A
// メール送信: 件名B
既定実装の重要な制約
既定実装にはインスタンスフィールドを持てません。 インターフェース自体がフィールドを持てないためです。状態を保持したい場合は引き続き抽象クラスを使います。
また、既定実装はインターフェース型の参照を通じてのみ呼び出せます。具象型の変数からは見えません。
EmailNotifier e = new EmailNotifier();
// e.NotifyAll(...); // コンパイルエラー — EmailNotifier には NotifyAll が見えない
INotifier n = e;
n.NotifyAll(new[] { "msg" }); // OK — インターフェース経由なら呼び出せる
既定実装で上書き可能なメソッド
実装クラスで既定実装を上書きする場合は、通常どおりメソッドを実装します。
public class SmsNotifier : INotifier
{
public void Notify(string message) => Console.WriteLine($"SMS送信: {message}");
// NotifyAll を独自実装で上書き
public void NotifyAll(IEnumerable<string> messages)
{
string joined = string.Join(", ", messages);
Notify($"一括: {joined}");
}
}
INotifier sms = new SmsNotifier();
sms.NotifyAll(new[] { "件名A", "件名B" });
// SMS送信: 一括: 件名A, 件名B
既定実装の主な用途
既定実装は「インターフェースのバージョンアップ」に特に有用です。新しいメソッドを既定実装付きで追加すれば、既存の実装クラスを一切変更せずにインターフェースを拡張できます。
// v1
public interface IDataSource
{
IEnumerable<string> GetAll();
}
// v2 — 既定実装付きで追加。既存の実装クラスに変更不要
public interface IDataSource
{
IEnumerable<string> GetAll();
IEnumerable<string> GetFiltered(Func<string, bool> predicate)
=> GetAll().Where(predicate);
}
static コンストラクタと static メンバ(C# 8.0)
C# 8.0 以降、インターフェースに static メンバと static コンストラクタを定義できます。これらはインターフェース型自体に紐づき、インスタンスとは独立しています。
static フィールドと static プロパティ
インターフェースは通常のインスタンスフィールドを持てませんが、static フィールドは持てます。
public interface IVersion
{
static string Version { get; } = "1.0.0";
static int MaxRetry { get; set; } = 3;
}
Console.WriteLine(IVersion.Version); // "1.0.0"
IVersion.MaxRetry = 5;
Console.WriteLine(IVersion.MaxRetry); // 5
static メソッド
インターフェースに static メソッドも定義できます。ファクトリやバリデーションなど、インスタンスに紐づかないユーティリティを提供するのに使えます。
public interface IParser<T>
{
static abstract T Parse(string input); // C# 11 の static abstract(後述)
}
// C# 8 では static abstract ではなく通常の static も使える
public interface IConstants
{
static string DefaultName => "Unknown";
static int Clamp(int value, int min, int max)
=> Math.Max(min, Math.Min(max, value));
}
Console.WriteLine(IConstants.DefaultName); // "Unknown"
Console.WriteLine(IConstants.Clamp(150, 0, 100)); // 100
static コンストラクタ
インターフェースに static コンストラクタを書くと、そのインターフェースが初めて参照されたときに一度だけ実行されます。
public interface ICache
{
static readonly Dictionary<string, object> Store;
static ICache()
{
Store = new Dictionary<string, object>();
Store["default"] = new object();
Console.WriteLine("ICache static コンストラクタ実行");
}
}
// ICache に初めてアクセスしたときに static コンストラクタが走る
var val = ICache.Store["default"]; // "ICache static コンストラクタ実行" が表示される
C# 11 の static abstract メンバ(参考)
C# 11 ではインターフェースに static abstract や static virtual メンバを定義でき、ジェネリック制約と組み合わせることで数値演算などの汎用処理を型安全に記述できます(いわゆる “Generic Math”)。
public interface IAddable<T>
{
static abstract T operator +(T a, T b);
}
public static T Sum<T>(IEnumerable<T> values) where T : IAddable<T>, new()
{
T result = new T();
foreach (var v in values)
result = result + v;
return result;
}
インターフェース階層の設計
インターフェース同士を継承させることで、段階的に機能を積み上げる階層を作れます。
public interface IReadable
{
string Read();
}
public interface IWritable
{
void Write(string data);
}
public interface IReadWritable : IReadable, IWritable
{
// 追加メンバがなくても IReadable + IWritable を合成した契約として機能する
}
public class Buffer : IReadWritable
{
private string _data = "";
public string Read() => _data;
public void Write(string data) => _data += data;
}
IReadWritable rw = new Buffer();
rw.Write("Hello");
Console.WriteLine(rw.Read()); // Hello
// IReadable だけが必要な場所には IReadable 型で受け取れる
IReadable r = rw;
Console.WriteLine(r.Read()); // Hello
既定実装を持つインターフェース階層(C# 8.0)
派生インターフェースで既定実装を上書きしたり、新しい既定実装を追加したりすることもできます。
public interface ISerializer
{
string Serialize(object obj);
// 基底インターフェースの既定実装
string SerializeList(IEnumerable<object> items)
=> string.Join(",", items.Select(Serialize));
}
public interface IJsonSerializer : ISerializer
{
// 親の既定実装を上書き
string ISerializer.SerializeList(IEnumerable<object> items)
=> "[" + string.Join(",", items.Select(Serialize)) + "]";
// 新しい既定実装を追加
string SerializeIndented(object obj)
=> Serialize(obj); // 実際はインデント付き実装を想定
}
public class SimpleJsonSerializer : IJsonSerializer
{
public string Serialize(object obj) => $"\"{obj}\"";
}
ISerializer s = new SimpleJsonSerializer();
Console.WriteLine(s.SerializeList(new object[] { "a", "b" }));
// IJsonSerializer の上書き実装: ["a","b"]
IJsonSerializer js = new SimpleJsonSerializer();
Console.WriteLine(js.SerializeList(new object[] { "a", "b" }));
// 同じく: ["a","b"]
ダイヤモンド問題と解決策
複数のインターフェースが共通の基底インターフェースを継承し、それぞれが同じメソッドの既定実装を持つ場合、実装クラスは曖昧さを解消するために明示的にどちらを使うかを指定する必要があります。
public interface IBase
{
void Hello() => Console.WriteLine("IBase.Hello");
}
public interface ILeft : IBase
{
void IBase.Hello() => Console.WriteLine("ILeft.Hello");
}
public interface IRight : IBase
{
void IBase.Hello() => Console.WriteLine("IRight.Hello");
}
// Diamond: ILeft と IRight どちらの Hello を使う?
public class Diamond : ILeft, IRight
{
// コンパイルエラーを避けるために明示的に解決する
void IBase.Hello() => ((ILeft)this).Hello(); // ILeft の実装を採用
}
IBase d = new Diamond();
d.Hello(); // "ILeft.Hello"
このような複雑な階層はできるだけ避け、設計を単純に保つことが実用上の鉄則です。
まとめ
| テーマ | ポイント |
|---|---|
| オブジェクトレベルの呼び出し | 具象型変数でも呼べる。インターフェース変数経由でも仮想ディスパッチが働く |
as による参照取得 |
キャスト失敗時に null を返す。null チェックが必須 |
is による参照取得 |
型チェックとキャストを同時に行えるパターンマッチングが推奨 |
| パラメータとして使う | 疎結合・差し替え可能・テスト容易性が向上する |
| 戻り値として使う | 実装の詳細を隠蔽し、呼び出し側の依存を減らせる |
| 配列として使う | 異なる実装を統一コレクションで扱える |
| 既定実装(C# 8.0) | インターフェースへの後方互換な拡張に有効。状態は持てない |
| static メンバ(C# 8.0) | インターフェースにユーティリティや定数を付与できる |
| 階層設計 | 段階的な契約合成が可能。既定実装の上書きも可能 |
インターフェースを「パラメータ」や「戻り値」として設計の節々に織り込むことが、C# で疎結合なコードを書く最も効果的な方法です。まず小さなインターフェースを定義し、必要に応じて階層化していくアプローチが実践的です。