クラスを使う難しさ(TypeScript編)

筆者はTypeScriptのクラスを使うのが下手だ。 フロントエンドでもバックエンドでもTypeScriptを書いているが、下手であるがゆえにクラスを過剰に忌避しているような気がしたので、クラスを避けたくなる理由を考えてみた。 最初に結論を書いておくと、考えることと同じ処理を書く際のコードの記述量が増えることが原因のようだった。

なお、本稿ではクラスやオブジェクトの厳密な定義についてはあまり気にしないことにしている。 データの集合とそれに対する手続きがひとかたまりになったもの、つまりプロパティとして関数を持つオブジェクトが関心の焦点であり、クラスとして定義されているかどうかには興味がない。

クラスがメソッドの動作主体になっているかどうか問題

一般に、クラスとクラスから生えている関数(メソッド)の関係は主語(動作主体)と述語の関係になっていてほしい。 クラス設計の難しさのほとんどすべてがこの願いをチームに浸透させること、あるいはこの願いに対するメンバーの執着度・温度感の違いから生じているような気さえする。 こんなの気にしないよという人はきっと人生を楽しんでいるだろうが、そうでない人(筆者含む)はこの願望を実現するために激しく精神をすり減らしているはずだ。

うまくないエンジニアが設計したクラスは主語と述語の対応関係がめちゃくちゃになりがちで、 「なんでこのメソッドがこのクラスから生えてるんだ???」問題を頻繁に発生させる。 当の本人はそんなこととはつゆ知らず、 悪気なく日常的にクソコードを生産している。

たとえば、ユーザを表すクラスUserを使ってログイン操作を行うコードが次のようになるのは理解できる。

const user: User = new User()
// 資格情報(credential)を使ってサインアップする
user.signUpWith(credential)

一方で、決済トランザクションを表すクラスPaymentを使って与信確保(オーソリ)を行うコードが次のようになっていたら大変気持ち悪い。

const payment = await getPayment()
// 与信確保を行う
payment.authorize()

一見するとオブジェクトとそれに関連する操作がクラスとしてまとめられており、良いように思われる。 しかし、Paymentとauthorizeは主語と述語の関係ではなく、この節の冒頭に書いた要請を満たしていない。

通常、オーソリを行う動作主体は(その決済手段の)加盟店であり、決済トランザクションは操作の対象である。 つまり、次のコードのような対応関係が妥当である。

// 加盟店(store)が与信確保を行う
store.getAuthorization(payment)

ここでstore.authorize(payment)とするのは適当ではないことに注意してほしい。 authorizeする(与信を与える)のは決済手段のプロバイダである。

余談だが、かつてネイティブな英語話者のエンジニアと一緒に仕事をしていたとき、このへんの関連付けが極めてうまかったのを覚えている。 一般的な日本人エンジニアは英語がネイティブほどうまくないことに加え、日本語では主語を省略しがちなので、このあたりの設計がどうも得意でない傾向が強いのではないだろうか。

関数による動作主体の隠蔽

このように、主語だ述語だ受動態だ能動態だといったことをいちいち考えるのは疲れるので、関数で実装して主語を隠蔽したほうが便利じゃんとなるのも割と自然な発想だと思われる。

const authorize = async (payment: Payment): Payment => {
  // オーソリを取得する処理
  // ......
  return {
    status: 'authorized',
    ...payment
  }
}

// 加盟店・決済プロバイダの存在を隠蔽している
await authorize(payment)

処理をクラスから独立した関数にすることにより、可搬性とテストしやすさを獲得できる。 クラスのかわりにJSONライクなオブジェクトでデータを扱うことで、シリアライズが容易になりモジュール間の通信がシンプルになりそうでもある。

関数スタイルはそもそも動作主体が曖昧な(あるいは存在しない)算術計算とか日付・文字列操作などのユーティリティ的な処理の実装にも適している。

隠蔽を多用すべからず

残念ながら関数を用いた動作主体の隠蔽も万能の解決策ではない。

隠蔽は本来は存在するものを見えなくする方法であるから、度が過ぎれば分かりにくさが増すだけである。 隠されたものが容易に推測できるうちはいいが、さまざまなドメインモデルが隠蔽された世界はコードを読む人に混乱をもたらす。

つまるところ、あらゆるアクターをクラスで適切に表現せよという話になる日が来るのかもしれない。 同じ処理を書くにしても、考えることとコードの記述量が激増するのでしんどそうだ。

また、なんでもかんでも関数にしようとすると、オブジェクトの状態を算出する関数を作ろうとしたときにその名前が怪しくなる問題が知られている。

クラスの利用を検討するときはだいたいなんらかの状態管理がセットでついてまわるが、そもそも一般人類にとって状態管理は難易度が高い問題である。 算出可能な状態をうっかりデータベースに永続化したりすると、ビジネスルールの変更に対して脆弱になってしまう。 とすれば、ドメインモデルは必要最小限のプリミティブなデータの集合であるオブジェクトとして表現し、状態の算出はビジネスロジックで持つほうがよいだろう。

さて、「状態の算出はビジネスロジックで持つ」「動作主体を隠蔽すべく関数で表現する」という2つの思想が悪魔合体を果たした結果、次のようなコードが生まれる。

const isAuthorized = (payment: Payment): boolean => {
  return payment.status === 'authorized'
}

// 与信が確保されている場合の分岐
if (isAuthorized(payment)) {
  // ......
}

状態検査を受ける主体たるpaymentがなぜかそのポジションを放棄して引数のところに出現している。 これでは動作主体をうまく隠蔽できているとは言えず、主体が迷子であることを明らかにしてしまっている。良くない例だ。

このような状態を算出する関数を書くならば、やはりオブジェクトからメソッドを生やすほうが自然だろう。

if (payment.isAuthorized()) {
  // ......
}

勘のいい読者の中には、次のように状態を直接参照すればいいじゃないかと思われる方もいるかもしれない。

if (payment.status === 'authorized') {
  // ......
}

しかしこの方法はオススメできない。 このやり方が通用するのは状態の算出ロジックが十分にシンプルであり、かつコードベース内に同様の処理が繰り返し出現しない場合に限られる。

一般に、コード全体に渡って算出ロジックがこの条件を満たすことはまずありえない。 局所的にこのようなコードを用いることを許すと、今度はコード全体での一貫性が損なわれる。

一般化できない・汎用性のないルールはクソコードの温床となる。 状態を算出する関数に良い名前を常につけることができればクラスを使わなくてもなんとかなる可能性はあるが、無理をすれば関数や状態そのもの命名が難しくなるといった形で別の歪みを生むだけである。 isHogeのような奇怪な命名を受け入れられる人でなければ、大人しくクラスの存在を許容して状態を取得するメソッドを生やすべきなのかもしれない。

あたりまえな感想

まだクラスを使わなくていいと思っていられるのは、たぶんこの状態の算出まわりが十分にシンプルなコードしか書いていないだけの可能性が高い。

思考の整理のために本稿を書いてみたが、なんでもクラスにせよ・なんでも関数にせよという偏った思想はいずれもどこかで無理が生じるのだろう。 どういうときにクラスを使い、どういうときに関数で用を足らすのかの見極めが重要という身も蓋もない結論になりそう。

share with:
この記事をTwitterで共有
この記事をはてなブックマークに追加