概要
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オブジェクトを渡してインスタンス化し、バージョン不明のparams
をassign_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
の背景となるコンビネータ論理と、上位互換の実装についても上に紹介した通りです。