Ruby 2.7.0 で導入された Arguments Forwarding

Ruby 2.7.0 で導入された Arguments Forwarding について、RuboCop でスタイル部署の新たな Cop として開発中なので、Cop 開発の過程で得ている知見やらを進捗を記しておきます。

Arguments Forwarding とは

def foo(...) といった形で引数への構文拡張がされています。以下のコードを動かしてみるとイメージがつくかもしれません。

% cat arguments_forwarding.rb
def foo(...)
  bar(...)
end

def bar(str)
  puts "hello, #{str}"
end

foo('world') # => hello, world

... でイメージがつくかもしれませんが、サンプルではひとつの引数のみですが、実際は複数の引数をそのままフォワードします。

効能

pocke さんによる RuboCop への Feature Request に詳しいです。

github.com

キーワード引数の分離への警告 (Ruby 3.0 ではエラー) の回避の側面を提案に含めているのが興味深かったです。実際に検知できるケースがアプリケーションレイヤーでどの程度あるかはわかりませんが、Ruby 3.0 へのマイグレーターのひとつになるかと思い着手することにしました。

構文上の注意

Arguments Forwarding を使ったメソッド定義では引数の括弧は省略できません。

def foo ...
end

以下のような構文エラーになります。

% ruby -vc foo.rb
ruby 2.7.0p0 (2019-12-25 revision 647ee6f091) [x86_64-darwin17]
foo.rb:1: warning: ... at EOL, should be parenthesized?
foo.rb:1: syntax error, unexpected ..., expecting ';' or '\n'
def foo ...

つまり以下のようなコードを Arguments Forwarding に置き換える場合は括弧が必須になります。

# Before
def foo *args, **kwargs, &block
end

# After
def foo(...)
end

一方でフォワーディング先のメソッド呼び出しは括弧を省略できます。

def foo(...)
  bar ...
end

ただし以下のような警告が発生するため括弧をつけることが好ましいでしょう。 ただし以下のような警告が発生します。追記した内容を踏まえて括弧をつけることを一考するとよいでしょう。

% ruby foo.rb
foo.rb:2: warning: ... at EOL, should be parenthesized?

2020年1月15日追記

こちらについて pocke さんから括弧の有無で意味合いが変わるというフィードバックをいただく。

Parser gem でも試して見たところ以下のように結果が変わりました。pocke さん、いつもありがとうございます!

% ruby-parse -e 'def foo(...); bar ...; end'
(def :foo
  (forward-args)
  (erange
    (send nil :bar) nil))

% ruby-parse -e 'def foo(...); bar(...); end'
(def :foo
  (forward-args)
  (send nil :bar
    (forwarded-args)))

(追記ここまで)

また想像にかたくないですが、メソッド定義が Arguments Forwarding でない場合に、フォワード先のみ ... にしていると構文エラーです。

def foo(*args, **kwargs, &block)
  bar(...)
end

def bar(*args, **kwargs, &block)
end

foo('world', num: 42)

実行した内容は以下です。

% ruby foo.rb
foo.rb:2: unexpected ...

Style/ArgumentsForwarding cop

以下で開発中の Ruby 2.7 以上を対象とした現在開発中の新しい Cop です。

github.com

pocke さんのイシューにあるとおり以下のケースを Arguments Forwarding にするとブロックまで引き渡されるという振る舞いの破壊が入ります。

# We can replace the parameter with `...`, but it will change the behavior.
# Because `...` forwards block also.
def foo(*args)
  bar(*args)
end

そのためこのケースは Strict オプションとして設定できるようにして、デフォルトでは Safe な振る舞いにするため無効にしています。

オプションを含めてここまでの説明について概ね実装ができています。何が不足してるかというとメソッド定義の (Arguments Forwarding ではない) 通常の引数がメソッド内で使われているケースで誤検知がおきます。

例えば以下のようなケースです。

def foo(*args, **kwargs, &block)
  bar(*args, **kwargs, &block)
  args.first.do_something
end

式展開で使われているケースや、以下のようなコーナーケースもアウトです。

# Array リテラルでもちいたケース
def foo(*args, **kwargs, &block)
  bar(*args, **kwargs, &block)
  var = [args]
  do_something(var)
end

# Hash リテラルでもちいたケース
def foo(*args, **kwargs, &block)
  bar(*args, **kwargs, &block)
  h = {key: args} # key: value 両方とも
  do_something(h)
end

以下のようにフォワーディングだけしているケースを検出するのであれば容易ですが、フォワーディングの前後でデコレーションあるいはフィルタリング処理をいれているような実践ケースが検知できなくなります。

def foo(*args)
  bar(*args)
end

このあたりをどのように実装するかというのが現状で、variable_table あたりを使ったなにか良い方法があるのか、地道に例外ケースを潰すのかといった調査と設計をしているが現状のステータスです。


おわりに - OSS の開発、メンテナンスについて

このような OSS 開発への活動を支援していただけるスポンサーを募集しています。

github.com

実際のところ GitHub Sponsors は想像していた以上に OSS 活動のモチベーション継続の一要因になっています。これを通じて RailsConf 2019 (at ミネアポリス) にて DHH が講演した OSS の社会構図が変わるきっかけになるといいなと思っています。