Adapter — 構造的部分型が消す境界
構造的部分型が引き取る部分と、変換関数やクラスが残る部分を切り分けます。
このパターンが解こうとした問題
GoF は Adapter を、互換性のないインターフェースを持つクラス同士を協働させるためのパターンとして収録しています1。呼び出し側が期待する形と、手元にあるライブラリの形が違うとき、あいだに変換役を挟みます。
このパターンには 2 つの動機が混ざっています。
- 形が違う — メソッド名や引数の並びが期待と合わない
- 名前が違う — 中身は合っているのに、その型を実装していると宣言していない
TypeScript で減るのは 2 つ目です。1 つ目は言語が何をしようと残ります。
クラスで素直に書く
天気データの取得を題材にします。アプリ側は次の形を期待しているとします。
type Weather = {celsius: number; humidity: number};
interface WeatherSource {
fetchAt(city: string): Promise<Weather>;
}
// 外部ライブラリ。メソッド名も、引数の種類も、単位も、戻り値の形も違う
class LegacyWeatherApi {
async get(_cityCode: string): Promise<{tempF: number; humidityPercent: number}> {
return {tempF: 68, humidityPercent: 40}; // 通信部分は省略
}
}
const CITY_CODES: Record<string, string> = {sendai: '040010'};
// Adapter。期待される形に合わせて包む
class LegacyWeatherAdapter implements WeatherSource {
constructor(private readonly api: LegacyWeatherApi) {}
async fetchAt(city: string): Promise<Weather> {
const raw = await this.api.get(CITY_CODES[city]); // 都市名を都市コードへ
return {
celsius: Math.round(((raw.tempF - 32) * 5) / 9), // 華氏を摂氏へ
humidity: raw.humidityPercent,
};
}
}
この形は TypeScript でもそのまま書けますし、間違ってもいません。次の節では、この構えのうちどこまでが言語側に吸収されるかを見ます。
言語機能で置き換える
TypeScript の型の互換性は名前ではなく形で決まります。
Type compatibility in TypeScript is based on structural subtyping. Structural typing is a way of relating types based solely on their members.2
ハンドブックは名前で判定する言語との違いも明記しています。
In nominally-typed languages like C# or Java, the equivalent code would be an error because the
Dogclass does not explicitly describe itself as being an implementer of thePetinterface.2
つまり 形が合っていれば、implements を書いていなくても代入できます。名前を合わせるためだけの包み紙には、出番がありません。
ただし同じページに例外も書かれています。
Private and protected members in a class affect their compatibility. When an instance of a class is checked for compatibility, if the target type contains a private member, then the source type must also contain a private member that originated from the same class. ... This allows a class to be assignment compatible with its super class, but not with classes from a different inheritance hierarchy which otherwise have the same shape.2
形がまったく同じでも、private や protected のメンバーを持つクラスどうしは、同じクラス由来でないかぎり代入できません。 # 始まりのプライベートフィールドも同じです。この場合は implements を書き足しても通らないので、包み直す変換が残ります。
type Weather = {celsius: number; humidity: number};
interface WeatherSource {
fetchAt(city: string): Promise<Weather>;
}
// implements を書いていないが、形が合っているのでそのまま渡せる
const inMemorySource = {
async fetchAt(_city: string): Promise<Weather> {
return {celsius: 20, humidity: 40};
},
};
async function report(source: WeatherSource, city: string): Promise<string> {
const {celsius} = await source.fetchAt(city);
return `${city}: ${celsius}℃`;
}
report(inMemorySource, 'sendai').then((text) => console.log(text));
形が違う場合 (動機 1) は、言語が何をしようと変換が残ります。ここでは変換を関数として書いた場合を見ます。
type Weather = {celsius: number; humidity: number};
type LegacyWeather = {tempF: number; humidityPercent: number};
interface WeatherSource {
fetchAt(city: string): Promise<Weather>;
}
type LegacyApi = {get(cityCode: string): Promise<LegacyWeather>};
function toWeather(raw: LegacyWeather): Weather {
return {
celsius: Math.round(((raw.tempF - 32) * 5) / 9),
humidity: raw.humidityPercent,
};
}
const CITY_CODES: Record<string, string> = {sendai: '040010'};
// 変換関数を挟むだけで、Adapter クラスと同じ役目を果たす
function adaptLegacyApi(api: LegacyApi): WeatherSource {
return {
fetchAt: async (city) => toWeather(await api.get(CITY_CODES[city])),
};
}
toWeather は入力から出力を作るだけの関数なので、単体で読めますし単体でテストできます。クラスの版では、この変換ロジックがメソッドの中に埋まっていました。
本連載の判断
判定: 条件付き。 何を合わせたいかで答えが変わります。
- 名前だけの不一致 (どちらも素のオブジェクトか、
privateを持たないクラス) — 対処は要りません。構造的部分型がそのまま解決します。既存の型に合わせるためにimplementsを書き足す作業も生じません。 - 形は同じだが
private/protectedを持つクラスどうし — 代入できないので包み直す変換が要ります。上で見たとおりimplementsを書き足しても通りません。この枝だけは、形が合っていても Adapter が残ります。 - 形の不一致で、変換に状態が要らない — 変換関数を書きます。
adaptLegacyApiのようにオブジェクトを返す関数でまとめると、呼び出し側からは 1 つの入り口に見えます。 - 形の不一致で、変換に状態が要る — クラスを選びます。接続の保持、認証トークンの更新、応答のキャッシュなど、呼び出しをまたいで持ち回るものがある場合です。
implements を書くこと自体は、無意味ではありません。実装側が意図した契約から外れたときに、実装側のファイルでエラーを出すためのものです。これは型を合わせるためではなく、間違いを早く見つけるための注釈にあたります。
この判定が覆る条件
構造的部分型は「形が合っていれば通る」ので、意味が違うのに形が同じ型を取り違えても気付けません。type Celsius = number と type Fahrenheit = number は名前こそ違いますが、型エイリアスなので互いに代入できます。{id: string} を持つ利用者 ID と注文 ID も同じです。単位や識別子を扱う型では、この取り違えが実害を生みます。
この場合は形を区別できるようにする対処 (判別用のプロパティを持たせる、いわゆるブランド型にする) が要り、そこでは「形が合えば通る」という前提そのものを意図的に壊すことになります。
もう 1 つ、toWeather のような変換関数は変換が一方向のときに素直です。読み書きの両方を仲介する必要がある場合は、対になる変換を 1 か所にまとめたくなり、オブジェクトかクラスを選ぶ判断に戻ります。
既存記事との関係
- クラスとインターフェース は合成を優先する考え方を扱っています。Adapter は合成の具体例の 1 つにあたります。
- クラスと継承 に抽象クラスとインターフェースの使い分けの比較表があります。本章の
implementsの扱いは、そこで整理されている内容を前提にしています。
なお、クリーンアーキテクチャの文脈で出てくる「インターフェースアダプター」は層の名前であって、本章の Adapter パターンとは指すものが異なります。