はじめに
オブジェクト指向を支える仕組みの一つである「インターフェース」は、
正しく使用すれば大きなメリットが得られる一方で、曖昧な理解のまま使用しているとメリットを感じとることが難しい概念です。
今回はインターフェースのメリットについてまとめてみました。
インターフェースとは
Wikipediaでは、インターフェースについて下記の通り説明されています。
クラスやオブジェクトが提供すべき操作の仕様のみを定め、具体的な実装を含まない型
この概念を知った当初は、インターフェースのメリットについて「クラスに対して実装を強制できる」程度の理解で、あまりピンときていませんでした。
「なぜクラスのプロパティやメソッドを直接呼び出してはいけないの?」「手間が増えて煩わしいな〜」と悩んだ記憶があります。
理解を難しくしていた原因は、「何を起点にインターフェースを定義するか」の捉え方にありました。
【以前】実装クラスが起点
XxxRepository(実装クラス)
↓ 実装クラスを元にインターフェースを定義する
IXxxRepository
【現在】呼び出す側が起点
XxxService(呼び出し元)
↓ 必要となる機能をインターフェースとして定義する
IXxxRepository
↑ インターフェースの定義に合わせて実装する
XxxRepository(実装クラス)
呼び出し元が必要としていることを定義したものがインターフェースであり、
その定義に沿って実装クラスを実装する、という捉え方です。
呼び出し元から見れば、インターフェースは
「実装クラスを意識することなく、担保されている機能を確認できるもの」 になります。
この考え方であれば、「クラスに対して実装を強制できる」だけではないインターフェースのメリットが見えてきます。
メリット
① 呼び出し元を変えずに、実装部分を差し替えられる
例として、以下の要件を満たす給与計算機能が必要になったとします。
- 従業員の給与を計算する
- 自社DBより従業員データ(従業員ID、基本給、扶養家族フラグ)を取得する
- 扶養家族ありの場合は、基本給に加えて扶養手当(2万円)を付与する
以下は、インターフェースを使わずに実装した例です。
// 従業員データ
public class Employee
{
// 従業員ID
public int EmployeeId { get; set; }
// 基本給
public int BaseSalary { get; set; }
// 扶養家族有無
public bool HasDependents { get; set; }
}
public class EmployeeRepository
{
public Employee FindById(int employeeId)
{
// 従業員IDを元に、自社DBから従業員データを取得する
}
}
public class PayrollService
{
private readonly EmployeeRepository _repository = new EmployeeRepository();
// 扶養手当
private const int DependentAllowance = 20000;
// 給与計算処理
public int CalculateSalary(int employeeId)
{
var employee = _repository.FindById(employeeId);
// 扶養家族ありの場合は扶養手当がつく
var allowance = employee.HasDependents ? DependentAllowance : 0;
return employee.BaseSalary + allowance;
}
}
問題なく実装できました。
ただし、PayrollService のテストコードを書こうとすると、このままでは実DBへの接続が必要になってしまいます。
(PayrollService は内部で EmployeeRepository を直接 new して生成しているため)
そこで、呼び出し元である PayrollService が必要としていることだけをインターフェースとして書き出します。
呼び出し元が必要としているのは、「IDを渡すと従業員情報が返ってくる処理」です。
public interface IEmployeeRepository
{
Employee FindById(int employeeId);
}
実装クラスは、この宣言に沿って実装します。
public class EmployeeRepository : IEmployeeRepository
{
public Employee FindById(int employeeId)
{
// 自社DBに接続して従業員データを取得する
}
}
テスト用に、DBに接続せず固定値を返すスタブを用意します。
// テスト用
public class StubEmployeeRepository : IEmployeeRepository
{
private readonly Employee _employee;
public StubEmployeeRepository(Employee employee)
{
_employee = employee;
}
// どのIDでも、渡された従業員データをそのまま返す
public Employee FindById(int employeeId) => _employee;
}
呼び出し元の PayrollService も修正します。
ただし、以下のようにフィールドの型をインターフェースに変えるだけでは、
引き続き実装クラス(EmployeeRepository)を自分で new する形になるためNGです。
public class PayrollService
{
private readonly IEmployeeRepository _repository = new EmployeeRepository();
}
PayrollService 内でインスタンスを生成するのではなく、外から受け取る形にします。(= 依存性注入)
依存性注入(DI)
クラスが必要とするオブジェクトを、そのクラスの中で new するのではなく、外から渡してもらう設計パターン。
これにより、PayrollService は IEmployeeRepository の実装クラスが何かを気にせず動作できます。
public class PayrollService
{
private readonly IEmployeeRepository _repository;
// 扶養手当
private const int DependentAllowance = 20000;
// 依存性注入
public PayrollService(IEmployeeRepository repository)
{
_repository = repository;
}
public int CalculateSalary(int employeeId)
{
var employee = _repository.FindById(employeeId);
var allowance = employee.HasDependents ? DependentAllowance : 0;
return employee.BaseSalary + allowance;
}
}
あとは、本番とテストで渡すものを変えるだけです。
var service = new PayrollService(new EmployeeRepository());
// DBに接続せず、スタブを渡してテストする
public class PayrollServiceTest
{
[Fact]
public void CalculateSalary_HasDependents()
{
var employee = new Employee
{
EmployeeId = 1,
BaseSalary = 300000,
HasDependents = true
};
var service = new PayrollService(new StubEmployeeRepository(employee));
Assert.Equal(320000, service.CalculateSalary(1));
}
[Fact]
public void CalculateSalary_NotHasDependents()
{
var employee = new Employee
{
EmployeeId = 1,
BaseSalary = 300000,
HasDependents = false
};
var service = new PayrollService(new StubEmployeeRepository(employee));
Assert.Equal(300000, service.CalculateSalary(1));
}
}
これにより、「呼び出し元(PayrollService)を変えずに、実装部分(EmployeeRepository / StubEmployeeRepository)を差し替える」ことができます。
また、従業員データの取得元が自社DBから他社APIに変わった場合でも、
IEmployeeRepository を実装した新しいクラスを用意することで対応できます。
public class ApiEmployeeRepository : IEmployeeRepository
{
private readonly HttpClient _httpClient;
public ApiEmployeeRepository(HttpClient httpClient)
{
_httpClient = httpClient;
}
public Employee FindById(int employeeId)
{
// Web APIを呼び出して従業員データを取得する
}
}
あとは、先ほどと同様に渡すものを変えるだけで対応できます。
var service = new PayrollService(new ApiEmployeeRepository(httpClient));
図にすると以下の通りです。
実装クラスが複数あったとしても、PayrollService は IEmployeeRepository だけに依存します。
② 呼び出し元に応じて、見せる機能を切り分けられる
1つの実装クラスを複数の呼び出し元から使う場合、それぞれが必要とする処理は異なることがあります。
このようなときは、呼び出し元ごとにインターフェースを分けて定義するのが有効です。
例として、従業員データを使うServiceが複数存在するとします。
-
PayrollService(給与計算)… 従業員情報の取得のみ行う -
EmployeeAdminService(従業員管理)… 従業員情報の登録・更新・削除を行う
まず、インターフェースを介さない場合です。
EmployeeRepository に必要な機能を実装して、これを呼び出す形としました。
public class EmployeeRepository
{
public Employee FindById(int employeeId) { /* ... */ }
public void Insert(Employee employee) { /* ... */ }
public void Update(Employee employee) { /* ... */ }
public void Delete(int employeeId) { /* ... */ }
}
PayrollService(給与計算)では FindById のみ実行できれば十分です。
しかし、実装クラスを直接参照しているため、それ以外のDB更新メソッドまで実行できてしまいます。
// 給与計算処理
public class PayrollService
{
private readonly EmployeeRepository _repository = new EmployeeRepository();
// 扶養手当
private const int DependentAllowance = 20000;
public int CalculateSalary(int employeeId)
{
var employee = _repository.FindById(employeeId);
// 扶養家族ありの場合は扶養手当がつく
var allowance = employee.HasDependents ? DependentAllowance : 0;
// ここで _repository.Delete() なども実行できてしまう
return employee.BaseSalary + allowance;
}
}
一方、呼び出し元を起点に定義したインターフェースを介する場合です。
以下の通り、呼び出し元ごとにインターフェースを定義します。
// PayrollService が必要とする処理
public interface IEmployeeReader
{
Employee FindById(int employeeId);
}
// EmployeeAdminService が必要とする処理
public interface IEmployeeWriter
{
void Insert(Employee employee);
void Update(Employee employee);
void Delete(int employeeId);
}
実装クラスは、両方のインターフェースを実装します。
public class EmployeeRepository : IEmployeeReader, IEmployeeWriter
{
// 実装は1クラスのまま
}
これにより、PayrollService は IEmployeeReader、EmployeeAdminService は IEmployeeWriter を経由し、それぞれ必要な処理だけを扱えるようになります。
図にすると以下の通りです。
呼び出し元ごとに依存するインターフェースが分かれ、実装は1クラスで両方を引き受けます。
③ 実装の抜け漏れや誤削除に気づける
冒頭で触れた「クラスに対して実装を強制できる」のことです。
呼び出し元を起点にインターフェースが正しく定義されていれば、実装クラスがその宣言を満たしているかをコンパイラが確認してくれます。
必要なメソッドを実装し忘れたり、誤って消したりすれば、その場でコンパイルエラーになります。
疎結合
PayrollService が EmployeeRepository を直接 new していた最初のコードでは、実装クラスの変更が呼び出し元まで波及しがちです。
一方で、①や②のように呼び出し元を起点にインターフェースを挟むと、呼び出し元は実装クラスを直接知らず、インターフェースだけを見ている状態になります。これによりコンポーネント間の依存度が低い「疎結合」の状態を実現できます。
こうすることで、実装クラスに修正が入っても、呼び出し元への影響を抑えられます。
注意点
① すべてのクラスにインターフェースが必要なわけではない
- 実装クラスとインターフェースが1対1で、実装クラスの追加見込みやテストの必要性もない
- 呼び出し元ごとに公開する機能を絞る必要もない
- 開発規模が小さい
といった場合は、あえて追加する必要はありません。
インターフェースの追加はコードの複雑性が増すというデメリットもあります。
得られる恩恵とデメリットを比較したときに、恩恵が上回るならインターフェースを追加すべき、というのが個人的な考えです。
② インターフェースを挟めば必ず疎結合になるわけではない
「インターフェースを挟みさえすれば疎結合になる」というのも誤解です。
例えば、実装クラスの全メソッドを機械的に写しただけのインターフェースや、
以下のようにインターフェースの中に実装クラス側の詳細が漏れ出しているケースでは
疎結合を実現できていません。
public interface IEmployeeRepository
{
// DBの接続情報がむき出しになっている
string ConnectionString { get; }
// 「SQL文を書いて渡す」という、DB前提のやり取りになっている
Employee FindBySql(string sql);
}
// 利用する側は「接続情報」や「SQL文」を前提にコードを書いてしまうため、
// DBを使わないAPI実装やテスト用スタブに差し替えられない。
実装側の都合ではなく、呼び出し元が必要とすることのみをインターフェースとして定義することがポイントです。
そうすることで呼び出し元は実装を知らずに済み、疎結合を実現できます。
おわりに
インターフェースについて、メリットと注意点をまとめました。
実装クラスではなく、呼び出し元を起点にインターフェースを捉えることで、メリットも理解しやすくなると思います。
同じところでつまずいている方の助けになれば幸いです。
参考資料