🐕

TypeScriptでポリモーフィズム入門〜関数型スタイル編〜

2024/09/16に公開

サンプルコードはTypeScriptです。

ポリモーフィズムとは

関数を再利用したい、しかし完全に同じコードは使えないときに使います。

関数型スタイルの場合のポリモーフィズムは、同じ関数名の関数に対して異なるデータ型の入力ができることをいいます。端的にいえば、この関数につっこんでおけば、なんか良しなにやってくれる、という感じです。

ポリモーフィズムが必要になる場面は基本的にストラテジーパターンかデータとパイプパターンです。そのため返り値の型は共通であることが多いです。

逆にlodashのようなutility関数を作成する場合、パラメトリック多相を意識すると使いやすい関数ができます。

オブジェクト指向スタイルの場合は1つのオブジェクトに複数のメソッドが備わってるため、ポリモーフィズムはより複雑になります。オブジェクト指向のポリモーフィズムは、また今度記事にできればと思います。

関数を再利用したい状況とその対応策

関数を再利用したい状況を整理します。

1. 関数の意図は異なるがたまたま同じ関数名だった

例えば足し算と文字列の連結の両方にAddという名前の関数名を使いたい場合です。

具体例

数字の足し算
function Add(a: number, b: number){
  return a + b;
}

Add(3,4); // 7
文字列の連結
function Add(a: string, b: string){
  return a.concat(b); 
}

Add("Hello","World"); // HelloWorld

対応策

名前空間をわける
この場合は、たまたま関数名が同じだけなので、名前空間を変える(別モジュールにするなど)のが良いです。

math.ts
function add(a: number, b: number){
  return a + b;
};

export {
  add
};
string.ts
function add(a: string, b: string){
  return a.concat(b); 
};

export {
  add
};

2. 関数の意図は同じ、振る舞いも同じ、しかし入力するデータ型が異なる

これは2パターンあります。

  • 型名が異なるが構造が同じ
  • 型名が異なりさら構造が異なる

「型名が異なるが構造が同じ」の具体例

最初にアメリカ版のフルネーム表示関数を作成したとします。

フルネーム表示_US版
// US版
type US = {
  firstName: string;
  lastName: string;
}

function getFullName(person: US){
  // 処理
}

const us: US = {
  firstName: 'hogehoge',
  lastName: 'fugafuga'
}

getFullName(us) 

そこにイギリス版も追加することになりました。

イギリス版も追加したい
// アメリカ版もイギリス版もおなじ構造なことに注目
// UK版
type UK = {
  firstName: string;
  lastName: string;
}

const uk: UK = {
  firstName: 'hogehoge',
  lastName: 'fugafuga'
}

// 再利用したい
getFullName(uk);

アメリカ版で作ったgetFullName関数を再利用したくなりますね。構造がおなじイギリス版でも再利用できるでしょうか。
実はTypeScriptの場合構造的型付けを採用しているので特に何もしなくてもgetFullName関数は再利用できます。名前的型付けを採用しているJavaなどの場合は、そのままでは再利用できないためオーバーロードといった仕組みを活用することになります。

「型名が異なるが構造が同じ」の対応策

TypeScriptのような構造的型付け言語の場合
そもそもそのまま動く。

Javaのような名前的型付け言語の場合
オーバーロードを実装する。

「型名が異なりさら構造が異なる」の具体例

振る舞いが完全に同じなのに構造が異なる場合、key名がエイリアスのように1対1の対応関係にあります。

先ほど同様に、アメリカ版のフルネーム表示関数を作成したとします。コードは同じなので省略します。

フルネーム表示アメリカ版
function getFullName(person: US){
  // 処理
}

そこにドイツ版も追加することになりました。ドイツ語でfirstNamevornamelastNamenachnameに対応します。

ドイツ版も追加したい
// US版とDE版は構造が異なることに注目
// DE版
type DE = {
  vorname: string;
  nachname: string;
}

const de: DE = {
  vorname: 'hogehoge',
  nachname: 'fugafuga'
}

// 再利用したい
getFullName(de);

アメリカ版で作ったgetFullNameを再利用したくなります。しかし構造が異なるため、これは動きません。

「型名が異なりさら構造が異なる」の対応策

対応策はいくつかあります。個人的にはパターンマッチが好みです。

オーバロード機能を利用した場合

オーバーロード
function getFullName(person: US){}
function getFullName(person: DE){}

function getFullName(person: US | DE){
  // 型ガード か クラスのインスタンスであれば instanceof を利用
  if ("vorname" in person && "nachname" in person) {
    return person.vorname + person.nachname
  } else {
    return person.firstName + person.lastName
  }
}

パターンマッチを利用した場合
いわゆるswitch-caseです。そもそもの型に識別子(例えばtype)が必要です。switch-case文を使ってもよいと思いますが、僕はts-patternライブラリを愛しています。

パターンマッチとガード節
import { match } from 'ts-pattern';

type US = {
  type: 'us',
  firstName: string;
  lastName: string;
};

type DE = {
  type: 'de',
  vorname: string;
  nachname: string;
}

function getFullName(person: US | DE){
  return match(person)
    .with({ type: 'us' }, (narrowed) => narrowed.firstName + narrowed.lastName)
    .with({ type: 'us' }, (narrowed) => narrowed.vorname + narrowed.nachname)
    .exhaustive();
}

getFullName({
  type: 'us',
  familyName: 'hogehoge',
  lastName: 'fugafuga',
});
getFullName({
  type: 'de',
  vorname: 'hogehoge',
  nachname: 'fugafuga',
})

オーバーロードとほぼ同じ書き方をしていますね。オーバーロードの場合は、引数の個数が2個以上のときにより真価を発揮します。今回のように単にオブジェクト1つを引数にとるだけであれば、パターンマッチのほうがシンプルに書けます。

アダプターパターンを利用した場合
getFullNameの再利用はできていますが、新しくgetDEFullName関数を作成しています。これは関数名が変わったためポリモーフィズムではないです。しかし、もしあなたがgetFullName関数の所有者でなくgetFullNameのコードを変更できない場合、このような書き方にせざるえないですね。

アダプターパターン
function getDEFullName(de: DE){
  const us =  {
    firstName: de.vorname,
    lastName: de.nachname,
  }
  return getFullName(us);
}

もしくは、関数化せずにそのまま変形してgetFullNameに入れてしまう人のほうが多そうですね。これでもいいですが、ほかの箇所でも同じコードが散見されるならちゃんと関数化したいですね。

const de: DE = {
  vorname: 'hogehoge',
  nachname: 'fugafuga'
}
getFullName({
  firstName: de.vorname,
  lastName: de.nachname,
});

マルチメソッドを利用した場合
switch-caseを使わずに、抽象と実装を分けるパターンとしてマルチメソッドがあります。マルチメソッドは「関数の意図は同じ、入力するデータに応じて、振る舞いを変えたい」ときにより真価を発揮しますので、解説は後述したいと思います。

マルチメソッド
import { multi, method } from '@arrows/multimethod';

function getFullNameDispatch(person: US | DE){
  // typeを識別子とする
  return person.type;
}

let getFullName = multi(getFullNameDispatch);

// typeが"us"だった場合の処理
getFullName = method("us", (us: any) => us.firstName + us.lastName)(getFullName);
// typeが"de"だった場合の処理
getFullName = method("de", (de: any) => de.vorname + de.nachname)(getFullName);

getFullName({
  type: 'us',
  familyName: 'hogehoge',
  lastName: 'fugafuga',
});
getFullName({
  type: 'de',
  vorname: 'hogehoge',
  nachname: 'fugafuga',
})

今回の場合は、僕個人の意見としてマルチメソッドはオーバーテクノロジーのように感じます。しかし、オープンクローズドの原則を推奨するコミュニティでは、switch-caseの使用は好まれません。というのは、新しいバリエーションを追加するとき(今回だとドイツ語版のこと)にgetFullName関数を編集しないといけないためです。2つのリスクがあります。

  • 誤ってgetFullName関数の関係ないほかのコードに手を加えてしまいバグを混入させてしまう
  • もしコンパイル型の言語であれば、新しいバリエーションが追加されるたびにgetFullNameのモジュールを再コンパイルしないといけない。もし新しいバリエーションの実装を別モジュールで追加できるのであれば、既存のgetFullNameをコンパイルしなくてもよい

またバリエーションの追加のたびにcaseが増えるため、コードが長くなり認知的負荷が上がります。

さらに、関数の宣言と、実装が同時に決まるため、抽象と実装の分離ができません。抽象と実装は別モジュールで分けて、抽象のみに依存することで、依存関係がきれいになりコミュニティに好まれます。

しかし、ここまでswitch-caseのデメリットと抽象と実装の分離のメリットを説明されても、switch-caseがオープンクローズドの原則に反しているとは、個人的には思えません。なにか納得にいく説明を知っている方がいましたら教えてください。

3. 関数の意図は同じ、入力するデータに応じて、振る舞いを変えたい

先ほど同様フルネーム表示をテーマに説明します。欧米諸国とは異なり、日本や中国などの東アジアの国では、フルネーム表示する際、最初に家族名がきて、あとから個人名がくるのが一般的です。

具体例

先ほど同様に、アメリカ版のフルネーム表示関数を作成したとします。コードは同じなので省略します。

フルネーム表示アメリカ版
function getFullName(person: US){
  // 処理
}

そこに日本版も追加することになりました。

日本版も追加したい
// US版とJP版は構造が異なることに注目
// JP版
type JP = {
  sei: string;
  mei: string;
}

const jp: JP = {
  sei: '山田',
  mei: '太郎'
}

// 再利用したい
getFullName(jp);

アメリカ版で作ったgetFullNameを再利用したくなります。しかし構造も振る舞いも異なるため、そのままでは再利用できません。

対応策

「型名が異なりさら構造が異なる」の対応策とほぼ同じですが、アダプターパターンだけ使えません。個人的にはパターンマッチが好みです。

オーバロード機能を利用した場合

function getFullName(person: EU){}
function getFullName(person: JP){}

function getFullName(person: EU | JP){
  // 型ガード か クラスのインスタンスであれば instanceof を利用
  if ("sei" in persin && "mei" in person) {
    return person.sei + person.mei // 最初に家族名
  } else {
    return person.firstName + person.lastName // 最初に個人名
  }
}

パターンマッチを利用した場合
いわゆるswitch-caseです。そもそもの型に識別子(例えばtype)が必要です。switch-case文を使ってもよいと思いますが、僕はts-patternライブラリを愛しています。

パターンマッチとガード節
import { match } from 'ts-pattern';

type US = {
  type: 'us',
  firstName: string;
  lastName: string;
};

type DE = {
  type: 'de',
  vorname: string;
  nachname: string;
}

function getFullName(person: US | DE){
  return match(person)
    .with({ type: 'us' }, (narrowed) => narrowed.firstName + narrowed.lastName)
    .with({ type: 'us' }, (narrowed) => narrowed.vorname + narrowed.nachname)
    .exhaustive();
}

getFullName({
  type: 'us',
  familyName: 'hogehoge',
  lastName: 'fugafuga',
});
getFullName({
  type: 'de',
  vorname: 'hogehoge',
  nachname: 'fugafuga',
})

アダプターパターンを利用した場合
アダプターパターンは振る舞いが異なるため採用できません。もし採用しようとすると、むりやり振る舞いをあわせるために前処理のロジックがおかしくなります。

アダプターパターン
function getJPFullName(jp: JP){
  const us = {
    lastName: jp.mei, // 家族名なのに日本版だと個人名いれてる!?
    firstName: jp.sei,
  };
  return getFullName(convertedEU);
}

マルチメソッドを利用した場合
マルチメソッドを利用すると、関数の抽象と実装を分離できます。ただしその仕組み上パターンマッチと異なり、型セーフにするのに少しトリックが必要です。ここでは、頑張らずにany使ってます。

マルチメソッド
import { multi, method } from '@arrows/multimethod';

function getFullNameDispatch(person: any){
  return person.type;
}

let getFullName = multi(getFullNameDispatch);

// typeが"us"だった場合
getFullName = method("us", (us: any) => us.firstName + us.lastName)(getFullName);
// typeが"jp"だった場合
getFullName = method("jp", (jp: any) => de.sei + de.mei)(getFullName);

getFullName({
  type: 'us',
  firstName: 'hogehoge',
  lastName: 'fugafuga',
});
getFullName({
  type: 'jp',
  sei: '山田',
  mei: '太郎',
});

マルチメソッドは一見すると、抽象と実装が分離している点以外はパターンマッチとほぼ同じですね。ししかし、パターンマッチとは異なり、dispatch関数を工夫することで、様々なパターンを網羅することができます。

例えば年齢が10歳以下だったらフルネームを大文字などの条件も追加できます。

import { multi, method } from '@arrows/multimethod';

function getFullNameDispatch(person: any){
  if(person.age <= 10) return [person.type, 'UPPER']
  return [person.type, 'LOWWER'];
}


let getFullName = multi(getFullNameDispatch);

// typeが"us"だった場合
getFullName = method(["us", "UPPER"], (us: any) => us.firstName.toUpperCase + us.lastName.toUpperCase)(getFullName);
getFullName = method(["us", "LOWWER"], (us: any) => us.firstName + us.lastName)(getFullName);

getFullName({
  type: 'us',
  firstName: 'hogehoge',
  lastName: 'fugafuga',
  age: 5,
}); // HOGEHOGEFUGAFUGA

詳しくはデータ指向プログラミングという本のポリモーフィズムの章を読んでください。

ポリモーフィズムが適用できる場面

データとパイプパターン

最初にユニオン型のデータがあって、そこに処理をしていくパターン。unixのパイプをイメージしてデータとパイプパターンと呼ぶ。

ユーザーに挨拶をするシステム
type Person = {
  type: 'us',
  lastName: 'hogehoge',
  firstName: 'fugafuga',
  age: 5
} | {
  type: 'jp',
  sei: '山田',
  mei: '太郎',
  age: 15
}

function greet(person: Person){
  const fullName = getFullName(person); // 日本人なら性名、アメリカ人ならfirst lastの順
  const greet = getGreet(person); // 国籍であいさつ文変える
  const statement = `${greet}${fullName}`

  console.log(statement);
}

ストラテジーパターン

アルゴリズムの振る舞いを変更する。関数型の場合は、引数に関数をとる。

DisplayUserNameコンポーネント
function displayUserName(user: Person, getFullName: (person: Person) => string){
  const fullName = getFullName(user);

  return <div>{fullName}</div>
}

まとめ

以上、関数型スタイルのポリモーフィズムでした。TypeScriptの場合クラス型言語のパラダイムも書けるため、インターフェイスや継承を使ったポリモーフィズムも可能です。それはまた今度書きたいと思います。

TRAPE(トラピ)

Discussion