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

Ruby2.5.xのパラメータの制約についてまとめてみた

こんにちは、hachi8833です。大江戸Ruby会議07で議論された「キーワード引数の現状と将来構想」をきっかけに、メソッド定義側の制約を中心にRubyのパラメータと引数について(主に自分のために)取り急ぎまとめました。誤りや理解不足がありましたら@hachi8833までお知らせください。

追記(2024/11/21)

本記事で取り上げられているキーワード引数の問題はRuby 3.0で解決されました。

参考: Ruby 3.0における位置引数とキーワード引数の分離について

Ruby 2.7: ハッシュからキーワード引数への自動変換が非推奨に(翻訳)

用語と書式

Rubyも含め、多くの言語では引数(argument)という用語とパラメータ(parameter)という用語は明確に区別されていません。そこで、以前翻訳した「Rubyのパラメータと引数の対応付けを理解する」に倣って、本記事に限って以下の用語と書式を用いることにしますのでご了承ください。これにより、一般にキーワード引数と呼ばれているものも定義側ではキーワードパラメータと表記します。

  • パラメータ: メソッド定義の変数リスト
  • 引数: メソッド呼び出し側で渡す値リスト(変数やリテラル)
  • キーバリュー: メソッド呼び出し側の引数におけるkey: :value形式の値(変数やリテラル)
  • 必要ない限り、引数はすべて()で囲む書式で統一

なお、昔の一部の言語では本記事で言うメソッド定義側のパラメータを「仮引数」(dummy arguments)、メソッド呼び出し側の引数を「実引数」(actual arguments)と呼ぶ慣習があります。

環境はRuby 2.5.1を前提とします。また、記述はパラメータを中心にします。

パラメータの種類と略称

表記を簡略にするため、本記事では以下の略記を用います。これも本記事だけの表記であり、一般的なものではありません。

pN/pND
通常のパラメータ(a)かデフォルト値付きパラメータ(a=1
pVA
可変長配列パラメータ(*a): splatパラメータとも
pK/pKD
キーワードパラメータ(a:)かデフォルト値付きキーワードパラメータ(a: 1
pVH
可変長ハッシュパラメータ(**a): double splatパラメータとも
pB
ブロックパラメータ(&block

なお、本記事ではいわゆる「オプションハッシュパラメータ」は使い方の呼び名とみなし、パラメータの種類とは別ものと考えます(opt={}**optもそう呼ばれることがあるため)。

1.のpNとpNDと、2.のpVAは通称「positionパラメータ」と呼ばれ、引数の順序をパラメータの順序と合わせる必要があります。

以上のパラメータは、デフォルト値付きとデフォルト値なしのものを類似とみなすと、以下のように5つのグループに分けられます(グループごとに色を変えてあります)。

パラメータをフルに使った極端なメソッド定義は以下のようになります(実際にここまですることはまずありませんが)。

def test(id, type='normal', *friends, name:, age: 0, **opts, &block)
  puts "name=#{name}, age=#{age}, type=#{type}"
  puts "id=#{id} friends=#{friends} opts=#{opts}"
  puts "block=#{block}"
end

Rubyの構文上の制約

Ruby構文では、メソッド定義側のパラメータ順序の明確な制約は以下のようになります。思ったほどは多くありません。
バグかどうかという議論とは別に、「制約」という形で現状の動作を記述します。

制約(1)

  • デフォルト値を持たないpNやpKについては引数を省略できない
def foo(a);end
foo()       # 不可

def foo(a:);end
foo()       # 不可

制約(2)

  • pNDはグループ化されていなければならない
    • pNDとpNDの間に他のパラメータを置いて分断してはならない
def foo(a=1, b, c=1)       # 不可(pND, pN, pND)

def foo(a, b=1, c=1, d)    # 可(pNDの連続は許される)

制約(3)

  • pKやpKDは、positionパラメータ(pN、pND、pVA)より後でなければならない
def foo(key:, a, b=1)      # 不可(pK, pN, pD)

def foo(a, b=1, key:)       # 可(pN, pD, pK)

制約(4)

  • pVAはpNDより後に1つしか置けない
    • ただし、pNDがなければpVAをpNの前に1つだけ置ける
def foo(*a, *b)            # 不可(pVA, pVA)

def foo(*ary, a, b=1)      # 不可(pVA, pN, pD)
def foo(a, b=1, *ary)      # 可(pN, pD, pVA)

def foo(*a, b)             # 可(pVA, pN)

補足: 制約(4)の挙動について

このことから、pVAが前にあると、まず後ろのpNに引数を割り当て、残った引数が配列としてpVAに割り当てられると考えられます。

def foo(*a, b, c, d)       # pVAが前にあると引数の値が後ろのpNから1つずつ割り当てられる
  puts "a: #{a}"
  puts "b: #{b}"
  puts "c: #{c}"
  puts "d: #{d}"
end

foo(1, 2, 3, 4, 5, 6, 7)
# a: [1, 2, 3, 4]
# b: 5
# c: 6
# d: 7

補足: pVAの挙動

ドキュメントによれば↓、pVA(*)は引数で任意の値(注: 原文ではarguments)を受け取ると記述されています。これはあくまでpKやpKDがない場合の挙動です(制約7を参照)。


ドキュメントより

制約(5)

  • pVH(**)がない場合、存在しないpKやpKDには引数からキーバリューを渡せない
def foo(a:);end
foo(b:)  # 不可
  • pVH(**)があれば、pKやpKDがなくても引数のキーバリューはそこに吸い込まれる(他にも渡す方法あり)
def foo(a:, **b)
  puts "a: #{a}"
  puts "b: #{b}"
end

foo(a:1, x: 99, y: 88)
# a: 1
# b: {:x=>99, :y=>88}

制約(6)

  • pVH(**)の引数は省略可能だが、渡せるものはキーバリューのみ
    • 省略すると空ハッシュ{}が入る
    • これは事実上型を制約している
def foo(a:, **b)
  puts "a: #{a}"
  puts "b: #{b}"
end

foo(a:1)
# a: 1
# b: {}

foo(a:1, 99)   #=> SyntaxError: unexpected ')', expecting =>

Rubyの構文には型制約がないのが基本ですが、pVHは数少ない例外ということになります。

制約(7)その1

pVAの挙動は、pK/pKDがあるかどうかで異なる

※ここでの「キーバリュー」はすべて、変数に入っていないキーバリューリテラル({x: 99}など)であることが前提です。

  • pKやpKDがない場合: 引数の個数や種類にかかわらず制約(4)に沿ってpVAに配列として渡される(動作A)
  • pKやpKDがある場合: 引数のキーバリューは位置にかかわらずpVAには渡されなくなる
    • 引数にキーバリューが1つもなければ、pKDではデフォルト値が使われ(動作B)、pKではエラー(動作C)
    • 引数にあるキーバリューがすべてpKやpKDに該当する場合も動作Cと同じ(動作D)
    • 引数にpKやpKDに該当しないキーバリューが1つでもあると、pVAには渡されず、不明なキーとみなされる(動作E)
# 動作A
def foo(*a)
  puts "a: #{a}"
end

foo(*[1, 2, 3])
# a: [1, 2, 3]

foo(1, x:99)
# a: [1, {:x=>99}]
# 動作B
def foo(*a, b:1, c: 2)
  puts "a: #{a}"
  puts "b: #{b}"
  puts "c: #{c}"
end

foo(1, 2, 3, 4, 5)
# a: [1, 2, 3, 4, 5]
# b: 1
# c: 1
# 動作C
def foo(*a, b:, c: )
  puts "a: #{a}"
  puts "b: #{b}"
  puts "c: #{c}"
end

foo(1, 2, 3, 4, 5) #=> ArgumentError: missing keywords: b, c
# 動作D
def foo(*a, b:1, c: )
  puts "a: #{a}"
  puts "b: #{b}"
  puts "c: #{c}"
end

foo(1, 2, 3, 4, 5, c:99, b:88)
# a: [1, 2, 3, 4, 5]
# b: 88
# c: 99
# 動作E
def foo(*a, b:1)
  puts "a: #{a}"
  puts "b: #{b}"
end

foo(1, x:99)  # pKDがあるとx:99がaに吸い込まれてくれない
# ArgumentError: unknown keyword: x

制約(7)その2

  • 制約(7)その1は、実はpNDとキーバリューについても同じようなことが起きる(ドキュメントには見当たらなかった)
    • pKやpKDがない場合、引数でキーバリューをいくつ渡されてもpNDにハッシュとして渡される
    • pkやpKDがある場合、pKやpKDに該当しないキーバリューはpNDに渡されず、不明なキーとみなされる
def foo(a={})
  puts "a: #{a}"
end

foo(x:1, y:2)     #普通の動作
# a: {:x=>1, :y=>2}

foo(1, x:99)
# a: [1, {:x=>99}]
def foo(a={}, b:1)
  puts "a: #{a}"
  puts "b: #{b}"
end

foo(x:99)  # pKDがあるとx:99がa={}に吸い込まれてくれない
# ArgumentError: unknown keyword: x

foo(x:99, b:1)  # キーワードを指定しても同じ
# ArgumentError: unknown keyword: x

補足: 制約(7)の挙動について

ドキュメントによれば↓、pVH(**)は任意のキーバリュー(注: 原文ではkeyword arguments)を受け取ると記述されています。

このことと制約(7)その1とその2を合わせると、pKやpKDが存在する場合、引数のキーバリューをpK/pKD/pVHだけに絞って振り分ける処理が優先されると推測できます。その意味で、pK/pKD/pVHに割り当てられる引数のキーバリュー群は、振り分け時に1かたまりのグループとして扱われると推測します。

制約(8)

  • pVHは、pN/pND/pVA/pK/pKDパラメータより後、pBの前に1つしか置けない
def foo(**a, **b)           # 不可
def foo(**b, a)             # 不可

伊藤淳一「プロを目指す人のためのRuby入門」より

制約(9)

  • pVAやpVHにはデフォルト値を指定できない
def foo(*ary=[1, 2])           # 不可
def foo(*ary:[1, 2])           # 不可
def foo(**opt={key: 1})        # 不可
def foo(**opt:{key: 1})        # 不可

制約(10)

  • pBはパラメータの末尾に1つしか置けない
def foo(&block, a)     # 不可
def foo(&block1, &block2)     # 不可

pBは&という記号で明確に区別されることもあり、比較的問題になりにくいと思われます。

補足: パラメータの個数

ここでパラメータの個数について上の制約をまとめておきます。

  • いくつでも置ける: pN、pND、pK、pKD
  • 1つしか置けない: pVA、pVH、pB

制約(11)

  • パラメータと引数の個数が合っている場合、pN/pND/pVAについては引数が順序通りパラメータに渡される
    • pK/pKD/pVHについてはその範囲においてパラメータ/引数ともに順序の制約はない
def foo(a, b, c=1, d=2, *e)
  puts "a: #{a}"
  puts "b: #{b}"
  puts "c: #{c}"
  puts "d: #{d}"
  puts "e: #{e}"
end

foo(99, 88, 77, 66, 55)
# a: 99
# b: 88
# c: 77
# d: 66
# e: [55]

制約(12)

  • (暗黙?)引数が不足する場合、デフォルト値を持たないパラメータ(pND)に優先的に引数が割り当てられる
    • パラメータ/引数の順序の適用はそれより優先順位が低くなる
def foo(a, b=1, c=2, d)
  puts "a: #{a}, b: #{b}, c: #{c}, d: #{d}"
end

foo 88, 99  # aとdに割り振られ、bとcではデフォルト値が使われる
# a: 88, b: 1, c: 2, d: 99

補足: 制約(12)の挙動について

上述の通り、pNとpNDが入り交じると、引数で渡される値の順序が記述どおりにならないことがあります。その意味でも、pNを先に、pNDをその次に記述する方が、引数の値の順序が変わらずに済むので好ましいと言えます。

def foo(a, b, c=3, d=4)  # これなら順序は変わらない
  puts "a: #{a}, b: #{b}, c: #{c}, d: #{d}"
end

foo 88, 99
# a: 88, b: 99, c: 3, d: 4

おまけ: あまり知られてない?Rubyのパラメータ

その(1): 引数を一切受け取らない*

  • パラメータを*のみにすると、引数を明示的にすべて無視する
def foo(*)
end

foo(1, 2, 3, 4)  # 引数はすべて捨てられる

継承した不要なメソッドを活かしたまま引数を無効にする場合などに使われるようです。

その(2): 配列の分解(array decomposition)

  • 個別のパラメータをさらに丸かっこ () で囲むと、そこで受け取った配列を展開してかっこ内のパラメータに渡せる
def my_method(a, (b, c), d)
  p a: a, b: b, c: c, d: d
end

# 引数の2は配列展開としてbとcに割り当てられるのでcはnilになる
my_method(1, 2, 3) #=> {:a=>1, :b=>2, :c=>nil, :d=>3}

配列の分解はたまに使われることがあるようです。

パラメータと引数の望ましい順序

冒頭の図を再録します。

後述する制約をクリアしつつ混乱を避けるために、原則としてパラメータの種類は上のグルーピングの順序を守り、それに沿って引数を渡すのが望ましいと言えます。しかしこれはあくまでconventionというか紳士協定であり、制約の範囲内であればこの限りではありません。オプションハッシュを2種類受け取れるようにopt={}**hashを両方使うAPIもあるほどです(望ましくはありませんが)。

というのも、Rubyでは(pVH **を除いて)パラメータに渡す引数の型については制約をかけないからです。たとえばpNやpKにハッシュや配列を渡しても別に構いません。主にこのことを指して「Rubyには型制約がない」と言われます。

def foo(a)
  puts a
end

foo([1, 2, 3])
# 1
# 2
# 3

foo({key1: 1, key2: 2})
#=> {:key1=>1, :key2=>2}

大江戸Ruby会議で問題になった制約(7)

ここまでまとめて、自分としてはやっと冒頭のスライド「キーワード引数の現状と将来構想」の取っ掛かりができたように思います。詳しくはスライドに譲りますが、現在主に問題になっている以下の2つは、上述の制約(7)から来ています。



キーワード引数の現状と将来構想より

本記事では触れるだけにとどめますが、BPS社内勉強会で発表したときにmorimorihogeさんから「パラメータや引数の問題はdefine_methodpublic_sendなどが絡むと原因の特定がさらに厄介になる可能性がある」という指摘をもらいました。メタプログラミングや委譲での問題、考えるのも恐ろしい😱。

最後に

本記事では、メソッド定義側のパラメータを中心に制約を追ってみました。同スライドの「引数のキーバリューがリテラルかどうかで挙動が違う」など、引数側にもさまざまな制約や挙動(やバグ)がありますが、引数側の挙動まで同じ記事に盛り込むと組み合わせが爆発して煩雑になるので、本記事ではパラメータ中心に記述し、引数については最小限にとどめました。

不足や誤りがありましたら@hachi8833までお知らせください。

結論は「Rubyのパラメータや引数は(私にはまだまだ)難しい!」😭です。

キーワード引数の問題は、何らかの形でのbreaking changesが避けられなさそうな流れですが、大江戸Ruby会議では少なくとも3.0まで(=次の東京オリンピックの年)には修正したい、2.6あたりでwarningを出すようにしたいという意向が示されました。問題に取り組んでいるRubyコミッターの皆さまの苦労が偲ばれました🙇。ライブラリやフレームワークの作者も、どんな引数が今後増設されるかわからないのでつくづく大変と感じます🙇。

なお以下の記事にも書きましたが、先頃Rubocopのスタイルガイドが更新され、キーワードパラメータ(キーワード引数)が推奨、オプション引数やデフォルト値付き引数が非推奨という流れになりつつあります。

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

参考

Ruby 2.0.0リリース! - キーワード引数を使ってみよう

Ruby 2.1.0リリース!注目の新機能を見てみましょう


CONTACT

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