😜

TypeScriptでクラス思考と関数思考のギャップを少し埋める

2022/04/16に公開

クラスのコンストラクタと関数

person.ts
class Person {
  private name: string;
  
  private age: number;
  
  constructor(name: sring, age: number){
    this.name = name;
    this.age = age;
  }
  
  selfIntroduce(): string {
    return `Hi! I'm ${this.name}!`;
  }
  
  isAdult(): boolean {
    return this.age >= 18;
  }
}

const person = new Person('takeshi', 20);
const introduce = person.selfIntroduce();
const isAdult = person.isAdult();

selfIntroduceはageつかってないし、isAdultはnameつかってない。
コンストラクタを依存とするなら、それぞれのメソッドに使ってない依存があることになる。
そこで、これを1つのメソッドにつき1つのクラスにする。

selfIntroduce.ts
class selfIntroduce {
  private name: string;
  
  constructor(name: sring){
    this.name = name;
  }
  
  do(): string {
    return `Hi! I'm ${this.name}!`;
  }
}

const introduce = new selfIntroduce('takeshi').do();
isAdult.ts
class isAdult {
  private age: number;
  
  constructor(age: number){
    this.age = age;
  }
  
  do(): boolean {
    return this.age >= 18;
  }
}

const isAdult = new isAdult(20).do();

毎回コンストラクタを挟むのも、ファイルを別にする(1クラス1ファイルというプラクティスがある)のもめんどいので関数にする。

export const selfIntroduce = (name: string) => {
  return `Hi! I'm ${this.name}!`;
}

export const isAdult = (age: number) => {
  return this.age >= 18;
}

JSにおけるビルダーと関数のカリー化

JSは関数を返り値として持てる(関数が第一級オブジェクトである)ので、次のようなビルダーを作成できる。

/**
 * hogeとfugaをいれて、最後に連結させて返す。
 */
const hogeFugaBuilder = (hoge: string) => (fuga: string) => {
  return `${hoge}${fuga}`;
}

// これでも同じ意味。関数を返す関数。
const hogeFugaBuilder = (hoge: string) => {
  return (fuga: string) => {
    return `${hoge}${fuga}`;
  }
}

// こう使う
hogeFugaBuilder('hoge!')('fuga!') // hoge!fuga!;

べつにこのようなことをしなくても、一気に初期化すればいいと思うかもしれない。

hogeFuga.ts
const hogeFuga = (hoge: string, fuga: string) => {
  return `${hoge}${fuga}`;
}

// こう使う 
hogeFuga('hoge!', 'fuga!') // hoge!fuga!

hogeFugaBuilder関数とhogeFuga関数の違いはなんだろうか?それはビルダーパターンのほうは、依存に時間差をつけることができることだ。すぐにhogeは決まるけど、fugaはフローの後の方で入れたいとかに使える。またさらによいことに1つ目の依存を固定した新しい関数を作りやすい。この引数の1つ目の依存さえ決めればとりあえず成り立つということが関数の再利用性と取り回しやすさを向上させている。

const mochiFuga = hogeFugaBuilder('mochi');
const tamaFuga = hogeFugaBuilder('tama');

mochiFuga('fuga!!!') // mochifuga!!!
tamaFuga('fuga!!!') // tamafuga!!!

関数型言語だとこれらはカリー化とよばれ、すべての関数がデフォルトでビルダーパターンになっている。

クラスのデコレータと高階関数

クラスにデコレータがある言語もある。TypeScriptもデコレータが使える。デコレーターをつけたクラスやメソッドの情報を受け取って加工して返したり、メタデータに一時的な変数を記録しておいたりする。

https://www.typescriptlang.org/docs/handbook/decorators.html

プロパティデコレータ
import "reflect-metadata";
const formatMetadataKey = Symbol("format");
function format(formatString: string) {
  return Reflect.metadata(formatMetadataKey, formatString);
}
function getFormat(target: any, propertyKey: string) {
  return Reflect.getMetadata(formatMetadataKey, target, propertyKey);
}

class Greeter {
  @format("Hello, %s")
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  greet() {
    let formatString = getFormat(this, "greeting");
    return formatString.replace("%s", this.greeting);
  }
}

例は上記のリンクからとってきた。上記のデコレータは、@formatでプロパティの値をメタデータとして保持し、getFormatでメタデータから値を取得している。取得した値を用いて書き換えている。

const greeter = new Greeter('タマですにゃ');
console.log(greeter.greet()); // Hello, タマですにゃ

話がそれるが、これせっかくデコレータ使ってるのに、getFormatgreetの中に入れてしまってるのはもったいない。getFormatをgreetの引数にしたほうが、よりgreetメソッドがピュアになると思う。

これを関数で再現してみる。

目標

// 純粋
const resGreet = greet('タマですにゃ');
console.log(resGreet); // タマですにゃ

// フォーマット
const formatGreet = format("Hello, %s", greet);
const resFormatGreet = formatGreet('タマですにゃ');
console.log(resFormatGreet); // Hi! タマですにゃ

また話がそれるが、関数として再現してみたらわかるが、format("Hello, %s", greet)の部分、どのようにフォーマットするのかのロジックがgreetに入り込んでしまっている。format("Hello, %s", howFormatFunction, greet)のようにフォーマットの仕方を別に分離して実装できたほうがより扱いやすいコードになるはず。

話を戻して、

クラスを最初の例のようにクラスを関数に変換する。まずはデコレータを無視してつくってみる。

greet関数1
const greet = (message: string) => {
  return message;
}

しかしよくみると、クラスの例ではgreetメソッドでフォーマット用の文字列を受け取っている。これを受け取るものが必要なのでもう少し書き換える。

greet関数書き換え
const greet = (message: string, formatString?: string) => {
  // formatStringが存在すればフォーマット、しなければmessageをそのまま返す。
  return formatString 
	  ? formatString.replace("%s", message) 
	  : message;
}

format関数を考えてみると。

format関数
type GreetFunction = (message: string, formatString?: string) => string;
const format = (formatString: string, greet: GreetFunction) => {
  return (message: string) => greetFunction(message, formatString); // greetFunction(formatString)は関数を返す。
}

greet関数にformatStringを入れた新しい関数を返していることに注意。

これで目標が達成できた。ここまでのコード。

Observerパターンとデータとパイプ

デザインパターンの一つにオブサーバーパターンというものがある。
https://www.techscore.com/tech/DesignPattern/Observer

これは、クラスA -> クラスB と クラスA -> クラスC という処理の流れがあったときに、クラスAがクラスBとクラスCのメソッドをコールするのではなく、クラスAが処理が終わったイベントを発火して、クラスBとクラスCがそれを検知して処理を行うもの。

メリットとしては、クラスAがクラスBとクラスCを知らなくてもよく(逆にクラスBとクラスCは検知する仕組みが必要)、クラスAが責任をもちすぎて神クラスになることを防ぐ。

ただObserverパターンのデメリットとしては、クラスA -> クラスB -> クラスC のように複数のクラスの流れがある処理だと、全体のの流れを把握しにくいというものがある。なぜならクラスBはクラスAのイベントを検知するようにして、クラスCはクラスBのイベントを検知するようにするコードがバラバラにかかれてあるからだ。

このような場合、データとパイプという考え方が役に立つ。

F#
(データ)
  |> 処理1
  |> 処理2
  |> 処理3

クラスはデータとふるまいが紐付いているが、関数はデータと振る舞いが紐付いていないため、データとパイプという考え方を発展させてきた。上記はF#のコードで、データ元に対して、処理1~ 処理3が順に適応される。

TypeScriptでは残念ながらこんなにきれいにはかけず関数を順番に呼び出すことになる。

TypeScript
処理3(処理2(処理1(データ)))

Haskellだとドット演算子で合成できたりする。

haskell ポイントフリー
処理3 . 処理2 . 処理1 

Promiseがもつコンテキスト

JSにPromiseが普及してきたおかげでコードが書きやすくなった。単純にコールバック地獄を解決してくれただけでなく、もしその関数がPromiseを返すのであれば以下の2つのことを伝えてくれる

  1. このメソッドは非同期であること
  2. このメソッドは例外が発生する可能性があること

Promiseがなければこの関数は例外を投げるのか、同期的なのか非同期的なのか知るには、例えば関数名を工夫したり中を覗いたりとコードの仕組み外でやるしかなかった。

const res = someFunction();

このPromiseがもつ2つのコンテキスト(文脈)が僕らを楽にさせてくれる。

コンテキストは変数をもつことができる。TypeScriptではジェネリクスと呼ばれている。

type stringPromise = Promise<string>;
type numberPromise = Promise<number>;
type booleanPromise = Promise<boolean>;

つまりコンテキストをプログラミングできるようになっている。

TypeScriptには一部存在しないが例えば他にも便利なコンテキストが考えられる

  • 失敗する可能性
  • 値がない可能性
  • 非同期で実行される
  • 副作用が生じる

そういえば、関数型言語ででてくるモナドという言葉がでてくるが、これはこの同じコンテキスト内でのプログラミングを楽にするためのツール。

Discussion