多重継承できない言語でなるべく誰でもわかるように共通化のために継承を使うべきではない理由をまとめた。 継承を使うべきでない理由は他にもあるがとりあえず一番簡単に納得できそうな理由について記載した。 多重継承できたとしても、共通化のために継承を使うのは個人的にあまり良くないと思っているが、それについては記載しない。
前提
ある程度の規模感があるJavaのコードは責務ごとにクラスを分割するとコードが読みやすくなる(≒全部mainメソッドに書いたコードは読みづらい)。
そもそもコンストラクタでバリデーションしろよ、とかは無視してもとりあえず言いたいことは伝わる気がするので諦めた。
多重継承できる言語だとこの論理では継承よりもコンポジションを主張できない。 リスコフの置換原則の話とかはしない。
まとめ
Javaで継承による共通化を行うと、多重継承ができないため1つの親クラスに全ての共通処理を入れる必要がある。 つまり処理ごとにクラスを分割することができないし、クラスに適切な名前をつけることもできない(なので多くの場合Baseなんとかクラスになってしまう)。
これを避けるために無理やりクラスを分割すると、全く関連の無いクラス同士を親子関係にする必要が出てくる。 こうなると、あるクラスに手を入れる際に関係ないクラスを破壊していないか意識する必要が出てきて辛い。 関係ないクラス同士に親子関係があると、あとから見たときに混乱するので辛い。
例
以下のようなコードがあったとする。
public class UserCreator { public User createUser(User user) { if (user.id != null) { throw new IllegalArgumentException("ユーザーIDは必須です。"); } if (user.age < 0) { throw new IllegalArgumentException("年齢は0以上である必要があります。"); } // 永続化 return db.create(user); } } public class UserUpdater { public User updateUser(User user) { if (user.id != null) { throw new IllegalArgumentException("ユーザーIDは必須です。"); } if (user.age < 0) { throw new IllegalArgumentException("年齢は0以上である必要があります。"); } // 永続化 return db.update(user); } }
ユーザーを永続化する際にバリデーションを行っているが、このバリデーションはCreaterとUpdaterで重複している。 これを継承で共通化すると以下のようになる。
public abstract class UserPersistentBase { public void validateUser(User user) { if (user.id != null) { throw new IllegalArgumentException("ユーザーIDは必須です。"); } if (user.age < 0) { throw new IllegalArgumentException("年齢は0以上である必要があります。"); } } } public class UserCreator extends UserPersistentBase { public User createUser(User user) { validateUser(user); return repository.create(user); } } public class UserUpdater extends UserPersistentBase { public User updateUser(User user) { validateUser(user); return repository.update(user); } }
validateUserがUserPersistentBaseに共通化され、UserCreator、UserUpdaterがすっきりした。 次にユーザーが作成されたり更新した際に、WebhookでHTTPリクエストが飛ぶ、という要件が追加されたとする。
public abstract class UserPersistentBase { public void validateUser(User user) { if (user.id != null) { throw new IllegalArgumentException("ユーザーIDは必須です。"); } if (user.age < 0) { throw new IllegalArgumentException("年齢は0以上である必要があります。"); } } } public class UserCreator extends UserPersistentBase { public User createUser(User user) { validateUser(user); try { HttpClient client = HttpClientFactory.create(); UserChangeEvent event = new UserChangeEvent(user, WebHookType.CREATE_USER); client.post(System.getProperty("WEBHOOK_URL"), event.toString()); } catch (HttpRequestFailureException e) { // webhookのリクエストエラーの場合は通知のみで処理をすすめる log.error("failed webhook request", event); } return repository.create(user); } } public class UserUpdater extends UserPersistentBase { public User updateUser(User user) { validateUser(user); try { HttpClient client = HttpClientFactory.create(); UserChangeEvent event = new UserChangeEvent(user, WebHookType.UPDATE_USER); client.post(System.getProperty("WEBHOOK_URL"), event.toString()); } catch (HttpRequestFailureException e) { // webhookのリクエストエラーの場合は通知のみで処理をすすめる log.error("failed webhook request", event); } return repository.update(user); } }
Webhookのリクエストを行うコードが重複しているので共通化する。
public abstract class UserPersistentBase { public void validateUser(User user) { if (user.id != null) { throw new IllegalArgumentException("ユーザーIDは必須です。"); } if (user.age < 0) { throw new IllegalArgumentException("年齢は0以上である必要があります。"); } } public void requestWebhook(String message) { try { HttpClient client = HttpClientFactory.create(); client.post(System.getProperty("WEBHOOK_URL"), message); } catch (HttpRequestFailureException e) { // webhookのリクエストエラーの場合は通知のみで処理をすすめる log.error("failed webhook request", event); } } } public class UserCreator extends UserPersistentBase { public User createUser(User user) { validateUser(user); requestWebhook(new UserChangeEvent(user, WebHookType.CREATE_USER).toString()); return repository.create(user); } } public class UserUpdater extends UserPersistentBase { public User updateUser(User user) { validateUser(user); requestWebhook(new UserChangeEvent(user, WebHookType.UPDATE_USER).toString()); return repository.update(user); } }
UserPersistentBaseにユーザー永続化時のバリデーションとWebhookの送信処理が追加された。 UserだけではなくItemの作成クラスについても考えてみる。この場合も同様にWebhookの送信が必要である。
public class ItemCreator { public Item createItem(Item item) { try { HttpClient client = HttpClientFactory.create(); client.post(System.getProperty("WEBHOOK_URL"), new ItemChangeEvent(item, WebHookType.CREATE_ITEM).toString()); } catch (HttpRequestFailureException e) { // webhookのリクエストエラーの場合は通知のみで処理をすすめる log.error("failed webhook request", event); } return repository.create(item); } }
Webhookの送信処理はユーザーの場合と同様であり、共通化できそうだがそのまま共通化するとこうなる。
public class ItemCreator extends UserPersistentBase { public Item createItem(Item item) { requestWebhook(new ItemChangeEvent(item, WebHookType.CREATE_ITEM).toString()); return repository.create(item); } }
UserPersistentBaseを継承したItemCreatorが出来てしまった。 さらにItemについても更新処理とバリデーションを追加する。
public class ItemCreator extends UserPersistentBase { public Item createItem(Item item) { validateItem(item); requestWebhook(new ItemChangeEvent(item, WebHookType.CREATE_ITEM).toString()); return repository.create(item); } } public class ItemUpdater extends UserPersistentBase { public Item createItem(Item item) { validateItem(item); requestWebhook(new ItemChangeEvent(item, WebHookType.CREATE_ITEM).toString()); return repository.create(item); } }
UserPersistentBaseには現在以下の処理がある。
- ユーザーのバリデーション
- アイテムのバリデーション
- Webhookの送信
これはもはやUserPersistentBaseという名前にマッチしない。 PersistentBaseに変更しても良いが今後永続化するリソースが増えたり共通化したい処理が増えるたびにPersistentBaseに処理が追加され続けると、PersistentBaseが肥大化する。 この状況はもはや責務ごとに処理が分割されているとは言いがたい。
責務ごとにクラスを分けつつ継承で共通化した場合、以下のようになる。
public abstract class UserValidator extends WebhookRequester { public void validateUser(User user) { } } public abstract class ItemValidator extends WebhookRequester { public void validateUser(User user) { } } public abstract class WebhookRequester { public void requestWebhook(String message) { } } public class UserCreator extends UserValidator { public User createUser(User user) { validateUser(user); requestWebhook(new UserChangeEvent(user, WebHookType.CREATE_USER).toString()); return repository.create(user); } } public class UserUpdater extends UserValidator { public User updateUser(User user) { validateUser(user); requestWebhook(new UserChangeEvent(user, WebHookType.UPDATE_USER).toString()); return repository.update(user); } } public class ItemCreator extends ItemValidator { public Item createItem(Item item) { validateItem(item); requestWebhook(new ItemChangeEvent(item, WebHookType.CREATE_ITEM).toString()); return repository.create(item); } } public class ItemUpdater extends ItemValidator { public Item createItem(Item item) { validateItem(item); requestWebhook(new ItemChangeEvent(item, WebHookType.CREATE_ITEM).toString()); return repository.create(item); } }
UserPersistentBaseがUserValidator、ItemValidator、WebhookRequesterに分割された。 コードは確かに分割されたが、UserValidatorにはWebhookRequesterから継承されたrequestWebhookが生えている。
UserValidatorはバリデーションを行うためのクラスだが、バリデーション以外のことをしている。名前と責務が一致していない。
しかも、UserValidatorはWebhookRequesterに依存したり、overrideによってWebhookRequesterの処理に影響を与える事ができる。 例えば、WebhookRequesterの修正によってバリデーションを壊したり、UserValidatorの修正によってWebhookの処理を壊したりする可能性がでてくる。 つまりそれぞれの処理に手を入れる際、親クラスや子クラスを気にしながら手を入れる必要がある。
さらにこの構造では、共通処理が増えるたびに継承の階層が深くなり、何が何を継承しているのかを判断するのが難しくなってくる。 この階層はJavaの制約を満たすためだけに存在する階層で本質的には不要であり、複雑さを増している。
継承をやめてコンポジションで共通化を行うと以下のようになる。
public class UserValidator { public void validateUser(User user) { } } public class ItemValidator { public void validateItem(Item item) { } } public class WebhookRequester { public void requestWebhook(String message) { } } public class UserCreator { UserValidator validator = new UserValidator(); WebhookRequester webhookRequester = new WebhookRequester(); public User createUser(User user) { validator.validateUser(user); webhookRequester.requestWebhook(new UserChangeEvent(user, WebHookType.CREATE_USER).toString()); return repository.create(user); } } public class UserUpdater { UserValidator validator = new UserValidator(); WebhookRequester webhookRequester = new WebhookRequester(); public User updateUser(User user) { validator.validateUser(user); webhookRequester.requestWebhook(new UserChangeEvent(user, WebHookType.UPDATE_USER).toString()); return repository.update(user); } } public class ItemCreator { ItemValidator validator = new ItemValidator(); WebhookRequester webhookRequester = new WebhookRequester(); public Item createItem(Item item) { validator.validateItem(item); webhookRequester.requestWebhook(new ItemChangeEvent(item, WebHookType.CREATE_ITEM).toString()); return repository.create(item); } } public class ItemUpdater { ItemValidator validator = new ItemValidator(); WebhookRequester webhookRequester = new WebhookRequester(); public Item createItem(Item item) { validator.validateItem(item); webhookRequester.requestWebhook(new ItemChangeEvent(item, WebHookType.CREATE_ITEM).toString()); return repository.create(item); } }
UserValidator、ItemValidator、WebhookRequesterは完全に独立していて各クラスの名前と処理の剥離も無い。 完全に独立しているため、WebhookRequesterに手を入れる時はWebhookRequesterのことだけを意識すればよい。
上記のような状態になってしまうことを避けたいという点だけでも継承よりコンポジションを選べる状況なのであれば、コンポジションを選ぶべきだと思う。
継承はいつ使えばいいのか
共通化ではなく拡張のために使うものだと思っている。思っています。
まとめ
Javaで継承による共通化を行うと、多重継承ができないため1つの親クラスに全ての共通処理を入れる必要がある。 つまり処理ごとにクラスを分割することができないし、クラスに適切な名前をつけることもできない(なので多くの場合Baseなんとかクラスになってしまう)。
これを避けるために無理やりクラスを分割すると、全く関連の無いクラス同士を親子関係にする必要が出てくる。 こうなると、あるクラスに手を入れる際に関係ないクラスを破壊していないか意識する必要が出てきて辛い。 関係ないクラス同士に親子関係があると、あとから見たときに混乱するので辛い。
Webhookまわりのコード、何?
わかんない