Ruby
Rails

yield_selfを使ったリファクタリング

More than 1 year has passed since last update.

概要

Ruby 2.5で追加されたKernel#yield_selfを使ったRailsアプリのリファクタリング例を3つ紹介する。また、Kernel#yield_selfと同一の機能を表す Tコンビネータ を含む コンビネータ論理 と、上位互換となる実装についても紹介する。

yield_selfとは

yield_selfはブロックを受け取り、そのブロック引数に"自分"を渡すメソッドです。例えば下のコードでは

(a - b).yield_self{|n|
  n > 0 ? n : a
}

(a - b)の結果がnとなり、ブロックの実行結果が最終的な値となります。同じことをyield_selfを使わずに書くと、a - bの結果を保存する変数を(ブロックの外側に)用意するか、(a - b)を2回書かなければなりません。

以下ではyield_selfをRailsアプリのリファクタリングに使う実践的な例を見ていきます。

例1. フィルタリングとソートをするindexアクション

下のindexアクションはHTTPパラメータに従ってモデルのscopeを使い分けます。

class ProductsController < ApplicationController
  def index
    products = Product.all

    products = case params[:filter]
      when 'popular'
        products.liked_at_least(10)
      when 'liked'
        products.liked_by(current_user)
      else
        products
      end

    products = case params[:order]
      when 'likes'
        products.popularity_order
      when 'release'
        products.newer_first
      else
        products.recommendation_order
      end

    @products = products.page(params[:page])
  end
end

このコードには幾つか問題がありますが、まず2つのcase文はindexメソッド全体から見ると処理の粒度が小さいので、独立したメソッドに展開します。

class ProductsController < ApplicationController
  def index
    products = Product.all
    products = filter(products)
    products = order(products)
    @products = products.page(params[:page])
  end

  private

  def filter(products)
    case params[:filter]
    when 'popular'
      products.liked_at_least(10)
    when 'liked'
      products.liked_by(current_user)
    else
      products
    end
  end

  def order(products)
    case params[:order]
    when 'likes'
      products.popularity_order
    when 'release'
      products.newer_first
    else
      products.recommendation_order
    end
  end
end

products =が並んでしまいました。このような再代入は変数の役割を曖昧で分かりにくくします。つまり最初の代入の時点では全てのProduct、次の代入ではフィルタリングしたProduct、3番目の代入ではさらに並べ替えを施したProductと、別の値なのにすべてproductsと呼んでいます。

代入をネガティブに評価する例は他にもあります。例えばコードの読み難さを評価する静的解析ツールFlogでは、条件分岐と同等の減点です。

またソフトウェアのサイズを評価するABC Metricでも条件分岐と同じ重み付けです。ソフトウェアのサイズが増せばバグの発生率も上がるので、ネガティブな評価と言えるでしょう。

では代入を減らすことを考えます。下のようにも書けますが、

  def index
    @products = order(filter(Product.all)).page(params[:page])
  end

より一般的なModel.scopeの語順で書いた方が多くの人に読みやすいでしょう。そこでyield_selfを使うと下のように書けます。

  def index
    @products = Product
      .all
      .yield_self(&method(:filter))
      .yield_self(&method(:order))
      .page(params[:page])
  end

例2. 時刻の構築

次は受け取ったパラメータに従って年と月を決定する処理です。ただしパラメータを受け取らない場合もあり、仕様は以下の通りです:

  • デフォルトは処理時点の時刻
  • 年を受け取ったら、指定の年の始めにリセットする
  • 月を受け取ったら、指定の月の始めにリセットする

元のコードは下の通りです:

def time
  t = Time.now
  t = t.change(year: params[:year]) if params[:year]
  t = t.change(month: params[:month]) if params[:month]
  t
end

例1と同様に再代入を無くしたいのでyield_selfを使い、以下のようになります。

def time
  Time.now.yield_self {|time|
    params[:year]  ? time.change(year: params[:year])   : time
  }.yield_self {|time|
    params[:month] ? time.change(month: params[:month]) : time
  }
end

例3. 複数の正規化処理

次の例はparamsに複数の正規化を施すものです。背景として、様々なバージョンのAPIクライアントが存在し、同じデータをバージョンによって別の名前で送ってくると考えてください。その差異を吸収するクラスUserFormの実装です。

外から見える部分の実装は下の通りです:

class UserForm
  delegate :save, to: :@user

  def initialize(user)
    @user = user
  end

  def assign_attributes(params)
    @user.assign_attributes(normalize(params)[:user])
  end

  # 以下略

つまりUserオブジェクトを渡してインスタンス化し、バージョン不明のparamsassign_attributes(params)すれば、差異を吸収して処理してくれるということです。使用例は次の通り。

  form = UserForm.new(User.new)
  form.assign_attributes(params)
  if form.save
    # 以下略

次にUserFormが担う正規化処理です。まず古いAPIではparams[:avatar]だったものがparams[:user][:avatar]に変わったことを吸収するメソッドです。

  def normalize_avatar(params)
    params[:user][:avatar] = params.delete(:avatar) if params[:avatar]
    params
  end

次にparams[:user][:profile]params[:user][:profile_attributes]に変わったことに対応するメソッドです。

  def adjust_association_attributes(params)
    params[:user][:profile_attributes] = params[:user].delete(:profile) if params[:user][:profile]
    params
  end

この2つの処理を行うのが、次のnormalizeメソッドです:

  def normalize(params)
    adjust_association_attributes(normalize_avatar(params))
  end

このnormalizeメソッドは「paramsに対してnormalize_avatarし、さらにadjust_association_attributesする」ものですが、書かれている順序が逆です。そこでyield_selfを使い、次のようにリファクタします。

  def normalize(params)
    params
      .yield_self(&method(:normalize_avatar))
      .yield_self(&method(:adjust_association_attributes))
  end

これで思考の順序と記述の順序が一致しました。

リファクタリング例は以上にして、以下ではyield_selfの背景を掘り下げていきます。

yield_selfはTコンビネータ

Kernel#yield_selfはコンビネータ論理においてTコンビネータと呼ばれる機能です。コンビネータ論理は値と関数を区別しないシンプルな記法が特長の数学的な論理体系で、1920年代にSchonfinkelによって考案されました。コンビネータ論理においてTコンビネータは以下のように定義されます。

Txy = yx

つまりTにxを与え、さらにyを与えることが、yにxを与えることと等しい、という意味です。yield_selfにとってはxがレシーバ、yが引数です。つまりyがProcオブジェクトのとき、

x.yield_self(&y)

y.(x)

と等しいという意味ですから、正にTコンビネータです。

tapはKコンビネータ

実はKernel#tapはKコンビネータと呼ばれています。Kコンビネータの定義は下のようになります。

Kxy = x

Rubyで言えば、どんなレシーバxに対してx.tap(y)を実行してもxを返す、となります。

コンビネータと鳥

TやKというアルファベットは鳥の名前に由来しています。TはThrush(ツグミ)、KはKestrel(チョウゲンボウ)です。パズルを通じてコンビネータ論理を紹介するTo Mock a Mocking Birdという本には、この2種類を含め人間の呼びかけに応える能力を持った鳥が24種類と、それらの亜種が登場します。

これらの鳥に何らかの鳥の名前を言うと、その性質に沿った名前を返します。例えば自分の名前を呼ばれると自分の名前を返す鳥は 自己中心的(egocentric) と言われ、次のように表されます。

xx = x

コンビネータ論理においても()は優先を表します。Kxy(Kx)yの省略であり、Kに「x」と呼びかけたとき得られた鳥に「y」と呼びかけることを表します。

Starling(ムクドリ)は下の性質を持っており、

Sxyz = xz(yz)

その他すべての鳥はKとSで再現できることが分かっています。

気づいた人もいると思いますが、これらの鳥は以下の点が高階関数と共通しています。

  • 値を渡して呼べる
  • 戻り値に対し、別の値を渡して呼べる

コンビネータ論理を知ることで高階関数に対する理解が深まると思いますので、興味のある方は調べてみてください。

yield_selfの改良

実はyield_selfの上位互換のメソッドintoが6年以上前に発表されています。その実装は以下の通りです(ライセンスについてはオリジナルのページを参照してください)。

class Object
  def into expr = nil
    expr.nil? ? yield(self) : expr.to_proc.call(self)
  end
end

この実装がyield_selfより優れているのは、ブロック以外の引数を渡したとき.to_procしてくれる点です。この実装を使うと例1のコードは以下のようになります。

  def index
    @products = Product
      .all
      .into(method :filter)
      .into(method :order)
      .page(params[:page])
  end

また例3のコードは下のようになります。

  def normalize(params)
    params
      .into(method :normalize_avatar)
      .into(method :adjust_association_attributes)
  end

&()が不要でメソッド名も短く、個人的にはこちらの方が読みやすいと感じます。5年前にyield_selfのアイデアが挙がり、その4日後には名前の議論が始まっていたので、議論に加わらなかったこと悔やみます。(機能拡張だけでも今から提案してみようかと思います。)

まとめ

Kernel#yield_selfを使ったリファクタリングには以下の効果があると言える:

  • 再代入をなくす
  • 思考の順序と記述の順序を合わせる

またyield_selfの背景となるコンビネータ論理と、上位互換の実装についても上に紹介した通りです。