前回までの振り返り
ということで、第二回です。
第一回では、クリーンアーキテクチャのソースコードを写経して持った二つの疑問点
- Contorollerってなんのためにあるんだろう?
- Interfaceってなんのためにあるんだろう?
のうち、Contorollerについてその役割や必要性を学びました。
https://qiita.com/aramayu-1111/items/0d19023855b34ae01d15
なので今回は、
「Interfaceってなんのためにあるんだろう?」
ということを考えていきます。
事前知識:Interfaceとは
まず初めに、私が持っている事前知識の確認し、そこからInterfaceについてさらに掘り下げていきます。
Interfaceとは
Interfaceとは、実装を持たない抽象クラスです。
言葉では難しいので、具体的にコードを書くと、以下のようなものです。
interface AnimalInterface { // クラスのインターフェイス
walk()
eat()
sleep()
}
class Cat implements AnimalInterface { // ViheicleInterfaceをimplementsしたCarクラス
walk() {
logger.info('屋根の上を歩いています')
}
eat() {
logger.info('魚を食べています')
}
sleep() {
logger.info('こたつで丸まって寝ています')
}
}
この時、Catクラスは、AnimalInterfaceをimplementしているため、walk()
,eat()
,sleep()
を必ず実装しなければなりません。
このように、Interfaceには、methodの定義を書くことができます。
Interfaceの利点
Interfaceを使う利点としては、以下の2点だと理解しています。
Interfaceの利点
- クラスが持つべき機能の実装漏れを防ぐ
- 同じ機能を持つが、具体的な処理が異なる別のクラスの実装が容易になる
「クラスが持つべき機能の実装漏れを防ぐ」というのは、先ほど説明した通り、Interfaceに定義した機能は必ず処理を実装する必要があるからです。例では特に引数や戻り値はありませんが、引数と戻り値もInterfaceで定義した通りのものを実装する必要があります。
「同じ機能を持つが、具体的な処理が異なる別のクラスの実装が容易になる」については、たとえば犬クラスを新規で実装するとします。
class Dog implements AnimalInterface
こう書くだけで、Dogクラスにwalk()
,eat()
,sleep()
機能を持たせなければいけなくなります。ただし、実装の中身はCatクラスとは異なります。たとえば、以下のような形です。
class Dog implements AnimalInterface { // ViheicleInterfaceをimplementsしたCarクラス
walk() {
logger.info('尻尾を振って歩いています')
}
eat() {
logger.info('ドッグフードを食べています')
}
sleep() {
logger.info('うつ伏せで寝ています')
}
}
また、Catクラス、Dogクラスは同じ振る舞いをすることが保証されているので、以下のような実装が可能になります。ポリモーフィズムってやつですね。
function animal_life(type: ANIMAL_TYPE){
if(ANIMAL_TYPE == CAT){
var animal = new Cat()
}
if(ANIMAL_TYPE == DOG){
var animal = new DOG()
}
animal.walk()
.
.
.
}
勉強をする前の私は、だいたいこんな理解でした。
なので、正直、「複数のクラスにimplementsしないんだったら、interfaceの役割薄いよなー」なんて思ってました。
本当にInterfaceは複数クラスにimplementsしないと意味がないのか?
結論から言うと、意味はあります。
こちらのサイトで、かなり丁寧にインターフェイスの意義についてまとめられていました。
私の認識していなかったinterfaceの利点としては、以下のようなものがありました。
Interfaceのさらなる利点
- クラスへの安全なアクセスを提供する
- 疎結合になる
1つずつ見ていきます。
Interfaceでクラスへの安全なアクセスを提供する
たとえば、以下のような、他の人が書いた記事を読んだり、自分が投稿できたりするアプリのクラスの実装を考えます。
class User {
private val id
private val name
public createArticle(title, content): Article{
//記事を作成する
}
public getArticle(article_id: String): Article {
//記事を取得する
}
public postArticle(article: Article) {
//記事を投稿する
}
public editArticle(): Article {
//記事を編集する
}
public delete() {
//記事を削除する
}
}
Userクラスには、記事を取得して読んだり、記事を作成投稿したり、という機能が実装されています。
さて、このクラスをこのまま使ってみます。
Aさんが、自分で書いた記事を投稿します
User userA = new User()
//記事を作成
val article = userA.createArticle(title, content)
// 作成した記事を投稿
userA.postArticle(article)
読者(Bさん)が、投稿した記事を読みました。
userB = new User()
// BさんがAさんの記事を取得
userB.getArticle(article_id)
そして記事の内容の間違いに気づいたBさんが、勝手に記事を編集して再度投稿してしまいました!!
// Bさんが記事を編集して再投稿
article = userB.editArticle()
userB.postArticle(article)
そうです。
このように、読者が本来できないはずの操作まで、Bさんは行えてしまいました。
そこで、interfaceです!!
//執筆者用のインターフェース
interface AuthorUserInterface {
public createArticle(title, content): Article
public postArticle(article)
public editArticle(): Article
public delete()
}
//読者用のインターフェース
interface ReaderUserInterface {
public getArticle(ariticle_id): Article
このように、インターフェースを分けて、ReaderUserクラス、AuthorUserクラスにそれぞれimplementsします
class ReaderUser implements ReaderUserInterface {
private val id
private val name
public getArticle(article_id): Article {
//記事を取得する処理
}
}
class AuthorUser implements AuthorInterface{
private val id
private val name
public createArticle(title, content): Article{
//記事を作成する処理
}
public postArticle(article) {
//記事を投稿する処理
}
public editArticle(): Article {
//記事を編集する処理
}
public delete() {
//記事を削除する処理
}
}
こうすることで、読者(ReaderUser)は記事を読むことしかできなくなります。
このように、Interfaceを細かく分けて、必要な機能のみを切り出せるようにすることを、「Interface分離の原則」といいます。SOLIDのひとつ、Iに当たる部分ですね。
ちなみに、全ての機能を持ったAdminクラスを作成したいとします。
その時には、以下のように複数インターフェースをimplementsすることも可能です。
class AdminUser implements ReaderInterface, AuthorInterface {
private val id
private val name
public getArticle(article_id): Article{
//記事を取得する処理
}
public createArticle(title, content): Article{
//記事を作成する
}
.
.
.
}
疎結合の実現
例えば、以下のような実装があったとします。
class ArticleRepository{
public get(id): Article {
// DBからidでデータを参照
// Articleの形に変換してreturn
}
}
class GetArticleService {
private val articleRepository = new ArticleRepository()
public getArticle(id) {
articleRepository.get(id)
}
}
この場合、GetArticleServiceは、ArticleRepositoryに依存している状態です。
これの何が問題なのか、
例えば、ArticleRepositoryのget()
はDBからデータを参照していますが、他のDBを使用したいとなったり、別の種類のデータソースを使用したいとなるかもしれません。
それによって、例えばget()
の引数が変わってしまったら、getArticle()
も変更を余儀なくされてしまいます。つまり、ArticleRepositoryとGetArticleServiceは密結合になってしまっています。
この後、さらにGetArticleService
に依存するクラスまで出てきたら、さらに複雑に絡み合って、少しコードを変更しただけで、関係ないところに影響が出た。ということにもなりかねません。
もう一点問題があります。
テストのやりづらさです。GetArticleService
内でArticleRepository
をnewしてしまうと、mockに置き換えることが難しく、テストがやりづらいです。
どうやら、最近ではmockが作れないこともないらしいです。何のツールを使ってテストするのかにもよると思いますが、あまりテストに詳しくないので、不確定なことは言えません。何にせよ、疎結合である方が単体テストはやりやすいのに変わりはないでしょう。
ということで、GetArticleServiceを、ArticleRepositoryに依存させず、インターフェースに依存させます。
interface ArticleRepositoryInterface
{
public getArticle(id): Article
}
class ArticleRepository implements ArticleRepositoryInterface{
public get(id): Article {
// DBからidでデータを参照
// Articleの形に変換してreturn
}
}
class GetArticleService {
private val articleRepository: ArticleRepositoryInterface
public getArticle(id) {
articleRepository.get(id)
}
}
こうすることで、GetArticleServiceもArticleRepositoryもArticleRepositoryInterfaceに依存する形になりました。
こうすることで、GetArticleService
とArticleRepository
結合度を下げることができました。
こうして、インターフェースに依存させることで依存関係を逆転させることを、SOLID原則の一つ、依存性逆転の原則といいます。
ただし、私のように物分かりが悪い人間は、まだよくわかりませんでした。
これで何が変わるの?と
例えば、先ほどの例にあった、ArticleRepositoryのget()
の引数が変わってしまったら結局GetArticleService
の変更もしないといけなくなるじゃんと思ったのです。
ここで大事になってくるのが、冒頭でのお話しにあった、Interfaceで定義した関数は、名前、引数、戻り値まで全て同じでなければいけない。という決まりです。
ある程度機能を実装したプロジェクトに、何にも知らない新人が入ってきたとします。そこで、新人さんがArticleRepositoryのget()
関数をいじったとして、もしInterfaceに反していた場合、エラーになります。
それだけでも、何にも知らない人から、めちゃくちゃな機能変更をされて、関係ないところにバグを埋め込まれるような事態を、少しは防ぐことができそうです。
まとめ
今回はInterfaceの役割と、Interfaceは複数クラスにImplementしないと意味がないのか?ということについて勉強しました。
結論、「Interfaceとは、クラスが持つ機能の設計書」です。
設計書の持つ役割は、さまざまなものがあり、一つのクラスにimplementするだけでも意味があります。
Interfaceの役割
- 同じ機能を持つが、処理内容が異なるクラスの実装がやりやすくなる
- クラスに不必要な機能を持たせない
- そのクラスが持つべき機能の理解をしやすくする
- クラス間の結合度を下げる
システムを作る時、多くの場合、複数人で作成します。システムが大きくなるほど、多くの人が関わり、ヒューマンエラーのリスクが高まっていきます。
Interfaceがあることで、ヒューマンエラーのリスクが少しだけ下げられる。というのが一番のInterfaceを使用する理由なのかもと思いました。
参考
https://qiita.com/yutorisan/items/d28386f168f2f3ab166d
https://qiita.com/k2491p/items/7b4e56789964ac6328b3
https://zenn.dev/keiichiro/articles/9397379ab638b1
https://zenn.dev/chida/articles/e46a66cd9d89d1
https://annulusgames.com/blog/dependency-injection/