Ruby: メソッド引数のデフォルト値で遊んでみた(翻訳)
So many years with Ruby, and I suddenly understood this works (and might even be a good idea sometimes) pic.twitter.com/1Qp0JfUcqr
— zverok (@zverok) November 18, 2020
ちょうど昨日、突如ちょっとした小技を思いつきました。メソッドのパラメータがない場合に親切なエラーメッセージを表示する方法です。
# デフォルトのやり方
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つから自然に導かれます。
- 引数のデフォルト値には任意のRuby式を書ける。メソッド呼び出しのたびに、メソッド本体と同じコンテキストで評価される(引数が渡されない場合)。
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フレームワークから呼び出すべきではない」など)、非常にフレンドリーな例外を発生します。
「今すぐそうすべき」ということではなく、単に「こんなことが可能なのが面白い」「そのうち試せるといいよね」ということです。
楽しみましょう!
概要
原著者の許諾を得て翻訳・公開いたします。