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

Ruby: メソッド引数のデフォルト値で遊んでみた(翻訳)

概要

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

Ruby: メソッド引数のデフォルト値で遊んでみた(翻訳)

ちょうど昨日、突如ちょっとした小技を思いつきました。メソッドのパラメータがない場合に親切なエラーメッセージを表示する方法です。

# デフォルトのやり方
def read_data(file)
  # ...
end

read_data('1.txt') # => works
read_data
# wrong number of arguments (given 0, expected 1) (ArgumentError)
# ^^ ちょっとそっけない

def read_data(file:)
  # ...
end

read_data
# missing keyword: file (ArgumentError)
# ^^ 何が欲しいのか、少しはヒントが欲しい

# ではこれならどう?
def read_data(file = raise(ArgumentError, '#read_data requires path to .txt file with data in proper format'))
  # ...
end

read_data
# #read_data requires path to .txt file with data in proper format (ArgumentError)
# ^^ いい感じになった

もちろん、引数が1つしかない単純なメソッドでは便利というほどでもありませんが、キーワード引数がいくつもある複雑なAPIならそれなりに使いみちがあるかもしれません。しかし自分にとって嬉しい驚きは、この方法が実にシンプルかつ実際に動いたことでした。

しくみ

引数 = raise(...)という独立した機能がRubyにあるのではなく、以下の2つから自然に導かれます。

  1. 引数のデフォルト値には任意のRuby式を書ける。メソッド呼び出しのたびに、メソッド本体と同じコンテキストで評価される(引数が渡されない場合)。
  2. raiseは特別な構文ではなく、単なるメソッドに過ぎない。他のメソッド呼び出しと同様に「式」なので、引数のデフォルト値として使える。

任意の式?信じていいの?

もちろんです。

その気になれば以下のようにだって書けます(たぶんやらない方がよいと思いますが)。

def read_data(file = begin
                       puts "Using default argument"
                       if Time.now.hour < 12
                        'morning.txt'
                       else
                        'evening.txt'
                       end
                     end)
  puts "Reading #{file}"
end

read_data
# Prints:
#  Using default argument
#  Reading evening.txt

既に述べたように、これが可能なのは(たぶんすべきではありませんが)評価のコンテキストがメソッド本体と同じであり、すべてのデフォルト値が順に評価されるからです。

class ArgsTracker
  attr_reader :args

  def initialize
    @args = []
  end

  def track(
    a: begin; args << :a; 100 end,
    b: begin; puts "a was #{a}"; args << :b end)
  end
end

tracker = ArgsTracker.new

tracker.track
# "a was 100"を出力して[:a, :b]をtrackerに追加する

tracker.track(a: 5)
# "a was 5"を出力し、引数に渡されなかった[:b]だけをtrackerに追加する

tracker.args # => [:a, :b, :b]

クールかつひどいとも言えますが、でもクールですよね。

これに使いみちはあるの?

「デフォルト値が呼び出しのたびに、しかも呼び出されたクラスのコンテキストで算出される」という性質から、シンプルかつ便利な利用法がいくつか編み出せます。中には、おそらく皆さんが既に見たり使ったりしたものもあるでしょう。

# logを呼び出すたびに、at:が渡されなければ算出する
def log(something, at: Time.now) 
  #...
end

# warnのデフォルト出力先デバイスは常に`out`と同じになる
def setup_output(out: $stdout, err: $stderr, warn: out)

  # ...
end

class A
  # デフォルト値を算出するために同じオブジェクトのメソッドを呼び出す
  def process(order: default_order) 
  end

  private

  def default_order
    # オブジェクトのステートに応じて何か複雑な計算を行う
  end
end

さらに高度な利用法

上で紹介した例以外にも、ある程度「常識」を保ちつつ、より複雑なオンザフライ計算方法が考えられそうです。たとえば、デフォルト値の利用状況のトラッキングなどです(レガシーコードのリファクタリングで、デフォルト値が使われているかどうかが不明だが、コードベースを壊すわけにいかないような場合などに便利でしょう)。

def log_default(name, value)
  # or logger.debug
  puts "#{caller.first}: default value for #{name} was invoked from #{caller[2]}"
  value
end

def some_method(factor: 100)
end
# 上を以下のように変える
def some_method(factor: log_default(:factor, 100))
end

# そして実行
some_method
# すると以下がログ出力される
#   ...in `some_method': default value for factor was invoked from `some_other_method'

また、本記事の最初の例(failを使ったもの)をfun(arg: friendly_fail(:arg))のような「非常にフレンドリーな」APIに拡張することも考えられそうです。このAPIでは、定数またはi18n設定から長い説明文を取得し、呼び出しコンテキストで補足して(あるメソッドがcallerに含まれていたら「このメソッドはXXフレームワークから呼び出すべきではない」など)、非常にフレンドリーな例外を発生します。

「今すぐそうすべき」ということではなく、単に「こんなことが可能なのが面白い」「そのうち試せるといいよね」ということです。

楽しみましょう!

関連記事

Ruby 2.5の`yield_self`が想像以上に何だかスゴい件について(翻訳)


CONTACT

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