Groovyの名前付き引数(パラメータ)について

Groovy + Spockでユニットテストを書いているが、Groovyの名前付き引数・名前付きパラメータを知らないメンバーがいた。

なかなか特殊な仕様なのでメモ。

環境

サクッとGroovy Web Consoleで確認。

println System.getProperty('java.version')
println GroovySystem.version

してみたところ、

1.8.0_181-google-v7
2.5.7

だった。

仕様

ドキュメントにはサラッとした記述しかない。

昔は Named argument と呼んでいたが、今見ると Named parameters と呼ばれている模様。

いつから変わったのかと思ったら、 v2.5.3までは Named argumentv2.5.4Named parameters になった模様。

コンストラクタの名前付き引数

デフォルトコンストラクタが存在するか、第1引数にMapを取るコンストラクタが存在する場合、フィード名をキーとしたMapを渡すことで、名前付き引数として扱える。

また、GroovyでのMapリテラル[key: value] だが、名前付き引数呼び出し時であれば、 [] で囲む必要がなくなる。

// デフォルトコンストラクタの場合
@groovy.transform.ToString
class ExampleClass1 {
  Integer id
  String name
}

assert new ExampleClass1().toString() == 'ExampleClass1(null, null)'
assert new ExampleClass1(name: 'name').toString() == 'ExampleClass1(null, name)'
assert new ExampleClass1(id: 1).toString() == 'ExampleClass1(1, null)'
assert new ExampleClass1(name: 'name', id: 1).toString() == 'ExampleClass1(1, name)'

// Mapを引数に取るコンストラクタの場合
@groovy.transform.ToString
class ExampleClass2 {
  Integer id
  String name

  // デフォルト値として空のMapを指定するか、Nullセーフ演算子を使わないと、引数省略時にNullPointerExceptionが発生する
  ExampleClass2(Map parameters) {
    this.id = parameters?.id
    this.name = parameters?.name
  }
}

assert new ExampleClass2().toString() == 'ExampleClass2(null, null)'
assert new ExampleClass2(id: 1, name: 'name').toString() == 'ExampleClass2(1, name)'

メソッドやクロージャの名前付き引数

メソッドやクロージャの場合も、第1引数にMapを取れば名前付き引数となる。

名前付き引数呼び出し時であれば、 [] が省略できるのもコンストラクタと同様。

// デフォルト値として空のMapを指定するパターン
def ExampleMethod(Map parameters = [:]) {
  return [parameters.id, parameters.name]
}

assert ExampleMethod() == [null, null]
assert ExampleMethod(id: 1) == [1, null]
assert ExampleMethod(name: 'name') == [null, 'name']
assert ExampleMethod(id: 1, name: 'name') == [1, 'name']

注意点

第1引数にMapを取る場合、デフォルト値として空のMapを指定しても、明示的に null を渡されると参照時に NullPointerException が発生する。

null が渡される可能性があれば、Mapの値の参照は、常にNullセーフ演算子 ?. で行っておくのが無難。

名前付き引数のデフォルト値の設定

第1引数にMapを取る場合、デフォルト値としてMapを設定し、そこにkeyとvalueを指定すると、引数省略時はそのMapおよびkeyとvalueが使用される。

だが、引数の一部を指定した場合、未指定の引数はnullになる。

@groovy.transform.ToString
class ExampleClass {
  Integer id
  String name

  ExampleClass(Map parameters = [id: 0, name: 'default']) {
    this.id = parameters.id
    this.name = parameters.name
  }
}

// 引数省略時はうまくいったように見える
assert new ExampleClass().toString() == 'ExampleClass(0, default)'

// 引数をすべて指定した場合も問題なし
assert new ExampleClass(id: 1, name: 'name').toString() == 'ExampleClass(1, name)'

// 引数の一部を指定した場合、未指定の引数はnullになる
assert new ExampleClass(id: 1).toString() == 'ExampleClass(1, null)'
assert new ExampleClass(name: 'name').toString() == 'ExampleClass(null, name)'

引数の一部を指定した場合でも、未指定の引数にデフォルト値を設定したい場合、コンストラクタやメソッド内でデフォルト値を設定したMapを用意し、そこに第1引数のMapをputAllするのがよさそう。

def ExampleMethod(Map parameters) {
  def params = [id: 0, name: 'default'] + (parameters ?: [:])
  return [params.id, params.name]
}

assert ExampleMethod() == [0, 'default']
assert ExampleMethod(id: 1) == [1, 'default']
assert ExampleMethod(name: 'name') == [0, 'name']
assert ExampleMethod(id: 1, name: 'name') == [1, 'name']

名前付き引数と通常の引数の組み合わせ

第2引数以降に、通常の変数を宣言することも可能。

その場合、 key: value 形式で指定した引数は第1引数のMapに、それ以外の引数は第2引数以降に指定順に渡される。

必須としたい引数は第2引数以降で宣言し、必須ではない引数は名前付き引数にする、といったことが可能。

ただし、第1引数のMapにはデフォルト値を指定しておかないと、名前付き引数が1つも渡されていない場合、 MissingMethodException が発生する。

Groovyの仕様では、引数のデフォルト値を指定すると、通常はそれ以降の引数にもデフォルト値を設定する必要があるが、この名前付き引数と通常の引数の組み合わせの場合、第2引数以降はすべて必須パラメータとなり、デフォルト値を指定できない(指定しても、省略してメソッドを呼び出せない)模様。

そのため、第2引数以降を省略したければ、メソッドをオーバーロードする必要がある。

def ExampleMethod(Map parameters = [:], int id, String name) {
  return [id, name, parameters.address, parameters.email]
}

def ExampleMethod(Map parameters = [:], int id) {
  return ExampleMethod(parameters, id, 'default')
}

assert ExampleMethod(1) == [1, 'default', null, null]
assert ExampleMethod(1, 'name') == [1, 'name', null, null]
assert ExampleMethod(address: 'tokyo', email: 'mail@example.com', 1) ==[1, 'default', 'tokyo', 'mail@example.com']

// 名前付き引数の宣言順は不定
assert ExampleMethod(
    email: 'mail@example.com',
    1,
    'name',
    address: 'tokyo'
) == [1, 'name', 'tokyo', 'mail@example.com']

ここの挙動について、ドキュメントが見つけられなかった。動きから逆算しているので、仕様とは説明が異なるかもしれない。

振り返り

いつの間にやら「名前付き引数と通常の引数の組み合わせ」を使っていたが、どういった経緯でこの方法を知ったかは思い出せない。

機能名としては「名前付き引数」で通したが、ドキュメントの英語からだと「名前付きパラメータ」が正式名称になるのかな?