TypeScript で学ぶインターフェース(抽象型)
FORCIAアドベントカレンダー2020 10日目の記事です。
こんにちは。旅行プラットフォーム部エンジニアの乙村です。
フォルシアでは JavaScript を利用して開発することが多いのですが、最近は JavaScript の世界にも TypeScript という形で「型」の概念が広まり始めています。私が社会人エンジニアとして初めて触った言語は C++ という型付けがキッチリしている言語でしたが、学び始めた当初「インターフェース(抽象型)って何の役に立つのだろう?」と、ずっと疑問に思っていました。
インターフェース(抽象型)は何がうれしいのか、どういう場面で役に立つのかについて TypeScript を使って説明してみたいと思います。
はじめに
- オブジェクト指向(Object Oriented, 以下OO)の世界では当たり前のように使われるインターフェースですが、メソッドのシグニチャ定義がされているだけで実装がないため、最初は何が嬉しいのかよくわからないことが多いです。実際には依存関係の制御や実装の隠蔽などで、これがないと OO は成り立たないと言って良いくらい重要な概念です。
- pure JavaScript には構文としての interface はありませんが、TypeScript は interface 構文をサポートしており、同じような恩恵が受けられます。本文中のサンプルコードは TypeScript で、Node.js 13.0.1、TypeScript 4.1.2 で動作確認をしています。
- 以降の話は、クラス(Class)による設計を中心とする言語(C++, C#, Java 等) におけるインターフェースの使い方の話ですので、TypeScript の文法としての interface を知りたい方は 公式ドキュメントを読むことをお勧めします。
オブジェクト指向言語における「インターフェース」とは
- 実装のないクラスだと思って下さい。メソッドの型だけ定義してありますが、そのメソッドを呼んだときの処理は何も定義されていません。プロパティを持つ場合もあります。
- 処理が定義されていないので、インスタンス化(new)できません。
- C++、C#、Java などのオブジェクト指向言語ではインターフェースを作るための構文として "interface" が存在します。
- クラスの多重継承は禁止されている言語が多いですが、インターフェースは多重継承が可能です。
インターフェースについて覚えるべきことは 2 つ
1. インターフェースを実装(継承)するクラスは、そのメソッドの処理を必ず実装する必要がある
以下のクラス図のように ClassA が InterfaceA を実装しているとします。
ClassA は method1() を実装していないと(コンパイル時やトランスパイル時の)型チェックでエラーになります。method2() のように ClassA 独自のメソッドがあっても問題ありません。InterfaceA で定義されている method1() が実装されてさえいれば、インターフェースを継承するクラスとしてのルールは守っています。
2. インターフェースを実装(継承)したクラスのインスタンスはインターフェース型の変数に代入できる
ClassAのインスタンス(クラスを new した実体)を、InterfaceA の型の変数に代入できます。ただし、インターフェース型の変数からアクセスできるメソッドはインターフェースに定義されたもののみになります。
// インターフェース InterfaceA
// 実装内容はなんでもよいが、このインターフェースを継承するクラスは
// method1 を実装しないといけない
interface InterfaceA {
method1() : void;
};
// InterfaceA を継承(実装) したクラス ClassA
// method1 の実装があるので型チェックは通る
class ClassA implements InterfaceA {
// method1() をコメントアウトするとエラー
method1() : void{
console.log("ClassA - method1()");
}
method2() : void {
console.log("ClassA - method2()")
}
};
let dummy = new InterfaceA(); // エラー(TS2693). インターフェイスは new できない
let classTypedVar : ClassA = new ClassA(); // OK. クラスのインスタンスをクラスの型の変数に代入可能
let interfaceTypedVar : InterfaceA = new ClassA(); // OK. クラスのインスタンスをインターフェースの型の変数に代入可能
classTypedVar.method1(); // OK
classTypedVar.method2(); // OK
interfaceTypedVar.method1(); // OK
interfaceTypedVar.method2(); // エラー(TS2551). インターフェースに定義のあるメソッドしか呼べない
この「エラー」は TypeScript であればトランスパイル時に検知されます。コンパイル型の静的言語であればコンパイル時に指摘されます。エディタやIDEによっては、コーディング中に即時に指摘されます。
引数で渡す場合も代入と同様です。引数の型に InterfaceA が指定されている場合に、ClassA のインスタンスを渡すことができます。
// 関数の引数でも話は同じ. InterfaceA が要求されている箇所に ClassA のインスタンスを渡せる
const someFunction = (interfaceTypedVar : InterfaceA) => {
interfaceTypedVar.method1(); // OK
interfaceTypedVar.method2(); // エラー(TS2551). インターフェースに定義のあるメソッドしか呼べない
}
someFunction(new ClassA()); // OK
注目すべきポイント
ここで注目すべきなのは、someFunction の内部では ClassA の存在を知らなくてもそのメソッド(処理)を呼べるということです。つまり、InterfaceA を実装していさえすれば、ClassB でも ClassCでもそのインスタンスを渡すことができ、someFucntion はそれが実際は何のクラスのインスタンスかを知ることなく、そのメソッドを呼ぶことができます。
OOでよく聞くプラクティスの一つに「実装ではなく抽象に依存すべき」というものがあります。「抽象」を実現したものがインターフェース、「実装」はそのインターフェースを継承(実装)したクラスと考えてください。このプラクティスに従えば、someFunction の引数の型としては、ClassA ではなく InterfaceA のほうが好ましいということになります。
const someFunction = (classTypedVar: ClassA) => {...} // こうするとClassAに依存してしまう!
const someFunction = (interfaceTypedVar : InterfaceA) => {...} // こうしたほうがいい
で、一体何が嬉しいんでしょうか・・・?この例だとよくわかりません。
インターフェース活用の例
インターフェースが有用なケースを、Key-Value ストアクラス の作成を例に考えてみます。
1.よくあるパッケージ構成
アプリケーション用のクラスは app 、共通で使うクラスを infra というパッケージに置くことにします。共通して使うためのクラスとして、 KeyValueStore クラスを用意します。
共通部分を抜き出して複数のファイルから参照して利用するという、よく見る構成です。
KeyValueStore クラスはKey-Value 型のデータを管理するクラスですが、今回は永続化が不要という要件だったとして、メモリ上に Key-Value の情報を保持することにします。TypeScript での実装例は以下の通りです。
// infra/ keyValueStore.ts
class KeyValueStore {
// 属性 (データの保存先.ただのオブジェクトを辞書として使う)
dictionary: {
[key: string]: string; // TypeScript 記法. key も value も string 型の意味
};
// コンストラクタ
constructor() {
this.dictionary = {};
}
// Key-Value の組み合わせで保存する
save(key: string, val: string): void {
this.dictionary[key] = val;
}
// key に結びついた Value を取得する
load(key: string): string {
return this.dictionary[key];
}
// 保存されているデータを表示する
showAll(): void {
Object.keys(this.dictionary).map((key) => {
console.log(`${key} : ${this.dictionary[key]}`);
});
}
}
export default new KeyValueStore(); // インスタンスをエクスポートする
KeyValueStore クラスのインスタンスをどう取得するかですが、ここではお手軽に、クラスを定義しているモジュール keyValueStore で、クラスではなくインスタンス自体を export しています。Node のように一度しかロードされないことが保証されているモジュールシステムを想定すると、このインスタンスはシングルトンになります。
ClientA と ClientB が それぞれ違うキーと値を保存します。違いは保存する Key-Valueだけです。
// app/ClientA.ts
import keyValueStore from "../infra/keyValueStore";
export default class ClientA {
public someMethod() : void {
keyValueStore.save("A", "A desuyo.");
}
}
// app/ClientB.ts
import keyValueStore from "../infra/keyValueStore";
export default class ClientB {
public someMethod() : void {
keyValueStore.save("B", "B desuyo.");
}
}
ちゃんと Key-Value が保存されているか main で確認します。
// main.ts
import ClientA from "./app/ClientA"
import ClientB from "./app/ClientB"
import keyValueStore from "./infra/keyValueStore"
new ClientA().someMethod();
new ClientB().someMethod();
keyValueStore.showAll();
// output
// A : A desuyo.
// B : B desuyo.
このようにクラスを直接参照して使用する方法は、非常によくあるやり方です。サードパーティ製のライブラリをこのように使うことも多いはずです。
特に問題ないように見えますし、実際問題ないことも多いのですが、OO的にはこの構成では以下のような要求に応えにくいことが問題点とされています。
- 自作の Key-Value ストアではなくて世の中のイケてる Key-Value DBを使いたくなった
- 単体テスト時は決まった初期データでテストしたいから、ローカルファイルのデータを参照するようにしたい
- そもそも Key-Value ストアの実体はクライアントにとってはどうでもいい。実体の決定をなるべく遅らせたい
また、言語によりますが、
- keyValueStore のソースを少しでも変更すると、クライアントをすべてビルドしなおす必要がある
という開発上のデメリット(ビルド時間の増大)が発生します。
これをインターフェースを使って解決します。
2.Key-Value ストアへの依存を切り離し、実装を切り替えやすくした構成
図にすると一気に複雑に見えますが・・・
IKeyValueStore という名前のインターフェースが登場しています。
この IKeyValueStore インターフェース を実装(継承)するクラスが、DBStore, OnMemoryStore, TextFileStore といった具象クラスです。これらはあくまで例ですが、それぞれ、DBへの保存、メモリ上への保存、テキストファイルへの保存をする Key-Value ストアを表しています。
ポイントは、クライアント(ClientA, ClientB)からは、インターフェース(IKeyValueStore) に依存があるだけで、Key-Valueストアの実処理が定義してあるクラス(DBStore, OnMemoryStore, TextFileStore)に依存(--->)がないということです。
クライアントは InfraFactory クラスの getStore() というメソッドを呼んでストアの実体を取得しますが、その返り値の型はインタフェースであり、実際にそれが何クラスのインスタンスなのかはわかりません。
実装を一部見ていきます。まず、インターフェース(IKeyValueStore ) には Key-Value ストアとして備えていてほしい機能(メソッド)を定義しておきます。
// infra/IKeyValueStore .ts
export default interface IKeyValueStore {
save(key: string, val: string): void; // Key-Value の保存
load(key: string): string; // Key-Value の読み出し
showAll() : void; // 保存している値の出力(これは無くてもいい)
}
インターフェースを実装するクラスで、その実処理を定義します。
// infra/OnMemoryStore .ts
import IKeyValueStore from "./IKeyValueStore";
// TypeScript の'implements' キーワードを使って、IKeyValueStore を
// 継承したクラス OnMemoryStore を作る
// IKeyValueStore で定義したメソッドを実装していないとエラーになる
class OnMemoryStore implements IKeyValueStore {
// (略) 「1.よくあるパッケージ構成」の KeyValueStore の実装と全く同じ
}
export default OnMemoryStore; // インスタンスではなく、クラスをエクスポート
DBStore クラス、TextFileStore クラスも同様に IKeyValueStore インターフェースで定義されているメソッドを実装します。DBStore であれば Key-Value DB への値の読み書きがされるように実装し、TextFileStore であれば File への値の読み書きがされるように実装します。
クライアントから直接 OnMemoryStore などのクラスを new してしまうと具体的なクラスへの依存が発生することになり、後から切り替える場合にクライアント側の修正が必要になってしまいます。それを避けるため、インスタンスを作って返してくれるファクトリ(工場)の役割を持つクラスを用意します。
// infra/ InfraFactory.ts
// key-value ストアインターフェース
import IKeyValueStore from "./IKeyValueStore";
// インターフェースを実装(継承)したクラス群
import OnMemoryStore from "./OnMemoryStore";
import DBStore from "./DBStore";
import TextFileStore from "./TextFileStore";
// ストアの実体を返してくれるクラス
class InfraFactory {
private store : IKeyValueStore; // インターフェース型の属性
constructor(config : {storeType:String}) {
if (config.storeType === "OnMemory") {
// メモリ上にデータを保存するストア
this.store = new OnMemoryStore();
} else if (config.storeType === "DB") {
// DB上にデータを保存するストア
this.store = new DBStore();
} else if (config.storeType === "Text"){
// テキストファイルにデータを保存するストア
this.store = new TextFileStore();
} else {
// default
console.error("Wrong Configuration :", config)
this.store = new OnMemoryStore();
}
}
getKeyValueStore() : IKeyValueStore {
return this.store;
}
}
// プログラム全体で、どの Key-Value ストアを使うのか、ここだけで変更が可能
// 環境変数や設定ファイルで変更することもできる
const infraFactory = new InfraFactory({storeType:"OnMemory"});
export default infraFactory;
このファクトリにより、あとで Key-Value ストアを変更したくなった場合に、このファクトリだけ修正すればよいことになります。
1.の問題点への回答
- 自作の Key-Value ストアではなくて世の中のイケてる Key-Value DBを使いたくなった
- 「イケてる Key-Value DB 」のAPIを save, load で呼び出すクラスを作成して getKeyValueStore() で返すようにする
- 単体テスト時は決まった初期データでテストしたいから、ローカルファイルのデータを参照するようにしたい
- テキストファイルを読み出して初期データとして持つクラスを作成して getKeyValueStore() で返すようにする
- そもそも Key-Value ストアの実体はクライアントにとってはどうでもいい。実体の決定をなるべく遅らせたい
- とりあえずデフォルトの OnMemoryStore を返すようにしておいて、決定次第返すクラスを差し替えればよい
となります。
TypeScript のインターフェースはもっと柔軟
ここまでに説明した方法は、処理をクラスのメソッドとして表現する言語(JavaやC++)の場合によく行われるやり方ですが、JavaScript は関数そのものを引数や返り値として受け渡すことができる性質(第一級関数)をもつため、必ずしもクラスのインスタンスを返り値で返す必要はありません。TypeScript の場合はクラスだけでなく、データや関数にもインターフェースを設定することができるため、より柔軟でお手軽に実装への直接の依存を切り離すことができます。
(今回の例はリンク先の「Class Types」としてのインターフェースの使い方です)実際には、関数やデータの取得箇所を一か所に集めることで、実装を一気に切り替えるという方法は pure JavaScipt でも可能です。ただし、interface がないために、クライアント側は返されるデータの型(プレーンなデータオブジェクト?クラスのインスタンス?関数?)や持っているプロパティについては何も保証されていない状態で使うことになります。
残る問題点は、パッケージ間の依存関係
これまではクラス間の依存関係に注目してきましたが、app パッケージと infra パッケージというパッケージ間で依存関係を見てみると、infra パッケージにあるインターフェース(IKeyValueStore )やファクトリ(InfraFactory )を app で使用しているため、app --> infra という依存関係ができています。これが問題になるケースがあります。
- infra パッケージがないと、app の開発ができない(IKeyValueStore や InfraFactory の import 箇所でエラーになり、単独でビルドできない)
- infra でインターフェースを変更されると、app の修正が必要になる
呼び出し先ができていないと呼び出し元の開発ができないのは当たり前で、特に問題点ではないように思えます。 app パッケージと infra パッケージが別のチームにより開発されていると考えると、少し問題点がわかるかもしれません。infra チームがころころ interface のメソッドを変えてきたらどうでしょうか?
- infra 開発チーム
- 「Key-Value DB の中身を XXXDB から YYYDB に変更することにしたわ!」
- 「IKeyValueStore も変わるんで呼び出し元の修正よろしく!」
- app 開発チーム
- 「(・・・なんのためのインターフェースやねん)」
ということが起きないように、 Interface を使って app が主導権を握れるようにします。依存関係を制御することで、app が infra に依存するのではなく、infra が app に依存するようにできます。
3.Key-Value ストアクラスだけでなく、それを含むパッケージへの依存を切り離した構成
これで infra パッケージが app パッケージに依存するようになりました。インターフェースとクラスの依存関係について補足すると、inferfaceA を classA が実装するとき、classA は intefaceA を知っていなければならない(importの必要がある)ため、依存性の向きは classA --> interfaceA となります。
まず、infra にあった IKeyValueStore インターフェースが app に移動しています。これで app はパッケージ内のインターフェースを参照すればよいことになり、infra のファイルを参照する必要がなくなります。これまでは、使われる側の infra が「うちはこういうAPIなのでそれに従って使ってください」という、変更の主導権が infra 側にある形(infra でAPIを変えられると app は従わざるをえない)でしたが、変更後は、使う側の app が「こういうインターフェースがほしい」と宣言して、infra がそれに従って実装するという主従関係の逆転が起きています。
ただし、InfraFactory クラスも infra にあったので、これを放置すると app と infra の相互依存になってしまい問題です。もう一段階抽象化して、Factory の実体を直接参照するのではなく、FactoryRepository から取得するようにします。
どれだけ抽象化を進めてもどこかでその実体を決定しなければなりません。それは通常、プログラムの開始位置 main に近い場所になります。プログラムの開始直後に、インターフェースから返る実体クラスを決定しておく必要があります。これを依存性の注入といいます。
新しく登場したインターフェース・クラスの実装をみてみます。
まずは、ファクトリのインターフェース IInfraFactory です。ストアを返すメソッド getStore() を実装する必要がある、ということを表現しています。
// app/IInfraFactory
import IKeyValueStore from "./IKeyValueStore";
export default interface IInfraFactory {
getStore() : IKeyValueStore;
}
ファクトリの実体クラス InfraFactory は IInfraFactory インターフェースを implements する以外は、前の例と変わっていません。すでに getStore() は実装済だからです。
// infra/InfraFactory
// key-value ストアインターフェース
// ★ インターフェースの場所が infra から app に代わっている
import IKeyValueStore from "../app/IKeyValueStore";
import IInfraFactory from "../app/IInfraFactory";
// インターフェースを実装(継承)したクラス群
import OnMemoryStore from "./OnMemoryStore";
import DBStore from "./DBStore";
import TextFileStore from "./TextFileStore";
// ストアの実体を返してくれるクラス
class InfraFactory implements IInfraFactory{ // ★ implements キーワードで「実装」を表す
// 実装は前と同じ
}
export default InfraFactory;
FactoryRepository はファクトリを登録するだけの場所です。
// app/factoryRepository.ts
import IInfraFactory from "./IInfraFactory";
class FactoryRepository {
private infraFactory : IInfraFactory;
// ファクトリの実体は外部から注入する
setInfraFactory(infraFactory : IInfraFactory) {
this.infraFactory = infraFactory;
}
getInfraFactory() : IInfraFactory {
return this.infraFactory;
}
}
export default new FactoryRepository(); // インスタンスを返す. シングルトン.
main でファクトリの実体を決定します。
// main.ts
import InfraFactory from "./infra/InfraFactory";
import factoryRepository from "./app/factoryRepository"
import ClientA from "./app/ClientA";
import ClientB from "./app/ClientB";
// 依存性の注入
// infra/InfraFactory を app の FactoryRepository に設定
factoryRepository.setInfraFactory(new InfraFactory({storeType:"OnMemory"}));
new ClientA().someMethod();
new ClientB().someMethod();
factoryRepository.getInfraFactory().getStore().showAll();
// output
// A : A desuyo.
// B : B desuyo.
クライアントは FactoryRepository からファクトリを取得して使用します。
// app/ClientA
import factoryRepository from "./factoryRepository";
export default class ClientA {
public someMethod() : void {
factoryRepository.getInfraFactory().getStore().save("A", "A desuyo.");
}
}
この状態で infra パッケージをまるごと削除しても、コンパイル/トランスパイルの型チェックでエラーが発生するのは main だけです。infra が開発を完了するまで、ダミーの infra パッケージを用意して main で依存性注入しておけば、app は infra を気にすることなく開発を進めることができます。
依存関係逆転の原則とクリーンアーキテクチャ
呼び出し元と呼び出し先の依存関係は「呼び出し元 → 呼び出し先」になるのが普通ですが、この例のように interface を使って、その方向を逆にすることができます。このテクニックは「依存関係逆転の原則」と呼ばれており、「Clean Architecture 達人に学ぶソフトウェアの構造と設計」 に詳しく載っています。
この依存関係逆転の原則を徹底し、その中心点(依存の行きつく先)に Domain Driven Development (DDD) でいうドメインモデルを置いたものが「クリーンアーキテクチャ」 になります。
まとめ
インターフェースを使ってクライアントから実装を隠蔽することで、後からの変更をしやすくしたり、また、パッケージ間の依存関係のコントロールができるようになります。ただ、この例を見てもわかるとおり、同じ処理をするにもクラスやインターフェースの数が増えています。抽象化に伴う開発コストを払ってでも、変更容易性を確保したいかどうかは見極める必要があります。
Discussion