最終更新:

【図解で比較】オブジェクト指向 vs 関数型プログラミング — 2大パラダイムの違いを徹底解説


買い物かごで設計を比べる オブジェクト指向関数型の例cart に追加を依頼内部にデータと操作cart.total() → 600入力 → 関数 → 新しい値元の配列は変えないtotal(after) → 600同じ買い物:300円 × 2個 = 600円 買い物かごで設計を比べる オブジェクト指向関数型の例cartに追加を依頼データと操作合計 600円関数で新しい値元の配列は維持合計 600円同じ買い物:300円 × 2個
本文のJavaScript例に対応します。変更可能なオブジェクトと、入力を変更せず新しい値を返す関数の対比です。両方の考え方を組み合わせることもできます。
ひよこ ひよこ
オブジェクト指向と関数型プログラミングってよく聞くけど、何がどう違うの?
ペンギン先生 ペンギン先生
ざっくり言うと、データと操作を持つオブジェクトを中心に整理するのがオブジェクト指向、関数によるデータ変換や合成を重視するのが関数型だよ。ここではクラスを使うオブジェクト指向と、純粋関数を使う関数型の例で比べよう。すべての言語や設計が、この形だけに当てはまるわけではないんだ。
ひよこ ひよこ
オブジェクト指向のクラスとか継承って、具体的にはどういうこと?
ペンギン先生 ペンギン先生
たとえば「動物」というクラスを作って、その中に「名前」や「鳴き声」というデータと、「鳴く」という操作をまとめる。そこから「犬」や「猫」を継承で派生させれば、共通部分は再利用しつつ個別の振る舞いだけ上書きできる。内部の操作を決めた入口にまとめるカプセル化も大切だね。継承は設計の選択肢の1つで、すべてのオブジェクト指向設計で必要なわけではないよ。
ひよこ ひよこ
じゃあ関数型のほうはどういう考え方なの?
ペンギン先生 ペンギン先生
大切な考え方を3つ紹介するね。純粋関数は、同じ入力に同じ結果を返し、外部の状態を変えるなどの副作用を持たない関数。不変データは作成後に変更せず、変更後の状態を別の値で表す考え方だよ。高階関数は関数を引数に取るか関数を返すもので、mapやfilterが例になる。高階関数だから必ず純粋とは限らないよ。
ひよこ ひよこ
状態の管理の仕方がだいぶ違いそうだね?
ペンギン先生 ペンギン先生
変更可能なオブジェクトなら「この犬の名前をタロウに変える」、不変データなら「名前がタロウの新しい犬データを返す」という対比ができるね。ただしオブジェクト指向でも不変オブジェクトを作れるし、関数型の考え方を使うプログラムにも入出力は必要だよ。状態や副作用をどこに置くかが設計のポイントなんだ。
ひよこ ひよこ
それぞれの代表的な言語って何があるの?
ペンギン先生 ペンギン先生
JavaやC#はオブジェクト指向を使える言語で、Haskellは関数型言語の代表例だね。ただし言語を二つの箱に完全に分けられるわけではない。PythonやC#などでは複数の考え方を組み合わせられるよ。たとえばクラスのメソッド内で、関数を使ったデータ変換を書くこともできるんだ。
ひよこ ひよこ
Reactのフックも関数型の影響を受けてるって聞いたことがあるよ!
ペンギン先生 ペンギン先生
Reactでは、同じprops・state・contextから同じ表示結果を計算し、レンダー中に外部を変更しないことを重視しているよ。ただしuseStateは状態の管理、useEffectは外部システムとの同期のための仕組みで、役割は違う。関数コンポーネントを使えばプログラム全体から副作用がなくなる、ということではないんだ。
ひよこ ひよこ
結局どっちを使えばいいの?
ペンギン先生 ペンギン先生
オブジェクトの役割や操作を整理したいときはオブジェクト指向、入力から出力への変換を独立してテストしたいときは純粋関数が役立つよ。これは使い分けの観点で、どちらなら必ず大規模開発や並行処理に成功するという保証ではない。問題やチームに合う組み合わせを選ぼう。
ひよこ ひよこ
純粋関数かどうかは、どう見分ければいい?
ペンギン先生 ペンギン先生
たとえば受け取った数を2倍して返すだけなら純粋な計算だよ。同じ値を返しても、毎回ファイルへ書き込むなら副作用があるので純粋とはいえない。計算と保存処理を分けると、計算部分はファイルを用意せずテストできる。こうした小さな分離から、両方の考え方を組み合わせられるんだ。

同じ「買い物かご」を2通りで書く

JavaScriptで300円の商品を2個追加する例です。違う問題で比べるより、同じ結果をどのように表すかを見ると理解しやすくなります。

オブジェクトにデータと操作をまとめる

class Cart {
  #items = [];
  add(price, quantity) {
    this.#items.push(
      { price, quantity }
    );
  }
  total() {
    return this.#items.reduce(
      (sum, item) =>
        sum + item.price *
          item.quantity,
      0
    );
  }
}
const cart = new Cart();
cart.add(300, 2);
console.log(cart.total()); // 600

商品を持つcartに追加・合計の操作を依頼します。この例では内部配列を直接触らず、addを通して状態を変えます。入力値の検証などは省略しており、そのまま販売システムに使うコードではありません。

関数で新しい値へ変換する

const addItem = (
  items, price, quantity
) => [
  ...items,
  { price, quantity }
];
const total = items => items.reduce(
  (sum, item) =>
    sum + item.price * item.quantity,
  0
);
const before = [];
const after = addItem(
  before, 300, 2
);
console.log(total(after)); // 600
// 元の配列は変更しない
console.log(before.length); // 0

addItemは受け取った配列を変更せず、新しい配列を返します。ただしJavaScriptの配列自体が不変になるわけではなく、他のコードが変更すれば結果も変わります。表示や保存のためのconsole.logなどは副作用なので、純粋な計算部分とは分けて考えます。

選び方のまとめ

オブジェクトの操作を入口として整理し、その内部の合計計算を純粋関数へ分ける、といった組み合わせもできます。オブジェクト指向なら変更可能、関数型なら副作用が一切ない、という二択ではありません。役割と状態の置き場所を明確にするための考え方として使いましょう。

参考資料