🌟

一日一処: PHPのtraitはあまり好きく無いけど、JSで実現できてTSで実現しにくいtraitの模倣

2024/02/27に公開

PHPのTrait

PHPには、クラスの継承関係にない機能を個別に追加することができる。フレームワークなどでも、通常の機能的なクラスを拡張するように使用する場合もある。まずは、通常のクラスでの継承関係を見てみよう。

class Animal {
  private string $voice = '';
  function __construct(string $voice) {
    $this->voice = $voice;
  }
  function howling(): void {
    echo $this->voice;
  }
}

class Dog extends Animal {}

$dog = new Dog('bowwow');
$dog->howling(); // bowwow

至って普通の動物クラスだ。動物といえば、なにかしら音を発することがあるので、吠えさせてみたが、これが取りだった場合、動物クラスに不足がある。そんなときに、継承関係を乱さず、直接動物クラスを継承したサブクラスで飛行機能を追加してみる。

// Animalクラスは前述と同様のため省略

trait Flyable {
  private int $current = 0;
  private int $flyableDistance = 5;
  public function fly() {
    $old = $this->current;
    $new = $this->current + $this->flyableDistance;
    $this->current = $new;
    echo "flied {$new}meters from {$old}meters";
  }
}

class Bird extends Animal {
  use Flyable;
}

$bird = new Bird('tweet');
$bird->fly(); // flied 5meters from 0meters

このように、継承関係にない機能をtraitを使用し、クラス内でuse句を用いることで、実現することができる。非常に面白い仕組みだ。他言語でも同様の仕組みが存在する場合もあるが、個人的にはあまり好まない。なぜならば、継承関係上のクラスの機能を調べようとしていたら、なぜか、継承関係に存在しない機能があるが、これはどこで実現しているのか、という疑問に衝突してしまうことがあるからだ。もちろん、設計した本人であれば知っているだろうか、作られたあとに見た人にとって見れば、混乱を招く要因の一つとなってしまう。

JavaScriptでの実現

JavaScriptは、具体的に同じような仕組みとは言えないクラスの仕組みになっているが、PHPと同様に継承関係外の機能を付加したいこともあるだろう。このtraitの仕組みを模倣するためにはいくつか方法がある。

Prototype

1つ目はPrototypeだ。JavaScriptはPrototypeを知ってからが楽しくなる。JavaScriptはクラスの概念を持っているが、classによるクラス宣言は最近の仕組みだ。これまでは関数を用いて行ってきた。そして、その関数でさえもObjectと呼ばれる存在だ。そのObjectには、Prototypeという継承関係遡ることのできる情報がある。(プロトタイプチェーン)
実際に継承関係にない仕組みは、これを使うと容易に追加することができる。
まずは、PHPと同じクラスを実装する。

class Animal {
  #voice = ''
  constructor(voice) {
    this.#voice = voice
  }
  howling() {
    console.log(this.#voice)
  }
}

class Dog extends Animal {}

const dog = new Dog('bowwow')
dog.howling()

ここまではいいだろう。問題はここからで、Birdクラスを実現したうえで、Prototypeを経由して、異なるクラスを混ぜ込む。Prototypeは使い方によっては、混ぜるな危険、といえる代物だ。

// Animalクラスは前述と同様のため省略

function flyable() {
  this.current = this.current ?? 0
  this.flyableDistance = this.flyableDistance ?? 5
  const oldValue = this.current
  const newValue = this.current + this.flyableDistance
  this.current = newValue
  console.log(`flied ${newValue}meters from ${oldValue}meters`)
}

class Bird extends Animal {}
Bird.prototype.fly = flyable

const bird = new Bird('tweet')
bird.howling() // tweet
bird.fly() // flied 5meters from 0meters
bird.fly() // flied 10meters from 5meters

若干、プロパティのアクセスレベルが変わってしまったが、このような事ができる。要は、関数を分離して、Prototype上に設置することで、常に自身の関数(プロパティもインスタンスのもの)として扱ってくれる。実際、あまり使われないし、PHPのtrait同様に処理を見つけ出すのが大変なので使用は推奨できないが、例えば、ライブラリやフレームワークの機能に自身の機能を直接埋め込みたい場合には最適かもしれない。また、デメリットとして、継承関係にないため、プライベートなプロパティにはアクセスすることもできないし、追加することもできない。

外部的に組み合わせて呼び出す

Prototypeを用いた方法は、そこそこ納得感があるが、Prototypeの使い方以外にライブラリなどでよく目にする処理がある。それが、applyだ。

const bird = new Bird('tweet')
bird.howling() // tweet
flyable.apply(bird) // flied 5meters from 0meters
flyable.apply(bird) // flied 10meters from 5meters

文字数は増えるものの、必要に応じて、処理を実行することになるので、これはこれで悪くないかもしれない。こちらの場合は、Prototypeと異なり、インスタンス化されたあとでの実行となる。若干発生しているタイミングが異なるというのは覚えておいた方が良いかもしれない。

メソッドとして呼び出したい場合

呼び出し方はPrototypeで行ったほうの記述がシンプルだし、機能の適用は、applyを使ったほうがわかりやすい。こう考えると、以下の方法でも悪くないだろう。

class Bird extends Animal {
  constructor(voice) {
    super(voice)
    flyable.apply(this)
  }
}

const bird = new Bird('tweet')
bird.howling() // tweet
bird.fly() // flied 5meters from 0meters
bird.fly() // flied 10meters from 5meters

PHPのuseと同じとまではいかないが、インスタンス化される際に適用するという点では、こちらのほうがわかりやすいかもしれない。

TypeScriptでの実現

TypeScriptとして記述する場合は、Prototypeで追加したところで、結局のところ型の定義も扱わなければならなくなるので、TypeScriptではあまり適さないだろう。applyを使う方法であれば、型の多重継承でどうにかなりそう。最も簡単な方法としては、公式ドキュメントにもあるMixinの考えだろう。

おわりに

TypeScriptにはあまり触れなかったが、他言語と同様の方法が模索できないか考えるのも面白い。オブジェクトやPrototypeに関しては、ESのバージョンが変わるにつれて、非推奨な方法や新たな仕組みも導入されたりするので、興味を持った人は、ぜひPrototypeについて理解を深めてほしい。

Discussion