関数型とオブジェクト指向のメリット・デメリットのトレードオフ?Lispならどうする?
のぶさんの記事(オブジェクト指向は幻想だったのか ― 関数型との比較と、AIがコードを書く時代)を読んで、オブジェクト指向と関数型それぞれの違いや、メリット・デメリットのトレードオフの構造がすごくわかりやすく言語化されていて、強い関心をもった。
自分は新米 Lisper なので、 Lisp ではどうアプローチするか調べてまとめてみる。
ちなみにLispの 訛 りは、Clojureだ。
先ほどの記事で紹介されていた「円と正方形の面積を求めるプログラム」をベースに、この2つのアプローチの本質的な違いと、Clojureがどう向き合うのかを自分なりに整理してみたい。
1. プログラミングにおける永遠のジレンマ:「式問題(The Expression Problem)」
のぶさんの記事でも触れられていた通り、オブジェクト指向と関数型(パターンマッチング)のどちらを選ぶかは、「今後、システムに新しいデータ(図形)を増やしやすいようにするか、新しい操作(機能)を増やしやすいようにするか」というトレードオフに直結しているらしい。
計算機科学者のフィリップ・ワドラーが1998年に名付けた「式問題(The Expression Problem)」こそが、まさにこの板挟みを指しているそうだ。
① オブジェクト指向のアプローチ
得意なこと(図形の追加が容易): 新しい「図形(例: Triangle)」を追加するときは、
Shapeを継承したクラスを新しく作るだけで、既存のコードを変更せずに済む(オープン・クローズドの原則)。苦手なこと(操作の追加が難しい): 新しい「操作(例: 周囲長を求める
perimeter()や、描画するdraw())」を追加したい場合、既存のすべてのクラスに新しいメソッドを追加していく必要があるらしい。
② 関数型(パターンマッチング)のアプローチ
得意なこと(操作の追加が容易): 新しい「操作(例:
perimeter(shape)やdraw(shape))」を追加ときは、関数を1つ新しく生やすだけで済む。既存のデータ型を一切書き換える必要がない。苦手なこと(図形の追加が難しい): 新しい「図形(例: Triangle)」を追加すると、すべての関数内にある
match文を修正し、case Triangle:の分岐をもれなく追加し直す必要があるそうだ(網羅性チェックの恩恵は受けられるものの、変更箇所が散らばるみたいだ)。
どちらの設計が優れているかというより、「今後、新しい図形が増えやすいドメインか、新しい操作(機能)が増えやすいドメインか」によって使い分けるのがエンジニアリングの面白いところなのだろう。
2. Clojureのアプローチ:マルチメソッドによるデータ駆動設計
では、Lisp方言であるClojureの世界では、この「式問題」にどう立ち向かっているのだろうか。
調べてみると、Clojureではデータをプレーンなマップやレコードとして扱い、処理をマルチメソッド(defmulti / defmethod)で紐付けるのが一般的なアプローチのようだ。オブジェクトのメソッドでも、巨大な switch 文でもなく、ディスパッチの軸を自由に変えられるのが非常に強力らしい。
(ns shape.core)
;; データの定義(イミュータブルなマップ)
(defrecord Circle [r])
(defrecord Square [side])
;; ディスパッチ関数: 引数の「型」や「属性」に基づいて処理を振り分ける
(defmulti area (fn [shape] (type shape)))
(defmethod area Circle [c]
(* 3.14159 (Math/pow (:r c) 2)))
(defmethod area Square [s]
(Math/pow (:side s) 2))
;; 使用例:
;; (area (->Circle 5)) -> 78.53975
特徴: データを純粋なデータとして扱い、関数(メソッド)を後からいくらでも外付けできるため、先ほどの「式問題」における関数型のアプローチをさらに柔軟に拡張できるようだ。
3. 「データ」と「操作」を完全に独立させる世界
ClojureのマルチメソッドやCommon Lisp (SBCL) の CLOS(Common Lisp Object System)あたりは、「データ(型)」と「操作(関数)」を完全に独立させているらしい。
そのため、あとから新しい要素を足すときのフットワークが驚くほど軽くなる。
;; 1. あとから新しい図形を追加(既存の area 関数は1行も触らない)
(defrecord Triangle [base height])
;; 2. その図形に対する area の計算方法だけを「後付け」で定義する
(defmethod area Triangle [t]
(* 0.5 (:base t) (:height t)))
このように、「データ」と「操作」のどちらを増やしても、お互いに影響を与えない。この「式問題」の理想的な解決策の一つが、マルチメソッドや型クラスが持つアプローチのようだった。
ひとつの記事から随分と勉強させてもらえるものだ。改めてのぶさんの元記事に感謝しつつも、元記事の「AIがコードを書く時代」のところまでは全然考察が追いつけていないなと痛感する。もっと勉強せねば。 一旦は今日書いたことを足がかりに、また自分でもいろいろ試して発展させた記事を書いてみたい。
(ns shape.core)
;; data定義(immutabule map)
(defrecord Circle [r])
(defrecord Square [side])
;; Dispath関数:引数の型や属性に基づいて処理を振り分け
(defmulti area (fn [shape] (type shape)))
(defmethod area Circle [c]
(* 3.14159 (Math/pow (:r c) 2)))
(defmethod area Square [s]
(Math/pow (:side s) 2))
;;;; Use Case ;;;;
(area (->Circle 6))
(area (->Square 6))
;;;; Forcus ;;;;;
;; 1. あとから、新しい図形を追加! 既存のarea関数はさわらない
(defrecord Triangle [base height])
;; 2. その図形に関する areaの計算方法だけを後から定義する。
(defmethod area Triangle [t]
(* 0.5 (:base t) (:height t)))
;;;; Use Case 2 ;;;;
(area (->Triangle 2 3))
;;; ↑:冗長 ↓:訂正;;;
(defmulti area :shape)
(defmethod area :circle [{:keys [r]}] (* Math/PI r r))
(defmethod area :square [{:keys [side]}] (* side side))
(area {:shape :circle :r 5})