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

2018/09/13 Yusuke Endoh


アジェンダ

  • 背景:キーワード引数とは
  • 問題:キーワード引数拡張が危険
  • 手法:キーワード引数拡張を安全にする
  • 課題:移行パスについて

キーワード引数とは(1)

メソッドの引数に名前を付ける機能

def foo(x: 1, y: 2, z: 3)
  p [x, y, z]
end

foo(y: "Y", x: "X") #=> ["X", "Y", 3]

キーワード引数とは(2)

普通の引数と混在できる

def foo(name, age=42, x:1, y:2, z:3)
  p [name, age, x, y, z]
end

foo("mame", 36, y: "Y", x: "X")
  #=> ["mame", 36, "X", "Y", 3]

キーワード引数とは(3)

最後の引数にハッシュを渡すのと(ほぼ)同じ

def foo(h)
  p h
end

foo(y: "Y", x: "X") #=> {:y=>"Y", :x=>"X"}

↑ハッシュとして受け取れる

def foo(x:1, y:2, z:3)
  p [x, y, z]
end

hash = { y: "Y", x: "X" }
foo(hash) #=> ["X", "Y", 3]

↑ハッシュとして渡せる

(この仕様は歴史的経緯)


キーワード引数とは(4)

double splat ** で展開的なことができる

def foo(x: 1, **h)
  p [x, h]
end

foo()       #=> [1, {}]
foo(x: "X") #=> ["X", {}]
foo(y: "Y") #=> [1, {:y=>"Y"}]

hash = {x: "X"}
foo(y: "Y", **hash) #=> ["X", {:y=>"Y"}]

クイズ

def foo(opt=42, **kw)
  p [opt, kw]
end

foo({}, **{}) #=> ???

答え:

foo({}, **{}) #=> [42, {}]

理由:

foo({})       #=> [42, {}] # キーワード引数扱いになる
foo({}, **{}) #=> [42, {}] # foo({})と同じなので
foo({},   {}) #=> [{}, {}] # [{}, {}]にする唯一の手段

ただのバグ?

リテラルかどうかで挙動が違う

def foo(opt=42, **kw)
  p [opt, kw]
end

foo({}, **{})         #=> [42, {}]

empty_hash = {}
foo({}, **empty_hash) #=> [{}, {}]

どちらに合わせるかべきかは
議論の余地がある(最後に説明)


アジェンダ

  • 背景:キーワード引数とは
  • 問題:キーワード引数拡張が危険
  • 手法:キーワード引数拡張を安全にする
  • 課題:移行パスについて

キーワード引数に何を期待するか

安心してメソッドを拡張できること

def foo(...)
end

foo(...)

def foo(..., option1: false)
end

foo(...)                # 従来通りに動く
foo(..., option1: true) # 拡張したモードで動く

この書き換えが常にうまく行ってほしい


裏切られる例1

pみたいなメソッドを書いた

def my_p(*args)
  args.each {|v| puts v.inspect }
end

my_p([1, 2, 3]) #=> [1, 2, 3]
my_p(k: 1)      #=> {:k=>1}

出力先を制御できるようにしよう

def my_p(*args, out: $stdout)
  args.each {|v| out.puts v.inspect }
end

my_p([1, 2, 3]) #=> [1, 2, 3]
my_p(k: 1)      #=> unknown key: k  !!!

既存の呼び出しが死んだ!


裏切られる例2

HTMLの要素を作るメソッドを書いた

def create_element(name, attrs={})
end

create_element("a", href: "URL")

子要素のリストを受け取れるようにしよう

def create_element(name, attrs={}, children: elements)
end

create_element("a", href: "URL") #=> unknown key: href  !!!

また死んだ!


『問題』のまとめ

メソッドがキーワード引数を取るようになると
既存コードが死ぬ

  • 実際にバグ報告が多数来ている
  • 実際にAPI拡張できなくて困っている
    • Thread.new(stack_size: 100000)
    • Struct.new(keyword_init: true)
  • 今の意味は複雑すぎる
  • 型シグネチャで表現できない

WDYT?


アジェンダ

  • 背景:キーワード引数とは
  • 問題:キーワード引数拡張が危険
  • 手法:キーワード引数拡張を安全にする
  • 課題:移行パスについて

いくつかの解決案

  1. 何もしない
  2. 引数の混在を禁止する
  3. キーワード引数を完全分離する
  4. その他、素晴らしいアイデア

1. 何もしない

現状のまま放置

  • メリット:
    • 完全互換
  • デメリット:
    • 『問題』は解決せず、広がっていく

2. 引数の混在を禁止する

rest/optional引数とキーワード引数の両方を
受け取る場合だけ問題が起きる(たぶん)

def foo(*args, **kw) # エラー(または警告)
end
  • メリット:
    • 『問題』に気づける
    • 警告だけなら互換性は保たれる
  • デメリット:
    • 可変長引数メソッドはキーワード拡張できない

3. キーワード引数を完全分離する

最後の引数とキーワード引数の相互変換をやめる

def foo(*args, **kw)
  p [args, kw]
end

foo({ k: 1 }) #=> [[{:k=>1}], {     }]
foo(  k: 1  ) #=> [[{     }], {:k=>1}]
  • メリット:
    • 『問題』がそもそも起きえない
    • 可変長引数メソッドも拡張できる
  • デメリット:
    • 互換性はまあまあ厳しい?

互換性は厳しいのか?

  • 実験
    • 相互変換したら警告を出すパッチを作った
    • 警告有効でmake test-allを走らせる
    • 警告箇所を修正していく
  • 感想
    • 私見では、大体straightforward
      • ** を付けてキーワードに寄せるだけ
      • 1.9のencodingに比べれば全然余裕
    • 一部困難はあった(後で述べる)

Jeremyの妥協案

相互変換の片方だけをやめる

  • 「ハッシュ引数→キーワード」は禁止する
def foo(key: 1); p key; end
foo({}) #=> wrong number of arguments
  • 「キーワード→ハッシュ引数」は維持する
def foo(a); p a; end
foo(k: 1) #=> OK: {:k=>1}
  • 根拠
    • 後者はRuby 1.6から動いていた
    • 後者の挙動についてのバグ報告はない(当然)

Jeremy案の問題点

def foo(a)
  p a
end
foo(k: 1) #=> OK: {:k=>1}

↑を許すと、fooをキーワード拡張できない

def foo(a, output: $stdout)
  $stdout.puts a.inspect
end
foo(k: 1) #=> unknown keyword: k
  • 元コードを禁止するしか無い(と思う)
  • 目先の互換性問題 vs. 将来の互換性問題

手法のまとめ

キーワード引数を完全分離したい

  • 目先の互換性問題は、対処容易
    • (個人の感想)
  • 将来的な互換性問題は、広がっていく

アジェンダ

  • 背景:キーワード引数とは
  • 問題:キーワード引数拡張が危険
  • 手法:キーワード引数拡張を安全にする
  • 課題:移行パスについて

分離する場合の移行パス

  • 相互変換が起きたら警告する
    • 2.6 or 2.7
    • (Cメソッドはrest引数を受け取る問題)
  • 推奨する書き換え方法を決める
    • 問題1: 既存API
    • 問題2: 委譲のコード

問題1: 既存API(1)

ERB#result_with_hashは両方↓動いてほしい

erb.result_with_hash(k:1)

hash = {k:1}
erb.result_with_hash(hash)

Sequelのwhereは両方↓動いてほしい(らしい)

where(k: 1)   #=> {:k=>1}
where("k"=>1) #=> {"k"=>1}

Kernel#spawnはいろいろ受け取る↓

spawn(..., out: File::NULL, 10=>11)

こういう既存APIを定義したいとき、どうする?


問題1: 既存API(2)

  • 解決案:両方受け取って頑張る
    • 微妙な非互換はある
def result_with_hash(h1={}, **h2)
  h = h1.merge(h2)
  ...
end

別案:互換APIを定義する方法を用意する?

define_last_hash_method(:result_with_hash) do |h|
  ...
end

問題1: 既存API(3)

spawnのように混ぜて受け取るやつ

def foo(h1, **h2)
  p [h1, h2]
end

foo(key: 1, "str" => 2) #=> [{"str"=>2, :key=>1}, {}]
foo("str" => 2, key: 1) #=> [{"str"=>2, :key=>1}, {}]

def foo(h1={}, **h2)
  p [h1, h2]
end

foo(key: 1, "str" => 2) #=> [{"str"=>2}, {:key=>1}]
foo("str" => 2, key: 1) #=> [{"str"=>2}, {:key=>1}]

↑は2.5での挙動、trunkではエラー

(移行パス的には、2.5の挙動がよいかも……)


問題2: 委譲のコード(1)

def forward(*args, &blk)
  target(*args, &blk)
end

↑のコードは動かなくなる、どうする?


問題2: 委譲のコード(2)

委譲の記法を入れる?

def forward(...)
  target(...)
end

悪くないと思う


問題2: 委譲のコード(3)

キーワード引数も明示的に委譲する?

def forward(*args, **kw, &blk)
  target(*args, **kw, &blk)
end
  • 実は、↑は2.5で動かない場合がある
def target(*args)
  p args
end

def forward(*args, **kw, &blk)
  target(*args, **kw, &blk)
end

target(1, 2, 3)  #=> [1, 2, 3]
forward(1, 2, 3) #=> [1, 2, 3, {}]

foo(**{}) の意味をいい感じにする必要がある


foo(**{})の2.5での意味(1)

↓リテラルと非リテラルで意味が違う

def foo(*args)
  p args
end

foo(**{}) #=> []

empty_hash = {}
foo(**empty_hash) #=> [{}]

この挙動はバグで異論ないと思う

どちらに合わせるべきか?


foo(**{})の2.5での意味(2)

  • 委譲を考えると、**empty_hashは消えてほしい
  • 次の挙動を考えると、**{}は消えないでほしい
def foo(*args)
  p args.pop
end
foo(**{k1: 1, k2: 2, k3: 3}) #=> {:k1=>1, :k2=>2, :k3=>3}
foo(**{k1: 1, k2: 2})        #=> {:k1=>1, :k2=>2}
foo(**{k1: 1})               #=> {:k1=>1}
foo(**{})                    #=> nil !?

foo(**{})の2.5での意味(3)

  • 次の挙動を考えると、**{}は消えないでほしい
def foo(opt=42, **kw)
  p [opt, kw]
end

foo({}      ) #=> actual: [42, {}] as expected
foo({}, **{}) #=> actual: [42, {}], expected: [{}, {}]
foo({},   {}) #=> actual: [{}, {}] as expected

Marc-Andreの案

**empty_hash**{}も消す方に揃えたい

詳しい意味の素案は次ページ


  1. When calling method(a, b, last), then last will be promoted to a keyword argument if possible, i.e. if:
    a) method takes keyword arguments (any of key:, key: val, or **options in signature)
    b) and all mandatory positional arguments of method are provided (here by a and b)
    c) and last is hash-like (i.e. responds_to? :to_hash)
    d) and all keys of last.to_hash are symbols

Otherwise, last will remain a positional argument.


  1. When calling method(a, b, key: value) or method(a, b, **hash) or a combination of these, the keyword arguments (here {key: value} or hash) will be demoted to a positional argument if needed, i.e.
    a) if method does not accept keyword arguments
    b) and they are non empty

とてもややこしい


まとめ

キーワード引数の現状と将来案について色々語った

  • 我々はどこを目指すべきか

    • 現状のクソ仕様と心中する
    • 互換性を壊して成長していく
  • 個人的な所感

    • 現状の仕様は本当に複雑怪奇
    • 書き換えの互換性の問題はそこまで大きくない
    • キーワード拡張でハマる将来の互換性問題の方が大きい

その他

↓はキーワード引数のままでよいか?

foo(:key => 42)
Select a repo