Groovy
Groovy 徹底解説 — 動的型付けJVM言語の仕組みと現実
目次
- Groovyとは何か — 位置づけと本記事の立場
- 実行環境 —
groovyCLIが無くても動かせる - Groovy truth — 真偽値の規則
- クロージャ — DSLが成立する仕組み
- GStringとString — 似て非なる2つの型
- 演算子 — Javaに無い記号の意味
- コレクション操作 — 標準ライブラリの拡張
- メタプログラミング — 実行時にクラスを書き換える
- 静的型付けへの回帰 —
@CompileStaticと@TypeChecked - AST変換 — アノテーションがコードを生成する
- Javaとの相互運用
- 数値の意味論 — Javaと違う4つの規則
- Groovy 3 と 4 — Parrotパーサとパッケージ移動
- 性能の現実 — 測って分かること
- 実運用での位置づけ — SMPを実測する
- 落とし穴
- まとめ
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.md | Gradleのライフサイクル、タスク、依存解決、マルチプロジェクト |
Gradle with Groovy-get-started-ai_JP.md | Gradleを実際に動かす手順書 |
重複を避けるため、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.* の一部を削除、sealed、record、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]
本記事のコンソール出力はすべてこの方法で得たものである。 以降、
$GJはgroovy-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 | 常に |
Boolean | false |
数値(Integer / BigDecimal など) | 0 / 0.0 |
CharSequence | 空文字列 |
Collection | 空 |
Map | 空 |
Iterator / Enumeration | 次の要素が無い |
Matcher | マッチしない |
Optional | empty() |
| それ以外のすべてのオブジェクト | 常に真 |
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 — 名前解決の順序
owner と delegate の両方に同名メソッドがある場合、どちらが呼ばれるかを決める。
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_FIRST | 0(既定) | owner → delegate | delegateへフォールバック |
DELEGATE_FIRST | 1 | delegate → owner | ownerへフォールバック |
OWNER_ONLY | 2 | ownerのみ | 例外 |
DELEGATE_ONLY | 3 | delegateのみ | 例外 |
TO_SELF | 4 | クロージャ自身のみ | 例外 |
_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 >> g は g(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章で「クロージャは真」だった理由である — Closure は Collection でも 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では引けない。 HashMap は hashCode でバケットを決めるため、== の特別扱いが効かない。
contains も外す。
['prod'].contains(g) false
[g].contains('prod') false
['prod'].indexOf(g) -1
Set([g]).contains(s) false
ArrayList.contains は equals を使うので、すべて外れる。 これは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.List は Comparable を実装していないため例外になる。 複数キーのソートはリストを返すクロージャを渡す。
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 | 完全一致するか |
=~ の結果が真偽値として使えるのは、Matcher の asBoolean が「マッチしたか」を返すからである(第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との対応はこうなる。
| Groovy | Java 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
sort と unique は破壊的である。 引数に 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 invokeMethod は GroovyInterceptable が必要
よくある誤解を実測で潰す。
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 主要なものの一覧
| アノテーション | 生成するもの |
|---|---|
@ToString | toString() |
@EqualsAndHashCode | equals() / hashCode() |
@TupleConstructor | 位置引数のコンストラクタ |
@MapConstructor | Mapを受けるコンストラクタ |
@Canonical | 上記3つをまとめて |
@Immutable | @Canonical + フィールドを final 化 + コレクションを不変化 |
@Builder | ビルダクラス |
@Memoized | メソッド結果のキャッシュ |
@Delegate | フィールドの型のメソッドを委譲で生やす |
@Sortable | Comparable の実装 |
@Singleton | getInstance() と instance プロパティ |
@Lazy | 遅延初期化 |
@AutoClone / @AutoExternalize | clone() / 直列化 |
@InheritConstructors | 親のコンストラクタを継承 |
@Field | スクリプト内でフィールドを宣言 |
@Newify | new を省略できる記法 |
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
Outer に Inner のメソッドがすべて生える。 多重継承の代替として使える。
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
def は Object として宣言される。 つまりすべてがボクシングされる。 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の型 |
|---|---|---|
1 | Integer | int |
1L | Long | long |
1.0 | BigDecimal | double |
1.0f | Float | float |
1.0d | Double | double |
1G | BigInteger | — |
1.0G | BigDecimal | — |
1.0 が BigDecimal になる点が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 / 2 は 3(整数除算)だが、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 ** 40 は Integer に収まらないので BigInteger になる。 結果の大きさに応じて型が変わる。
オーバーフローを避けるには L か G を付ける。
def a = 1000000L * 1000000 // Long
def b = 1000000G * 1000000 // BigInteger(上限なし)
12.4 4つの規則をまとめる
| 演算 | Groovyの結果 | Javaとの差 |
|---|---|---|
int / int | BigDecimal | Javaは整数除算 |
int + - * % | Integer(オーバーフローする) | 同じ |
int ** int | 結果に応じて Integer / BigInteger | Javaに ** は無い |
| 小数リテラル | BigDecimal | Javaは 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で使えるようになったもの。
| 構文 | 例 |
|---|---|
var | var x = 1 |
| ラムダ記法 | (int a, int b) -> a + b |
| メソッド参照 | Integer::parseInt(MethodClosure になる) |
!in | 1 !in [2,3] |
!instanceof | x !instanceof Integer |
do-while | do { } while (cond) |
C風 for | for (int i=0, j=9; i<2; i++, j--) |
interface の default | Java 8互換 |
Integer::parseInt が MethodClosure になる点に注意する。 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 |
|---|---|
record | record Point(int x, int y) {} |
sealed | sealed 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.XmlSlurper | groovy.xml.XmlSlurper |
groovy.util.XmlParser | groovy.xml.XmlParser |
groovy.util.XmlNodePrinter | groovy.xml.XmlNodePrinter |
groovy.util.GroovyTestCase | groovy.test.GroovyTestCase |
groovy.util.ConfigSlurper と groovy.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.x | 2.5.x |
| 6.x | 2.5.x |
| 7.x | 3.0.x |
| 8.x | 3.0.x |
実測でも確認できる。
$ ls "$D/lib" | grep -E '^groovy-3'
groovy-3.0.24.jar
...
含意が2つある。
① ビルドスクリプトで使える構文はGradleが決める。 Gradle 8.14.1を使う限り、record や switch 式は書けない。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 | ビルドスクリプト |
Jenkinsfile | 2 | 45行 と 2行 |
*Spec.groovy(Spock) | 0 | テストにも使われていない |
src/*/groovy | 0 | Groovyのソースディレクトリが無い |
つまりこの資産では、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との比較。
| 観点 | Spock | JUnit 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 | 動的なので検出できない | @CompileStatic | 9.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節)。
⑤ invokeMethod は GroovyInterceptable が無いと既存メソッドを傍受しない。 未定義メソッドだけが対象になる(第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、演算子) |
| Closures | delegate / resolveStrategy の詳細 |
| Metaprogramming | メタクラス、AST変換 |
| Static type checking | @CompileStatic / @TypeChecked |
| GDK API | Javaクラスに追加されたメソッド一覧 |
| Groovy 4 Release Notes | パッケージ移動とgroupId変更 |
| Spock | テストフレームワーク |
姉妹記事:
| 記事 | 内容 |
|---|---|
Gradle with Groovy-overview-ai_JP.md | Gradleのライフサイクル、タスク、依存解決(17章) |
Gradle with Groovy-get-started-ai_JP.md | Gradleを動かす手順書 |
Gradle with Kotlin-overview-ai_JP.md | Kotlin DSLとの対比 |
本記事の検証環境: Groovy 3.0.24(Gradle 8.14.1 同梱)/ AppleJDK 21.0.8 / macOS 26(arm64、Apple M4 Max)。掲載したコンソール出力はすべてこの環境で実測したものである。SMPの数値は core-smp / aqueduct / payments の各ツリーを走査して得た。