🐶

TypeScriptで学ぶポリモーフィズム~オブジェクト指向サブタイプポリモーフィズム編~

2025/02/02に公開

はじめに

ポリモーフィズムにはおおきく3種類あります。

  • アドホック多相: 同じ関数・メソッドで異なる引数・振る舞いを許容したい(オーバーライドなど)
  • パラメトリック多相: 同じ関数・メソッドで異なる入力値型を許容したい(ジェネリクスなど)
  • サブタイピング: 共通の関数・メソッドをもつ様々な種類のオブジェクト・インスタンスを渡して振る舞いを変えたい

参照: Wikipediaポリモーフィズム

今回は、特にオブジェクト指向において便利なサブタイピングを紹介したいと思います。このサブタイピングはほかのポリモーフィズムが実現できない強力な仕組みを提供してくれます。

なお毎回、関数名・メソッド名のように併記するのは面倒なので、適宜次のように読み替えてください。

  • 関数 = クラスのメソッド
  • 振る舞いをもつオブジェクト = インスタンス
関数とクラスのメソッド
function cry(animal){}

class Animal {
  cry() {}
}
振る舞いをもつオブジェクトとインスタンス
クラス
class Dog(){
  private name: string;
  constructor(name: string){
    this.name = name;
  }

  cry() {
    return "ワン!"
  }

  getName() {
    return this.name;
  }
}

const myDog = new Dog("ポチ");

const myDog2 = {
  name: 'ポチ',
  // 振る舞い
  cry: function() {
    return "ワン!"
  }
  // 自身の状態を参照できる振る舞い
  getName: function() {
    return this.name;
  }
}

サブタイピングとは「自身の状態を参照できる振る舞いを持つオブジェクト」を「関数の引数」に渡すことで拡張性を持たせる書き方

サブタイピングは関数の引数に、自身の状態を参照できる振る舞いを持ったオブジェクト を渡せる言語で実現できます。自身の状態を参照できる振る舞いを持ったオブジェクトとは以下のようなものです。

自身の状態を参照できる振る舞いを持ったオブジェクト
const me = {
  // 状態
  familyName: '山田',
  givenName: '太郎',
  // 振る舞い
  getName: function() {
    // 自身の状態を参照できる
    return this.familyName + this.givenName;
  }
}

そして、そのオブジェクトを関数の引数に渡すとは次のようなコードです。

関数の引数に「自身の状態を参照できる振る舞いを持ったオブジェクト」を渡す
// そのまま自身の振る舞いを呼び出せるし...
me.getName(); // '山田太郎'

// 「自身の状態を参照できる振る舞いを持ったオブジェクト」を関数の引数として渡せる
function getAnimalName(animal){
  return animal.getName();
}

getAnimalName(me); // 山田太郎 

これを実現するためには言語に2つの機能が必要です。

  • 自身の状態を参照できる振る舞いをオブジェクトに持たせることができる
  • そのオブジェクトを引数でうけとることができる

オブジェクト指向言語では、クラスとインスタンスを使ってこの仕組みを実現しています。
関数型言語でも何らかの手段でこの性質をもてば実現可能です(HaskellやClojureなどでも可能)。TypeScriptの場合は、直接このようなオブジェクトを渡すこともできますし、クラスを利用することも可能です。

重要なのは「振る舞い」を渡すことでも「状態」を渡すことでもありません。「振る舞い」と「状態」を同時に渡せ、さらに「振る舞い」から自身の「状態」を参照できることです。このことで、アドホック多相やパラメトリック多相が実現できない拡張性を提供できます。

なぜこれが嬉しいのか

これが実現できると、既存のコードを書き換えずに新しい種類の振る舞いを追加することができます。

例えばつぎのコードを読んでください。myDogmeという異なる属性をもつオブジェクトをそれぞれ共通のgetAnimalNameから呼び出すことに成功しています。

const myDog = {
  name: 'ポチ',
  getName: () => {
    this.name;
  }
}

const me = {
  familyName: '山田',
  givenName: '太郎',
  getName: function() {
    return this.familyName + this.givenName;
  }
}

function getAnimalName(animal: Animal){
  return animal.getName();
}

getAnimalName(myDog) // ポチ
getAnimalName(me) // 山田太郎

このコードのさらにすごいところは、オブジェクトがgetNameの振る舞いさえ持っていれば、新しい種類のオブジェクトでも適用できることです。例えば、性別を持ったhumanを定義し、男性であればMr.、女性であればMs.という接頭語を付けてみます。

...省略

const specialHuman {
  sex: 1,
  familyName: '山田',
  givenName: '太郎',
  getName: function() {
    if(this.sex === 1}{
      return `Mr.${this.familyName} ${this.givenName}`;
    } else if (this.sex === 2){
      return `Ms.${this.familyName} ${this.givenName}`;
    }
    return this.familyName + this.givenName;
  }
}

getAnimalName(myDog) // ポチ
getAnimalName(me) // 山田太郎
getAnimalName(specialHuman); // Mr.山田太郎

このようにサブタイピングが実現できれば既存のコードを変更せずに拡張性を持たせることが可能になります。

もしも状態しか引数に渡せなかったら

もし関数の引数に、状態しか渡せなかった場合、次の2つの書き方のどちらかになります。それぞれの書き方において、オブジェクトの種類が増えれば既存のコードを修正する必要がでてきます。

ユニオン型で各typeごとに処理を分岐させる

import { match } from 'ts-match';
type Animal = {
  type: 'dog';
  name: string;
} | {
  type: 'human';
  name: string;
}

const myDog = {
  type: 'dog',
  name: 'ポチ'
}

const me = {
  type: 'human',
  familyName: '山田',
  givenName: '太郎',
}

function getAnimalName(animal: Animal){
  // ts-match使うとパターンマッチの書き方で取りこぼしを防げるのでお勧め。
  return match(animal)
    .with({ type: 'dog' }, (narrowed) => narrowed.name)
    .with({ type: 'human' }, (narrowed) => narrowed.familyName + narrowed.givenName)
    .exhaustive(); // typeの取りこぼしがあったらコンパイルエラー出してくれる。
  // switch-caseやif文でもok
  // if(animal.type === 'dog'){
  //  return data.name;
  // else if(animal.type === 'human'){
  //  return data.familyName + data.givenName;
  // }
}

getAnimalName(me) // 山田太郎
getAnimalName(myDog) // ポチ

各データごとに専用の関数を作る

const myDog = {
  name: 'ポチ'
}

const me = {
  familyName: '山田',
  givenName: '太郎',
}

function getDogName(dog){
  return dog.name;
}

function getHumanName(human){
  return human.familyName + human.givenName;
}

getDogName(myDog); // ポチ
getHumanName(me) // 山田太郎

もしサブタイピングを利用しない場合は、ユニオン型で実装するのがおすすめです。
ユニオン型とポリモーフィズムのそれぞれのメリデメとユースケースの違いに関しては、また後日記事にしたいと思います。

もしも状態と振る舞いしか引数に渡せなかったら(状態と振る舞い分離パターン)

言語によっては関数そのものを引数に渡すことができます。パラメトリック多相を組み合わせれば、サブタイピングと同じことが実現できます。

const myDog = {
  name: 'ポチ'
}

const me = {
  familyName: '山田',
  givenName: '太郎',
}

function getDogName(dog){
  return dog.name;
}

function getHumanName(human){
  return human.familyName + human.givenName;
}

// パラメトリック多相(ジェネリクス)を利用
function getAnimalName<T>(animal: T, getNameFn: (animal: T) => string){
  return getNameFn(animal);
}

getAnimalName(myDog, getDogName); // ポチ
getAnimalName(me, getHumanName); // 山田太郎

無名関数を使うとその場限りの(オンデマンドな)関数も実現できそうです。

getAnimalName(me, (human) => human.familyName + "様")) // 山田様

これを便宜上**「状態と振る舞い分離パターン」**と呼ぶことにします。

状態と振る舞い分離パターンとサブタイピングの違い

  • サブタイピングの場合getNameさえ定義していればgetAnimalNameに放り込むだけでよい
    • モジュールとしてよくまとまっている
  • 状態と振る舞い分離パターンの場合は、状態と振る舞いの両方を渡す必要がある
  • 状態と振る舞い分離パターンの場合は状態は同じだが振る舞いが異なるパターンを簡単につくれる
  • 状態と振る舞い分離パターンの場合はオンデマンドな関数を簡単に作れる
サブタイピング
getAnimalName(me);
状態と振る舞い
getAnimalName(me, getHumanName);

僕の感覚だけかもしれませんが個人的に「状態と振る舞い分離パターン」よりも「サブタイピング」のほうが思考からコード化しやすいなと思います。というのは、概念モデルからサブタイプを考えるとき、モデルは四角で表現するからです。人間の認知的に振る舞いの連続よりも、振る舞いを持ったモノを組み合わせるほうが得意な気がするのです。ただ、いったんコード化すれば「状態と振る舞い分離パターン」に変換するのは簡単ですね。

まとめ

以上で一通りサブタイピングに関してみていきました。

近年、オブジェクト指向を見直して関数型を部分的に取り入れるスタイルも流行ってきていますし、両方のパラダイムを取り入れた言語も増えてきました。オブジェクト指向か関数型かの二者択一ではなく、それぞれの良さを取り入れてプログラミングできるとより良いですね(^^)

関数編はこちら

以前書いた関数編のポリモーフィズムはこちらです。
ユニオン型のパターンマッチとごちゃごちゃしているので、近いうちにまとめ直そうと思います。
https://zenn.dev/trape_inc/articles/98769cb21e2741#ポリモーフィズムとは

Discussion