Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 10 minutes
  • Examples run with groovy 6.0.0, groovy5 5.1.3, groovyGradle 4.0.33, groovyJenkins 2.4.21, Java temurin-25.0.4+101.0.LTS, gradle 9.8.0, spock 2.4-groovy-5.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

ops.groovy GROOVY · ops.groovy
// 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]}"
Spread, spaceship and subscripts, on four runtimes Python · ops_session.py
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:

ranges.groovy and openRanges.groovy

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()}"
Ranges on four runtimes, and the Groovy 4 open ranges Python · ranges_session.py
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

  1. Question 1 of 2 stages holds 4 elements. What do stages[9] and stages[2..9] do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: stages[9] gives null, and stages[2..9] throws IndexOutOfBoundsException

    Groovy's getAt is forgiving for one index and strict for a range. Check the size first, or use take() and drop(), when a slice may run past the end.

  2. Question 2 of 2 What does "${1..3}" print on Groovy 6.0.0 and on 5.1.3?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 1..3 on 6.0.0, and [1, 2, 3] on 5.1.3

    Groovy 6 changed how a range is displayed in a GString. Compare toList() or the bounds in tests instead of the printed text.

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:

money.groovy, playlist.groovy and tally.groovy

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}"
}
Operator methods, and += with only plusAssign Python · overloading_session.py
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

  1. One question Which method makes report << line work for a class Report of your own?

    Choose one answer.

    Show the answer: One question

    Answer: Report leftShift(String line)

    Every operator calls a method with a fixed name: << is leftShift, [] is getAt, () is call and + is plus. Return this from leftShift so that calls can be chained, as list << 'intro' << 'theme' was.

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:

Member.groovy GROOVY · Member.groovy
class Member {
    static boolean check(Object x, Object y) { x in y }
}
What x in y compiles to, on Groovy 6.0.0 and 5.1.3 Python · bytecode_session.py
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:

Interactive · stage by stage What x in y asks on Groovy 6.0.0 Step through four lines of membership.groovy. Watch which question answers each one, and what Groovy 5.1.3 asked instead.
What x in y asks on Groovy 6.0.0, step 6 of 6. Showing Is y null?, Is y a Map? (y.containsKey(x)), Are x and y both text? (y.contains(x)), Anything else (isCase: equals is false). In focus: Anything else.Is y null?then true only if x is nullIs y a Map?y.containsKey(x)Are x and y both text?y.contains(x)Anything elseisCase: equals is false

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. '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.
  2. 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.
  3. 'b' in 'abc'. Both sides are text, so Groovy 6.0.0 asks whether 'abc' contains 'b', and printed true.
  4. 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'.
  5. 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.
  6. 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.

membership.groovy and notIn.groovy

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"
The in operator on four runtimes Python · membership_session.py
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.

Timeline · 5 events in order The operators of this lesson, release by release Walk through the release lines in order. Each event names what changed for the operators, and what that means on Jenkins' 2.4.21.
  1. 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).

  2. 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.

  3. 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.

  4. 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.

  5. 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:

portable.groovy GROOVY · portable.groovy
// 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 }}"
Explicit membership checks on four runtimes Python · portable_session.py
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

  1. Question 1 of 2 def env = [CI: '']. What is 'CI' in env on Groovy 6.0.0 and on 5.1.3?

    Choose one answer.

    A hint

    On one runtime in asks about the key, on the other about the value's truth.

    Show the answer: Question 1 of 2

    Answer: true on 6.0.0, false on 5.1.3

    Groovy 6 compiles in to isIn, which calls containsKey for a map; older runtimes ask the map's isCase, which tests the truth of the value.

  2. Question 2 of 2 Which check finds rc in the tag 'v6.0.0-rc1' with the same answer on every runtime?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: tag.contains('rc')

    Name the question: contains for a part of a text, containsKey for a map key. in on text changed meaning in Groovy 6.

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.

  1. Question 1 of 15 What does builds*.job return for a list of three build maps?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: A list of the three job values

    list*.prop is list.collect { it.prop }, and list*.method() works the same way for methods.

  2. Question 2 of 15 What does this print?

    Read the code, then choose one answer.

    def mins = [12, 7, 30]
    println mins.sort(false) { a, b -> b <=> a }
    Show the answer: Question 2 of 15

    Answer: [30, 12, 7]

    A comparator returns a negative number, 0 or a positive number, which is exactly what <=> gives. Swapping a and b reverses the order.

  3. Question 3 of 15 Builds are sorted with { a, b -> a.mins <=> b.mins }, and some builds have no mins yet. What happens?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: The builds without mins come first, because null <=> 1 is -1

    The spaceship operator treats null as smaller than any value, so nulls sort first. Reverse the comparison or sort on a default value when they must come last.

  4. Question 4 of 15 Which numbers does 1..<4 hold?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: 1, 2 and 3

    .. includes both ends, ..< leaves out the upper end, and from Groovy 4 <.. leaves out the lower end.

  5. Question 5 of 15 What is 2.5 in (1..5)?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: false

    1..5 is a list of five integers, so only those integers are in it. For a numeric interval, compare the bounds: x >= 1 && x <= 5.

  6. Question 6 of 15 A shared library uses (0<..<n).each { … } and its tests pass on Groovy 5.1.3. What happens in Jenkins?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: It fails to compile, because ranges open on the left arrived in Groovy 4

    Write (1..<n) instead of (0<..<n) in code that Jenkins compiles; both hold 1 to n - 1.

  7. Question 7 of 15 Which method does a + b call?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: a.plus(b)

    Each operator is a call to a fixed method on its left operand; define plus and + works for your class.

  8. Question 8 of 15 Money defines plus but no plusAssign. What does total += tea do on Groovy 6.0.0?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: It falls back to total = total.plus(tea), as on older runtimes

    Groovy 6 tries plusAssign first and falls back to the old x = x.plus(y), so classes written for older runtimes keep working.

  9. Question 9 of 15 A shared library's Tally defines only plusAssign, and its tests on Groovy 6.0.0 pass. What happens when Jenkins runs tally += 2, and what is the fix?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: MissingMethodException for plus; define plus as well, so every runtime has a method for +=

    Older runtimes desugar a += b to a = a.plus(b). A class that must work everywhere defines plus, and may add plusAssign for Groovy 6.

  10. Question 10 of 15 Which runtime method does Groovy 6.0.0 compile x in y to?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: ScriptBytecodeAdapter.isIn

    javap -c showed ScriptBytecodeAdapter.isIn for 6.0.0 and ScriptBytecodeAdapter.isCase for 5.1.3, the same source compiled twice.

  11. Question 11 of 15 What is 'b' in 'abc' on Groovy 6.0.0 and on 5.1.3?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: true on 6.0.0, false on 5.1.3

    On text, in became a substring test in Groovy 6. Code that runs on several runtimes should call contains.

  12. Question 12 of 15 A job counts errors in def counts = [:].withDefault { 0 } and checks if ('timeout' in counts). On Groovy 5.1.3 a report later lists timeout: 0 although none happened. Why?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: in asked the map for the value, and withDefault created the key with its default

    Before Groovy 6, in on a map read the value, and reading a missing key of a withDefault map creates it. GROOVY-9848 names this mutation as one reason for the change. containsKey reads nothing.

  13. Question 13 of 15 Which call tests that a map has a key, the same way on every runtime?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: map.containsKey(key)

    containsKey says what it means and gave the same answer on all four runtimes; in on a map changed in Groovy 6.

  14. Question 14 of 15 What does [7].contains(7L) return?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: false

    Groovy's == compares numbers by value, but Java's collections use equals, which also compares the class.

  15. Question 15 of 15 A helper gets build ids as a List of Long from one API and an Integer id from another. Which check finds the id whatever the two classes are?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: ids.any { it == id }

    When numbers of mixed classes meet, compare with ==, or convert both sides to one class first, such as ids.contains(id as long).

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

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 as 1..5, so tests compare toList().
  • Every operator calls a method with a fixed name, such as plus, compareTo, leftShift, getAt and call, and Groovy 6 adds plusAssign and its siblings, which older runtimes do not use.
  • Groovy 6 compiles x in y to isIn, which tests a map’s keys and finds text inside text, where Groovy 5.1.3 and older call isCase.
  • Membership code that must run everywhere uses containsKey, containsValue, contains or any { 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.