Ruby 2.0.0 のキーワード引数

書いた人:遠藤侑介 (@mametter)

はじめに

Ruby 2.0.0 のリリース、おめでとうございます。

本稿では 2.0.0 の新機能の一つである、キーワード引数についてご紹介します。

一目でわかるキーワード引数

ログを出力する機能の例。

def log(msg, level: "ERROR", time: Time.now)
  puts "#{ time.ctime } [#{ level }] #{ msg }"
end
log("Hello!", level: "INFO")  #=> Mon Feb 18 01:46:22 2013 [INFO] Hello!

あまりに自然すぎて、何が新しいのかわからない?

説明

1.9 でも、呼び出し側のキーワード引数はできていました。

log("Hello!", level: "INFO")

このキーワード引数は { :level => "INFO" } というハッシュとして当該メソッドに渡されます。 受け取り側でこのハッシュを分解する処理が必要でした。

def log(msg, opt = {})
  level = opt[:level] || "ERROR"
  time  = opt[:time]  || Time.now
  puts "#{ time.ctime } [#{ level }] #{ msg }"
end

これだけならそんなに複雑じゃないんですが、細かいことを考え出すと段々面倒になります。 例えば

  • 可変長引数も組み合わせたい
  • 知らない引数が渡された時はわかりやすく例外を出してほしい
  • nil を渡したいこともあるんです

など。コードとしてはこんな感じになります。

def log(*msgs)
  opt = msgs.last.is_a?(Hash) ? msgs.pop : {}
  level = opt.key?(:level) ? opt.delete(:level) : "ERROR"
  time  = opt.key?(:time ) ? opt.delete(:time ) : Time.now
  raise "unknown keyword: #{ opt.keys.first }" if !opt.empty?
  msgs.each {|msg| puts "#{ time.ctime } [#{ level }] #{ msg }" }
end

いちいちこういうの書くのは面倒くさいよね、というだけの動機で生まれたのが本機能です。 本機能を使えば、冒頭の通り、極めて簡潔にキーワード引数を分解できます。

def log(msg, level: "ERROR", time: Time.now)
  puts "#{ time.ctime } [#{ level }] #{ msg }"
end

もう少し詳しく

このメソッドは引数無しで呼び出すと、level: "ERROR", time: Time.now が渡されたのと同じように動きます。

log("Hello!")                                  #=> Mon Feb 18 01:46:22 2013 [ERROR] Hello!
log("Hello!", level: "ERROR", time: Time.now)  #=> Mon Feb 18 01:46:22 2013 [ERROR] Hello!

キーワード引数の順番は順不同です。(ただし、他の種類の引数と順番を変えることはできません)

log("Hello!", time: Time.now, level: "ERROR")  #=> Mon Feb 18 01:46:22 2013 [ERROR] Hello!
log(level: "ERROR", time: Time.now, "Hello!")  # これはダメ

片方だけ指定することもできます。

log("Hello!", level: "INFO")  #=> Mon Feb 18 01:46:22 2013 [INFO] Hello!

知らない引数を与えると例外を投げてくれます。

log("Hello!", date: Time.new)  #=> unknown keyword: date

例外にしてほしくないんだ!という人は、** 引数で残りのハッシュを明示的に受け取ればいいです。

def log(msg, level: "ERROR", time: Time.now, **kwrest)
  puts "#{ time.ctime } [#{ level }] #{ msg }"
end

log("Hello!", date: Time.now)  #=> Mon Feb 18 01:46:22 2013 [ERROR] Hello!

また、オプション引数や可変長引数などと組み合わせることも可能です。(極端なのはあまりお勧めしませんが)

def f(a, b, c, m = 1, n = 1, *rest, x, y, z, k: 1, **kwrest, &blk)
  puts "a: %p" % a
  puts "b: %p" % b
  puts "c: %p" % c
  puts "m: %p" % m
  puts "n: %p" % n
  puts "rest: %p" % rest
  puts "x: %p" % x
  puts "y: %p" % y
  puts "z: %p" % z
  puts "k: %p" % k
  puts "kwrest: %p" % kwrest
  puts "blk: %p" % blk
end

f("a", "b", "c", 2, 3, "foo", "bar", "baz", "x", "y", "z", k: 42, u: "unknown") { }
  #=> a: "a"
      b: "b"
      c: "c"
      m: 2
      n: 3
      rest: "foo"
      x: "x"
      y: "y"
      z: "z"
      k: 42
      kwrest: {:u=>"unknown"}
      blk: #<Proc:0x007f7e7d8dd6c0@-:16>

制限

可変長引数でハッシュを渡す場合は要注意。 キーワード引数とみなされて最後の 1 個が消えてしまいます。

def foo(*args, k: 1)
  p args
end

args = [{}, {}, {}]

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

また、unknown keyword の例外を抑制する ** 引数の引数名は省略できません。

def foo(**)
end
foo(k: 1) #=> unknown keyword: k

(この挙動はどうなのかなあ、と書いてるうちに疑問に)*1

さらに、元々キーワード引数っぽいことしていた関数を本機能で書き換えようという際の注意点ですが、キーワードに Ruby の予約語を使うことはできません。

def foo(if: false)
end
foo(if: true)

↑は動くことは動くのですが、if というローカル変数にアクセスできないので読みだすことができません。こういう場合はやむを得ないので ** 変数を使ってください。

def foo(**kwrest)
  p kwrest[:if]
end
foo(if: true) #=> true

おわりに

2.0.0 では、キーワード引数を受け取るための仮引数の構文が追加されました。

実装はほぼ構文糖なので、実のところそれ自体は大した機能ではありませんが、 Ruby の文化としてキーワード引数が第一級になったという事実が重要かと思います。 今後キーワード引数を使った API を見かける機会が増え、 あなたの Ruby 生活に彩りを与えてくれると思っています。

筆者について

遠藤侑介。 Ruby コミッタ (アカウントは mame)。Ruby のテストカバレッジをあげたり、1.9.2 のリリースマネージャ補佐をやったり、2.0.0 のリリースマネージャをやったり。

*1 この挙動は修正されることになりました。いずれパッチレベルリリースがあると思います。https://bugs.ruby-lang.org/issues/7922