Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

「モノイド」マジックでRubyとRailsをパワーアップしよう(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

「モノイド」マジックでRubyとRailsをパワーアップしよう(翻訳)

自分たちが今本当に欲しかったもの

Rubyには、オブジェクト指向プログラミング由来でないさまざまなパターンもあります。関数型の世界からやってきたパターンもあれば、面白いことに、おそらくまったくそれと気づかずに既に使っているパターンもあります。

今そうしたパターンを学ぶ理由は何でしょう?パターンの名前はそれだけで強力ですし、それらのコンセプトを認識すれば、より強力で素晴らしい抽象化を構築できるようになります。

その中でも特に最近のRubyのあちこちで見かけるようになったパターンがあります。そしてこのパターンが、聖なる祝福を受けたHaskellやIdrisから降臨した関数型コード100%でなくてもめちゃくちゃ便利であることに気づきました。

そのパターンのアイデアや抽象化の核心は次のとおりです。そのパターンは言語を超越し、これまで言葉でうまく表せなかったアイデアを表現するのに役立ちます。

必要な知識

reduceがよくわからない方や、RubyのEnumerableメソッドの再実装周りがよくわからない方は、本記事を読む前に「Enumerableの縮小(reduce)」について予習しておくことを強くおすすめします。

昨年のRubyConfカンファレンスでの以下のトークを動画でご覧いただけます。

あるいは以下のテキスト版シリーズ記事もどうぞ。

「Enumerableの縮小」の秘密とは

上述のセッションで私がまったく触れていない楽しい秘密がひとつあります。本記事では親愛なる読者の皆さんに、まったく新しい別の概念と、それに関する直感を養う方法についてご紹介します。

先に進む前に、コアとなる概念について簡単におさらいしておきましょう。

reduceのおさらい

reduceは、以下を用いて項目のコレクションを1つの項目に「まとめます」。

  • 1つのコレクション([1, 2, 3]
  • コレクションの要素を結合して同じ型で返す方法(+
  • 初期値(多くの場合空)(0

つまり、あるリストの合計を得るには、以下のような長い書き方になります(便利なsumメソッドがあることはこの際無視します)。

[1, 2, 3].reduce(0) { |acc, v| acc + v }

ここで0を渡している理由がおわかりでしょうか?0ならそれにどんな数値を足しても同じ数値になるので、値の開始値にふさわしい「空の値」になります。

パターンの誕生

ここからが楽しいところです。これは「繰り返し可能なパターン」と「ルールのセット」に非常に似ています。同じことを乗算でやってみるとどうなるでしょうか?

[1, 2, 3].reduce(1) { |acc, v| acc * v }

乗算の場合は初期値が0だと無意味になってしまいます。0は何倍しても0にしかならないからです。初期値を1にした理由はこれです。

これで、*の関数でつなげた数値のリストと、空の値1ができます。

パターンには十分な事例が必要ですし、実際まだまだ多くの事例があるのです。

今度は文字列のリストについて考えてみましょう。空文字は""で、結合には+を使います。

list_of_strings.reduce('') { |acc, v| acc + v }

お馴染みの形になっていますよね。では配列[]+ではどうでしょう?ハッシュ{}mergeではどうでしょう?Procv { v }composeではどうでしょう?

どのやり方についても、「その型の空の項目を表す方法」と「2つの項目をつないで同じ型の別の項目を得る方法」が存在しています。これはあたかもルールのセットのようです。

そして親愛なる皆さん、秘密はここにあります。

「Enumerableの縮小(reduce)」とは、薄いベールをまとったモノイド(Monoid)のチュートリアルなのです

私たちの新たな友となるルール、それが「モノイド」

以下のルールは「モノイド」と呼ばれる概念、すなわち「1個の項目の形への」ゆるい変換を形成します。

連結(join)
(closure: 閉包)
2つの項目を連結して、同じ型の項目を1つ返す方法
零(empty)
(identity: 単位法則)
空の項目、つまり同じ型のどんな他の項目と結合しても、同じ項目を必ず返す
順序(order)
(associativity: 結合法則)
項目の順序が保たれている限り、どんなに好きなようにグループ化しても必ず同じ結果を返す

結合法則は新しい言葉ですが、要するに以下のようなものです。

1 + 2 + 3 + 4 == 1 + (2 + 3) + 4 == 1 + 2 + (3 + 4) == ....

上はどれも同じ結果になります。そして加算という操作には、反転(inversion)や交換法則(commutativity)のようなものを実現する特性もいくつか含まれます。

反転(inversion)
あらゆる操作に反転が存在する(+ 1- 1など)
交換法則(commutativity)
操作の順序に関わらず同じ結果を得られる

モノイドに反転(逆元)を追加すると数学における群(group)になり、交換法則を追加するとアーベル群(Abelian group)になります。本記事ではこれより詳しい知識について気にする必要はありませんが、数値の加法がアーベル群を形成するということだけ覚えておけばよいでしょう。

「なるほど完全に理解した、しかしどうしてまたこんな斬新な抽象概念についてここまで精密に気にしないといけないのか?」とお思いでしょう。実は、Rails経験者にはとっくにお馴染みのある振る舞いが、驚くほどモノイドの振る舞いに似ているのです。

Active Recordのクエリ

コントローラで以下のようなコードを見かけたことがあるでしょう。

class PeopleController
  def index
    @people = Person.where(name: params[:name]) if params[:name]
    @people = @people.where(birthday: params[:birthday_start]..params[:birthday_end]) if params[:birthday_start] && params[:birthday_end]
    @people = @people.where(gender: params[:gender]) if params[:gender]
  end
end

突然話題が飛んだのではと思われるかもしれませんが、私にとってはつながった話題です。そして私は以下の事実に気づいて初めて、これらを修正する方法に開眼しました。

  1. スコープは「昔ながらのピュアなRubyコード」そのものである
  2. Active Recordのクエリには「零(empty)」の概念が存在する
  3. これらはモノイドに実によく似ている

スコープとは

Railsにおけるスコープは、クエリに「名前」を与える手段のひとつです。

class Person
  # マクロヘルパー
  scope :me, -> { where(name: 'Brandon') }

  # 上と同種のアイデア
  def self.me
    where(name: 'Brandon')
  end
end

スコープは単なるRubyコードなので、以下のようにどんな引数でも自由に追加できます。

class Person
  def self.born_between(start_date, end_date)
    where(birthday: start_date..end_date)
  end
end

条件をいくつか適用する

先ほどのコントローラのコードに戻りましょう。日付が渡されたかどうかに応じて誕生日のクエリを送信するかどうかを制御していたのを覚えていますか?

@people = @people.where(birthday: params[:birthday_start]..params[:birthday_end]) if params[:birthday_start] && params[:birthday_end]

スコープで条件が使えないなどということはありませんし、モノイドではいかなるものも「零(empty)」値と連結すれば同じものが返ります。すなわち、条件が満たされない場合はスコープを効果的に無視できるということです。

class Person
  def self.born_between(start_date, end_date)
    if start_date && end_date
      where(birthday: start_date..end_date)
    else
      all
    end
  end
end

ここでいう連結は、「.」、つまりメソッド呼び出しです。Active Recordのクエリはビルダーのように振る舞うので、値を問い合わせるまでは実行されません。つまり、そのままメソッドチェインの追加を繰り返せます。

allは「零(empty)」とみなされます。つまりメソッドチェインのどこかに置いたallは、既に適用済みのすべてを対象とするallを意味するので、現在のクエリは以下のように引き続き問題なく動作します。

Model.where(a: 1).all.where(b: 2) ==
Model.where(a: 1, b: 2).all ==
Model.all.where(a: 1, b: 2) ==
...

これは実に便利なテクニックです。「no-op(=何もしない)」という条件を表現できるようにすると、さらに強力なスコープを作り出せるようになるのです。

結合法則

スコープは追加を繰り返すことも組み合わせも可能で、しかもActive Recordのどこでも利用できるのが素晴らしい点です。つまりjoinincludesも同様に使えるということです。これらのメソッドの動作は結合法則を満たすので、以下のようにかなり高い自由度でグループ化できます。

class Model
  def self.a; where(a: 1) end
  def self.b; where(b: 2) end
  def self.c; a.b.where(c: 3) end
end

このテクニックをjoinincludeorといった概念と組み合わせれば、実に強力なことができるようになります。条件付きスコープというアイデアと組み合わせれば、この型を含めるかどうかをパラメータに応じて決めることができ、コントローラがさらに柔軟になります。

警告: この方法はpluckselectto_aのようなメソッドには使えません。これらはクエリメソッドではなく、クエリを強制実行して自分自身を評価するので、メソッドチェインの末尾に置く必要があります。

方法をまとめる

以下は元のコードです。

class PeopleController
  def index
    @people = Person.where(name: params[:name]) if params[:name]
    @people = @people.where(birthday: params[:birthday_start]..params[:birthday_end]) if params[:birthday_start] && params[:birthday_end]
    @people = @people.where(gender: params[:gender]) if params[:gender]
  end
end

スコープを用いて上をリファクタリングすると以下のような感じになります。

class Person
  def self.born_between(start_date, end_date)
    if start_date && end_date
      where(birthday: start_date..end_date)
    else
      all
    end
  end

  def self.with_name(name)
    name ? where(name: params[:name]) : all
  end

  def self.with_gender(gender)
    gender ? where(gender: params[:gender]) : all
  end
end

class PeopleController
  def index
    @people = Person
      .with_name(params[:name])
      .born_between(params[:birthday_start], params[:birthday_end])
      .with_gender(params[:gender])
  end
end

以下のPostsコントローラのようなコードももっと高度な方法に書き換えられます。

class PostsController
  def index
    @posts = Post.where(params.permit(:name))
    @posts = @posts.join(:users).where(users: {id: params[:user_id]}) if params[:user_id]
    @posts = @posts.includes(:comments) if params[:show_comments]
    @posts = @posts.includes(:tags).where(tag: {name: JSON.parse(params[:tags])}) if params[:tags]
  end
end

この方法を用いれば、以下のようにモデルにincludesをラップすることもできます。

class Post
  def self.by_user(user)
    return all unless user
    join(:users).where(users: {id: user})
  end

  def self.with_comments(comments)
    return all unless comments
    includes(:comments)
  end

  def self.with_tags(tags)
    return all unless tags
    includes(:tags).where(tag: { name: JSON.parse(tags) })
  end
end

これでコントローラを以下のような感じに書き換えられます。

class PostsController
  def index
    @posts =
      Post
        .where(params.permit(:name))
        .by_user(params[:user_id])
        .with_comments(params[:show_comments])
        .with_tags(params[:tags])
  end
end

さらに、これらの一部だけをグループ化することもできますし、パラメータを渡すこともできます。可能性が大いに広がりますね。

まとめ: 可能性の領域を広げよう

私がこんな抽象概念についての記事を書いた理由、そして「モノイド」などという名前を与えた理由は、私たちの視点を大きく広げ、よくある問題を(おそらく)さらに明確な方法で解決する新しいソリューションを見つけるのに、こうした抽象概念が役に立つからです。

これこそプログラミングの醍醐味です。ちょうど、パズルのピースをひっくり返しながら悩むうちにピースがぴったりはまったときの嬉しさにも似ています。ピースを表裏や上下にひっくり返したり回転させたりしながら、思いつく限りのあらゆる角度からピースをじっと見つめているようなものでしょう。

プログラミングをやっていると、普段の私たちには思いもよらないような角度からのアプローチがいろいろ見つかりますが、これこそがプログラミングの楽しさでしょう。学ぶべきことは常にありますし、多くの斬新な概念たちが私たちに発見される日をいつも心待ちにしています。

おたより発掘

「モノイド」マジックでRubyとRailsをパワーアップしよう(翻訳)|TechRacho(テックラッチョ)〜エンジニアの「?」を「!」に〜|BPS株式会社

数学の抽象的な概念が一般のプログラミングに降りてくるとアガるよね

2020/08/17 11:23

関連記事

Ruby: Enumerableを`reduce`で徹底理解する#1 基本編(翻訳)

Ruby: Enumerableを`reduce`で徹底理解する#2 — No-OpとBoolean(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。