Groovy (Jenkins, Gradle and Spock) Module 2 – Core syntax: values, strings, operators and control flow
Operators, ranges, spread and membership
Groovy's spread, spaceship, subscript and range operators, operators you write as methods, and why x in y answers differently on Groovy 6.
What you will learn
- Use the spread, spaceship, subscript and range operators on lists, strings and ranges
- Implement operator methods such as plus, compareTo, leftShift, getAt and call in a class of your own
- Explain how the in operator changed in Groovy 6, with the bytecode as evidence
- Write membership checks that give the same answer on every runtime of this track
Before you start
What you’ll learn · Before you start
What you will learn
- Use the spread, spaceship, subscript and range operators on lists, strings and ranges
- Implement operator methods such as plus, compareTo, leftShift, getAt and call in a class of your own
- Explain how the in operator changed in Groovy 6, with the bytecode as evidence
- Write membership checks that give the same answer on every runtime of this track
Before you start
On this page
'debug' in [debug: false] is false on Groovy 5.1.3 and true on Groovy 6.0.0: the same line, one release apart,
gives opposite answers. Groovy’s operators are short names for method calls, and Groovy 6 changed which method in
calls. Most of the other operators behave the same on all four runtimes of this track, and they are what makes list
and string code short: *., <=>, subscripts with ranges, and operators you define for your own classes.
You will slice lists and strings with ranges, give a class +, <, <<, [] and (), read the bytecode that shows
what in compiles to, and write membership checks that mean the same in a Jenkinsfile and in a Groovy 6 script.
Spread, spaceship and subscripts work on whole lists
The spread operator: The operator *. in Groovy: list*.name calls name on every element and collects the results, like list.collect { it.name }. It gives null for a null list and null for each null element., *., calls a property or a method on every element of a
collection and collects the results; the documentation says cars*.make is equivalent to cars.collect{ it.make }.
The spaceship operator: The operator <=> in Groovy, which calls compareTo and gives a negative number, 0 or a positive number, as a sort comparator needs. null sorts before any value instead of throwing., <=>, delegates to compareTo and gives a negative number,
0 or a positive number, which is what a sort comparator returns. A subscript takes a position, a negative position
counted from the end, a range or a list of positions:
// Spread, spaceship and subscripts on a list of builds and a list of stages.
def builds = [[job: 'api', mins: 12], [job: 'web', mins: 7], [job: 'docs', mins: 2]]
println "builds*.job ${builds*.job}"
println "builds*.mins.sum() ${builds*.mins.sum()}"
println "1 <=> 2, 2 <=> 2, 3 <=> 2 ${1 <=> 2}, ${2 <=> 2}, ${3 <=> 2}"
println "null <=> 1, 1 <=> null ${null <=> 1}, ${1 <=> null}"
println "sorted by mins ${builds.sort(false) { a, b -> a.mins <=> b.mins }*.job}"
def stages = ['build', 'test', 'scan', 'deploy']
println "stages[-1] ${stages[-1]}"
println "stages[1..2] ${stages[1..2]}"
println "stages[-2..-1] ${stages[-2..-1]}"
println "stages[0, 3] ${stages[0, 3]}"
println "stages[7] ${stages[7]}"
try {
stages[1..7]
} catch (IndexOutOfBoundsException e) {
println "stages[1..7] IndexOutOfBoundsException"
}
println "'release-6.2'[8..-1] ${'release-6.2'[8..-1]}"
println "[1, 2, 2, 3] - [2] ${[1, 2, 2, 3] - [2]}" from session import jenkins_jar, run, use
# Spread, spaceship and subscripts on Groovy 6.0.0, then on each older runtime: diff prints only the lines that differ from 6.0.0's.
run("""
groovy ops.groovy | tee on-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
use(version)
run(f"""
groovy ops.groovy > on-{version}.txt
diff on-6.0.0.txt on-{version}.txt && echo 'Groovy {version} printed the same lines' || echo 'Groovy {version} differs in the lines above'
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain ops.groovy > on-2.4.21.txt
diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines' || echo 'Groovy 2.4.21 differs in the lines above'
""") Output
$ groovy ops.groovy | tee on-6.0.0.txt builds*.job [api, web, docs] builds*.mins.sum() 21 1 <=> 2, 2 <=> 2, 3 <=> 2 -1, 0, 1 null <=> 1, 1 <=> null -1, 1 sorted by mins [docs, web, api] stages[-1] deploy stages[1..2] [test, scan] stages[-2..-1] [scan, deploy] stages[0, 3] [build, deploy] stages[7] null stages[1..7] IndexOutOfBoundsException 'release-6.2'[8..-1] 6.2 [1, 2, 2, 3] - [2] [1, 3] # The groovy command is Groovy 5.1.3 from here on. $ groovy ops.groovy > on-5.1.3.txt $ diff on-6.0.0.txt on-5.1.3.txt && echo 'Groovy 5.1.3 printed the same lines' || echo 'Groovy 5.1.3 differs in the lines above' Groovy 5.1.3 printed the same lines # The groovy command is Groovy 4.0.33 from here on. $ groovy ops.groovy > on-4.0.33.txt $ diff on-6.0.0.txt on-4.0.33.txt && echo 'Groovy 4.0.33 printed the same lines' || echo 'Groovy 4.0.33 differs in the lines above' Groovy 4.0.33 printed the same lines # groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central. $ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain ops.groovy > on-2.4.21.txt $ diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines' || echo 'Groovy 2.4.21 differs in the lines above' Groovy 2.4.21 printed the same lines
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ops_session.py
builds*.job collected the three job names, and builds*.mins.sum() added the minutes, 21. null <=> 1 is -1: a
null sorts before any value instead of throwing. stages[-1] is the last stage, stages[1..2] and
stages[-2..-1] are slices, and stages[0, 3] picks two positions. Reading past the end is lenient for one position,
stages[7] is null, but strict for a range, which threw IndexOutOfBoundsException. On a list, - removes every
copy of each value, so [1, 2, 2, 3] - [2] left [1, 3], unlike the String minus of the previous lesson. All four
runtimes printed the same lines.
Key idea
*. maps a property over a list, <=> returns what a comparator needs, and a subscript takes a position, a range or a
list; a single position past the end gives null, a range past the end throws.
A range is a list that stores only its two ends
A range: A Groovy List such as 1..5 or 'a'..'e' that stores only its two ends. ..< leaves out the upper end and, from Groovy 4, <.. the lower one. such as 1..5 implements List, but the documentation says that “only the lower
and upper bounds are stored”, so 1..1000000 costs no more than 1..5. ..< leaves out the upper end, a range can
count down, and any Comparable with next() and previous() can form one, as letters do. Groovy 4 added ranges that
leave out the lower end too:
GROOVY (ranges.groovy) · ranges.groovy
// Ranges: what they hold, how they print, and what they contain.
def show(String code, Object value) {
println "${code.padRight(22)}$value"
}
show '(1..5).toList()', (1..5).toList()
show '(1..<5).toList()', (1..<5).toList()
show '(5..1).toList()', (5..1).toList()
show "('a'..'e').toList()", ('a'..'e').toList()
show '(1..10).step(3)', (1..10).step(3)
show '(1..5).size()', (1..5).size()
show '3 in (1..5)', 3 in (1..5)
show '3.5 in (1..5)', 3.5 in (1..5)
show '"${1..5}"', "${1..5}"
show '(1..5).toString()', (1..5).toString() GROOVY (openRanges.groovy) · openRanges.groovy
println "(1<..<5).toList() ${(1<..<5).toList()}"
println "(1<..5).toList() ${(1<..5).toList()}" from session import jenkins_jar, run, use
# Ranges on Groovy 6.0.0, then on each older runtime (diff prints the lines that differ); then the Groovy 4 open ranges.
run("""
groovy ranges.groovy | tee on-6.0.0.txt
groovy openRanges.groovy
""")
for version in ("5.1.3", "4.0.33"):
use(version)
run(f"""
groovy ranges.groovy > on-{version}.txt
diff on-6.0.0.txt on-{version}.txt && echo 'Groovy {version} printed the same lines' || echo 'Groovy {version} differs in the lines above'
groovy openRanges.groovy
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain ranges.groovy > on-2.4.21.txt
diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines' || echo 'Groovy 2.4.21 differs in the lines above'
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain openRanges.groovy 2>&1 | grep 'openRanges.groovy: 1:'
""") Output
$ groovy ranges.groovy | tee on-6.0.0.txt
(1..5).toList() [1, 2, 3, 4, 5]
(1..<5).toList() [1, 2, 3, 4]
(5..1).toList() [5, 4, 3, 2, 1]
('a'..'e').toList() [a, b, c, d, e]
(1..10).step(3) [1, 4, 7, 10]
(1..5).size() 5
3 in (1..5) true
3.5 in (1..5) false
"${1..5}" 1..5
(1..5).toString() 1..5
$ groovy openRanges.groovy
(1<..<5).toList() [2, 3, 4]
(1<..5).toList() [2, 3, 4, 5]
# The groovy command is Groovy 5.1.3 from here on.
$ groovy ranges.groovy > on-5.1.3.txt
$ diff on-6.0.0.txt on-5.1.3.txt && echo 'Groovy 5.1.3 printed the same lines' || echo 'Groovy 5.1.3 differs in the lines above'
9c9
< "${1..5}" 1..5
---
> "${1..5}" [1, 2, 3, 4, 5]
Groovy 5.1.3 differs in the lines above
$ groovy openRanges.groovy
(1<..<5).toList() [2, 3, 4]
(1<..5).toList() [2, 3, 4, 5]
# The groovy command is Groovy 4.0.33 from here on.
$ groovy ranges.groovy > on-4.0.33.txt
$ diff on-6.0.0.txt on-4.0.33.txt && echo 'Groovy 4.0.33 printed the same lines' || echo 'Groovy 4.0.33 differs in the lines above'
9c9
< "${1..5}" 1..5
---
> "${1..5}" [1, 2, 3, 4, 5]
Groovy 4.0.33 differs in the lines above
$ groovy openRanges.groovy
(1<..<5).toList() [2, 3, 4]
(1<..5).toList() [2, 3, 4, 5]
# groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central.
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain ranges.groovy > on-2.4.21.txt
$ diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines' || echo 'Groovy 2.4.21 differs in the lines above'
9c9
< "${1..5}" 1..5
---
> "${1..5}" [1, 2, 3, 4, 5]
Groovy 2.4.21 differs in the lines above
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain openRanges.groovy 2>&1 | grep 'openRanges.groovy: 1:'
openRanges.groovy: 1: unexpected token: < @ line 1, column 33.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ranges_session.py
3 in (1..5) is true, but 3.5 in (1..5) is false, because an integer range holds whole numbers only. (1<..<5)
gave [2, 3, 4] everywhere except on 2.4.21, which refused to compile it. The line that differs on every older runtime is
"${1..5}": Groovy 6 prints a range in a GString as 1..5, from the groovyToString extension methods that its
release notes describe, while the three older runtimes print the list, [1, 2, 3, 4, 5].
Comparing printed ranges in tests and logs
A test that checks a message such as "retrying ${1..3}" passes on one runtime and fails on another. Compare
range.toList(), or the bounds, range.from and range.to, instead of the text.
Checkpoint
Check: Spread, spaceship and subscripts · Ranges and how they print
Operators are methods you can write
Each operator is a call to a method with a fixed name. The documentation lists them; these are the ones this lesson uses:
| Operator | Method it calls | Operator | Method it calls |
|---|---|---|---|
a + b |
a.plus(b) |
a << b |
a.leftShift(b) |
a - b |
a.minus(b) |
a[i] |
a.getAt(i) |
a * b |
a.multiply(b) |
a() |
a.call() |
a <=> b, a < b |
a.compareTo(b) |
a in b |
b.isCase(a), on Groovy 5 and older |
A Money class that defines four of them reads like arithmetic, and a playlist that defines three reads like a list:
GROOVY (money.groovy) · money.groovy
// A Money class whose operators are methods: plus, minus, multiply and compareTo.
class Money implements Comparable<Money> {
final long cents
Money(long cents) { this.cents = cents }
Money plus(Money other) { new Money(cents + other.cents) }
Money minus(Money other) { new Money(cents - other.cents) }
Money multiply(int times) { new Money(cents * times) }
int compareTo(Money other) { cents <=> other.cents }
boolean equals(Object other) { other instanceof Money && other.cents == cents }
int hashCode() { Long.hashCode(cents) }
String toString() { String.format('%d.%02d', cents.intdiv(100), cents % 100) }
}
def tea = new Money(1250)
def cake = new Money(4599)
println "tea + cake ${tea + cake}"
println "cake - tea ${cake - tea}"
println "tea * 3 ${tea * 3}"
println "tea < cake ${tea < cake}"
println "[cake, tea].max() ${[cake, tea].max()}"
println "[tea, cake].sum() ${[tea, cake].sum()}"
println "tea == new Money(1250) ${tea == new Money(1250)}"
def total = new Money(0)
total += tea
total += cake
println "total after two += $total" GROOVY (playlist.groovy) · playlist.groovy
// <<, [] and () as methods of a class: leftShift, getAt and call.
class Playlist {
private final List<String> songs = []
Playlist leftShift(String song) { songs << song; this }
String getAt(int index) { songs[index] }
int call() { songs.size() }
}
def list = new Playlist()
list << 'intro' << 'theme' << 'outro'
println "list[1] is ${list[1]}, list() is ${list()}" GROOVY (tally.groovy) · tally.groovy
// A class with plusAssign and no plus: what does += do?
class Tally {
List<Integer> values = []
void plusAssign(int n) { values << n }
}
def tally = new Tally()
def same = tally
try {
tally += 2
tally += 5
println "values ${tally.values}, still a ${tally.getClass().simpleName}, same object: ${tally.is(same)}"
} catch (MissingMethodException e) {
println "MissingMethodException: Groovy looked for a method named ${e.method}"
} from session import jenkins_jar, run, use
# Operator methods on Groovy 6.0.0, then on each older runtime, compared with 6.0.0's output; then += on a class with
# only plusAssign.
run("""
groovy money.groovy | tee money-6.0.0.txt
groovy playlist.groovy | tee playlist-6.0.0.txt
groovy tally.groovy
""")
for version in ("5.1.3", "4.0.33"):
use(version)
run(f"""
groovy money.groovy > money-{version}.txt
groovy playlist.groovy > playlist-{version}.txt
diff money-6.0.0.txt money-{version}.txt && diff playlist-6.0.0.txt playlist-{version}.txt && echo 'Groovy {version} printed the same lines'
groovy tally.groovy
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain money.groovy > money-2.4.21.txt
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain playlist.groovy > playlist-2.4.21.txt
diff money-6.0.0.txt money-2.4.21.txt && diff playlist-6.0.0.txt playlist-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines'
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain tally.groovy
""") Output
$ groovy money.groovy | tee money-6.0.0.txt tea + cake 58.49 cake - tea 33.49 tea * 3 37.50 tea < cake true [cake, tea].max() 45.99 [tea, cake].sum() 58.49 tea == new Money(1250) true total after two += 58.49 $ groovy playlist.groovy | tee playlist-6.0.0.txt list[1] is theme, list() is 3 $ groovy tally.groovy values [2, 5], still a Tally, same object: true # The groovy command is Groovy 5.1.3 from here on. $ groovy money.groovy > money-5.1.3.txt $ groovy playlist.groovy > playlist-5.1.3.txt $ diff money-6.0.0.txt money-5.1.3.txt && diff playlist-6.0.0.txt playlist-5.1.3.txt && echo 'Groovy 5.1.3 printed the same lines' Groovy 5.1.3 printed the same lines $ groovy tally.groovy MissingMethodException: Groovy looked for a method named plus # The groovy command is Groovy 4.0.33 from here on. $ groovy money.groovy > money-4.0.33.txt $ groovy playlist.groovy > playlist-4.0.33.txt $ diff money-6.0.0.txt money-4.0.33.txt && diff playlist-6.0.0.txt playlist-4.0.33.txt && echo 'Groovy 4.0.33 printed the same lines' Groovy 4.0.33 printed the same lines $ groovy tally.groovy MissingMethodException: Groovy looked for a method named plus # groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central. $ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain money.groovy > money-2.4.21.txt $ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain playlist.groovy > playlist-2.4.21.txt $ diff money-6.0.0.txt money-2.4.21.txt && diff playlist-6.0.0.txt playlist-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines' Groovy 2.4.21 printed the same lines $ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain tally.groovy MissingMethodException: Groovy looked for a method named plus
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 overloading_session.py
tea + cake called plus and printed 58.49, tea < cake used compareTo, and [tea, cake].sum() added the list
with the same plus. total += tea is total = total.plus(tea) on every runtime, so it worked without a method of its
own. Groovy 6 adds compound assignment overloading: The Groovy 6 rule that a += b calls a.plusAssign(b) when the class defines it, changing a in place. Without such a method, a += b means a = a.plus(b), as on older runtimes.: a class may define
plusAssign, and then += changes the object in place. Tally defines only plusAssign, so Groovy 6.0.0 kept the same
object and collected [2, 5], while 5.1.3, 4.0.33 and 2.4.21 looked for plus and threw MissingMethodException.
Define plus as well as plusAssign
A class that defines both works everywhere: older runtimes use plus for +=, and Groovy 6 uses plusAssign when it
exists. Keep the two consistent, because the release notes say that += falls back to plus when no plusAssign
matches.
Checkpoint
Check: Operators as methods of your class
in asks a different question on Groovy 6
On Groovy 5.1.3 and older, x in y calls the isCase: The method a switch calls on each case value with the switch value, and that in calls on Groovy 5 and older: a class matches its instances, a range its members, a pattern matching text, a closure a true result, a list its elements. More in “Control flow, loops and the Groovy switch” method of y with x, the same
method that a switch uses for its cases. Groovy 6 compiles it to a new runtime method, isIn, and its release notes
record one effect: map membership “is now key-based”.
The issue behind it, GROOVY-9848, lists “remap operator in to isIn” among its options. Compile a one-line class with
both versions, and javap, the JDK’s class file disassembler, shows the call; a second javap shows what isIn
itself does in groovy-6.0.0.jar:
class Member {
static boolean check(Object x, Object y) { x in y }
} from session import run, use
# Member.groovy compiled by Groovy 6.0.0 and by 5.1.3; javap shows which runtime method the in operator calls. Then the
# method isIn itself, from the groovy-6.0.0.jar next to the groovy command: the checks it makes before it calls isCase.
run("""
groovyc -d out-6.0.0 Member.groovy
javap -c -p -cp out-6.0.0 Member | grep -o 'ScriptBytecodeAdapter.is[A-Za-z]*'
javap -c -cp "$(dirname "$(command -v groovy)")/../lib/groovy-6.0.0.jar" org.codehaus.groovy.runtime.ScriptBytecodeAdapter | awk '/boolean isIn\\(/,/^$/' | grep -o -e 'ifnonnull' -e 'class java/[A-Za-z/]*' -e 'Method [A-Za-z/]*[.]*[A-Za-z]*:'
""")
use("5.1.3")
run("""
groovyc -d out-5.1.3 Member.groovy
javap -c -p -cp out-5.1.3 Member | grep -o 'ScriptBytecodeAdapter.is[A-Za-z]*'
""") Output
$ groovyc -d out-6.0.0 Member.groovy $ javap -c -p -cp out-6.0.0 Member | grep -o 'ScriptBytecodeAdapter.is[A-Za-z]*' ScriptBytecodeAdapter.isIn $ javap -c -cp "$(dirname "$(command -v groovy)")/../lib/groovy-6.0.0.jar" org.codehaus.groovy.runtime.ScriptBytecodeAdapter | awk '/boolean isIn\(/,/^$/' | grep -o -e 'ifnonnull' -e 'class java/[A-Za-z/]*' -e 'Method [A-Za-z/]*[.]*[A-Za-z]*:' ifnonnull ifnonnull class java/util/Map class java/util/Map Method java/util/Map.containsKey: class java/lang/CharSequence class java/lang/CharSequence Method java/lang/Object.toString: Method java/lang/Object.toString: Method java/lang/String.contains: Method isCase: # The groovy command is Groovy 5.1.3 from here on. $ groovyc -d out-5.1.3 Member.groovy $ javap -c -p -cp out-5.1.3 Member | grep -o 'ScriptBytecodeAdapter.is[A-Za-z]*' ScriptBytecodeAdapter.isCase
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bytecode_session.py
isIn makes three checks before it falls back to isCase: whether y is null, whether it is a Map, which it asks
with containsKey, and whether both sides are text, which it compares with String.contains. Step through four lines
of the next program and watch which check answers each one:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 'debug' in flags. flags is [debug: false, …], not null, so Groovy 6.0.0 asks the next question. It is a Map, so the answer is flags.containsKey('debug'), true.
- The same line on 5.1.3. Groovy 5.1.3 sends every in to isCase, and a map's isCase tests the truth of the value. The value is false, so 5.1.3 printed false.
- 'b' in 'abc'. Both sides are text, so Groovy 6.0.0 asks whether 'abc' contains 'b', and printed true.
- The same line on 5.1.3. A String's isCase compares the two texts for equality, as a switch case does, so 5.1.3 printed false for 'b' in 'abc' and true only for 'abc' in 'abc'.
- 2 in [1, 2, 3]. A list is neither a Map nor text, so even Groovy 6.0.0 ends at isCase, which asks the list whether it contains 2. Every runtime printed true.
- 7L in [7]. The same path for a Long. A list compares with equals, and Long 7 does not equal Integer 7, so every runtime printed false, although 7L == 7 is true.
GROOVY (membership.groovy) · membership.groovy
// The in operator on lists, ranges, text and maps.
def show(String code, Closure check) {
def result
try { result = check() } catch (e) { result = e.getClass().simpleName }
println "${code.padRight(26)}$result"
}
def flags = [debug: false, retries: 0, verbose: true]
def counts = [:].withDefault { 0 }
show "2 in [1, 2, 3]", { 2 in [1, 2, 3] }
show "2 in 1..3", { 2 in 1..3 }
show "'b' in 'abc'", { 'b' in 'abc' }
show "'abc' in 'abc'", { 'abc' in 'abc' }
show "'debug' in flags", { 'debug' in flags }
show "'retries' in flags", { 'retries' in flags }
show "'verbose' in flags", { 'verbose' in flags }
show "'x' in counts", { 'x' in counts }
show "counts afterwards", { counts }
show "7L in [7]", { 7L in [7] } GROOVY (notIn.groovy) · notIn.groovy
// !in, the negated membership operator that Groovy 3 added.
def released = ['6.0.0', '5.1.3']
assert '4.0.33' !in released
println "'4.0.33' !in released holds" from session import jenkins_jar, run, use
# The in operator on Groovy 6.0.0, then on each older runtime: diff prints only the lines that differ from 6.0.0's.
# notIn.groovy shows the Groovy 3 operator !in on 6.0.0 and on the Groovy that Jenkins bundles.
run("""
groovy membership.groovy | tee on-6.0.0.txt
groovy notIn.groovy
""")
for version in ("5.1.3", "4.0.33"):
use(version)
run(f"""
groovy membership.groovy > on-{version}.txt
diff on-6.0.0.txt on-{version}.txt && echo 'Groovy {version} printed the same lines' || echo 'Groovy {version} differs in the lines above'
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain membership.groovy > on-2.4.21.txt
diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines' || echo 'Groovy 2.4.21 differs in the lines above'
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain notIn.groovy 2>&1 | grep 'notIn.groovy: 3:'
""") Output
$ groovy membership.groovy | tee on-6.0.0.txt 2 in [1, 2, 3] true 2 in 1..3 true 'b' in 'abc' true 'abc' in 'abc' true 'debug' in flags true 'retries' in flags true 'verbose' in flags true 'x' in counts false counts afterwards [:] 7L in [7] false $ groovy notIn.groovy '4.0.33' !in released holds # The groovy command is Groovy 5.1.3 from here on. $ groovy membership.groovy > on-5.1.3.txt $ diff on-6.0.0.txt on-5.1.3.txt && echo 'Groovy 5.1.3 printed the same lines' || echo 'Groovy 5.1.3 differs in the lines above' 3c3 < 'b' in 'abc' true --- > 'b' in 'abc' false 5,6c5,6 < 'debug' in flags true < 'retries' in flags true --- > 'debug' in flags false > 'retries' in flags false 9c9 < counts afterwards [:] --- > counts afterwards [x:0] Groovy 5.1.3 differs in the lines above # The groovy command is Groovy 4.0.33 from here on. $ groovy membership.groovy > on-4.0.33.txt $ diff on-6.0.0.txt on-4.0.33.txt && echo 'Groovy 4.0.33 printed the same lines' || echo 'Groovy 4.0.33 differs in the lines above' 3c3 < 'b' in 'abc' true --- > 'b' in 'abc' false 5,6c5,6 < 'debug' in flags true < 'retries' in flags true --- > 'debug' in flags false > 'retries' in flags false 9c9 < counts afterwards [:] --- > counts afterwards [x:0] Groovy 4.0.33 differs in the lines above # groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central. $ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain membership.groovy > on-2.4.21.txt $ diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines' || echo 'Groovy 2.4.21 differs in the lines above' 3c3 < 'b' in 'abc' true --- > 'b' in 'abc' false 5,6c5,6 < 'debug' in flags true < 'retries' in flags true --- > 'debug' in flags false > 'retries' in flags false 9c9 < counts afterwards [:] --- > counts afterwards [x:0] Groovy 2.4.21 differs in the lines above $ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain notIn.groovy 2>&1 | grep 'notIn.groovy: 3:' notIn.groovy: 3: unexpected token: ! @ line 3, column 17.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 membership_session.py
Four lines changed between 6.0.0 and the older runtimes. 'b' in 'abc' became a substring test, and 'debug' in flags
and 'retries' in flags became key tests, so the false and the 0 no longer hide their keys. The withDefault map
shows the other side of the old rule: on 5.1.3 and older, 'x' in counts asked for the value, which created the key with
its default, [x:0], while Groovy 6.0.0 left the map empty. A switch still matches its cases with isCase, as the
6.0 release notes say. The negated form !in in notIn.groovy is Groovy 3 syntax, so Jenkins’ 2.4.21 stopped at its ! with
unexpected token.
Version note
The operators page of the Groovy 6.0.0 documentation still says that in “is equivalent to calling the isCase method”.
On 6.0.0 that still holds for lists and ranges, but not for maps and text, as the session shows.
- 2.4
The base that Jenkins has: *., <=>, .., ..< and in Jenkins
Groovy 2.4.21 runs the spread operator, the spaceship operator, inclusive and exclusive ranges, subscripts with ranges and in, which it sends to isCase. Identity is a.is(b).
- 3.0
===, !== and !in new syntax
The Groovy 3 release notes add the identity operators === and !==, and !in and !instanceof. Groovy 2.4.21 stopped at !in with unexpected token.
- 4.0
Ranges open on the left new syntax
From Groovy 4 a range can exclude its first value, as in 1<..5, or both ends, as in 1<..<5. On 2.4.21 the same file failed to compile.
- 5.0
% calls remainder(), and ==> arrives
Operator overloading for % moved from the method mod to the method remainder, and the logical implication operator ==> was added.
- 6.0
in asks a new question, and += can mutate changed meaning
in now compiles to isIn, which tests a map's keys and finds text inside text. A class may define plusAssign for += and its siblings. A range in a GString prints as 1..5.
The timeline has 5 events, from 2.4 to 6.0.
Membership checks that every runtime answers the same
Code that runs in a Jenkinsfile, a Gradle build and a Groovy 6 script needs checks whose meaning does not depend on the
version. Name the question instead of using in for maps and text:
| What you mean | Write | Same on every runtime |
|---|---|---|
| The map has this key | map.containsKey(key) |
yes |
| The map has this value | map.containsValue(value) |
yes |
| The text contains this part | text.contains(part) |
yes |
| The list holds an equal element | list.contains(x), or x in list |
yes |
The list holds a number equal by == |
list.any { it == x } |
yes |
The last two rows differ only when numbers of different classes meet. Java’s collections compare elements with
equals, which also compares the class, while Groovy’s == compares the values, so any { it == x } asks Groovy’s
question at the cost of one closure call per element. All five forms compile on Groovy 2.4.21:
// Membership checks that say what they mean, so every runtime gives the same answer.
def flags = [debug: false, retries: 0]
def tag = 'release-6.2'
println "flags.containsKey('debug') ${flags.containsKey('debug')}"
println "flags.containsValue(0) ${flags.containsValue(0)}"
println "tag.contains('6.2') ${tag.contains('6.2')}"
println "[1, 2, 3].contains(2) ${[1, 2, 3].contains(2)}"
println "[7].contains(7L) ${[7].contains(7L)}"
println "[7].any { it == 7L } ${[7].any { it == 7L }}" from session import jenkins_jar, run, use
# Explicit membership checks on Groovy 6.0.0, then on each older runtime: diff prints only the lines that differ from 6.0.0's.
run("""
groovy portable.groovy | tee on-6.0.0.txt
""")
for version in ("5.1.3", "4.0.33"):
use(version)
run(f"""
groovy portable.groovy > on-{version}.txt
diff on-6.0.0.txt on-{version}.txt && echo 'Groovy {version} printed the same lines' || echo 'Groovy {version} differs in the lines above'
""")
jenkins_jar()
run("""
java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain portable.groovy > on-2.4.21.txt
diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines' || echo 'Groovy 2.4.21 differs in the lines above'
""") Output
$ groovy portable.groovy | tee on-6.0.0.txt
flags.containsKey('debug') true
flags.containsValue(0) true
tag.contains('6.2') true
[1, 2, 3].contains(2) true
[7].contains(7L) false
[7].any { it == 7L } true
# The groovy command is Groovy 5.1.3 from here on.
$ groovy portable.groovy > on-5.1.3.txt
$ diff on-6.0.0.txt on-5.1.3.txt && echo 'Groovy 5.1.3 printed the same lines' || echo 'Groovy 5.1.3 differs in the lines above'
Groovy 5.1.3 printed the same lines
# The groovy command is Groovy 4.0.33 from here on.
$ groovy portable.groovy > on-4.0.33.txt
$ diff on-6.0.0.txt on-4.0.33.txt && echo 'Groovy 4.0.33 printed the same lines' || echo 'Groovy 4.0.33 differs in the lines above'
Groovy 4.0.33 printed the same lines
# groovy-all-2.4.21.jar in this folder is the Groovy jar that Jenkins bundles, from Maven Central.
$ java -cp groovy-all-2.4.21.jar groovy.ui.GroovyMain portable.groovy > on-2.4.21.txt
$ diff on-6.0.0.txt on-2.4.21.txt && echo 'Groovy 2.4.21 printed the same lines' || echo 'Groovy 2.4.21 differs in the lines above'
Groovy 2.4.21 printed the same lines
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 portable_session.py
Every runtime printed the same six lines. Note the last two: [7].contains(7L) is false, because a list compares
with equals and a Long never equals an Integer, as 7L in [7] was false above, even though 7L == 7 is true.
When numbers of mixed types meet, compare with == inside any.
Key idea
Use in only for lists, ranges and classes, where every runtime agrees; for maps and text write containsKey,
containsValue or contains, which say the same thing on Groovy 2.4.21 and 6.0.0.
Checkpoint
Check: What in asks on Groovy 6 · Membership checks for every runtime
Practise: operators and membership
The questions below cover slicing, ranges, operator methods and both meanings of in. Their order adapts to your
answers, and each reason cites the output shown above:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What does the spread operator *. do in Groovy, and what does it give for a null list?
list*.name calls name on every element of the list and collects the results into a new list, so it is a short form of
list.collect { it.name }, and list*.size() works the same way for a method. It is null-safe twice over: an element
that is null gives null in the result instead of throwing, so [null, [name: 'db1']]*.name is [null, db1], and a
receiver that is null gives null for the whole expression. That second case matters, because the result is then
null, not an empty list, so a following .size() or .each needs ?. or a check of its own.
Two longer interview questions of this module start from this lesson:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A release script treats a feature as on with if ('dryRun' in options), where options = [dryRun: false] means off. It behaved on Groovy 5.1.3, but after the move to 6.0.0 every release runs as a dry run. Explain the change, write a check that means the same everywhere, and say how you would find other affected lines.
-
Mid to senior
Design a Money type for Groovy scripts so that totals read as a + b, comparisons as a < b and lists of amounts add up with sum(). Which methods do you write, what must equals and hashCode do, and what should happen when two currencies meet?
Key takeaways
*.collects a property or a method over a list,<=>returns what a comparator needs, and subscripts take positions, negative positions, ranges and lists of positions.- A range stores only its ends;
..<and Groovy 4’s<..leave out an end, and on Groovy 6 a range in a GString prints as1..5, so tests comparetoList(). - Every operator calls a method with a fixed name, such as
plus,compareTo,leftShift,getAtandcall, and Groovy 6 addsplusAssignand its siblings, which older runtimes do not use. - Groovy 6 compiles
x in ytoisIn, which tests a map’s keys and finds text inside text, where Groovy 5.1.3 and older callisCase. - Membership code that must run everywhere uses
containsKey,containsValue,containsorany { it == x }.
Terms in this lesson
- compound assignment overloading
- The Groovy 6 rule that a += b calls a.plusAssign(b) when the class defines it, changing a in place. Without such a method, a += b means a = a.plus(b), as on older runtimes.
- range
- A Groovy List such as 1..5 or 'a'..'e' that stores only its two ends. ..< leaves out the upper end and, from Groovy 4, <.. the lower one.
- spaceship operator
- The operator <=> in Groovy, which calls compareTo and gives a negative number, 0 or a positive number, as a sort comparator needs. null sorts before any value instead of throwing.
- spread operator
- The operator *. in Groovy: list*.name calls name on every element and collects the results, like list.collect { it.name }. It gives null for a null list and null for each null element.
References
- Operators (Groovy 6.0.0 documentation) (The Apache Software Foundation (Apache Groovy))
- Operators, operator overloading and compound assignment (Groovy 6.0.0 documentation) (The Apache Software Foundation (Apache Groovy))
- Release notes for Groovy 6.0 (map membership, compound assignment overloading, groovyToString) (The Apache Software Foundation (Apache Groovy))
- GROOVY-9848: change map membership (in) from value-truthy to key-based (The Apache Software Foundation (Apache Groovy issue tracker))
- ScriptBytecodeAdapter (Groovy 6.0.0 API) (The Apache Software Foundation (Apache Groovy))
- Release notes for Groovy 5.0 (the remainder operator, logical implication) (The Apache Software Foundation (Apache Groovy))
- Release notes for Groovy 4.0 (enhanced ranges) (The Apache Software Foundation (Apache Groovy))
- Release notes for Groovy 3.0 (!in and the identity operators) (The Apache Software Foundation (Apache Groovy))
- Map methods of the Groovy Development Kit (isCase, withDefault) (The Apache Software Foundation (Apache Groovy))
- The javap command (Java SE 25 tools) (Oracle)
- Jenkins 2.580.1 bill of materials (bom/pom.xml) (The Jenkins project)
- Gradle compatibility matrix (embedded Groovy versions) (Gradle (docs.gradle.org))
Report a problem with this lesson
Kept only in this browser. Your Learn progress