Groovy

Groovy 徹底解説 — 動的型付けJVM言語の仕組みと現実

目次

  1. Groovyとは何か — 位置づけと本記事の立場
  2. 実行環境 — groovy CLIが無くても動かせる
  3. Groovy truth — 真偽値の規則
  4. クロージャ — DSLが成立する仕組み
  5. GStringとString — 似て非なる2つの型
  6. 演算子 — Javaに無い記号の意味
  7. コレクション操作 — 標準ライブラリの拡張
  8. メタプログラミング — 実行時にクラスを書き換える
  9. 静的型付けへの回帰 — @CompileStatic@TypeChecked
  10. AST変換 — アノテーションがコードを生成する
  11. Javaとの相互運用
  12. 数値の意味論 — Javaと違う4つの規則
  13. Groovy 3 と 4 — Parrotパーサとパッケージ移動
  14. 性能の現実 — 測って分かること
  15. 実運用での位置づけ — SMPを実測する
  16. 落とし穴
  17. まとめ

1. Groovyとは何か — 位置づけと本記事の立場

1.1 3行で言うと

Groovyは、Javaの構文をほぼそのまま受け入れつつ、動的型付け・クロージャ・メタプログラミングを足したJVM言語である。

Javaのコードは、たいていそのままGroovyとして動く(構文の上位互換に近い)
    + 型宣言を省ける(def / var)
    + クロージャが第一級(DSLが書ける)
    + 実行時にクラスを書き換えられる(メタプログラミング)
    + セミコロン・括弧・return を省ける
    ─ そのぶん、誤りがコンパイル時に見つからない

「そのぶん」の部分が本記事の主題である。 便利さの正体を理解しないと、動いているのに意図と違うコードを書いてしまう。

1.2 本記事の立場 — 「なぜそう動くか」に寄せる

Groovyの入門記事は多いが、「Javaと違う挙動」を実測で示したものは少ない。 本記事は次の方針で書いている。

① 掲載するコンソール出力は、すべて実際に実行して得た値である
② 「Javaならこうなる」との対比を必ず添える
③ よくある誤解は、反証となる実測値で潰す
④ 便利な機能は、それが成立している仕組みまで降りる

特に第4章(クロージャ)と第3章(真偽値)は、Groovyという言語の設計の中心である。 この2つを理解すると、GradleやJenkinsのDSLが「なぜあの書き方で動くのか」が説明できるようになる。

1.3 姉妹記事との分担

本記事はGroovyを「言語として」扱う。ビルドツールの話はしない。

記事扱う範囲
本記事Groovyの言語仕様・実行時の挙動・メタプログラミング・性能
Gradle with Groovy-overview-ai_JP.mdGradleのライフサイクル、タスク、依存解決、マルチプロジェクト
Gradle with Groovy-get-started-ai_JP.mdGradleを実際に動かす手順書

重複を避けるため、Gradle側で詳述した内容は参照に留める。 逆に、Gradle記事が「言語の性質だから」と流した部分は本記事で掘る。

Gradle記事 §5.4「クロージャは常に真」        → 本記事 第3章(言語仕様としての真偽値)
Gradle記事 §16.3「GStringとStringの混同」    → 本記事 第5章(型の実体と == の実装)
Gradle記事 §5.2「クロージャのdelegate」      → 本記事 第4章(3つのスコープとresolveStrategy)

静的型付け言語との対比は、同階層の Kotlin / Java の記事を参照する。

1.4 いま学ぶ動機は何か

率直に言うと、Groovyを新規に選ぶ理由は減っている。

2000年代後半     Javaの冗長さへの回答として登場。Grails が牽引
2010年代前半     Gradle が採用し、ビルドスクリプトの言語として普及
2010年代後半     Kotlin が登場。静的型付け+簡潔さで新規採用を奪う
                Gradle も Kotlin DSL を公式サポート
2020年代         Groovy は「既存資産を読み書きするための言語」に軸足が移る

それでも学ぶ理由は3つある。

① Gradleスクリプトが読めないと困る。 既存のJavaプロジェクトのビルドスクリプトはGroovy DSLで書かれている。第15章で実測するが、ある大規模な決済アプリケーション群ではGroovy DSLのビルドスクリプトが717ファイルある一方、Kotlin DSLは10ファイルしかない。

② Jenkins Pipelineがある。 Jenkinsfile はGroovyである。CIの記述を読むために必要になる。

③ Spockがある。 JVM系で最も表現力の高いテストフレームワークはGroovyで書く。

逆に、次の目的でGroovyを新規採用する理由は薄い。

アプリケーションの本体を書く       → Kotlin / Java のほうが安全で速い
マイクロサービスを作る            → 起動時間の不利がある(第14.3節で実測)
型安全性が要件になる領域          → @CompileStatic を全面適用するなら Kotlin でよい

1.5 バージョンの現状

本記事はGroovy 3.0.24で検証している。 これはGradle 8.14.1が同梱するバージョンである(第2章)。

系列状況主な変化
2.x保守終了長く使われた。Gradle 6以前が同梱
3.0.x広く使われているParrotパーサ導入var、ラムダ、!in)。Gradle 7〜8が同梱
4.0.x現行groovy.util.* の一部を削除、sealedrecord、switch式
5.x開発中JDK 17+ が前提

Gradleを使う限り、実質的にGroovy 3.0.x が対象になる。 Gradleは同梱バージョンを固定しており、ビルドスクリプトで使える構文はそれに縛られる(第13章)。


2. 実行環境 — groovy CLIが無くても動かせる

2.1 検証環境

$ uname -m
arm64

$ /usr/libexec/java_home -V
Matching Java Virtual Machines (1):
    21.0.8.9.1 (x86_64, arm64) "Apple Inc." - "AppleJDK 21"

macOS 26 / Apple Silicon(M4 Max)/ AppleJDK 21.0.8 である。

2.2 groovy コマンドは入っていない

$ for c in groovy groovysh groovyc grape; do printf "%-9s " "$c"; command -v $c >/dev/null && echo あり || echo なし; done
groovy    なし
groovysh  なし
groovyc   なし
grape     なし

しかも公開ネットワークが遮断されているため、インストールできない。

repo1.maven.org      到達不可
dlcdn.apache.org     到達不可
groovy.apache.org    到達不可

brew install groovy も、Apacheのtarball取得も、SDKMANも使えない。

2.3 GradleがGroovyを同梱している

Gradleは自身のDSLを実行するためにGroovyを内包している。 これを直接使える。

$ D=$(ls -d ~/.gradle/wrapper/dists/gradle-8.14.1-bin/*/gradle-8.14.1)
$ ls "$D/lib" | grep -E '^groovy' | sed 's/-3.0.24.jar//'
groovy
groovy-ant
groovy-astbuilder
groovy-console
groovy-datetime
groovy-dateutil
groovy-docgenerator
groovy-groovydoc
groovy-json
groovy-nio
groovy-sql
groovy-swing
groovy-templates
groovy-test
groovy-xml

15モジュールが揃っている。 コアは groovy-3.0.24.jar で、groovy-json / groovy-xml などは別JARである。

2.4 実行する

$ cat > hello.groovy <<'EOF'
println "Groovy ${GroovySystem.version} / JVM ${System.getProperty('java.version')}"
EOF

$ java -cp "$D/lib/groovy-3.0.24.jar" groovy.ui.GroovyMain hello.groovy
Groovy 3.0.24 / JVM 21.0.8

groovy.ui.GroovyMain がスクリプトを実行するエントリポイントである。 コアJAR1つで動く。

ワンライナーは -e を使う。

$ java -cp "$D/lib/groovy-3.0.24.jar" groovy.ui.GroovyMain -e 'println 1+1'
2

JSONやXMLを使う場合はJARを足す。

$ java -cp "$D/lib/groovy-3.0.24.jar:$D/lib/groovy-json-3.0.24.jar" \
    groovy.ui.GroovyMain -e 'println groovy.json.JsonOutput.toJson([a:1])'
{"a":1}

$ ALL=$(ls "$D"/lib/groovy*.jar | tr '\n' ':')
$ java -cp "$ALL" groovy.ui.GroovyMain \
    -e 'println new groovy.xml.XmlSlurper().parseText("<a><b>v</b></a>").b'
v

エイリアスにしておくと楽である。

# ~/.zshrc
GROOVY_LIB="$HOME/.gradle/wrapper/dists/gradle-8.14.1-bin"/*/gradle-8.14.1/lib
alias groovy='java -cp "$(ls -d $GROOVY_LIB)/groovy-3.0.24.jar" groovy.ui.GroovyMain'
alias groovyall='java -cp "$(ls -d $GROOVY_LIB)/groovy"*.jar groovy.ui.GroovyMain'

2.5 対話シェルは動かない

$ echo 'println 1+1' | java -cp "$D/lib/groovy-3.0.24.jar:$D/lib/groovy-console-3.0.24.jar" \
    org.codehaus.groovy.tools.shell.Main
Error: Could not find or load main class org.codehaus.groovy.tools.shell.Main
Caused by: java.lang.ClassNotFoundException: org.codehaus.groovy.tools.shell.Main

groovysh の実装(groovy-groovysh モジュール)はGradleに同梱されていない。 JLineなどの依存も必要になるため、この環境では対話シェルは使えない。

代わりに -e とヒアドキュメントで済ませる。

$ java -cp "$GJ" groovy.ui.GroovyMain -e '
def x = [1,2,3]
println x.collect{ it * 2 }
'
[2, 4, 6]

本記事のコンソール出力はすべてこの方法で得たものである。 以降、$GJgroovy-3.0.24.jar のパスを指す。

2.6 Gradleのスクリプトから試す方法もある

言語機能を確かめるだけなら、Gradleのタスクの中で書いても同じである。

// build.gradle
tasks.register('probe') {
    doLast {
        def c = { false }
        println "closure truthy = ${c ? 'true' : 'false'}"
    }
}
$ gradle -q probe
closure truthy = true

ただしGradleのビルドスクリプトには Project がdelegateとして注入されているため、素のGroovyとは名前解決が違う。 言語の挙動を確かめるなら、素のスクリプトで実行するほうが混乱が少ない。


3. Groovy truth — 真偽値の規則

3.1 何が偽になるか

Groovyは boolean 以外のあらゆる値を条件式に書ける。 その変換規則を「Groovy truth」と呼ぶ。

def show(label, v) { printf("  %-26s %-9s %s%n", label, v ? 'true' : 'false', v?.getClass()?.name ?: '-') }
show('null',        null)
show('false',       false)
show('0',           0)
show("''",          '')
show('[]',          [])
show('[:]',         [:])
show('[0]',         [0])
show('new Object()', new Object())
def c = { false }
show('{ false }(クロージャ)', c)
show('Optional.empty()', Optional.empty())
show('new File("/nope")', new File("/nope"))
$ java -cp "$GJ" groovy.ui.GroovyMain truth.groovy
  null                       false     -
  false                      false     java.lang.Boolean
  0                          false     java.lang.Integer
  0.0                        false     java.math.BigDecimal
  ''                         false     java.lang.String
  "0"                        true      java.lang.String
  []                         false     java.util.ArrayList
  [:]                        false     java.util.LinkedHashMap
  [0]                        true      java.util.ArrayList
  new Object()               true      java.lang.Object
  { false }(クロージャ)           true      truth$_run_closure1
  Optional.empty()           false     java.util.Optional
  new File("/nope")          true      java.io.File
  0 as BigDecimal            false     java.math.BigDecimal
  java.util.regex matcher    false     java.util.regex.Matcher

偽になるものを整理する。

偽になる条件
null常に
Booleanfalse
数値(Integer / BigDecimal など)0 / 0.0
CharSequence空文字列
Collection
Map
Iterator / Enumeration次の要素が無い
Matcherマッチしない
Optionalempty()
それ以外のすべてのオブジェクト常に真

3.2 最後の行が最重要である

「それ以外のすべてのオブジェクトは真」— これが事故の源である。

  { false }(クロージャ)           true
  new File("/nope")          true

false を返すクロージャが、条件式では真になる。

def c = { false }
println "  c        → ${c ? 'true' : 'false'}"
println "  c()      → ${c()}"
println "  c.class  → ${c.getClass().name}"
  c        → true
  c()      → false
  c.class  → t1$_run_closure1

クロージャは groovy.lang.Closure を継承したオブジェクトである。 コレクションでも文字列でも数値でもないので、「それ以外」に該当して真になる。

File も同じである。

// ❌ 「ファイルが存在するか」の判定にならない
if (file('config.xml')) { }
// ✅
if (file('config.xml').exists()) { }

File オブジェクトは「パスを表すオブジェクト」であり、ファイルの存在とは無関係に生成できる。 だから常に真になる。

3.3 これがGradleの実バグの言語側の根拠である

姉妹記事が扱った実在のバグを、言語仕様の側から説明できる。

// ある決済アプリケーションのビルドスクリプト(実在)
import org.apache.tools.ant.taskdefs.condition.Os

def allowMavenLocal = {
    return Os.isFamily(Os.FAMILY_MAC)
}

ext.repos = {
    if (allowMavenLocal) {          // ← クロージャそのものを評価している
        mavenLocal()
    } else {
        println "Disabling Maven Local"   // 到達しない
    }
}

書いた人の意図は「macOSでのみ mavenLocal() を有効にする」だった。 しかし allowMavenLocal はクロージャなので常に真であり、Os.isFamily(...) は一度も評価されない。

帰結
  ・mavenLocal() は macOS 限定にならず、Linux の CI でも常に有効
  ・else 枝の println は一度も出力されない
  ・import した Os.FAMILY_MAC は死んだコードになる

呼び出せば期待どおりになる。

if (allowMavenLocal()) { mavenLocal() }

詳細は Gradle with Groovy-overview-ai_JP.md の §5.4 と §16.2 にある。 本記事は「なぜ言語がそう振る舞うか」に責任を持つ。

3.4 静的型付け言語なら書けない

// Kotlin
val allowMavenLocal = { false }
if (allowMavenLocal) { }   // ❌ Type mismatch: inferred type is () -> Boolean but Boolean was expected
// Java
Supplier<Boolean> allowMavenLocal = () -> false;
if (allowMavenLocal) { }   // ❌ incompatible types: Supplier<Boolean> cannot be converted to boolean

Groovyの柔軟さの代償がここに集約されている。 条件式に何でも書けることと、間違いを検出できることは両立しない。

@CompileStatic を付けると検出できるようになる(第9章)。ただし真偽変換そのものは静的コンパイルでも許されるため、この特定のバグは @CompileStatic でも捕まらない。検出手段は grep である。

$ grep -rnE 'if \(\s*[a-z][a-zA-Z]*\s*\)' --include='*.groovy' --include='*.gradle' . | grep -v '()'

3.5 asBoolean で上書きできる

真偽変換は asBoolean() メソッドで制御できる。

class Empty { boolean asBoolean() { false } }
show('asBoolean(){false}', new Empty())
  asBoolean(){false}         false     Empty

自作クラスに「空であること」の意味を持たせられる。 逆に言えば、サードパーティのクラスが asBoolean を定義していると挙動が変わるため、条件式の意味は型に依存する。

3.6 実務での指針

① コレクションと文字列の空判定には Groovy truth を使ってよい
     if (list) { }        ← 慣用的で読みやすい
     if (name) { }        ← null と空文字列を同時に弾ける

② それ以外の型では明示的に書く
     if (file.exists())
     if (closure())
     if (obj != null)
     if (num > 0)

③ 「オブジェクトが返る式」を条件に書いたら疑う
     メソッドの戻り値型を確認する習慣を持つ

特に②の if (num > 0) は重要である。 0 が偽になるため、if (count) は「0件」と「null」を区別できない。件数のように0が正当な値である場合、Groovy truthは使ってはいけない。

4. クロージャ — DSLが成立する仕組み

Groovyで最も重要な機能である。 ここを理解すると、GradleやJenkinsのDSLが「なぜあの書き方で動くのか」が説明できる。

4.1 基本形と it

p('{ it * 2 }(21)',            { it * 2 }(21))
p('{ a, b -> a+b }(1,2)',      { a, b -> a+b }(1,2))
p('引数0個 { -> 1 }',            { -> 1 }.maximumNumberOfParameters)
p('{ it }',                    { it }.maximumNumberOfParameters)
p('{ }',                       { }.maximumNumberOfParameters)
p('{ } を引数付きで呼ぶ',           "${ { }(99) }")
  { it * 2 }(21)                     42
  { a, b -> a+b }(1,2)               3
  引数0個 { -> 1 } の maximumNumberOfParameters 0
  { it } の maximumNumberOfParameters 1
  { } の maximumNumberOfParameters    1
  { } を引数付きで呼べるか                     null  ← it として受ける

注目すべきは3行目と4行目の差である。

{ -> 1 }   引数0個。引数を渡すと MissingMethodException
{ }        引数1個(暗黙の it)。引数を渡せるが、値は無視される
{ it }     引数1個。明示しても同じ

{ }{ -> } は別物である。 前者は引数を1つ受け取れる。Gradleで doLast { } に引数が渡っても動くのはこれが理由である。

4.2 3つのスコープ — thisObject / owner / delegate

クロージャは3つの参照を持つ。

class Owner {
  def name = 'OWNER'
  def make() {
    return { [thisObject: thisObject.getClass().simpleName,
              owner:      owner.getClass().simpleName,
              delegate:   delegate.getClass().simpleName] }
  }
}
def c = new Owner().make()
  thisObject                         Owner
  owner                              Owner
  delegate(既定=owner)                 Owner

意味はこうである。

参照指すもの変更できるか
thisObjectクロージャを囲むクラスのインスタンス
ownerクロージャを定義した場所(クラス or 外側のクロージャ)
delegate名前解決の委譲先。既定は owner

delegate だけが差し替えられる。これがDSLの入口である。

def d = { }
p('既定の resolveStrategy',  d.resolveStrategy)
p('delegate の既定 == owner', d.delegate.is(d.owner))
  既定の resolveStrategy            0
  delegate の既定 == owner          true

4.3 resolveStrategy — 名前解決の順序

ownerdelegate の両方に同名メソッドがある場合、どちらが呼ばれるかを決める。

class Delegatee { def shared() { 'DELEGATE' }; def onlyOnDelegate() { 'D-only' } }
class Ownr {
  def shared() { 'OWNER' }
  def onlyOnOwner() { 'O-only' }
  def probe(int strategy) {
    def cl = { shared() }                 // ← owner は Ownr。両者が shared() を持つ
    cl.delegate = new Delegatee()
    cl.resolveStrategy = strategy
    try { cl() } catch (e) { e.getClass().simpleName }
  }
}
== resolveStrategy で shared() の解決先が変わる ==
  OWNER_FIRST(既定)                OWNER
  DELEGATE_FIRST                 DELEGATE
  OWNER_ONLY                     OWNER
  DELEGATE_ONLY                  DELEGATE
  TO_SELF                        MissingMethodException

== 片方にしか無いメソッド ==
  OWNER_FIRST + delegate専用       D-only
  DELEGATE_FIRST + owner専用       O-only
  OWNER_ONLY + delegate専用        MissingMethodException
  DELEGATE_ONLY + owner専用        MissingMethodException

完全な表にまとめる。

戦略定数値探索順片方に無いとき
OWNER_FIRST0(既定)owner → delegatedelegateへフォールバック
DELEGATE_FIRST1delegate → ownerownerへフォールバック
OWNER_ONLY2ownerのみ例外
DELEGATE_ONLY3delegateのみ例外
TO_SELF4クロージャ自身のみ例外

_FIRST はフォールバックする。_ONLY はしない。 DSLを作るときは、DELEGATE_FIRST が最も実用的である — DSL固有の名前を優先しつつ、外側のヘルパーメソッドも呼べる。

4.4 これがDSLの正体である

delegate を差し替えるだけで、任意の記法が作れる。

def build(Closure cl) {
    def m = [:]
    cl.delegate = m
    cl.resolveStrategy = Closure.DELEGATE_FIRST
    cl()
    m
}
p('build { name="x"; port=8080 }', build { name = 'x'; port = 8080 })
  build { name="x"; port=8080 }      [name:x, port:8080]

name = 'x' はMapへの代入に変換されている。 ブロックの中に「設定を並べただけ」に見えるものが、実際にはメソッド呼び出しとプロパティ代入である。

Gradleが行っているのは、まさにこれである。

build.gradle の 1行1行は Project オブジェクトへのメソッド呼び出し
    repositories { mavenCentral() }
        ↓
    project.repositories(closure)
        ↓
    closure.delegate = RepositoryHandler のインスタンス
    closure.resolveStrategy = DELEGATE_FIRST
    closure()
        ↓
    mavenCentral() が RepositoryHandler#mavenCentral() として解決される

「設定ファイルのように見えるが、実はプログラムである」という性質は、この仕組みから来ている。

入れ子も同じ原理である。

def config(Closure cl) {
    def node = [children: [], props: [:]]
    cl.delegate = new Object() {
        def methodMissing(String name, args) {
            if (args && args[0] instanceof Closure) {
                node.children << [name: name, body: config(args[0])]
            } else {
                node.props[name] = args ? args[0] : null
            }
        }
        def propertyMissing(String name, val) { node.props[name] = val }
    }
    cl.resolveStrategy = Closure.DELEGATE_FIRST
    cl()
    node
}

methodMissing(第8章)と組み合わせると、任意の階層構造を受け付けるDSLになる。

4.5 curry — 引数を部分適用する

def add3 = { p1, p2, p3 -> p1+p2+p3 }
p('add3.curry(1)(2,3)',        add3.curry(1)(2,3))
p('add3.curry(1,2)(3)',        add3.curry(1,2)(3))
p('add3.rcurry(3)(1,2)',       add3.rcurry(3)(1,2))
p('add3.ncurry(1,9)(1,3)',     add3.ncurry(1,9)(1,3))
  add3.curry(1)(2,3)                 6
  add3.curry(1,2)(3)                 6
  add3.rcurry(3)(1,2)                6
  add3.ncurry(1,9)(1,3)              13
メソッド固定する位置
curry(a)左から
rcurry(z)右から
ncurry(i, v)i番目(0起点)

ncurry(1, 9) は2番目の引数を9に固定するので、1 + 9 + 3 = 13 になる。

4.6 memoize — 結果をキャッシュする

int calls = 0
def slow = { n -> calls++; n*n }
def fast = slow.memoize()
fast(4); fast(4); fast(4)
p('memoize: 3回呼んで実体は',   "${calls}回")
  memoize: 3��呼んで実体は                  1回

3回呼んでも本体は1回しか走らない。 引数をキーにしたキャッシュが自動で入る。

上限付きの変種もある。

def m1 = slow.memoizeAtMost(100)      // 最大100件
def m2 = slow.memoizeAtLeast(10)      // 最低10件は保持(それ以上はGC可能)
def m3 = slow.memoizeBetween(10, 100)

メソッドに対しては @Memoized アノテーションを使う(第10章)。

4.7 合成 — >><<

p('composition (>>)',        ({ it+1 } >> { it*2 })(5))
  composition (>><<)                 12

f >> gg(f(x)) である。 (5+1)*2 = 12。逆向きは <<

4.8 クロージャの実体

def f = { false }
p('f.class.superclass',        f.getClass().superclass.name)
p('f instanceof Closure',      f instanceof Closure)
  f.class.superclass                 groovy.lang.Closure
  f instanceof Closure               true

クロージャは groovy.lang.Closure のサブクラスとしてコンパイルされる。 クラス名は <スクリプト名>$_run_closure<n> の形になる(第3章の実測で t1$_run_closure1 が出ている)。

これが第3章で「クロージャは真」だった理由であるClosureCollection でも CharSequence でも数値でもない。

4.9 パラメータの型を宣言できる

def typed = { String s, int n -> s * n }
println typed('ab', 3)                 // ababab

型を書くと実行時に強制される。

try { typed(1, 2) } catch (e) { println e.getClass().simpleName }
// → MissingMethodException

@CompileStatic と組み合わせると、これがコンパイル時検査になる(第9章)。DSLでは @ClosureParams でIDEに引数の型を伝えられる。

def each(List<String> l, @ClosureParams(value = SimpleType, options = ['java.lang.String']) Closure cl) {
    l.each { cl(it) }
}

@DelegatesTo はdelegateの型をIDEと静的コンパイラに伝える。

def build(@DelegatesTo(value = Config, strategy = Closure.DELEGATE_FIRST) Closure cl) { }

この2つのアノテーションが無いと、DSLは補完も型検査も効かない。 Gradleが @DelegatesTo を大量に使っているのはこのためである。


5. GStringとString — 似て非なる2つの型

5.1 "$x"String ではない

def x = 'prod'
def g = "$x"
p('"$x".class',           g.getClass().name)
p("'prod'.class",         'prod'.getClass().name)
p("g == 'prod'",          g == 'prod')
p("g.equals('prod')",     g.equals('prod'))
p("'prod'.equals(g)",     'prod'.equals(g))
p("hashCode 一致",         g.hashCode() == 'prod'.hashCode())
p("g instanceof String",  g instanceof String)
p("g instanceof GString", g instanceof GString)
  "$x".class               org.codehaus.groovy.runtime.GStringImpl
  'prod'.class             java.lang.String
  g == 'prod'              true   ← ★true(== は equals ではない)
  g.equals('prod')         false   ← ★false
  'prod'.equals(g)         false
  hashCode 一致              false
  g instanceof String      false
  g instanceof GString     true

ここが多くの解説と食い違う点である。

g == 'prod'          → true      ← Groovy の == は GString を特別扱いする
g.equals('prod')     → false     ← Java の equals は型が違うので false
hashCode             → 不一致

==equals の別名ではない。 Groovyの ==DefaultTypeTransformation.compareEqual を呼ぶ。

p("compareEqual",  org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation.compareEqual(g, 'prod'))
  compareEqual             true

このメソッドがGStringとStringを相互に変換して比較している。 だから == は期待どおりに動く。

5.2 事故は hashCode 経路で起きる

== が動くなら何が問題なのか。 Javaのコレクションが equals / hashCode を使うからである。

def m1 = ["$x": 1]
p("[\"\$x\":1]['prod']",   "${m1['prod']}")
p("キーの型",              m1.keySet().first().getClass().name)
p("[(x):1]['prod']",      [(x): 1]['prod'])
p("toString() 版",        [("${x}".toString()): 1]['prod'])
  ["$x":1]['prod']         null   ← ★null(hashCode が違う)
  キーの型                     org.codehaus.groovy.runtime.GStringImpl
  [(x):1]['prod']          1
  toString() 版             1

マップのキーにGStringを使うと、Stringでは引けない。 HashMaphashCode でバケットを決めるため、== の特別扱いが効かない。

contains も外す。

  ['prod'].contains(g)     false
  [g].contains('prod')     false
  ['prod'].indexOf(g)      -1
  Set([g]).contains(s)     false

ArrayList.containsequals を使うので、すべて外れる。 これはGroovyの演算子ではなくJavaのメソッドだからである。

規則としてはこうなる。

Groovy の演算子(==, in, <=>)          → GString と String は等しい
Java のメソッド(equals, hashCode,      → 等しくない
  contains, indexOf, Map のキー,
  Set の要素)

だから「== は動くのに Map から引けない」という一貫性の無い挙動になる。

5.3 対処

// ❌ キーがGStringになる
def config = ["$env": [url: '...']]

// ✅ 括弧で囲むと式が評価されてStringになる
def config = [(env): [url: '...']]

// ✅ 明示的にtoString()
def config = [("${env}".toString()): [url: '...']]

// ✅ そもそも補間が要らないなら使わない
def config = [prod: [url: '...']]

マップキーに式を書くには括弧が必須である。

def m3 = ["${x}".toString(): 1]     // ❌ コンパイルエラー
  Unexpected input: ':' @ line 16, column 28.
     def m3 = ["${x}".toString(): 1]
                                ^

メソッド呼び出しをキーにするには [( ... ): v] と書く。

5.4 遅延評価 — ${-> x}

def y = 'before'
def eager = "value=$y"
def lazy  = "value=${-> y}"
y = 'after'
p('eager "$y"',      eager)
p('lazy "${-> y}"',  lazy)
  eager "$y"               value=before
  lazy "${-> y}"           value=after   ← ★遅延する

${-> expr} はクロージャを埋め込む。 toString() が呼ばれるたびに評価される。

用途は主にログである。

// 文字列化のコストを、実際に出力されるときまで遅らせる
log.debug("state = ${-> expensiveDump()}")

逆に、意図せず遅延させると混乱する。

def messages = []
for (i in 1..3) { messages << "item ${-> i}" }
println messages*.toString()    // ループ変数が最終値になっている可能性

5.5 内部構造

def gs = "a${1+1}b${'c'}"
p("strings", gs.strings as List)
p("values",  gs.values as List)
  strings                  [a, b, ]
  values                   [2, c]

GStringは「固定��分の配列」と「値の配列」を保持している。 toString() はこれを交互に連結する。

この構造のおかげでSQLのプレースホルダに変換できる。

// groovy.sql.Sql は GString を安全にパラメータ化する
sql.execute("INSERT INTO t (a, b) VALUES (${a}, ${b})")
// → INSERT INTO t (a, b) VALUES (?, ?) にバインド。SQLインジェクションにならない

文字列連結との違いが本質的である。

sql.execute("... VALUES ('" + a + "')")   // ❌ ただの String。インジェクションの余地
sql.execute("... VALUES (${a})")          // ✅ GString。ドライバがバインドする

6. 演算子 — Javaに無い記号の意味

6.1 安全なナビゲーションとElvis

def n = null
p('n?.length()',             "${n?.length()}")
p('n?.foo?.bar',             "${n?.foo?.bar}")
p("n ?: 'default'",          n ?: 'default')
p("'' ?: 'default'",         '' ?: 'default')
p("0 ?: 'default'",          0 ?: 'default')
def mm = [a: [b: [c: 'deep']]]
p("mm?.a?.b?.c",             mm?.a?.b?.c)
p("mm?.x?.y?.z",             "${mm?.x?.y?.z}")
  n?.length()                    null
  n?.foo?.bar                    null
  n ?: 'default'                 default
  '' ?: 'default'                default
  0 ?: 'default'                 default
  mm?.a?.b?.c                    deep
  mm?.x?.y?.z                    null

?. は左辺がnullなら評価を止めてnullを返す。 連鎖できるので、深い階層のnullチェックが1行になる。

?:(Elvis演算子)は「偽なら右辺」である。 ここに注意点がある。

Java の三項演算子      cond ? a : b     ← boolean が必要
Groovy の Elvis        a ?: b           ← a が Groovy truth で偽なら b

'' ?: 'default'0 ?: 'default'default を返している。 つまり ?: はnull合体演算子ではない。 空文字列や0も「偽」として弾く。

// ❌ 0 を正当な値として扱いたい場合は使えない
def count = 0
def result = count ?: 10        // → 10 になってしまう

// ✅
def result = count != null ? count : 10

Kotlinの ?: はnullのみを見るため、移植時に挙動が変わる。

6.2 ==is()

def s1 = new String('abc'); def s2 = new String('abc')
p('s1 == s2',                s1 == s2)
p('s1.is(s2)',               s1.is(s2))
  s1 == s2                       true
  s1.is(s2)                      false

Groovyの == は値の比較、is() が同一性の比較である。 Javaと逆になっている。

Java     ==        参照の同一性
         equals()  値の等価性
Groovy   ==        値の等価性(compareEqual 経由)
         is()      参照の同一性

Javaからの移植で最も危険な差である。 Javaの if (a == b) をそのままGroovyに持ち込むと、意味が変わる。

Groovy 4以降は === が使えるis() と同義)。Groovy 3では構文エラーになる。

6.3 <=>(スペースシップ)

p('1 <=> 2',                 1 <=> 2)
p("'b' <=> 'a'",             'b' <=> 'a')
p('[3,1,2].sort()',          [3,1,2].sort(false))
p('[3,1,2].sort{-it}',       [3,1,2].sort(false){ -it })
  1 <=> 2                        -1
  'b' <=> 'a'                    1
  [3,1,2].sort()                 [1, 2, 3]
  [3,1,2].sort{-it}              [3, 2, 1]

<=>compareTo を呼ぶ。 ただしリスト同士には使えない。

  [1,2] <=> [1,3]                IllegalArgumentException(Listは非Comparable)

java.util.ListComparable を実装していないため例外になる。 複数キーのソートはリストを返すクロージャを渡す。

p('sort by 複数キー', [[n:1,s:'b'],[n:1,s:'a']].sort(false){ [it.n, it.s] }*.s)
  sort by 複数キー                       [a, b]

クロージャがリストを返すと、要素を順に比較してくれる。

6.4 スプレッド演算子

def words = ['apple', 'banana']
p('words*.length()',         words*.length())
p('words*.toUpperCase()',    words*.toUpperCase())
p('[[1,2],[3]]*.size()',     [[1,2],[3]]*.size())
def f = { a, b, c -> "$a-$b-$c" }
p('f(*[1,2,3])',             f(*[1,2,3]))
p('[*[1,2], 3]',             [*[1,2], 3])
p('[*:[a:1], b:2]',          [*:[a:1], b:2])
  words*.length()                [5, 6]
  words*.toUpperCase()           [APPLE, BANANA]
  [[1,2],[3]]*.size()            [2, 1]
  f(*[1,2,3])                    1-2-3
  [*[1,2], 3]                    [1, 2, 3]
  [*:[a:1], b:2]                 [a:1, b:2]

3種類ある。

記法意味
list*.method()各要素にメソッドを適用(collect{ it.method() } と同じ)
f(*list)リストを引数列に展開
[*list, x] / [*:map, k:v]リテラルの中で展開

*. はnull安全でもある — 要素がnullなら結果もnullが入る。

6.5 正規表現の3演算子

p("~/\\d+/ の型",             (~/\d+/).getClass().name)
p("'a1' =~ /\\d/ の型",       ('a1' =~ /\d/).getClass().name)
p("'a1' =~ /\\d/ (真偽)",     ('a1' =~ /\d/) ? 'true' : 'false')
p("'a1' ==~ /\\d/",          'a1' ==~ /\d/)
p("'a1' ==~ /\\w\\d/",       'a1' ==~ /\w\d/)
p("マッチ抽出",               ('2026-08-25' =~ /(\d+)-(\d+)-(\d+)/)[0])
  ~/\d+/ の型                      java.util.regex.Pattern
  'a1' =~ /\d/ の型                java.util.regex.Matcher
  'a1' =~ /\d/ (真偽)              true
  'a1' ==~ /\d/                  false
  'a1' ==~ /\w\d/                true
  マッチ抽出                          [2026-08-25, 2026, 08, 25]

3つを区別する。

演算子返すもの意味
~/re/Patternパターンをコンパイルする
=~Matcher部分一致の検索を始める
==~boolean完全一致するか

=~ の結果が真偽値として使えるのは、MatcherasBoolean が「マッチしたか」を返すからである(第3章)。マッチしない Matcher は偽になる。

/.../ はスラッシュ記法で、バックスラッシュをエスケープしなくてよい。

'\\d+'      // 通常の文字列だと \\ が必要
/\d+/       // スラッシュ記法ならそのまま

6.6 Range

p('1..5',                    (1..5) as List)
p('1..<5',                   (1..<5) as List)
p("'a'..'e'",                ('a'..'e') as List)
p('5..1',                    (5..1) as List)
p('(1..10).step(3)',         (1..10).step(3))
  1..5                           [1, 2, 3, 4, 5]
  1..<5                          [1, 2, 3, 4]
  'a'..'e'                       [a, b, c, d, e]
  5..1                           [5, 4, 3, 2, 1]
  (1..10).step(3)                [1, 4, 7, 10]

.. は両端を含み、..< は右端を含まない。 逆順も書ける。文字にも使える。

Rangeは遅延評価されるため、1..1_000_000 を作ってもメモリを食わない。

6.7 インデックスとその他

p('[1,2,3][-1]',             [1,2,3][-1])
p('[1,2,3,4][1..2]',         [1,2,3,4][1..2])
p("'hello'[0..2]",           'hello'[0..2])
p('2 ** 10',                 2 ** 10)
p('7.intdiv(2)',             7.intdiv(2))
  [1,2,3][-1]                    3
  [1,2,3,4][1..2]                [2, 3]
  'hello'[0..2]                  hel
  2 ** 10                        1024
  7.intdiv(2)                    3

負のインデックスは末尾から数える。 Rangeでスライスできる。文字列も同様。

** は冪乗、intdiv は整数除算である。 / は整数同士でもBigDecimalを返すため(第12章)、整数除算には intdiv が必要になる。

7. コレクション操作 — 標準ライブラリの拡張

7.1 GDK — Javaのクラスにメソッドが生えている

Groovyは java.util.List などのJavaクラスに大量のメソッドを追加している。 これを GDK(Groovy Development Kit)と呼ぶ。実装は DefaultGroovyMethods という静的メソッド群で、レシーバを第1引数に取る形になっている。

def ns = [3,1,4,1,5,9,2,6]
p('each(戻り値は元のリスト)',   ns.each{}.getClass().simpleName)
p('collect',                 ns.collect{ it*2 })
p('find(最初の1件)',           ns.find{ it > 3 })
p('findAll',                 ns.findAll{ it > 3 })
p('findIndexOf',             ns.findIndexOf{ it > 3 })
p('inject(畳み込み)',          ns.inject(0){ a, b -> a+b })
p('sum / max / min',         "${ns.sum()} / ${ns.max()} / ${ns.min()}")
p('count / countBy',         "${ns.count{it>3}} / ${ns.countBy{ it%2 }}")
p('groupBy',                 ns.groupBy{ it % 3 })
  each(戻り値は元のリスト)                    ArrayList
  collect                            [6, 2, 8, 2, 10, 18, 4, 12]
  find(最初の1件)                        4
  findAll                            [4, 5, 9, 6]
  findIndexOf                        2
  inject(畳み込み)                       31
  sum / max / min                    31 / 9 / 1
  count / countBy                    4 / [1:5, 0:3]
  groupBy                            [0:[3, 9, 6], 1:[1, 4, 1], 2:[5, 2]]

Javaのstream APIとの対応はこうなる。

GroovyJava Stream
collect { }.stream().map().toList()
findAll { }.stream().filter().toList()
find { }.stream().filter().findFirst()
inject(init) { }.stream().reduce()
groupBy { }.collect(groupingBy())
any { } / every { }anyMatch() / allMatch()
sum().mapToInt().sum()

Groovy版は中間操作のオブジェクトを作らず、その場でリストを返す。 短く書けるが、大きなコレクションでは中間リストが毎回できる点は意識する必要がある。

7.2 よく使うもの

  unique(破壊的でない版)                    [3, 1, 4, 5, 9, 2, 6]
  sort + reverse                     [9, 6, 5, 4, 3, 2, 1, 1]
  collate(分割)                        [[3, 1, 4], [1, 5, 9], [2, 6]]
  flatten                            [1, 2, 3]
  any / every                        true / true
  take / drop                        [3, 1, 4] / [9, 2, 6]
  takeWhile                          [3, 1, 4, 1]
  withIndex                          [0:a, 1:b]
  sum(初期値付き)                         ab
  join                               3-1-4-1-5-9-2-6
  combinations                       [[1, 3], [2, 3], [1, 4], [2, 4]]
  transpose                          [[1, 3], [2, 4]]
  subMap                             [a:1, c:3]
  collectEntries                     [1:1, 2:4]
  max{ }                             ccc

sortunique は破壊的である。 引数に false を渡すとコピーを返す。

def orig = [3,1,2]
orig.sort()          // ← orig 自体が書き換わる
orig.sort(false)     // ← コピーを返す
orig.toSorted()      // ← Groovy 2.4+ の非破壊版

これはバグの温床である。 sort() を呼んだ後に元のリストを使うコードは、順序が変わっていることに気付きにくい。

collectEntries はリストからマップを作る。

[1,2].collectEntries{ [it, it*it] }     // → [1:1, 2:4]

transpose は行列を転置する。

[[1,2],[3,4]].transpose()               // → [[1,3],[2,4]]

7.3 Mapの走査

p('Map の each(2引数)', { def r=[]; [a:1,b:2].each{ k,v -> r << "$k=$v" }; r }())
  Map の each(2引数)                    [a=1, b=2]

Mapの each は引数の数で挙動が変わる。

[a:1].each { entry ->  }          // 1引数 → Map.Entry
[a:1].each { k, v ->   }          // 2引数 → キーと値に分解

この「引数の数で意味が変わる」性質は、GroovyのDSLでよく使われる。 maximumNumberOfParameters を見て分岐している(第4.1節)。

Mapリテラルの既定は LinkedHashMap である。

println [b:1, a:2].getClass().name     // java.util.LinkedHashMap
println [b:1, a:2].keySet() as List    // [b, a] ← 挿入順が保たれる

キーは文字列として解釈される。 変数の値をキーにしたい場合は括弧が必要(第5.3節)。

7.4 スプレッドとの組み合わせ

p('*.(spread)',   [[a:1],[a:2]]*.a)
  *.(spread)                         [1, 2]

マップのリストから特定のキーだけ抜くのに便利である。 collect{ it.a } と同じだが短い。

7.5 実務での注意

① sort / unique / reverse の破壊的挙動を把握する
     → 迷ったら toSorted() / unique(false) を使う

② 巨大なコレクションでは中間リストを意識する
     → 1000万件のような規模では Java Stream か、明示的なループを検討

③ Groovy truth と組み合わせると空判定が短い
     → if (list) は「nullでも空でもない」を意味する

④ each の戻り値は元のコレクション(collect ではない)
     → 変換したいときは collect を使う

8. メタプログラミング — 実行時にクラスを書き換える

Groovyの最も強力で、最も危険な機能である。

8.1 既存クラスにメソッドを足す

String.metaClass.shout = { -> delegate.toUpperCase() + '!' }
p("'hi'.shout()",  'hi'.shout())

Integer.metaClass.isPerfect = { -> (1..<delegate).findAll{ delegate % it == 0 }.sum() == delegate }
p('6.isPerfect()',  6.isPerfect())
p('8.isPerfect()',  8.isPerfect())
  'hi'.shout()                         HI!
  6.isPerfect()                        true
  8.isPerfect()                        false

java.lang.String にメソッドを追加できている。 final クラスでも関係ない。クロージャの中では delegate がレシーバを指す。

8.2 既存メソッドを差し替えられる

String.metaClass.toUpperCase = { -> '差し替えられた' }
p("'abc'.toUpperCase()", 'abc'.toUpperCase())
String.metaClass = null   // 元に戻す
p('metaClass=null で復帰',  'abc'.toUpperCase())
  'abc'.toUpperCase()                  差し替えられた
  metaClass=null で復帰                   ABC

標準ライブラリの挙動を書き換えられる。 これがモンキーパッチである。

metaClass = null で元に戻るため、テストで一時的に差し替える用途には使える。

本番コードでこれを行うべきではない。 グローバルに効くため、同じJVM上の無関係なコードにも影響する。ライブラリがこれをやっていると、原因の特定が極めて困難になる。

8.3 methodMissing / propertyMissing

class Dyn {
  def store = [:]
  def methodMissing(String name, args)   { "呼ばれた:${name}(${args as List})" }
  def propertyMissing(String name)       { "読まれた:${name}" }
  def propertyMissing(String name, val)  { store[name] = val }
}
  d.anyMethod(1,2)                     呼ばれた:anyMethod([1, 2])
  d.anyProperty                        読まれた:anyProperty
  d.newProp = "v" → store              [newProp:v]

未定義のメソッド・プロパティへのアクセスを受け止める。 propertyMissing は引数の数でgetterとsetterを区別する。

用途はDSLとプロキシである。

// XMLビルダのような記法が作れる
class Markup {
    def out = new StringBuilder()
    def methodMissing(String name, args) {
        out << "<$name>"
        if (args && args[0] instanceof Closure) { args[0].delegate = this; args[0]() }
        else if (args) { out << args[0] }
        out << "</$name>"
        this
    }
}
def m = new Markup()
m.html { body { p 'hello' } }
println m.out       // <html><body><p>hello</p></body></html>

methodMissing は「見つからなかったとき」だけ呼ばれるため、既存メソッドとは衝突しない。

8.4 invokeMethodGroovyInterceptable が必要

よくある誤解を実測で潰す。

class Plain {
  def log = []
  def invokeMethod(String name, args) { log << name; "intercepted:$name" }
  def real() { 'real() の本体' }
}
class Intercept implements GroovyInterceptable {
  def log = []
  def invokeMethod(String name, args) { log << name; "intercepted:$name" }
  def real() { 'real() の本体' }
}
== invokeMethod は GroovyInterceptable が無いと既存メソッドを傍受しない ==
  Plain#real()                             real() の本体
  Plain のログ                                []
  Plain#notDefined()                       intercepted:notDefined
  Plain のログ(未定義呼び出し後)                      [notDefined]

  Intercept#real()                         intercepted:real
  Intercept のログ                            [real]

invokeMethod を定義しただけでは、既存メソッドは傍受されない。

実装傍受される範囲
invokeMethod のみ未定義メソッドだけmethodMissing とほぼ同じ)
+ implements GroovyInterceptableすべてのメソッド呼び出し

AOP的な用途(ログ、計測、トランザクション)では GroovyInterceptable が必要である。 ただしすべての呼び出しがフックを通るため、性能への影響が大きい。

8.5 ExpandoMetaClass — 階層全体に足す

Object.metaClass.describe = { -> "${delegate.getClass().simpleName}(${delegate})" }
p('1.describe()',       1.describe())
p("'a'.describe()",     'a'.describe())
p('[1,2].describe()',   [1,2].describe())
  1.describe()                         Integer(1)
  'a'.describe()                       String(a)
  [1,2].describe()                     ArrayList([1, 2])

Object に足すと、すべてのオブジェクトにメソッドが生える。 強力だが、名前の衝突が起きたときの影響範囲が全体になる。

8.6 Expando — 動的オブジェクト

def e = new Expando(name: 'x', greet: { -> 'hello' })
p('e.name',    e.name)
p('e.greet()', e.greet())
  e.name                               x
  e.greet()                            hello

JavaScriptのオブジェクトリテラルのように、プロパティとメソッドを後から足せる。 テストのスタブに便利である。

def stub = new Expando(
    findById: { id -> [id: id, name: 'stub'] },
    save:     { obj -> true }
)

Spockの Mock() が無い環境で、軽量なフェイクを作るのに使える。

8.7 カテゴリ — スコープを限定した拡張

class MathCat { static Integer twice(Integer self) { self * 2 } }
use(MathCat) { p('use(MathCat){ 5.twice() }', 5.twice()) }
try { 5.twice() } catch (e2) { p('スコープ外で呼ぶ', e2.getClass().simpleName) }
  use(MathCat){ 5.twice() }            10
  スコープ外で呼ぶ                             MissingMethodException

use(カテゴリ) { } のブロック内だけで拡張が有効になる。 metaClass と違ってグローバルに漏れない。

規約は「第1引数がレシーバの static メソッド」である。 これはGDK自身の実装方式と同じで、@Category アノテーションで書きやすくできる。

@Category(Integer)
class MathCat { Integer twice() { this * 2 } }    // this がレシーバになる

モンキーパッチが必要な場面では、metaClass よりカテゴリを優先すべきである。 影響範囲が構文的に明示される。

8.8 メタプログラミングの使いどころ

使ってよい場面
  ・テストでのスタブ・モック(Expando、use())
  ・DSLの実装(methodMissing + delegate)
  ・フレームワーク内部(Grails のダイナミックファインダなど)

避けるべき場面
  ・アプリケーションコードで標準クラスを書き換える
  ・ライブラリが利用者に見えない形で metaClass をいじる
  ・性能が要求される経路で GroovyInterceptable を使う

@CompileStatic を付けると、メタプログラミングは使えなくなる(第9章)。「動的な機能」と「静的な安全性」はトレードオフである。


9. 静的型付けへの回帰 — @CompileStatic@TypeChecked

9.1 動的なままだと実行時まで気付けない

class Dynamic {
  def run()       { def s = 'abc'; return s.noSuchMethod() }
  def typo()      { def x = 10; return x.tostring() }      // 小文字の s
  def wrongType() { int n = 'not a number' as int; n }
}
== 動的なままだと実行時まで気付けない ==
  存在しないメソッド                              実行時に MissingMethodException
  メソッド名のタイポ                              実行時に MissingMethodException
  文字列→int の強制                            実行時に NumberFormatException

3つすべてがコンパイルを通り、実行時に落ちる。 x.tostring() のような大文字小文字の誤りも通ってしまう。

9.2 @CompileStatic が捕まえるもの

アノテーションを1つ付けるだけで、これらがコンパイルエラーになる。

@groovy.transform.CompileStatic
class X { def run() { 'abc'.noSuchMethod() } }

実測した結果を並べる。

== @CompileStatic が捕まえるもの(すべてコンパイル時) ==
  存在しないメソッド            [Static type checking] - Cannot find matching method java.lang.String#noSuchMethod()
  メソッド名の大文字小文字        [Static type checking] - Cannot find matching method java.lang.Integer#tostring()
  存在しないプロパティ           [Static type checking] - No such property: b for class: X
  型の合わない代入             [Static type checking] - Cannot assign value of type java.lang.String to variable of type int
  引数の数違い               [Static type checking] - Cannot find matching method X#f(int)
  metaClass によるモンキーパッチ  [Static type checking] - No such property: shout for class: groovy.lang.MetaClass
  GString を String 引数へ    通った: A

6つが検出され、1つは通った。

通���た1つが重要��ある。

@CompileStatic
class X {
    String f(String s) { s.toUpperCase() }
    def run() { f("${'a'}") }        // GString を String 引数に渡す
}

@CompileStatic でもGStringは自動変換される。 第5章で見た型の違いは、引数渡しでは問題にならない。問題はマップのキーや equals であり、それは静的コンパイルでは検出できない。

9.3 @TypeChecked との違い

== @TypeChecked との差 ==
  @TypeChecked で同じタイポ    [Static type checking] - Cannot find matching method java.lang.String#noSuchMethod()

検出内容は同じである。違うのは生成されるバイトコードである。

アノテーションコンパイル時検査実行時のディスパッチ
なししない動的(メタクラス経由)
@TypeCheckedする動的(メタクラス経由)
@CompileStaticする静的(Javaと同等の直接呼び出し)

つまり @TypeChecked は「検査だけ」で、性能は変わらない。 @CompileStatic は検査に加えて呼び出しを直接化するため、速くなる(第14章で19倍を実測)。

使い分けはこうなる。

@TypeChecked    メタプログラミングを一部使いたいが、型の誤りは検出したい
                (metaClass への代入自体は検査で落ちるので、実際には併用が難しい)
@CompileStatic  性能も型安全性も欲しい。動的機能は捨てる

実務ではほぼ @CompileStatic を選ぶ。 @TypeChecked の存在意義は「移行の中間段階」である。

9.4 適用範囲を制御する

クラス全体に付けて、一部だけ動的に戻せる。

@CompileStatic
class Service {
    String process(String s) { s.toUpperCase() }          // 静的

    @CompileDynamic
    def dsl(Closure cl) { cl.delegate = this; cl() }      // ここだけ動的
}

逆にメソッド単位で付けることもできる。

class Service {
    @CompileStatic
    int hotLoop(int n) { int t = 0; for (int i=0;i<n;i++) t += i; t }

    def flexible(x) { x.whateverMethod() }                // 動的なまま
}

現実的な運用は「クラス全体に @CompileStatic、DSLを受ける箇所だけ @CompileDynamic」である。

9.5 型推論の限界

@CompileStatic でも def は使える。 ただし推論できる範囲に限られる。

@CompileStatic
def f() {
    def s = 'abc'          // String と推論される
    s.toUpperCase()        // OK
}

@CompileStatic
def g(x) {                 // 引数に型が無い → Object
    x.toUpperCase()        // ❌ Cannot find matching method java.lang.Object#toUpperCase()
}

引数に型を書かないと Object になり、ほとんどのメソッド呼び出しが落ちる。 @CompileStatic を付けるなら、引数と戻り値には型を書く必要がある。

この時点で、コードはほとんどJavaになる。

@CompileStatic
class Service {
    private final Map<String, Integer> cache = new HashMap<>()
    Integer lookup(String key) { cache.get(key) }
}

「Groovyで @CompileStatic を全面適用するなら、最初からKotlinでよい」という判断はここから来る。 Groovyの簡潔さの多くは動的型付けに依存しており、それを捨てると残るのは「セミコロンが省ける Java」に近い。

ただし例外が2つある。

① クロージャとコレクション操作は @CompileStatic でも使える
     → collect / findAll / inject は型が推論できる範囲で動く
② GStringの補間も使える
     → 文字列組み立ての簡潔さは残る

9.6 Gradleスクリプトには使えない

ビルドスクリプトに @CompileStatic を付けることは実質的に不可能である。

理由
  ・Gradle の DSL は delegate と methodMissing に依存している
  ・静的コンパイルは delegate の型を知らないため、すべての DSL 呼び出しが未解決になる
  ・@DelegatesTo が付いている API に限れば通るが、網羅されていない

そのため、Gradleスクリプトの型安全性を上げる唯一の現実的な手段はKotlin DSLへの移行である。 姉妹記事 §17.5 がこの判断を扱っている。

逆に buildSrc のconvention pluginは通常のGroovyクラスなので、@CompileStatic を付けられる。

// buildSrc/src/main/groovy/MyPlugin.groovy
@CompileStatic
class MyPlugin implements Plugin<Project> {
    void apply(Project project) {
        project.tasks.register('hello') { Task t -> t.doLast { println 'hi' } }
    }
}

ビルドロジックを buildSrc に移すと型検査が効くようになる — これがconvention pluginを推す理由の1つである。

10. AST変換 — アノテーションがコードを生成する

10.1 何をするものか

AST変換は、コンパイル時に抽象構文木を書き換えてメソッドを生成する仕組みである。 Javaのアノテーションプロセッサに近いが、Groovyでは標準で多数が用意されている。

実測した結果を並べる。

== AST 変換が生成するもの ==
  @ToString                              T1(x, 1)
  @ToString(includeNames=true)           T2(a:x, b:1)
  @EqualsAndHashCode equals              true
  @EqualsAndHashCode hashCode 一致         true
  @Canonical 位置引数コンストラクタ                 C1(v, 2)
  @Canonical Map コンストラクタ                 C1(v, 2)
  @Immutable toString                    I1(v, [1, 2])
  @Immutable への代入                        ReadOnlyPropertyException
  @Immutable のList変更                     UnsupportedOperationException
  @Immutable の List の実体                  java.util.Collections$UnmodifiableRandomAccessList
  @Builder                               h
  @Memoized: 3回呼んで実体は                    1回
  @Delegate でメソッドが生える                    from Inner
  @Sortable                              [1, 2]
  @TupleConstructor                      9
  @Singleton                             only
  @Singleton 同一性                         true

10.2 主要なものの一覧

アノテーション生成するもの
@ToStringtoString()
@EqualsAndHashCodeequals() / hashCode()
@TupleConstructor位置引数のコンストラクタ
@MapConstructorMapを受けるコンストラクタ
@Canonical上記3つをまとめて
@Immutable@Canonical + フィールドを final 化 + コレクションを不変化
@Builderビルダクラス
@Memoizedメソッド結果のキャッシュ
@Delegateフィールドの型のメソッドを委譲で生やす
@SortableComparable の実装
@SingletongetInstance()instance プロパティ
@Lazy遅延初期化
@AutoClone / @AutoExternalizeclone() / 直列化
@InheritConstructors親のコンストラクタを継承
@Fieldスクリプト内でフィールドを宣言
@Newifynew を省略できる記法

10.3 @Canonical — 最も使われるもの

@Canonical class C1 { String a; int b }

これだけで3つのコンストラクタと toString / equals / hashCode が生える。

  @Canonical 位置引数コンストラクタ    C1(v, 2)      ← new C1('v', 2)
  @Canonical Map コンストラクタ      C1(v, 2)      ← new C1(a: 'v', b: 2)
  @Canonical の宣言メソッド数         10
  @Canonical のコンストラクタ数        3

コンストラクタが3つある — 引数なし、位置引数、Map。Mapコンストラクタは名前付き引数のように書けるため、フィールドが多いクラスで読みやすい。

Javaのrecordとの対比が分かりやすい。

// Java 16+
record C1(String a, int b) { }        // toString/equals/hashCode/アクセサが生成される
// Groovy 3
@Canonical class C1 { String a; int b }    // 加えて Map コンストラクタと可変性
@Immutable class C2 { String a; int b }    // record に近い(不変)

@Immutable のほうがrecordに近い。 Groovy 4では record そのものが使える(第13章)。

10.4 @Immutable は本当に不変にする

@Immutable class I1 { String a; List b }
def i = new I1('v', [1,2])
try { i.a = 'z' } catch (e) { p('@Immutable への代入',  e.getClass().simpleName) }
try { i.b << 3 } catch (e) { p('@Immutable のList変更',  e.getClass().simpleName) }
p('@Immutable の List の実体', i.b.getClass().name)
  @Immutable への代入                        ReadOnlyPropertyException
  @Immutable のList変更                     UnsupportedOperationException
  @Immutable の List の実体                  java.util.Collections$UnmodifiableRandomAccessList

渡したリストがラップされている点が重要である。 単に final にするだけでなく、コレクションを Collections.unmodifiableList で包むため、深いレベルでも変更できない。

Javaのrecordはこれをしない。

record R(List<Integer> b) { }
var r = new R(new ArrayList<>(List.of(1,2)));
r.b().add(3);        // ✅ 通ってしまう。recordは浅い不変性

@Immutable のほうが強い保証を与える。

扱える型には制限がある。

そのまま使える    プリミティブ、String、Date(コピーされる)、
                 他の @Immutable クラス、Collection/Map(ラップされる)
使えない         可変な自作クラス(コンパイル時に警告 or 実行時エラー)
                → @KnownImmutable を付けるか、明示的にコピーする

10.5 @Memoized@Delegate

class Mem { int calls = 0
  @Memoized int square(int n) { calls++; n*n } }
def m = new Mem(); m.square(3); m.square(3); m.square(3)
  @Memoized: 3回呼んで実体は                    1回

メソッド版の memoize である(第4.6節のクロージャ版と同じ)。引数が同じなら本体を呼ばない。

@Memoized(maxCacheSize = 100, protectedCacheSize = 10)
int expensive(int n) { }

@Delegate はコンポジションを継承のように見せる。

class Inner { def hello() { 'from Inner' }; def shared() { 'Inner.shared' } }
class Outer { @Delegate Inner inner = new Inner() }
p('@Delegate でメソッドが生える', new Outer().hello())
  @Delegate でメソッドが生える                    from Inner

OuterInner のメソッドがすべて生える。 多重継承の代替として使える。

class Combined {
    @Delegate Logger logger = new Logger()
    @Delegate Cache  cache  = new Cache()
}

同名メソッドがある場合、先に宣言したものが優先される。 @Delegate(excludes = ['close']) で除外もできる。

10.6 自作のAST変換

標準のもので足りなければ自分で書ける。 ただし難易度は高い。

@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
class MyTransformation implements ASTTransformation {
    void visit(ASTNode[] nodes, SourceUnit source) {
        // ClassNode / MethodNode を直接操作する
    }
}

@ASTTest でテストできるが、Groovyのコンパイラ内部APIに依存するためバージョン間で壊れやすい。 実務では標準のAST変換の組み合わせで済ませるのが賢明である。

10.7 AST変換の注意点

① 生成されたコードはソースに現れない
     → IDEが認識しないことがある。@CompileStatic との併用で型エラーになる場合も

② @Canonical は継承と相性が悪い
     → 親クラスのフィールドは既定で含まれない(includeSuperProperties = true が必要)

③ @EqualsAndHashCode は全フィールドを使う
     → 一部を除外するには excludes を指定する
     → 可変なフ��ールドを含めると HashMap のキーとして壊れる

④ @Immutable のフィールドに可変な自作クラスは入れられない
     → @KnownImmutable を付けるか設計を変える

@Canonical の継承の問題は実際によく踏む。

class Base { String x }
@Canonical class Derived extends Base { String y }
println new Derived('yv').toString()      // Derived(yv) ← x が含まれない

@Canonical(includeSuperProperties = true)
class Derived2 extends Base { String y }

11. Javaとの相互運用

11.1 def の実体

p('def の実体(コンパイル後)', { def v = 1; v.getClass().name }())
p('1 の型',                   1.getClass().name)
  def の実体(コンパイル後)                    java.lang.Integer
  1 の型                               java.lang.Integer

defObject として宣言される。 つまりすべてがボクシングされる。 int を書いた場合でも、動的コードではボクシングが入ることがある。

p('1L / 1.0 / 1.0f / 1G', [1L, 1.0, 1.0f, 1G]*.getClass()*.simpleName)
  1L / 1.0 / 1.0f / 1G               [Long, BigDecimal, Float, BigInteger]

リテラルの型に注意する。

リテラルGroovyの型Javaの型
1Integerint
1LLonglong
1.0BigDecimaldouble
1.0fFloatfloat
1.0dDoubledouble
1GBigInteger
1.0GBigDecimal

1.0BigDecimal になる点がJavaと大きく違う。 詳細は第12章。

11.2 プロパティアクセス

class Bean { private String v = 'x'; String getV() { v }; void setV(String s) { this.v = s } }
def b = new Bean()
p('b.v は getV() を呼ぶ',       b.v)
b.v = 'y'
p('b.v = ... は setV()',      b.v)
p('直接 @ でフィールド',          b.@v)
  b.v は getV() を呼ぶ                   x
  b.v = ... は setV()                 y
  直接 @ でフィールド                        y

obj.prop は自動的に getProp() / setProp() に変換される。 Javaのgetter/setterを持つクラスをGroovyから使うとき、フィールドのように書ける。

@ を付けるとフィールドに直接アクセスする。 getterを飛ばすため、遅延初期化などを壊す可能性がある。

逆に、Groovyのクラスは自動的にgetter/setterを生成する。

class P { String name }         // → getName() / setName() が生成される
// Javaから使える
P p = new P();
p.setName("x");
System.out.println(p.getName());

private を明示するとプロパティにならない。

class P { private String name }    // getter/setter は生成されない

11.3 チェック例外を無視できる

def readIt = { new File('/definitely/not/here').text }   // throws IOException
try { readIt() } catch (e) { p('IOException を宣言せず呼べる', "${e.getClass().simpleName}(throws不要)") }
  IOException を宣言せず呼べる               FileNotFoundException(throws不要)

Groovyはチェック例外を強制しない。 throws を書かなくてもコンパイルが通る。

利点と欠点がある。

利点   ボイラープレートが減る。ラムダの中で try-catch が不要
欠点   どの例外が飛ぶかがシグネチャから分からない
       Javaから呼ぶ側は catch を書けない(コンパイラが知らないため)

Javaから呼ばれるライブラリをGroovyで書く場合は、throws を明示すべきである。

11.4 配列とリスト

int[] arr = [1,2,3]
p('int[] の型',              arr.getClass().name)
p('int[] に collect',        arr.collect{ it*2 }.getClass().simpleName)
p('as int[]',                ([1,2,3] as int[]).getClass().name)
p('List → String[]',         (['a'] as String[]).getClass().name)
  int[] の型                           [I
  int[] に collect                    ArrayList
  as int[]                           [I
  List → String[]                    [Ljava.lang.String;

リテラル [1,2,3]ArrayList だが、int[] に代入すると変換される。 as で明示的に変換もできる。

GDKのメソッドは配列にも使えるが、戻り値は List になる。 配列を返してほしい場合は再変換が必要。

11.5 Javaから見たGroovyクラス

コンパイル済みGroovyクラスは通常のJavaクラスとして使える。 ただし次の点が異なる。

① すべてのクラスが GroovyObject を実装する
     → getMetaClass() / invokeMethod() などが生える

② def は Object になる
     → Javaから見ると型情報が無い

③ クロージャは groovy.lang.Closure
     → Javaから渡すには Closure のサブクラスを作る必要があり、実用的でない
     → 代わりに interface(SAM型)を受けるように設計する

④ groovy-all がランタイムに必要
     → Javaプロジェクトから使うには依存が増える

③が実務上の制約になる。 GroovyのAPIをJavaから使いやすくするには、クロージャではなくインターフェースを受ける。

// ❌ Javaから使いにくい
void forEach(Closure cl) { }

// ✅ Javaからも使える
void forEach(java.util.function.Consumer<String> c) { }

Groovyは SAM型に対してクロージャを自動変換するため、Groovy側の書き味は変わらない。

forEach { println it }        // Consumer に変換される

12. 数値の意味論 — Javaと違う4つの規則

12.1 除算はBigDecimalになる

p("7 / 2",         7 / 2)
p("7.intdiv(2)",   7.intdiv(2))
p("7 % 2",         7 % 2)
p("7 + 2",         7 + 2)
  7 / 2                          3.5  (BigDecimal)
  7.intdiv(2)                    3  (Integer)
  7 % 2                          1  (Integer)
  7 + 2                          9  (Integer)

/ だけが型を変える。 Javaでは 7 / 23(整数除算)だが、Groovyでは 3.5(BigDecimal)になる。

// Java
int x = 7 / 2;        // 3
// Groovy
def x = 7 / 2         // 3.5 (BigDecimal)
int y = 7 / 2         // 3 ← int に代入すると切り捨てられる
int z = 7.intdiv(2)   // 3 ← 明示的な整数除算

Javaからの移植で最も気付きにくい差である。 型宣言が int なら結果は同じになるが、def を使っていると値が変わる。

12.2 小数リテラルはBigDecimal

p("0.1",                   0.1)
p("0.1 + 0.2",             0.1 + 0.2)
p("0.1d + 0.2d",           0.1d + 0.2d)
p("(0.1d + 0.2d) == 0.3d", (0.1d + 0.2d) == 0.3d)
  0.1                            0.1  (BigDecimal)
  0.1 + 0.2                      0.3  (BigDecimal)
  0.1d + 0.2d                    0.30000000000000004  (Double)
  (0.1d + 0.2d) == 0.3d          false  (Boolean)

0.1 + 0.2 == 0.3 が真になる。 小数リテラルが BigDecimal なので、浮動小数点の誤差が出ない。

これは金額計算では利点である。

def total = 0.1 + 0.2         // 0.3 ちょうど
def price = 19.99 * 3         // 59.97 ちょうど

明示的に d を付けると Java と同じ double になり、誤差が出る。

性能は double のほうが速い。 大量の数値計算���は double を明示すべきである。

12.3 整数演算はオーバーフローする

p("1000000 * 1000000",     1000000 * 1000000)
p("Integer.MAX_VALUE + 1", Integer.MAX_VALUE + 1)
p("1000000L * 1000000",    1000000L * 1000000)
p("1000000G * 1000000",    1000000G * 1000000)
  1000000 * 1000000              -727379968  (Integer)
  Integer.MAX_VALUE + 1          -2147483648  (Integer)
  1000000L * 1000000             1000000000000  (Long)
  1000000G * 1000000             1000000000000  (BigInteger)

Integer 同士の + - * はJavaと同じくオーバーフローする。 自動昇格しない。

注意。 「Groovyは自動でBigIntegerに昇格する」という説明を見かけるが、四則演算については誤りである。 実測どおり -727379968 になる。

昇格するのは **(冪乗)だけである。

p("2 ** 10",       2 ** 10)
p("2 ** 40",       2 ** 40)
p("(2G) ** 100",   (2G) ** 100)
  2 ** 10                        1024  (Integer)
  2 ** 40                        1099511627776  (BigInteger)
  (2G) ** 100                    1267650600228229401496703205376  (BigInteger)

2 ** 40Integer に収まらないので BigInteger になる。 結果の大きさに応じて型が変わる。

オーバーフローを避けるには LG を付ける。

def a = 1000000L * 1000000      // Long
def b = 1000000G * 1000000      // BigInteger(上限なし)

12.4 4つの規則をまとめる

演算Groovyの結果Javaとの差
int / intBigDecimalJavaは整数除算
int + - * %Integerオーバーフローする同じ
int ** int結果に応じて Integer / BigIntegerJavaに ** は無い
小数リテラルBigDecimalJavaは double

この4つを知らずにJavaコードを移植すると、静かに値が変わる。 特に①と④は例外を出さないため、テストが無いと気付けない。

12.5 実務での指針

① 金額・数量の計算 → BigDecimal のまま扱う(Groovyの既定が有利)
② 科学計算・大量演算 → 明示的に d を付けて double にする
③ 整数除算が必要 → intdiv() を使う。/ は使わない
④ カウンタや合計 → long を明示する(オーバーフロー回避)
⑤ Javaから移植 → 型宣言を省かず、int / long / double を明示する

④は第14章のベンチマークで実際に踏んだ。 5000万件の合計を int で受けていたため負の値になった。


13. Groovy 3 と 4 — Parrotパーサとパッケージ移動

13.1 Groovy 3のParrotパーサ

Groovy 3で新しいパーサ(Parrot)が導入され、Java 8以降の構文が使えるようになった。 実測で確認する。

== Groovy 3 の Parrot パーサで入った構文 ==
  var                                    OK: 1
  ラムダ記法 ->                               OK: 3
  !(x in y)                              OK: true
  !in 演算子                                OK: true
  !instanceof 演算子                        OK: true
  do-while                               OK: 3
  C風 for(複数変数)                           OK: ok
  多重代入                                   OK: 3
  2進/16進/アンダースコア                         OK: 10
  @Field                                 OK: 1
  メソッド参照 ::                              OK: [1, 2, 3]
  メソッド参照 Integer::parseInt               OK: org.codehaus.groovy.runtime.MethodClosure@710b18a6
  interface の default メソッド               OK: ok

Groovy 3で使えるようになったもの。

構文
varvar x = 1
ラムダ記法(int a, int b) -> a + b
メソッド参照Integer::parseIntMethodClosure になる)
!in1 !in [2,3]
!instanceofx !instanceof Integer
do-whiledo { } while (cond)
C風 forfor (int i=0, j=9; i<2; i++, j--)
interfacedefaultJava 8互換

Integer::parseIntMethodClosure になる点に注意する。 Javaの関数型インターフェースではなく、Groovyのクロージャとして扱われる。

13.2 Groovy 4で入るもの(3では使えない)

  switch 式(Groovy4)                      ✗ Unexpected input: '(' @ line 1, column 15.
  sealed(Groovy4)                        ✗ Unexpected input: 'S' @ line 1, column 18.
  record(Groovy4)                        ✗ (構文エラー)

Groovy 3.0.24では使えないことを確認した。

機能Groovy 4
recordrecord Point(int x, int y) {}
sealedsealed interface Shape permits Circle, Square {}
switch式switch(x) { case 1 -> 'a'; default -> 'd' }
=== / !==is() の演算子版

Groovy 4は「Javaに追いつく」変更が中心である。

13.3 Groovy 4のパッケージ移動

互換性を壊す最大の変更である。 Groovy 3では両方存在することを確認した。

=== 3.0.24 に groovy.util.XmlSlurper は存在するか ===
  groovy.util.XmlSlurper           あり
  groovy.xml.XmlSlurper            あり
  groovy.util.XmlParser            あり
  groovy.xml.XmlParser             あり
  groovy.util.ConfigSlurper        あり
  groovy.util.Expando              あり

groovy-xml JARの中身を見ると、groovy/util/ 配下に残っていることが分かる。

$ unzip -l "$D/lib/groovy-xml-3.0.24.jar" | grep -iE 'Slurper|XmlParser'
  groovy/util/XmlParser.class
  groovy/util/XmlSlurper$1.class
  groovy/util/XmlSlurper.class
  groovy/util/slurpersupport/...

Groovy 4ではこの groovy.util.* の別名が削除された。

Groovy 3(両方使える)Groovy 4(後者のみ)
groovy.util.XmlSlurpergroovy.xml.XmlSlurper
groovy.util.XmlParsergroovy.xml.XmlParser
groovy.util.XmlNodePrintergroovy.xml.XmlNodePrinter
groovy.util.GroovyTestCasegroovy.test.GroovyTestCase

groovy.util.ConfigSlurpergroovy.util.Expando は移動していない(コアJARに残る)。

もう1つの大きな変更がある。

Groovy 3   groupId: org.codehaus.groovy
Groovy 4   groupId: org.apache.groovy      ← ★変わる

依存の座標が変わるため、ビルドファイルの修正が必要になる。

13.4 GradleとGroovyバージョンの結びつき

Gradleは同梱するGroovyのバージョンを固定している。

Gradle同梱Groovy
5.x2.5.x
6.x2.5.x
7.x3.0.x
8.x3.0.x

実測でも確認できる。

$ ls "$D/lib" | grep -E '^groovy-3'
groovy-3.0.24.jar
...

含意が2つある。

① ビルドスクリプトで使える構文はGradleが決める。 Gradle 8.14.1を使う限り、recordswitch 式は書けない。Groovy 4をプロジェクトの依存に追加しても、ビルドスクリプト自体の解釈には使われない。

buildSrc のコードも同じGroovyでコンパイルされる。 convention pluginをGroovyで書く場合、Groovy 3の構文に制約される。

// buildSrc/build.gradle で別バージョンを指定しても
dependencies { implementation 'org.apache.groovy:groovy:4.0.21' }
// ← ビルドスクリプトの解釈は Gradle 同梱の 3.0.24 のまま

アプリケーションコードでGroovy 4を使うことは可能である。 ビルドスクリプトとアプリのGroovyは��物として扱われる。

dependencies {
    implementation 'org.apache.groovy:groovy:4.0.21'      // アプリ用
}
// ビルドスクリプト自体は Gradle 同梱の 3.0.24 で解釈される

13.5 移行の判断

Groovy 2 → 3     必須。Parrotパーサは後方互換なので、たいてい無変更で動く
                 ただし一部の構文解析の差で壊れることがある

Groovy 3 → 4     groupId とパッケージの変更が必要
                 得られるのは record / sealed / switch式
                 Gradle スクリプトには影響しない(Gradleが3.0.xを使い続ける)

Gradleスクリプトだけに使っているなら、Groovy 4への移行は不要である。 Gradle自身がバージョンを上げるまで待てばよい。

14. 性能の現実 — 測って分かること

14.1 動的ディスパッチのコスト

同じ処理を「動的」と「@CompileStatic」で書き分けて測った。

class DynCaller  { def target(x) { x % 7 }
                   def loop(int n) { def t=0; for (int i=0;i<n;i++) { t += target(i) }; t } }
@CompileStatic
class StatCaller { int target(int x) { x % 7 }
                   long loop(int n) { long t=0; for (int i=0;i<n;i++) { t += target(i) }; t } }
== メソッド呼び出し 5000万回(結果を積算して消去を防ぐ) ==
  動的ディスパッチ                          405.5 ms  (結果=149999997)
  @CompileStatic                     21.6 ms  (結果=149999997)

約19倍の差である。 動的ディスパッチはメタクラスを経由して呼び出し先を解決するため、この差が出る。

ループだけを測った場合も同様である。

== ループ 2000万回の合計 ==
  動的(def)                                92.0 ms
  @CompileStatic                          4.8 ms

こちらも約19倍である。

測定の注意。 最初の試行では @CompileStatic 側が 0.0 ms になった。JITがループを不要と判断して消去したためである。結果を積算して戻り値に使うことで、消去を防いだ。 マイクロベンチマークではこの罠を踏みやすい。

14.2 @CompileStatic が効かない場所

コレクション操作では差が出なかった。

class LD { def go(int n) { (0..<n).collect{ it*2 }.findAll{ it%3==0 }.sum() } }
@CompileStatic class LS { def go(int n) { (0..<n).collect{ int it2 -> it2*2 }.findAll{ int it2 -> it2%3==0 }.sum() } }
== リスト操作 100万件 ==
  動的 collect+findAll                 47.6 ms  (結果=-1673782422)
  @CompileStatic 同                   46.8 ms  (結果=-1673782422)

差は2%以下である。 理由は、時間の大半がGroovyランタイムのコレクションメソッドの内部で消費されており、呼び出しのディスパッチは全体のごく一部だからである。

結論はこうなる。

@CompileStatic が効く場所
  ・タイトなループ(数百万回以上)
  ・自作メソッドの反復呼び出し
  ・数値演算

@CompileStatic が効かない場所
  ・コレクション操作(collect / findAll / groupBy)
  ・I/O が絡む処理
  ・呼び出し回数が少ないコード

「とりあえず @CompileStatic を付ければ速くなる」わけではない。 プロファイルを取ってホットスポットを特定してから適用する。

結果が負の値になっている点に注目。 sum() の結果が Integer でオーバーフローした(第12.3節)。実測が言語仕様の理解を裏付けた例である。

14.3 起動時間

== 起動時間(Hello World) ==
  Groovy スクリプト実行 1: 0.34 秒
  Groovy スクリプト実行 2: 0.34 秒
  Groovy スクリプト実行 3: 0.34 秒
  素の Java 実行 1: 0.02 秒
  素の Java 実行 2: 0.02 秒

約17倍の差である。 Groovyは起動時に次を行う。

① groovy-3.0.24.jar(約7 MB)のクラスロード
② スクリプトをその場でコンパイル(AST生成 → バイトコード)
③ メタクラスレジストリの初期化
④ 生成したクラスのロード

②を省くには事前コンパイルする。

$ java -cp "$GJ" org.codehaus.groovy.tools.FileSystemCompiler hello.groovy
$ java -cp "$GJ:." hello

それでも①③は残るため、素のJavaには届かない。

含意はこうなる。

CLIツール             起動時間が体感に直結する → Groovyは不利
サーバアプリケーション    起動は1回だけ → 問題にならない
ビルドスクリプト        Gradleデーモンが常駐する → 問題にならない
サーバレス関数         コールドスタートが効く → Groovyは不利

Gradleがデーモンを持つ理由の1つがこれである。 JVMとGroovyの初期化を再利用している。

14.4 メモリ

Groovyは各クラスに対してメタクラスを保持する。 加えてクロージャがそれぞれ独立したクラスになるため、クラス数が増える。

クロージャ1個 = クラス1個(<外側>$_<メソッド>_closure<n>)

ビルドスクリプトが数百あるプロジェクトでは、これがメタスペースを圧迫する。 姉妹記事 §15.7 で、ある大規模プロジェクトが次を設定していることを示した。

org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError

メタスペース1 GBは、生成されるクラス数の多さに対する備えである。

14.5 性能改善の順序

① プロファイルを取る(どこが遅いかを数字で見る)
② ホットスポットに @CompileStatic を付ける(19倍を狙える)
③ 引数と戻り値に型を書く(付けないと Object になり効果が薄い)
④ コレクション操作が重い場合は Java Stream か明示的ループに置き換える
⑤ 数値計算は double を明示する(BigDecimal は遅い)
⑥ 起動時間が問題なら、そもそもGroovyを使わない

②と③はセットである。 @CompileStatic を付けても引数が def のままなら、型が Object になって静的化の恩恵が小さい(第9.5節)。


15. 実運用での位置づけ — SMPを実測する

15.1 大規模Javaアプリケーション群での使用状況

ある決済プラットフォームのリポジトリ群を走査した。

$ find . -name '*.groovy' -not -path '*/.git/*' | wc -l
0
$ find . -name '*.gradle' -not -path '*/.git/*' | wc -l
717
$ find . -name 'Jenkinsfile*' -not -path '*/.git/*' | wc -l
2
$ find . -name '*Spec.groovy' -not -path '*/.git/*' | wc -l
0
$ find . -type d -path '*src/*/groovy' -not -path '*/.git/*' | wc -l
0

結果を表にする。

種別件数意味
.groovy ファイル0アプリケーションコードにGroovyは無い
.gradle(Groovy DSL)717ビルドスクリプト
Jenkinsfile245行 と 2行
*Spec.groovy(Spock)0テストにも使われていない
src/*/groovy0Groovyのソースディレクトリが無い

つまりこの資産では、Groovyは「Gradle DSLとしてのみ」存在する。

15.2 この事実が意味すること

Groovyを学ぶ実務的な動機は、ビルドスクリプトを読み書きするためである。

アプリケーションコード     Java(+一部 Kotlin)
テスト                  JUnit(Spockではない)
CI パイプライン           Rio(社内システム。Jenkinsfileは2件のみで、片方は2行)
設定                    CUE / Terragrunt / HCL
ビルド                  ★ Gradle(Groovy DSL)717ファイル

Groovyの「アプリケーション言語としての採用」は、少なくともこの資産では起きていない。 これは業界全体の傾向とも一致する — Groovyの主戦場はビルドとCIに移った。

Jenkinsfileが2件しか無い点も示唆的である。

  payments/deployment/Jenkinsfile : 45行
  aqueduct/keystone2/Jenkinsfile : 2行

片方は2行しかない(実質的に使われていない)。CIは社内のRioに移行済みで、GroovyベースのJenkins Pipelineは残骸に近い。

15.3 一般的なユースケース(この資産では使われていないもの)

公平に、Groovyが本来強い領域を挙げる。

Spock — テストフレームワーク

class CalculatorSpec extends Specification {
    def "加算は交換法則を満たす"() {
        expect:
        a + b == b + a

        where:
        a  | b
        1  | 2
        -5 | 10
        0  | 0
    }
}

where ブロックによるデータ駆動テストは、JUnit 5の @ParameterizedTest より読みやすい。 ラベル(given: / when: / then:)が構造を明示する。

JUnitとの比較。

観点SpockJUnit 5
データ駆動where ブロック(表形式)@ParameterizedTest@MethodSource
モック組み込み(Mock() / Stub()Mockito等が別途必要
例外検証thrown(E)assertThrows
テスト名文字列でそのまま書ける@DisplayName
学習コストGroovyの知識が必要Javaのみ

採用の障壁は「Groovyを入れること」である。 Javaのみのチームでは、テストのためだけにGroovyランタイムとGroovyの学習を持ち込む判断が難しい。

Jenkins Pipeline

pipeline {
    agent any
    stages {
        stage('Build') { steps { sh './gradlew build' } }
        stage('Test')  { steps { sh './gradlew test'  } }
    }
}

これはGroovyのDSLである。 pipeline { } が第4章で見た「delegateを差し替えるクロージャ」そのものである。

ただしJenkins固有の制約が大きい。

・CPS変換(継続渡しスタイル)されるため、通常のGroovyと挙動が違う
・@NonCPS を付けないと使えないメソッドがある
・シリアライズ可能なオブジェクトしかステップ間で保持できない
・サンドボックスによりメタプログラミングが制限される

「JenkinsfileはGroovyだが、Groovyとして書くと落ちる」という状態になりやすい。

Grails

Ruby on Railsに影響を受けたフルスタックフレームワークである。 GORM(動的ファインダ)がメタプログラミングの代表例である。

Book.findByTitleAndAuthor('t', 'a')      // メソッドは定義されていない

methodMissing でメソッド名を解析してクエリを組み立てている(第8.3節)。強力だが、IDEの補完が効かず、タイポが実行時エラーになる。

Spring Bootの台頭により、新規採用はほぼ止まっている。

15.4 いま何を学ぶべきか

この資産の実測から言えることを整理する。

必ず要る(Gradleスクリプトを読むため)
  ・第3章 Groovy truth      ← if (closure) バグの理解
  ・第4章 クロージャ           ← DSLが成立する仕組み
  ・第5章 GString            ← マップキーの罠
  ・第6章 演算子              ← ?. ?: *. の読解
  ・第7章 コレクション操作        ← each / collect / findAll

あると役立つ
  ・第9章 @CompileStatic     ← buildSrc の convention plugin に付けられる
  ・第10章 AST変換            ← @Canonical などを読めるように

必要になったら学ぶ
  ・第8章 メタプログラミング       ← Grails や Spock の内部を読むとき
  ・第14章 性能              ← Groovyでアプリを書くとき

優先順位は明確である。 Gradleスクリプトを読むために必要な範囲(第3〜7章)が先で、それ以外は必要に応じてよい。


16. 落とし穴

16.1 クロージャを条件式に書く

// ❌ 常に真
def enabled = { someCondition() }
if (enabled) { }
// ✅
if (enabled()) { }

探し方。

$ grep -rnE 'if \(\s*[a-z][a-zA-Z]*\s*\)' --include='*.groovy' --include='*.gradle' . | grep -v '()'

第3.3節の実バグがこれである。

16.2 File を存在判定に使う

// ❌
if (new File(path)) { }
// ✅
if (new File(path).exists()) { }

File は「パスを表すオブジェクト」なので常に真である(第3.2節)。

16.3 0 を正当な値とする場面でGroovy truthを使う

// ❌ count が 0 のとき既定値になってしまう
def limit = count ?: 10
// ✅
def limit = count != null ? count : 10

?: はnull合体演算子ではない(第6.1節)。空文字列も0も弾く。

16.4 GStringをマップのキーにする

// ❌ 後から引けない
def m = ["$env": config]
// ✅
def m = [(env): config]
def m = [("${env}".toString()): config]

== は動くのに Map から引けないという非対称性がある(第5.2節)。

16.5 sort / unique の破壊的挙動

// ❌ orig の順序が変わる
def sorted = orig.sort()
// ✅
def sorted = orig.toSorted()
def sorted = orig.sort(false)

第7.2節。 元のリストを後で使うコードは静かに壊れる。

16.6 / が整数除算にならない

// ❌ Javaから移植すると値が変わる
def half = total / 2          // BigDecimal
// ✅
def half = total.intdiv(2)    // Integer
int half = total / 2          // 代入時に切り捨て

第12.1節。 例外が出ないため、テストが無いと気付けない。

16.7 整数オーバーフロー

// ❌ Integer のまま計算してオーバーフロー
def total = list.sum()                // 大きな合計で負になる
// ✅
def total = list.sum(0L)              // Long で受ける
def total = list.inject(0L){ a,b -> a+b }

第12.3節。 第14.2節のベンチマークで実際に踏んだ。

16.8 ==is() を逆に覚える

// Java の癖で書くと意味が変わる
if (a == b) { }        // Groovy: 値の比較(Javaは参照の比較)
if (a.is(b)) { }       // Groovy: 参照の比較

第6.2節。 Javaからの移植で最も危険な差である。

16.9 invokeMethod が傍受しないと思い込む/思い込まない

// ❌ 既存メソッドは傍受されない
class A { def invokeMethod(String n, args) { } ; def real() { } }
// ✅
class A implements GroovyInterceptable { def invokeMethod(String n, args) { } }

第8.4節で実測した。 GroovyInterceptable が必要である。

16.10 @CompileStatic を付けたのに効果が無い

// ❌ 引数が Object になるため静的化の恩恵が小さい
@CompileStatic
def process(x) { x.transform() }      // コンパイルエラーにもなる

// ✅
@CompileStatic
String process(Widget x) { x.transform() }

第9.5節。 型を書かないと @CompileStatic は機能しない。

16.11 メタプログラミングをライブラリで使う

// ❌ 利用者に見えない形でグローバルに影響する
class MyLib { static { String.metaClass.helper = { } } }
// ✅ スコープを限定する
use(MyCategory) { }

第8.7節。 カテゴリなら影響範囲が構文的に明示される。

16.12 症状別の逆引き

症状原因対処
if が常に真になるクロージャ・File を真偽評価() で呼ぶ / .exists()16.1 / 16.2
既定値が意図せず���われる?: ���0や空文字列を弾く明示的なnull比較16.3
マップから値が引けないキーがGString(key) / toString()16.4
リストの順序が変わっているsort() が破壊的toSorted()16.5
計算結果が小数になる/ がBigDecimalを返すintdiv()16.6
合計が負になるIntegerオーバーフローsum(0L)16.7
参照比較したいのに一致する== が値比較is()16.8
メソッド傍受が効かないGroovyInterceptable 未実装実装する16.9
@CompileStatic で速くならない引数が Object型を明示16.10
無関係なコードが壊れたmetaClass のグローバル汚染カテゴリに変える16.11
実行時に MissingMethodException動的なので検出できない@CompileStatic9.1
ビルドスクリプトの型検査ができないGradleのDSLは動的Kotlin DSLへ9.6

17. まとめ

17.1 Groovyの正体

Groovyの便利さは、3つの仕組みに集約される。

① あらゆる値を条件式に書ける(Groovy truth)
     → 短く書ける。しかし「オブジェクトは常に真」が事故を生む(第3章)

② クロージャの delegate を差し替えられる
     → DSLが書ける。Gradle も Jenkins も Spock もこれ(第4章)

③ 実行時にクラスを書き換えられる(メタプログラミング)
     → 動的ファインダのような魔法が作れる。原因追跡は困難になる(第8章)

この3つはすべて「静的に検証できないこと」と引き換えに得られている。 @CompileStatic を付けると①の一部と③が使えなくなる(第9章)。

17.2 実測から得た5つの事実

本記事で測って確定したことを挙げる。

① クロージャは常に真である。 { false } も真になる。これが実在のビルドスクリプトのバグの根拠だった(第3.3節)。

== はGStringを特別扱いするが、equals / hashCode はしない。 だから「== は動くのにマップから引けない」という非対称な挙動になる(第5.2節)。多くの解説が == も失敗すると書いているが、実測では true である。

③ 整数の四則演算はオーバーフローする。 「Groovyは自動でBigIntegerに昇格する」は誤りで、昇格するのは ** だけである(第12.3節)。

@CompileStatic は19倍速くするが、効く場所は限られる。 タイトなループでは19倍だが、コレクション操作では2%以下だった(第14.1〜14.2節)。

invokeMethodGroovyInterceptable が無いと既存メソッドを傍受しない。 未定義メソッドだけが対象になる(第8.4節)。

17.3 いま学ぶ理由と学ばない理由

学ぶ理由。

・既存のGradleスクリプトを読み書きする必要がある(実測で717ファイル)
・Jenkinsfile を読む必要がある
・Spock のテストを読む必要がある

新規採用しない理由。

・型安全性が要件なら Kotlin のほうが素直(@CompileStatic を全面適用するなら等価)
・起動時間が17倍遅い(第14.3節)
・動的ディスパッチが19倍遅い(第14.1節)
・エコシステムの新規開発が Kotlin に移っている

結論はこうなる。 Groovyは「読むための言語」として学ぶ価値が高く、「書くための言語」として新規に選ぶ理由は薄い。

17.4 学習の順序

Gradleスクリプトを読めるようになることを目標にするなら、この順序が効率的である。

1. 第3章 Groovy truth        条件式の規則を覚える(最重要)
2. 第4章 クロージャ             delegate と resolveStrategy を理解する
3. 第6章 演算子               ?. ?: *. =~ を読めるようにする
4. 第7章 コレクション操作         each / collect / findAll / inject
5. 第5章 GString             マップキーの罠を知る
6. 第16章 落とし穴             逆引き表を手元に置く

第8章(メタプログラミング)と第10章(AST変換)は、必要になってから読めばよい。

17.5 静的型付け言語との使い分け

目的推奨
既存Gradleスクリプトの保守Groovy(選択の余地がない)
新規のビルドロジックKotlin DSL、または buildSrc にGroovyで書いて @CompileStatic
アプリケーション本体Kotlin / Java
テストJUnit 5(Groovyを入れられるならSpock)
CIパイプライン宣言的な設定(YAML等)。Jenkinsを使うならGroovy
スクリプト・自動化起動時間を許容できるなら Groovy は書きやすい

「Groovyを捨てる」という判断が現実的でない場面が多いことが、実測からも分かる。717ファイルのビルドスクリプトを書き換えるコストは、得られる型安全性に見合わない(姉妹記事 §17.5)。

現実的な折衷案はこうなる。

・既存のビルドスクリプトはGroovyのまま維持する
・ビルドロジックは buildSrc に移し、@CompileStatic を付ける
・新規プロジェクトのみ Kotlin DSL を選ぶ
・grep で第16章の落とし穴を機械的に検出する仕組みをCIに入れる

付録A: よく使う実行コマンド

# --- 環境変数の設定 ---
D=$(ls -d ~/.gradle/wrapper/dists/gradle-8.14.1-bin/*/gradle-8.14.1)
GJ="$D/lib/groovy-3.0.24.jar"
ALL=$(ls "$D"/lib/groovy*.jar | tr '\n' ':')

# --- スクリプトの実行 ---
java -cp "$GJ" groovy.ui.GroovyMain script.groovy
java -cp "$GJ" groovy.ui.GroovyMain -e 'println 1+1'
java -cp "$ALL" groovy.ui.GroovyMain script.groovy      # JSON/XML を使う場合

# --- 事前コンパイル ---
java -cp "$GJ" org.codehaus.groovy.tools.FileSystemCompiler script.groovy
java -cp "$GJ:." script

# --- バージョン確認 ---
java -cp "$GJ" groovy.ui.GroovyMain -e 'println GroovySystem.version'

# --- 同梱モジュールの一覧 ---
ls "$D/lib" | grep -E '^groovy'

# --- ~/.zshrc に入れる ---
GROOVY_LIB="$HOME/.gradle/wrapper/dists/gradle-8.14.1-bin"/*/gradle-8.14.1/lib
alias groovy='java -cp "$(ls -d $GROOVY_LIB)/groovy-3.0.24.jar" groovy.ui.GroovyMain'

付録B: 棚卸しコマンド

既存のGroovy/Gradleコードを監査するのに使う。

# クロージャを真偽評価している疑い(第16.1節)
grep -rnE 'if \(\s*[a-z][a-zA-Z]*\s*\)' --include='*.groovy' --include='*.gradle' . | grep -v '()'

# File を存在判定に使っている疑い(第16.2節)
grep -rnE 'if \(\s*(new File|file)\([^)]*\)\s*\)' --include='*.groovy' --include='*.gradle' .

# GString をマップキーにしている(第16.4節)
grep -rnE '\[\s*"\$' --include='*.groovy' --include='*.gradle' .

# 破壊的な sort / unique(第16.5節)
grep -rnE '\.(sort|unique|reverse)\(\s*\)' --include='*.groovy' --include='*.gradle' .

# @CompileStatic の適用率
echo "static: $(grep -rlc 'CompileStatic' --include='*.groovy' . | wc -l) / all: $(find . -name '*.groovy' | wc -l)"

# 使われているAST変換
grep -rhoE '@(ToString|EqualsAndHashCode|Canonical|Immutable|Builder|Memoized|Delegate|Sortable|TupleConstructor|Singleton|Lazy)' \
  --include='*.groovy' . | sort | uniq -c | sort -rn

# metaClass によるモンキーパッチ(第16.11���)
grep -rn 'metaClass\.' --include='*.groovy' .

# Groovy 4 で壊れるパッケージ参照(第13.3節)
grep -rnE 'groovy\.util\.(XmlSlurper|XmlParser|XmlNodePrinter|GroovyTestCase)' --include='*.groovy' .

# DSLの型情報アノテーション(第4.9節)
grep -rc '@DelegatesTo\|@ClosureParams' --include='*.groovy' . | grep -v ':0'

付録C: 参考資料

資料内容
Groovy Documentation公式ドキュメント
Groovy Language Specification意味論(Groovy truth、演算子)
Closuresdelegate / resolveStrategy の詳細
Metaprogrammingメタクラス、AST変換
Static type checking@CompileStatic / @TypeChecked
GDK APIJavaクラスに追加されたメソッド一覧
Groovy 4 Release Notesパッケージ移動とgroupId変更
Spockテストフレームワーク

姉妹記事:

記事内容
Gradle with Groovy-overview-ai_JP.mdGradleのライフサイクル、タスク、依存解決(17章)
Gradle with Groovy-get-started-ai_JP.mdGradleを動かす手順書
Gradle with Kotlin-overview-ai_JP.mdKotlin DSLとの対比

本記事の検証環境: Groovy 3.0.24(Gradle 8.14.1 同梱)/ AppleJDK 21.0.8 / macOS 26(arm64、Apple M4 Max)。掲載したコンソール出力はすべてこの環境で実測したものである。SMPの数値は core-smp / aqueduct / payments の各ツリーを走査して得た。