Tech Racho エンジニアの「?」を「!」に。
  • 開発

Railsフレームワークで多用される「options = {} 」引数は軽々しく真似しない方がいいという話

こんにちは、hachi8833です。今回はBPSのSlackでのやりとりを元に記事にいたしました。

私も軽々しい方なので、メソッド定義でoptions = {}が使いたくなったらよく考えることにします。

= {}引数の使い方と歴史

今回の記事化にあたり、morimorihogeさんから= {}引数について詳しく教えていただきました。

本題に入る前に、ここでは= {}引数について必要な部分のみをまとめ、Rubyの引数そのものについては別記事といたします。

はじめに: 「オプション」とは

まずおさえておきたいのは、ITの文脈でオプション(option)という言葉を使ったら、その項目は原則として「必須ではない」「あってもなくても動く」という共通理解が求められるということです。つまり、「このオプションを指定しないと動かない」ような必須項目は、オプションとは呼びません。

英語辞書に載っているoptionの意味には「選択肢」「選択科目」「(金融用語の)オプション」などがありますが、ITで使われる「オプション」には原則として「指定しなくてもよい」という項目選択肢に含めておくべきです。自分がたとえそう思わなくても、「オプション」という言葉を使えば相手はそう考えると思っておく必要があります。

もちろん現実には必ずしも「オプション」という言葉がそのように使われていないことは多々あります。
「OSをアップグレードするか、後にするか」ダイアログのオプションに「OSをアップグレードしない」という選択肢はないこともよくありますし、選択肢全般を口頭でカジュアルに「オプション」と呼んでしまうこともよくあるでしょう。

= {}について

Rubyでは、メソッド定義の最後(ブロックを除く)にオプション名 = {}のような引数を置くことで、可変長のハッシュを受け取る引数オプション的な挙動を実現できます。この引数の名前はもちろんoptions以外でも何でも構いません。

def hoge(one, two, options = {})
end

上のようなメソッド定義に対して、hoge('1st', '2nd', opt1: true, opt2: true, opt3: 'hugahuga')のような多数の引数を持つ呼び出しを行えます。opt1: true, opt2: true, opt3: 'hugahuga'のハッシュ部分は可変長なのでいくらでも長くできます。

= {}がメソッド定義に複数ある場合

= {}は可変長のため、引数で= {}複数使いたい場合には一工夫必要です(その是非は今は考えないことにします)。

  • 呼び出し側で1つ目のオプション引数を{ }で囲まないと、1つ目のopt1 = {}にすべて吸い込まれてしまい、2つ目のopt2 = {}が空になってしまいます。
  • かといって2つ目のopt1 = {}に渡す引数だけを{ }で囲むと、今度はエラーになってしまいます。
def hoge(one, opt1 = {}, opt2 = {})
  pp one, opt1, opt2
end

# 期待どおりにならない
hoge :lion, lion: :female, tiger: :male, jibanyan: :cat
#=> [:lion, {:lion=>:female, :tiger=>:male, :jibanyan=>:cat}, {}]

# エラー
hoge :lion, lion: :female, tiger: :male, {jibanyan: :cat}
#=> SyntaxError: unexpected '\n', expecting =>

# 期待どおり
hoge :lion, {lion: :female, tiger: :male}, jibanyan: :cat
#=> [:lion, {:lion=>:female, :tiger=>:male}, {:jibanyan=>:cat}]

メソッド定義に= {}引数が複数ある場合、メソッド呼び出しで{ }を省略できるのは最後の={}だけです。

参考: Railsコードでオプション引数を渡すときに{ }が必要な例

ビューのForm系メソッドなどで、オプション引数を{ }で囲んで呼び出さないとうまく動かないものがときどきあります。

例: ActionView::Helpers::FormOptionsHelper#select

この#selectメソッド呼び出しでhtml_options = {}class: 'select2'というオプションを渡したい場合、以下のように明示的に{ }で囲まないと1つめのoptions = {}に全部吸い込まれてしまいます。これが地味に面倒です。

# ちゃんと動かない
select(obj, :obj_code, ['a', 'b', 'c'], include_blank: true, class: 'select2')

# 動く
select(obj, :obj_code, ['a', 'b', 'c'], {include_blank: true}, {class: 'select2'})

Rubyの記法ではブロックとハッシュの両方で{ }が使われますが、ブロックではないハッシュを呼び出しでじかに{ }で囲まないといけないというのもちょっと気持ち悪いですね。

options = {}の歴史

2.0以前のRubyにはキーワード引数がありませんでした。当時のRailsを含むさまざまなRubyプログラムでは、キーワード引数的なものを使うためにoptions = {}でハッシュのkeyとvalueでそれっぽいものをこしらえて使っていました。

キーワード引数が使える今なら、何でもかんでも= {}引数で受け取る必要はだいぶ下がってきました。

ただ= {}で受け取ったハッシュにたっぷり含まれているkey-valueペアをサブメソッドにそのまま投げられる(委譲)という特性があるので、そうした目的のために= {}が使われることはまだあるかもしれません。

Railsフレームワークで多用される「 options = {}引数」について

ここから本題に戻ります。

Railsフレームワークのコードでは、メソッド定義の引数にこのoptions = {}という名前のオプション引数が多用されています。キーワード引数がなかった時代の名残であるものが多いのではないかと思います。

# actionpack-4.2.8/lib/action_controller/metal/rendering.rb 31行目より
    def render_to_body(options = {})          # <= コレ
      super || _render_in_priorities(options) || ' '
    end

メソッド定義でoptions = {}引数を使うメリット

主にフレームワークやライブラリのメンテナが恩恵を受けます。メソッドを最初に書く人と、次回あたりにオプションを拡張する人がラクできます。

APIを最初に設計した時点では、将来どんなオプションが欲しくなるかを見通すのは簡単ではありません。しかしそこでじっくり考えているといつまでたっても実装が進みませんので、引数にoptions = {}を使ってoptionsで何でも受けられるようにしておくという手法があります。

将来オプションの数が増えたときもメソッドの引数部分を更新せずにすみますので、オプションが(変更ではなく)追加である限り既存のAPI利用者に影響を与えずに済みます。

前述のとおりoptionの引数は簡単に別メソッドに委譲できるので、メソッドに渡せばkeyで取り出せます。API利用者が与えたkeyが間違っていれば誰も処理しないだけなので何も起こらず、自然とオプションらしい動作になります。

メソッド定義でoptions = {}引数を使うデメリット

options引数のオプションが1つ2つ程度そのメソッド内だけで使われている限り、ほとんど問題にはなりません。

怖いのはそのメソッドが育って大きくなり、メソッド呼び出しのネストが深くなったときです。そのあおりをうけるのは、主にアプリ開発者と遠い将来のAPIメンテナです。特にアプリのデバッグがつらくなります。

options引数が上述のような便利な性質を備えているために、APIの成長に伴って、メソッド内の別メソッド呼び出しで引数にoption引数がそのまま中継されてしまい、しかもそれが繰り返しネストされてしまうことがしばしば発生します。なお、アンダースコア_で始まるメソッド名はprivateメソッドであることを示すのによく使われます。

# actionpack-4.2.8/lib/action_controller/metal/rendering.rb 63行目より
    def _normalize_options(options)
      _normalize_text(options)      # <= 上位から受け取ったoptionsをそのまま中継している
    #(略)
    end

さらにネストの途中で、option引数を読んだり書き換えたりするメソッドがあるかと思うとoptionを使わずにさらに下に中継するだけのメソッドもある、というように、options引数の使い方がバラバラになると深刻です。以下の例ではA〜Kがメソッドで、階層はメソッド呼び出しを表し、すべての呼び出しでoptionsを受け渡しているとします。

ネストの奥底あたりのメソッドでオプションの利用法が他のメソッドとたすきがけで依存していたり、本来必須ではないはずのオプション項目が深いところで必須扱いになり、大元の呼び出しオプションまで必須になってしまっているとイラッと来たりします。

このようにoption引数の利用場所が分散してしまうと、元のメソッドのoptions引数のすべてのオプションがどうなっているのかを見通せなくなってしまいます。知りたければ呼び出しのネストを一番深いところまですべて追い、オプションの処理をひとつひとつ拾い上げて理解して、自分でAPIドキュメントみたいなものをこしらえるはめになってしまいます。

あるいは、デバッガでたまたまこうしたoptionsネストに迷い込んでしまい、optionsの全体像がわからなくてハマったりします。

optionsという名前が広範囲にわたって使われていると、ひどいときにはIDEのサジェスチョンがループすることすらあるそうです。

babaさんいわく、options = {}引数を使うなら、将来オプションが30個ぐらいにふくれあがる覚悟が必要とのことです。

要点: 最適なコードは立場や状況によって変わることがある

babaさんとmorimorihogeさんの冒頭のやりとりを元に、私なりにまとめました。

重要なのは、フレームワーク開発者(またはライブラリの開発者)の多くは options = {}引数にともなうこうした問題を理解したうえで、開発速度や拡張性、API利用者の利便性などとのトレードオフを経て導入しているはずだということです。また、時代とともに手法も移り変わります。最適なコードは立場や状況や時期によって違うことがあると考えるのが、少なくとも私にとっては自然です。

むしろ注意したいのは、「フレームワークで使われている手法だから大丈夫」という理由だけでアプリの開発に options = {}引数を安易に導入してしまうことであると理解しました。options = {}引数は確かに便利です。だからこそチームによるアプリ開発でもこうした潜在的な問題について理解しておきたいと思いました。

私を含め、多くの人が「これさえやっておけば大丈夫」というような定番の手法をつい探してしまいがちなのですが、無条件のベストプラクティスというものは実はかなり限られているので、やはりほとんどの手法は限られた時間の中でトレードオフを繰り返しながらその都度選択するしかありません。今後Railsのソースを読むときにはフレームワーク開発者の立場や状況にも注意したいと思います。

options = {}引数をどうするか

options = {}引数には、まとまったオプションを楽に委譲できるというメリットが確かにあります。メソッド定義の引数でのオプション追加・変更・削除は大変ですし、定義の引数が10数個にもなると見た目も悪く、可読性も落ちます。

メソッド定義の引数を縦にアラインすることで見た目の問題は解消できますが、オプションをひとつひとつサブメソッドに渡すのはやはりつらいので、何らかの方法でまとめたいという需要は引き続き残ります。その一方、オプションの情報や処理の拡散も何とかしたいところです。

もちろん、書き方としてはoptions = {}のほかに*を使った可変長引数や**を使ったオプション引数などもありますが、プロジェクトのスタイルや要件に応じて考慮しましょう。

状況にもよりますが、必須の引数は原則としてキーワード引数にするのがよいと思います。

options = {}で拡散した情報をどうにかできるか

メソッド呼び出しの階層をクロールして、options = {}のオプションを自動ですべてかき集められればよいかもしれませんが、メソッドごとの使い方が一定してない以上現実には無理そうです。

呼び出しの階層が進むといつしかメソッドがprivateになっていることがよくありますが、privateメソッドはその性質上コメントがまったくないか非常に少ないことが多いので、呼び出し階層の深いところでオプションの意味を理解するにはコードやコミットメッセージを頼りにするしかなくなることが多くなります。

最初にoptionsを受けたメソッドに、がんばってすべての情報をコメントで残す方法も現実的ではなさそうです。そういうコメントはたいていメンテされなくなるのがオチなので。

しかし少なくとも、引数で受け取ったoptionsをそのまま投げ合うのは避けたいですね。

他の工夫

それよりは、受け取ったoptionsをメソッド間で直接渡さないようにする方法と、情報の拡散を防ぐ方法を早い段階で検討する方がよさそうです。
方法はいろいろ考えられますが、思いつく範囲で書いてみます。

1. Struct#newにオプションを保存してから渡す

RubyのStruct#newを使って構造体のクラスを作り、最初にoptions = {}引数を受け取ったメソッドがオプションを構造体のインスタンスに保存するというシンプルな方法です。

Webチームの元じゃばーkazzさんによると、これは引数オブジェクトと呼ばれるリファクタリング技法に相当するそうです。

Struct#newで定義したattributeはすべてリードライト可能になりますので、外部から受け取ったオプションにも使えますが、どちらかというと画面上の座標のような動的な値の受け渡しに向いていそうです。attributeごとのリードライトの細かい制御ができないので、なるべくオプションを小規模に保っておこうと思います。

# 構造体用の軽いクラスを作成
MyOpts = Struct.new(
  :address,       # メールサーバーのアドレス(デフォルト: localhost)
  :port,          # メールサーバーのポート番号(デフォルト: 25)
  :domain,        # HELOのドメインを指定
  :authentication # 認証方式(`:plain`, `:login`, `:cram_md5`)
  :user_name      # 認証のユーザー名
  :password       # 認証のパスワード
)

# 最初に構造体にオプションを保存する
def mymy_method(options = {})
  mymy_opts = Struct.new(
                options[:address],
                options[:port],
                options[:domain],
                options[:authentication],
                options[:user_name],
                options[:password]
              )

  sub_method(mymy_opts)
  sub_method2(mymy_opts)
  sub_method3(mymy_opts)
  ...
end

# サブメソッドは構造体経由でオプションを利用する
def sub_method(mymy_opts)
  if mymy_opts.address.nil?
    #(略)
  end

  sub_sub_method(mymy_opts)  # さらに別メソッドに投げる
end

attributeは文字列でも書けますが、シンボルで書くのが定番ですし便利です。

一手間かけることで、オプションの定義や用途を一箇所にまとめられますし、チーム開発で誰かがサブクラスにオプションを追加するときも自然にStruct#newの定義とインスタンス生成をメンテするので、情報が古くなりにくいというメリットがあると思います。

構造体なので、Struct#newのインスタンス生成時の値代入ではキーを指定しなくてよい(順序のみ守ればよい)のも地味に助かります。また、attributeにドット記法.でアクセスできるのも個人的にはうれしいです(タイプ量が減るので)。

grepしやすい(かつオプションとわかる)名前をインスタンスにつけておけば、ソース内で拡散しまくった同名のoptionsのように途方に暮れなくて済みそうです。

構造体にないkeyをオプションに与えられても、options[]から構造体に代入されないだけなので、オプションらしさを維持できます。

オプションの増加に伴い、オプションを書き換え禁止にしたいとか、型チェックしたいとか、初期化処理を加えたいといった要件が加わったら、そのときにStruct#newを普通のクラス定義に書き換えればよいでしょう。

「そこが面倒なんだよ」という方は、次をご覧ください。

2. 初期化を支援するgem

この間のRailsウォッチ(20170317)morimorihogeさんに教わったばかりですが、Struct#newよりもっと複雑な初期化を支援するgemがあります。これも引数オブジェクトとして活用できそうです。

両gemの機能や目的は似通っていて、requireしてクラスにincludeすることで使えます。virtusはRuby全般、active_attrはその名のとおりRailsのActiveRecordを意識しているようです。

# Virtusの使用例
module MyModule
  require 'virtus'

  class Nw
    include Virtus.model

    attribute :str_a, String
    attribute :str_b, String
    attribute :dim_a, Integer
    attribute :dim_b, Integer
    attribute :mod_a, String
    attribute :mod_b, String
    attribute :f_ary, Array
   end

  class Bt
    include Virtus.model

    attribute :x,    Integer
    attribute :y,    Integer
    attribute :diag, Integer
    attribute :up,   Integer
    attribute :left, Integer
  end

  def my_method
     str_a, str_b = 'hoge', 'huga'
     nw = Nw.new(
       str_a: str_a,
       str_b: str_b,
       dim_a: str_a.length,
       dim_b: str_b.length,
       mod_a: "", mod_b: "",
       f_ary: Array.new(str_a.length + 1) {Array.new(str_b.length + 1) {0}}
     )

    sub_method(nw)
  end

  def sub_method(nw)
    bt = Bt.new(
    x: nw.str_a.length - 1,
    y: nw.str_b.length - 1,
    diag: 0, up: 0, left: 0
    )

    pp bt.x, bt.y
  end
end

初期化での型チェック、バリデーション、初期値の設定など、多くの機能があります。私はまだvirtusを少し触ってみた程度なので、別途記事にしてみたいと思います。

追記(2018/11/19)

Rubocopで、オプション引数よりもキーワード引数が推奨されるようになりました。

Ruby: Rubocopスタイルガイドの最近の更新: オプション引数 options = {} が非推奨化など

関連記事

【保存版】Rubyスタイルガイド(日本語・解説付き)総もくじ


CONTACT

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