Javaのインターフェースについて学んでいると、次のようなコードを見かけた。
Payment payment = new CreditCard();
最初は、左辺と右辺で型が違うことが不思議だった。
さらに、インターフェースを使うメリットとして「実装を差し替えられる」と説明されることがある。
ただ、実装を差し替えられるといっても、実際にどこのコードを変更するのだろうか。
今回は、interfaceとimplementsの関係から、コンストラクタを使った実装の差し替えまでを整理する。
1. なぜインターフェース型に実装クラスを代入できるのか
まず、次のコードを考える。
interface Payment {
void pay();
}
Paymentはインターフェースで、決済処理としてpay()というメソッドが必要であることを定義している。
これを実装するクラスを作る。
class CreditCard implements Payment {
@Override
public void pay() {
System.out.println("クレジットカード決済");
}
}
implementsは英語で「実装する」という意味。
つまり、CreditCard implements Paymentは、CreditCardクラスがPaymentインターフェースを実装するという宣言になる。
この関係があるため、次のように書ける。
Payment payment = new CreditCard();
ここでは、
-
Payment:変数の宣言型 -
CreditCard:実際に生成するオブジェクトのクラス
となっている。
インターフェースは親クラスそのものではないが、実装クラスに対する上位の型として扱える。
そのため、Payment型の変数にCreditCardのインスタンスを代入できる。
同じ考え方は、普段使うListにもある。
List<String> names = new ArrayList<>();
Listはインターフェース、ArrayListはその実装クラスである。
2. なぜインターフェースと実装クラスを分けるのか
例えば、ユーザー情報を取得する処理を考える。
最初はPostgreSQLから取得することにする。
class PostgresUserRepository {
public User findById(Long id) {
// PostgreSQLから取得する処理
return null; // 説明用
}
}
この実装をUserServiceが直接使うと、次のようになる。
class UserService {
private final PostgresUserRepository repository;
public UserService() {
this.repository = new PostgresUserRepository();
}
public User getUser(Long id) {
return repository.findById(id);
}
}
これでも動作するが、UserServiceはPostgresUserRepositoryという具体的なクラスに依存している。
例えば、外部APIから取得する方式に変更する場合は、UserService側の修正が必要になる。
そこで、取得処理の共通ルールをインターフェースとして定義する。
interface UserRepository {
User findById(Long id);
}
そして、実装クラスを作る。
class PostgresUserRepository implements UserRepository {
@Override
public User findById(Long id) {
// SQLを実行して取得
return null; // 説明用
}
}
class ApiUserRepository implements UserRepository {
@Override
public User findById(Long id) {
// 外部APIを呼び出して取得
return null; // 説明用
}
}
どちらもUserRepositoryを実装しているため、同じインターフェース型として扱える。
なお、実際のシステムで外部APIによる取得をRepositoryに含めるかは設計による。ここでは実装の差し替えを理解するために、同じインターフェースとしている。
3. UserServiceはインターフェースを受け取る
次に、UserServiceを変更する。
class UserService {
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
public User getUser(Long id) {
return repository.findById(id);
}
}
注目したいのは、このコンストラクタ。
public UserService(UserRepository repository) {
this.repository = repository;
}
これは、UserServiceのオブジェクトを生成するときに、UserRepository型のオブジェクトを受け取るという意味。
this.repositoryはUserServiceが持つフィールドで、右辺のrepositoryはコンストラクタの引数である。
受け取ったRepositoryをフィールドに代入することで、getUser()から利用できるようにしている。
4. newが2回登場するコードを理解する
ここで、最初は分かりにくかったコードが登場する。
UserService service =
new UserService(new PostgresUserRepository());
このコードでは、newが2回使われている。
一度に理解するより、次のように分解すると分かりやすい。
// ① Repositoryのオブジェクトを作る
UserRepository repository =
new PostgresUserRepository();
// ② RepositoryをUserServiceに渡す
UserService service =
new UserService(repository);
処理の流れは次のとおり。
つまり、
new UserService(new PostgresUserRepository())
は、Repositoryを生成して、そのオブジェクトをUserServiceのコンストラクタに渡す処理を1行で書いているだけだった。
5. 実装を変更するとき、どこを書き換えるのか
ここまで理解すると、インターフェースを使うメリットが見えてくる。
PostgreSQLから取得する場合は、
UserService service =
new UserService(new PostgresUserRepository());
外部APIから取得する場合は、
UserService service =
new UserService(new ApiUserRepository());
となる。
変更したのは、コンストラクタに渡すオブジェクトだけ。
new PostgresUserRepository()
を
new ApiUserRepository()
へ差し替えている。
一方、UserServiceは変更しなくてよい。
class UserService {
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
public User getUser(Long id) {
return repository.findById(id);
}
}
UserServiceから見ると、どちらもUserRepository型だからだ。
ただし、これは両方の実装が同じ契約を満たしている場合の話であり、データの取得先を変更するだけで常にシステム全体の修正が不要になるわけではない。
6. クラス全体の構成を整理する
この構成では、UserServiceはPostgresUserRepositoryやApiUserRepositoryを直接指定していない。
UserRepositoryという共通の型に依存している。
そのため、どの実装クラスを渡すかを外側で決められる。
ここがインターフェースを使った設計の重要なところだと思う。
7. 単体テストでも実装を差し替えられる
この仕組みは単体テストでも利用できる。
例えば、DBを使わずに固定データを返すクラスを用意する。
class FakeUserRepository implements UserRepository {
@Override
public User findById(Long id) {
return new User(id, "テストユーザー");
}
}
※Userには、LongとStringを受け取るコンストラクタがある想定。
テスト時には、このFakeUserRepositoryを渡す。
UserService service =
new UserService(new FakeUserRepository());
User user = service.getUser(1L);
この場合、実際のPostgreSQLにはアクセスしない。
それでも、UserServiceのgetUser()という処理を確認できる。
つまり、UserService自身のロジックを、DBなどの外部環境から切り離してテストしやすくなる。
ただし、FakeUserRepositoryによるテストだけでは、実際のSQLやDBとの接続が正しく動作するかまでは検証できない。その部分は別途、結合テストなどで確認する必要がある。
8. これが依存性注入(DI)につながる
今回のコードを振り返る。
UserService service =
new UserService(new PostgresUserRepository());
UserService自身は、Repositoryのオブジェクトを生成していない。
外側で生成したRepositoryを、コンストラクタ経由で受け取っている。
このように、クラスが必要とするオブジェクトを外部から渡すことを依存性注入(Dependency Injection、DI)という。
今回の例は、特に「コンストラクタインジェクション」と呼ばれる方法。
Spring Bootでは、DIコンテナがオブジェクトの生成や依存関係の解決を管理してくれるため、アプリケーションコードで毎回new UserService(...)を書く必要がないことも多い。
ただし、Spring Bootの仕組みを理解する前に、まず今回のようにJavaだけでオブジェクトを生成して渡す流れを理解しておくと、DIの動作をイメージしやすくなる。
まとめ
今回理解したことは、次のとおり。
-
implementsはインターフェースを実装するという意味。 - インターフェース型の変数には、その実装クラスのインスタンスを代入できる。
-
new UserService(new PostgresUserRepository())は、Repositoryを生成してUserServiceのコンストラクタに渡している。 - UserServiceがインターフェースに依存していれば、渡す実装クラスを変更してもUserService自身のコードを変えずに済む場合がある。
- この仕組みは、DBアクセスの実装変更だけでなく、単体テストでの偽の実装にも活用できる。
最初は「インターフェースを使うと差し替えられる」と聞いても、どこがどう変わるのか分からなかった。
実際にコードを分解してみると、コンストラクタに渡すオブジェクトを切り替えているだけだと理解できた。
インターフェースは、単にメソッドの宣言をまとめるものではない。
利用する側と具体的な実装を分離し、変更やテストをしやすくするための設計手段でもある。
もちろん、すべてのクラスにインターフェースが必要なわけではない。実装が一つしかなく、差し替える理由もなければ、分離によって複雑さだけが増えることもある。
重要なのは、どの部分の依存関係を分離したいのかを考えて設計することだと思う。