Ruby2.6の機能を使ってみる〜関数合成でRailsのscope合成〜

皆さんこんにちは。虎の穴ラボのY.Fです。
今回は昨年のクリスマスにリリースされたRuby2.6ではProcオブジェクトやMethodオブジェクトに関数を合成する >> オペレターが追加されたので、
Railsで使える部分が無いか探ってみたいと思います。

関数合成とは

高校で数学が得意だった方は習った覚えがあるかもしれませんが、数学的な定義から見ていきたいと思います。

以下のような2つの関数を合成することを考えます。

 \begin{align} f(x) = x + 1 \end{align}
 \begin{align} g(x) = x ^ 2 \end{align}

この時、 x = 2に対して関数 fを適用した結果に、 gを適用すると以下のような感じになります。

 \begin{align} f(2) = 2 + 1 = 3\end{align}
 \begin{align} g(3) = x ^ 2 = 9
\end{align}

このことから、2つの関数を合成した新しい関数を考えることができ、それを以下のように定義します。 ※ 以下の式は  g(f(x)) (g \circ f)(x) は同じ意味であることを示しています。

 \begin{align} f'(x) = g(f(x)) = (g \circ f)(x) \end{align}

ただし、 適用順を変えると結果も変わることに注意です。 (  (g \circ f)(x) \neq  (f \circ g)(x))

 \begin{align} g(2) = 2 ^ 2 = 4 \end{align}
 \begin{align} f(4) = 4 + 1 = 5
\end{align}

Ruby2.6での関数合成

Ruby2.6から導入された関数合成オペレーターも基本的には同じように考えれば理解は簡単かと思います。

def f(x)
  x + 1
end

def g(x)
  x * x
end

# Procオブジェクトに変換して関数合成オペレーターを適用 g(f(x))
f_dash = method(:f).to_proc >> method(:g).to_proc
p f_dash[2] # => 9

# << オペレーターを使えばf(g(x))も簡単
f_dash = method(:f).to_proc << method(:g).to_proc
p f_dash[2] # => 5

メソッド名をシンボルで与えていることに着目すればもう少し工夫できそうです。

def f(x)
  x + 1
end

def g(x)
  x * x
end

def comp(*methods)
  # reduceの初期値として与えられた数値をそのまま戻す `proc {|n| n}` を与えている
  methods.reduce(proc {|n| n}) {|acc, m|
    acc >> method(m).to_proc
  }
end

f_dash = comp(:f, :g)
p f_dash[2]

コメントにあるようにreduceメソッドに初期値を与えています。
これは合成対象の関数 f , g いずれも引数を取るためです。
上記の説明であるように2つの関数を合成すると1つ目の関数の処理結果が2つ目の関数の引数に代入されます。
なので上記の関数は以下のような合成になるということです。

数字を一つ取ってそのまま戻す関数(reduceの引数)
↓
上記reduceの引数の適用結果を受け取り、1を足して返す関数f
↓
fの結果を受け取って二乗して返す関数g

Railsのscopeを合成してみる

上記でみたように合成される関数は前の関数で処理された結果が入ります。
したがって、scopeの定義がかなりイマイチな事になっています。

class User < ApplicationRecord
  # 引数に何も渡されなかった場合は通常のscopeと同様の動きをする
  # もし引数にオブジェクトが入れられている場合はそのオブジェクトのメソッドを呼び出す(関数合成の仕様のため)
  scope :active, ->(user = nil) {
    user ? user.where(disabled_at: nil) : where(disabled_at: nil)
  }
  scope :inactive, ->(user = nil) {
    user ? user.where.not(disabled_at: nil) : where.not(disabled_at: nil)
  }
  scope :recent, ->(user = nil) {
    user ? user.order(created_at: :desc) : order(created_at: :desc)
  }
  scope :latest, ->(user = nil) {
    user ? user.order(:created_at) : order(:created_at)
  }

  # 普通に合成する場合は以下
  scope :active_with_recent -> { active.recent }

  def self.to_method(method_name)
    method(method_name)
  end

  # 関数合成用メソッド
  def self.comp(*methods)
    # 引数に何も渡されなかった場合は何もしないprocオブジェクトを返す
    return proc {} if methods.empty?

    # 何もしないプロックオブジェクトを先頭に、引数で与えられたシンボルからメソッドオブジェクトを作って合成する
    methods.reduce(proc {}) do |acc, method|
      acc >> to_method(method)
    end
  end
end

普通にscopeを合成する場合は上記8行目の active_with_recent の定義のようになるかと思います。
ただし、このメソッドでは無効化されていないかつ、新しい順でユーザーしか取れません。
無効化されているユーザーが欲しかったり、古い順で取りたい場合、別途合成したスコープを定義するか、メソッドチェーンでscopeメソッドを呼び出す必要があります。

今回用意した comp メソッドを使えばそのようなメソッドをシンボルから動的に作成することができます。

# 有効なユーザーを新着順でソート
User.comp(:active, :recent).call

# 無効なユーザーを古い順でソート
User.comp(:inactive, :latest).call 

このように、予め定義されている scope 合成メソッドがなくてもソートや絞り込み条件によって柔軟に scope 関数を合成できます。
ただし、 params の値などをそのまま使おうとすると意図しない関数呼び出しが発生する可能性が高い点は注意が必要です。

まとめ

今回の例のように、メソッド呼び出しの条件が頻繁に変わったりする場合には細かいメソッドをたくさん用意しておいてそれを合成することで柔軟に対応できそうだなと思います。
Ruby自体も、関数合成が入ったことでメソッド周りの表現力はより増したなと感じました。
今後もRubyがバージョンアップされたら新機能の調査を行っていきたいと思います。

P.S.

虎の穴ではRubyエンジニアをはじめとして一緒に働く仲間を絶賛募集中です! この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧下さい。

yumenosora.co.jp