Proof Summit 2018 で発表した。
mameさんにRubyのキーワード引数の問題を説明してもらった。
少し議論したのだが、じつはキーワード引数をあまり使っていなくて 全体的な仕様を把握していなかったので、 現時点の parse.y から、仮引数と実引数のsyntaxを抜粋してみた。 実際にはだいたいの部分が省略可能で、そのために省略形の規則がたくさんあるのだが、 煩雑なので省略していないものだけにしてある。
仮引数:
f_args : f_arg ',' f_optarg ',' f_rest_arg ',' f_arg opt_args_tail f_arg : f_arg_item | f_arg ',' f_arg_item f_arg_item : f_arg_asgn | tLPAREN f_margs rparen f_arg_asgn : f_norm_arg f_optarg : f_opt | f_optarg ',' f_opt f_opt : f_arg_asgn '=' arg_value f_arg_asgn : f_norm_arg f_norm_arg : tIDENTIFIER f_rest_arg : restarg_mark tIDENTIFIER restarg_mark : '*' | tSTAR opt_args_tail : ',' args_tail args_tail : f_kwarg ',' f_kwrest opt_f_block_arg f_kwarg : f_kw | f_kwarg ',' f_kw f_kw : f_label arg_value | f_label f_label : tLABEL f_kwrest : kwrest_mark tIDENTIFIER kwrest_mark : tPOW | tDSTAR arg_value : arg
ここで、tIDENTIFIER は識別子、tSTAR は *、tLABEL は foo: みたいに識別子にコロンがついたもの、 tPOW と tDSTAR は ** である。
実引数:
call_args : args ',' assocs opt_block_arg args : arg_value | tSTAR arg_value | args ',' arg_value | args ',' tSTAR arg_value assocs : assoc | assocs ',' assoc assoc : arg_value tASSOC arg_value | tLABEL arg_value | tSTRING_BEG string_contents tLABEL_END arg_value | tDSTAR arg_value arg_value : arg
ここで tSTAR は * であり、tASSOC は => である。 また tSTRING_BEG string_contents tLABEL_END は文字列構文の後にコロンをつけたもので、これは "foo#{1+1}": みたいに式も書ける。
今回はキーワード引数 と positional argument (位置引数, ふつうの引数) の 関係だけに興味があるので、配列の分解の tLPAREN f_margs rparen と ブロック引数の opt_f_block_arg は除去し、 ある程度展開して簡単にしよう。
仮引数:
f_args : f_arg ',' f_optarg ',' f_rest_arg ',' f_arg ',' f_kwarg ',' f_kwrest f_arg : tIDENTIFIER | f_arg ',' tIDENTIFIER f_optarg : f_opt | f_optarg ',' f_opt f_opt : tIDENTIFIER '=' arg f_rest_arg : '*' tIDENTIFIER f_kwarg : f_kw | f_kwarg ',' f_kw f_kw : tLABEL arg_value | tLABEL f_kwrest : '**' tIDENTIFIER
同様に、ブロック引数の opt_block_arg は除去して、 あと arg | tSTAR arg は arg_or_star にくくりだして簡単にする。
実引数:
call_args : args ',' assocs args : arg_or_star | args ',' arg_or_star arg_or_star : arg | star_arg star_arg : tSTAR arg assocs : assoc | assocs ',' assoc assoc : arg tASSOC arg | tLABEL arg | tSTRING_BEG string_contents tLABEL_END arg | tDSTAR arg
で、今回は意味 (動作) が興味の対象なので、AST っぽくだいたいの記号は消して、 tSTRING_BEG string_contents tLABEL_ENDはstring_labelとまとめよう。 ('*' と '**' も消してしまった方が AST っぽいが、まぁなんとなくつけてある) あと、* で繰り返しを 0個以上っぽく表現することと、省略可能を示す ? をつけることで、 いろいろと省略可能な性質を復元してある。
仮引数:
f_args : f_arg f_optarg f_rest_arg? f_arg f_kwarg f_kwrest? f_arg : tIDENTIFIER* f_optarg : (tIDENTIFIER arg)* f_rest_arg : '*' tIDENTIFIER f_kwarg : f_kw* f_kw : tLABEL arg_value | tLABEL f_kwrest : '**' tIDENTIFIER
実引数:
call_args : args assocs args : (arg | star_arg)* star_arg : '*' arg assocs : assoc* assoc : arg '=>' arg | tLABEL arg | string_label arg | '**' arg
まぁ、こんなものか。
大江戸Ruby会議07で、キーワード引数についてさらに議論した。
昼休みにごはんを食べながら議論したり、 壇上で Ruby 3のキーワード引数について考える をネタに議論したり、 懇親会にいく路上や懇親会の中で議論したりした。
位置引数とキーワード引数を分離するには、 メソッド呼び出しで伝わる情報に、フラグをひとつ追加するだけでいいことに (ごはんを食べながら) 気がついたのは大きな進歩だったと思う。
現在のメソッド呼び出しで位置引数とキーワード引数はあわせて値の並びという情報が伝達される。 キーワード引数が渡された場合、キーがシンボルのHashオブジェクトとして表現され、値の並びの最後の値として追加される。
値の並びの最後にHashオブジェクトがあったときにそれがキーワード引数として渡されたものか、 そうでないのかが呼び出される側ではわからないのが問題なら、 フラグ (仮名 keyword_given) を伝達する情報として追加して、最後のHashオブジェクトがキーワード引数かどうかを 呼び出される側に伝えればいい。
そのフラグをどのように呼び出される側に伝えるか、 つまりメソッド定義の中でどう扱えるようにするかはいろいろな形がありうる。
フラグそのものをboolとして取得させることはせず、 mameさんの提案のようにメソッドの動作を変えてしまう実装の中で フラグを利用するというのもひとつの方法ではある。
また、フラグを参照する手段を提供するのもひとつの方法ではある。 (たとえばC言語では rb_keyword_given_p 関数, Rubyでは keyword_given? メソッド)
それらの折衷というのもひとつの方法ではある。 (たとえばC言語では後者、Rubyでは前者)
また両方というのもありうるだろう。 (rb_keyword_given_p, keyword_given? を提供し、さらに前者のようにメソッドの動作を変えてしまう)
フラグの利点は、フラグを伝達するメカニズムを提供するだけなら互換性を壊さないことである。 フラグを利用しない限り、動作は変わらないので互換性は壊れない。
互換性が壊れるのはいままでフラグを利用していなかった動作を、フラグを利用するように変更したときに起こる。
mameさんが説明している「キーワード拡張」の安全性を得るには、互換性を壊してフラグを利用する必要がある。 これはメソッドごとにもできるが、Rubyでふつうに定義したメソッドが安全になるには ふつうに定義したメソッドの動作をフラグを利用するように変える (メソッド呼び出しの機構を変える) 必要がある。
なおフラグの完全な互換性は、未来永劫に必要かどうかは議論の余地があるが、 フラグだけ先行して実装可能という点で非常に重要である。
さて、フラグを参照できるとした場合で、互換性が保たれている状況では移譲メソッドは以下のように書ける。
def f(*a) if keyword_given? g(*a, **a.pop) else g(*a) end end
そして、メソッドの動作も変わった後には移譲メソッドは以下のように書く必要があるだろう。
def f(*a, **kw) g(*a, **kw) end
うぅむ、メソッドの動作が変わる前後の両方で動くような移譲メソッドを書けるようにできないかな。
考えを吐き出すと次のアイデアが出てくるもので、思いついた。
とりあえず中間的な状態として、以下のふたつが同じ動作になるようにするのはどうか。
def f(*a) g(*a) end def f(*a, **kw) g(*a, **kw) end
これを実現するため、キーワード引数を **kw で受け取るときに、 位置引数からキーワード引数を取り出さなかった (最後の位置引数を削らなかった) ときには 仮引数の **kw が nil を束縛し、 また、実引数で **nil と与えた場合には位置引数にHashオブジェクトを追加しない、 という動作にする。 (なお、渡された位置引数の最後に空ハッシュ {} があった場合にはそれを削って kw に {} が束縛され、 実引数の **{} は位置引数の最後に {} を追加する) (あと、Hashオブジェクトの分割はやめる)
これにより、*a,**kw で受け取って *a,**kw で渡すという形で移譲メソッドが書けるようになり、 それは現在の *a で受け取って *a で渡すのと同等になる。
ただ、フラグを考えると、それらは移譲メソッドとして完璧ではなく、 フラグは保存されない。
フラグを保存するためには、以下のようにがんばる必要がある。
def f(*a, **kw) if keyword_given? g(*a, **kw) else g(*a, kw) end end
もし将来に位置引数とキーワード引数が混ざらなくなったときには、がんばらなくてもよくなる。
また、上記のようにがんばってしまったコードも問題なく動かすためには、 keyword_given? が常に真となるようにすればいいかもしれない。
まぁ、そんなに込み入った細工を理解してフォローできるひとは多くない気はする。
[latest]