nunulkのプログラミング徒然日記

毎日暇なのでプログラミングの話題について書きたいことがあれば書いていきます。

「関数型デザイン」の感想

はじめに

この記事について

Robert C. Martin 氏の「Functional Design: Principles, Patterns, and Practices」の日本語訳(以下、本書)が出版されたので、読んでみました。本記事はその感想文です。

コード例について

本書では、関数型プログラミングのコード例を Clojure で書いていますが、本記事では最近お気に入りの Gleam で書きます(Clojure も大好きな言語ではありますが、S式が苦手っていう人は少なからずいると思うので)。 Gleam については、以下の記事でも言及していますが、まだ若い言語のため Markdown で使われているシンタックスハイライトが対応していないので、Rust のを借りています。ご了承ください。

nunulk-blog-to-kill-time.hatenablog.com

「関数型デザイン」とは

プログラミングの文脈において、"design" は「デザイン」と訳されることもあれば(「デザインパターン」)「設計」と訳されることもあります(「ドメイン駆動設計」)。悩ましいですよね。ソフトウェア開発の文脈において日本語で「デザイン」というと、どうしても UI デザインとかそっち(見た目やグラフィカルなもの)を想像してしまいがちで、本書の邦題もおそらく「関数型設計」にするか「関数型デザイン」にするか悩んだんじゃないでしょうか。

また、英語圏に目を向けると "functional programming design" ("FP design" 含む)なんていう呼称も見かけます。

理由はよく分からないんですが、「オブジェクト指向設計」という名称はよく見かけるものの、「関数型設計」はあまり目にしません。

あまり、関数型プログラミングにおいて設計をどうするかっていうのは関心が低いことの表れかもしれません(単に私が知らないだけかもしれませんが)。

というわけで、「関数型デザイン」とは、関数型プログラミングをする際の設計のことを指しています。

完全に余談ですが、関数型デザインに関しては Scott Wlaschin という方が(F# の専門家のようです)行っていたプレゼンテーションで、以下のような対比がされていたのを思い出しました。

youtu.be

これは半分冗談としても( "This is true." と言っているからにはぜんぶ本気かもしれませんが)、データと関数が分離されていることにより、データの持ち方や操作方法がパターンに作用しなくなるので、パターンの種類はずっと少なくなるでしょう。例として、Factory パターンと Strategy パターンは返すのがデータか関数かの違いだけであって、ほとんど同じになっています。

// Factory pattern
type Product {
  ProductA(name: String)
  ProductB(name: String)
}

fn factory(params) -> Product {
  case some_condition(params) {
    True -> ProductA("A")
    False -> ProductB("B")
  }
}

// Strategy pattern
type Strategy = fn(Int) -> Int

fn strategy_a(n: Int) -> Int {
  n + 2
}
fn strategy_b(n: Int) -> Int {
  n * 2
}

fn strategy(params) -> Strategy {
  case some_condition(params) {
    True -> strategy_a
    False -> strategy_b
  }
}

つまり、関数型デザインは、オブジェクト指向デザインとは異なり、関数をどう作るか、どう分割して組み合わせるか、に焦点を当てるものです(型の話もありますが、本書ではそれについてあまり触れられていませんし、型デザインは型デザインで別に必要なものと思われます)。

「関数型デザイン」感想

前置きが長くなってしまいましたが、本書の感想です。

まず、「関数型デザイン」というタイトルがつけられていますが、内容としては「関数型とオブジェクト指向の比較および融合」がメインのトピックになっています。

多くの人たちが、オブジェクト指向プログラミングと関数型プログラミングには互換性がないと主張している。本書では「そうではない」ことを証明したい。

私自身も数年前から関数型プログラミングを学んでいて、「そうではない」と感じていたので、Martin 氏がそれを証明したいと表明したことは、背中を押されたようでうれしかったです。

関数型プログラミングがメジャーになりつつある(少なくともフロントエンド領域においては React の普及もあり、関数型プログラミングはもうメジャーといってもいいですよね?)昨今でも、バックエンドは依然としてオブジェクト指向プログラミングが主流です。

しかし、データの不変性(イミュータブル)や関数の参照透過性といった言葉や概念は、オブジェクト指向プログラミングの文脈においても以前より重要視されるようになってきていると感じます。

関数型プログラミングとは何か?」

この問に対して Martin 氏は以下のように答えています。

私が考えるより良い答えはこうだ。「代入文のないプログラミング」。

簡潔ですね。そして、まったく的を射ていると思います。オブジェクト指向プログラミングにおいて難しいことのひとつにオブジェクト(インスタンス)の状態管理があります。オブジェクトの参照が複数の関数(自身のメソッドも含む)を渡り歩きながら更新されていく処理の中で不具合が発生し、デバッグに苦労した経験がある方も多いのではないでしょうか。

これは代入によっても起こり得ます。長いライフタイムを持つ変数が複数の関数を渡り歩きながら更新されていく処理の中で不具合が発生し、(以下略)。

すべてのケースがそうではないですが、こうした状態管理の問題は、変数がミュータブルであること、関数が参照透過でないこと、この2つによって起きるケースが多いと感じます。

代入文のないコードはたとえば以下のようなものです(本書内で紹介されている地下鉄の改札口をシミュレートした有限状態マシンの例)。

fn turnstile_system(state: State) {
  case state {
    Done -> Nil
    state ->
      turnstile_fsm(state, get_event())
      |> turnstile_system
  }
}
fn turnstile_fsm(state: State, event: Event) -> State {
  case state {
    Locked -> turnstile_fsm_locked(event)
    Unlocked -> turnstile_fsm_unlocked(event)
    Done -> Done
  }
}
// 中略

// データの中身をプリントデバッグするときですら代入は不要
fn get_char_from_input() -> Result(String, erlang.GetLineError) {
  interaction()
  |> erlang.get_line
  |> result.map(fn(line) { string.trim(line) })
  |> function.tap(fn(v) { io.debug(v) })
}
// 中略

turnstile_system(Locked)

本筋とあまり関係ないですが、本書のサンプルコードは Clojure で書かれているんですが、スレディングマクロを使ってないのでちょっと読みづらいんですよね。個人的には、関数型プログラミングにはパイプ演算子が必須だと考えていて、関数を繋げて処理を組み立てる際の読みやすさが段違いです。

代入ができないと処理をアトミックに分割せざるを得ず、関数もほとんどが副作用のない参照透過なものになるため、デバッグがしやすくなっています。

私は普段オブジェクト指向言語を使っていますが、関数型プログラミングに関連してひとつ気をつけているのは、private メソッドをなるべく参照透過に書くようにしています。これにより、this(self) に依存せず、テスト1もリファクタリングしやすくなります。

<?php
class Foo {
    private array $data = [1, 2, 3];

    public function bar() {
        $this->data = $this->baz($this->data);
        // ...
    }

    // インスタンス変数を使わず参照透過な関数にする
    private function baz(array $data) {
        return array_map(fn($n) => $n * 2, $data);
    }
}

オブジェクト指向は有害?」

関数型のクラスやモジュールは、可変オブジェクトではなく不変オブジェクトを好む。だが、オブジェクトであることに変わりはないので、インターフェイスを実装するクラスとして表現や構成が可能である。

ときどき、オブジェクト指向と関数型を比較して、関数型のほうが優れているとする意見をみかけます。個人的には、これはパラダイムの違いであって、どちらが優れているかは一概に言えないと思いますが、関数型が備えている不変性や参照透過性は間違いなく有用だと思いますし、逆にいうと、オブジェクト指向であってもできるだけこの2つを備えることによって、堅牢で柔軟なプログラムを書くことができると思っています。

「追記:オブジェクト指向は有害?」の項の前に、GoFデザインパターンおよび SOLID 原則のようなオブジェクト指向設計のパターンや原則を関数型プログラミングで実現するアイデアを披露していて、これが両者はまさにパラダイムの違いでしかない、ということの証左だと思いますし、SOLID 原則はけっしてオブジェクト指向パラダイムにおいてのみ有用な原則ではないと感じました。

細かいいちゃもん

全体的には非常に学びの多い本でしたが、ひとつだけ気になる記述がありました。

気を付けておかないと、こうした小さなトリックがあなたのコードを支配し始めるだろう。 たとえば、to-framesconcat 関数に [[0]] を渡している理由がわかるだろうか。あるいは、first ではなく #(take 1 %) を使用した理由がわかるだろうか。 このコードにはトリックが多いので、理解するのが難しくても心配する必要はない。私も読み返したときに苦労した。

これは「第7章 ボウリングゲーム」の一節ですが、自分にとって馴染みのないコードをトリックと呼ぶのは個人的には好きじゃないです。 私は Clojure が好きではあるもののスキルレベル的にはジュニアレベルです。それでも、「to-framesconcat 関数に [[0]] を渡している理由」はわかりますし、first#(take 1 %) の違いは明白に理解しています。

私も、自分が慣れていない言語を使うとき、これはトリッキーだなぁと思うことはありますが、それはその言語を知らないからそう思う部分と、短く書きたいがために、あるいは便利さを追求するあまり、見た目ではわからない動きをさせている、みたいな部分とに分かれると思います。できるだけ、前者と後者を区別して、後者に関しては、自分自身でも書くのを避けたり、コードレビューで変更を依頼したりして、トリックを減らしていきたいとは思います(最近慣れない Ruby を使っているので、両者ともに遭遇します)。

おわりに

個人的に好きな Clojure を使ったコード例がたくさん載っているというのもうれしいですし、なによりもオブジェクト指向パラダイムと関数型パラダイムには互換性があるはず、という視点で書かれた本として他に類を見ないと思いますし、非常に参考になりました。

パラダイムにとらわれず、不具合を誘発する要素はなんなのか、それを防ぐためにはどうすればいいか、そのことにフォーカスしてこれからもプログラミングを楽しんでいきたい所存です。