Functional Programming
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26
(checked on 2026-10-14). Groovy is not a purely functional language, but closures are first-class values and the language
and the GDK ship the usual functional toolkit: partial application, memoization, composition, tail-call elimination,
method pointers, interoperability with the JDK functional interfaces, immutable data and, new in 6.0, a comprehension macro
for monadic code. Closures themselves (syntax, parameters, calling) are on
Closures; the higher-order collection methods (collect, findAll,
inject) are on Working with Collections. For the same ideas
in other languages on this site see Java Functional Programming,
Java Lambdas & Method References and
Kotlin Lambdas & Higher-order Functions.
Currying
Currying here means partial application: fixing some of a closure’s arguments to get a closure with fewer parameters.
Groovy has three methods on Closure, differing in which arguments they fix:
-
curry(a, b, ...)fixes the leading parameters. -
rcurry(a, b, ...)fixes the trailing parameters. -
ncurry(index, a, b, ...)fixes parameters starting at a given zero-based index.
def volume = { double rate, int minutes, String plant -> "${plant}: ${rate * minutes} L" }
def at2 = volume.curry(2.0d)
def at2for10 = at2.curry(10)
println at2for10('tomatoes')
println volume.rcurry('basil')(1.5d, 4)
println volume.ncurry(1, 5)(0.5d, 'mint') // fix the parameter at index 1
println volume.curry(2.0d, 10, 'x').maximumNumberOfParameters
println at2.parameterTypes*.simpleName
def adder = { a, b -> a + b }
println adder.curry(10)(5)
// closures are not auto-curried
try { adder.curry(1)() } catch (e) { println e.class.simpleName }
tomatoes: 20.0 L
basil: 6.0 L
mint: 2.5 L
0
[int, String]
15
MissingMethodException
Notes on the output:
-
A curried closure is a new closure; its
parameterTypeslist only the parameters that are still open ([int, String]after fixing the first one). -
Groovy never curries automatically: calling a two-argument closure with one argument is a
MissingMethodException, not a partial application. Callcurryexplicitly. -
Fixing every parameter gives a closure with no parameters (
maximumNumberOfParametersis 0) that still must be called.
Memoization
A pure closure can cache its results by argument list. memoize() returns a closure that computes each distinct argument list
once; the cache is unbounded. The bounded variants are memoizeAtMost(max), memoizeAtLeast(protected)
and memoizeBetween(protected, max). The first evicts the least recently used entry beyond max; the others keep the
protected most recent entries strongly referenced and let the rest be reclaimed by the garbage collector under memory
pressure. For methods, the AST transformation @Memoized does the same with protectedCacheSize and maxCacheSize
parameters (see AST Transformations: Code Generation & Class Design).
import groovy.transform.Memoized
int calls = 0
def slow = { int n -> calls++; n * n }
def fast = slow.memoize()
[3, 3, 4, 3].each { fast(it) }
println "calls: $calls"
calls = 0
def bounded = slow.memoizeAtMost(2)
[1, 2, 3, 1, 1].each { bounded(it) }
println "memoizeAtMost(2) calls: $calls"
calls = 0
def between = slow.memoizeBetween(1, 2)
[1, 2, 3, 1].each { between(it) }
println "memoizeBetween(1,2) calls: $calls"
calls = 0
def atMost0 = slow.memoizeAtLeast(1)
[5,5,5].each { atMost0(it) }
println "memoizeAtLeast(1) calls: $calls"
class Sensor {
static int reads = 0
@Memoized(maxCacheSize = 10)
double calibrated(int id) { reads++; id * 1.5 }
@Memoized
String banner() { reads++; 'sensor' }
}
def s = new Sensor()
3.times { s.calibrated(1) }
s.calibrated(2); s.banner(); s.banner()
println "reads: ${Sensor.reads}"
def fib
fib = { long n -> n < 2 ? n : fib(n - 1) + fib(n - 2) }.memoize()
println fib(80)
calls: 2
memoizeAtMost(2) calls: 4
memoizeBetween(1,2) calls: 4
memoizeAtLeast(1) calls: 1
reads: 3
23416728348467685
The first block counts how often the closure body runs: two for four calls with 3, 3, 4, 3, four when only two entries are
kept (the least recently used one is dropped and recomputed). The recursive Fibonacci closure at the end is fast only
because the memoized closure itself is what the recursion calls. Memoize only side-effect-free closures, since later calls
return the cached value without running the body, and remember that the cache holds its arguments alive.
Composition
The operators >> (right shift) and << (left shift) compose closures into a new closure. f >> g calls f first and passes
its result to g, that is g(f(x)); f << g is f(g(x)). Any callable works as an operand, including a method pointer.
The composed closure is a new closure; the last line of the example shows that it reports one parameter, like the first closure
in the chain.
def celsius = { double f -> (f - 32) / 1.8 }
def round1 = { double d -> Math.round(d * 10) / 10.0 }
def label = { it -> "${it} C" }
def pipeline = celsius >> round1 >> label // label(round1(celsius(x)))
println pipeline(98.6d)
def reversed = label << round1 << celsius // the same chain written right to left
println reversed(98.6d)
def up = String.&toUpperCase
def exclaim = { it + '!' }
def shout = up >> exclaim
println shout('ok')
println pipeline.maximumNumberOfParameters
37 C
37 C
OK!
1
The pipeline reads left to right: Fahrenheit to Celsius, round to one decimal, then format. Both spellings give the same
result, so use whichever reads better; >> matches the data-flow direction. The operator-to-method mapping (rightShift,
leftShift) is listed on Operator Overloading.
Trampolines and tail recursion
The JVM does not eliminate tail calls, so a deeply recursive closure or method overflows the stack. Groovy offers two remedies.
-
trampoline()turns a closure into one that returns aTrampolineClosureinstead of calling itself: the recursive call is writtenname.trampoline(args)and the trampoline runs the pending calls in a loop. -
@TailRecursive(fromgroovy.transform) rewrites a method whose recursive call is in tail position into a loop at compile time. The call must be the last thing the method does;@TailRecursiveis described with the other class-design transformations in AST Transformations: Code Generation & Class Design.
import groovy.transform.TailRecursive
def sumTo
sumTo = { long n, long acc = 0 -> n == 0 ? acc : sumTo.trampoline(n - 1, acc + n) }.trampoline()
println sumTo(100000)
def deep
deep = { long n -> n == 0 ? 0L : 1L + deep(n - 1) }
try { deep(200000) } catch (StackOverflowError e) { println 'StackOverflowError' }
class Counter {
@TailRecursive
long countdown(long n, long acc = 0) {
n == 0 ? acc : countdown(n - 1, acc + 1)
}
@TailRecursive
static BigInteger factorial(int n, BigInteger acc = 1G) {
n <= 1 ? acc : factorial(n - 1, acc * n)
}
}
println new Counter().countdown(500000)
println Counter.factorial(25)
5000050000
StackOverflowError
500000
15511210043330985984000000
The plain recursive closure overflows the stack at a depth of 200 000, while the trampolined sum to 100 000 and the
@TailRecursive methods run in constant stack.
Method pointers and method references
A method pointer obj.&method turns a method into a closure (a MethodClosure) bound to the receiver. Type.&method
points to a static method, or, for an instance method, to an unbound method that takes the receiver as its first argument.
Since Groovy 3 the Java method reference syntax Type::method and the constructor reference Type::new also work
and are what you pass where a java.util.function interface is expected. The .& form picks the overload at run time from
the actual argument types, so it can point to a group of overloaded methods; the :: form is closer to Java’s.
The functional interfaces of java.util.function (Function, Predicate, BiFunction, Supplier, UnaryOperator,
Consumer, Comparator) interoperate with closures in both directions. A closure is coerced to a functional interface
automatically wherever the target type is known (an assignment, a parameter), This is how Groovy code uses Java streams.
import java.util.function.*
import java.util.stream.*
class Zone { String id; double temp }
String describe(Zone z) { "${z.id}=${z.temp}" }
def z = new Zone(id: 'bench', temp: 21.5)
def ptr = this.&describe // method pointer to a script method
println ptr(z)
println ptr.class.simpleName
def up = 'hello'.&toUpperCase // bound to an instance
println up()
def max = Math.&max // static method
println max(3, 9)
println([1, 5, 3].collect(Integer.&toBinaryString))
def len = String.&length // unbound: the receiver is the first argument
println len('abcd')
println(['a', 'bb', 'ccc'].collect(String::length)) // method reference operator
def create = Zone::new // constructor reference
println create().class.simpleName
Function<Integer, Integer> twice = { it * 2 }
Predicate<String> empty = { it.isEmpty() }
BiFunction<Integer, Integer, Integer> add = { a, b -> a + b }
Supplier<String> now = { 'tick' }
UnaryOperator<String> shout = { it.toUpperCase() }
println twice.apply(4)
println empty.negate().test('x')
println add.andThen(twice).apply(1, 2)
println now.get()
println shout.apply('x')
println IntStream.rangeClosed(1, 5).map { it * it }.sum()
println Stream.of('a', 'bb', 'ccc').filter { it.size() > 1 }.map { it.toUpperCase() }.collect(Collectors.toList())
Comparator<Zone> byTemp = { a, b -> a.temp <=> b.temp }
println([new Zone(id: 'b', temp: 30), z].toSorted(byTemp)*.id)
bench=21.5
MethodClosure
HELLO
9
[1, 101, 11]
4
[1, 2, 3]
Zone
8
true
6
tick
X
55
[BB, CCC]
[bench, b]
Method pointers are the groovy.lang.Closure-typed counterpart of Java method references, so they compose (>>) and curry
like any closure. See also Object Operators for .& and ::.
Allocation-free GDK overloads in 6.0
Groovy 6.0 adds java.util.function overloads across the GDK (each, collect, findAll, inject and others). A method
reference or a lambda passed to one of them is used directly, with no Closure object allocated for every call. The release notes
list this under performance and describe it as a change with no change in semantics: the source code stays the same.
import java.util.function.*
def temps = [18.5d, 22.0d, 31.2d, 25.4d]
Predicate<Double> hot = { it > 24 } as Predicate
Function<Double, String> fmt = { String.format(Locale.ROOT, '%.1f C', it) }
println temps.findAll(hot)
println temps.collect(fmt)
println temps.inject(0d, (BiFunction<Double, Double, Double>) { a, b -> a + b })
temps.each((Consumer<Double>) { print "${it} " })
println()
// method reference works as a java.util.function argument
println temps.collect(Double::intValue)
println(['a', 'bb'].collect(String::length))
[31.2, 25.4]
[18.5 C, 22.0 C, 31.2 C, 25.4 C]
97.1
18.5 22.0 31.2 25.4
[18, 22, 31, 25]
[1, 2]
The typed arguments (Predicate, Function, BiFunction, Consumer) are the forms that select the new overloads; plain
closures keep using the closure-based ones. The practical effect is in hot loops, where it removes allocation; do not
rewrite working code for it without measuring. See Performance, invokedynamic & Native Images.
Immutability
Functional code prefers values that cannot change. Groovy offers three tools:
-
@Immutable(fromgroovy.transform) generates a class with final properties, a tuple constructor,equals,hashCodeandtoString, and defensive copies of mutable members such as lists, which it wraps in unmodifiable collections.copyWith = trueadds acopyWithmethod that builds a changed copy. -
recordtypes are immutable by construction; Groovy’s@RecordOptions(copyWith = true)addscopyWithto a record (see Records & Sealed Types). -
asImmutable()andasUnmodifiable()give read-only views of collections.
import groovy.transform.*
@Immutable(copyWith = true)
class Reading {
String sensor
double temp
List<String> tags
}
def r = new Reading('s1', 20.5, ['a'])
try { r.temp = 3 } catch (ReadOnlyPropertyException e) { println e.message }
try { r.tags << 'b' } catch (UnsupportedOperationException e) { println 'tags: UnsupportedOperationException' }
def r2 = r.copyWith(temp: 25.0d)
println r2
println r
def list = [1, 2, 3].asImmutable()
try { list << 4 } catch (UnsupportedOperationException e) { println 'list is read-only' }
@RecordOptions(copyWith = true)
record Point(int x, int y) {}
println new Point(1, 2).copyWith(y: 5)
Cannot set read-only property: temp for class: Reading
tags: UnsupportedOperationException
Reading(s1, 25.0, [a])
Reading(s1, 20.5, [a])
list is read-only
Point[x=1, y=5]
@Immutable makes the tags list read-only (the example shows the rejected <<), and assigning a property gives a
ReadOnlyPropertyException. The original and the changed copy are independent values. @Immutable is also covered in
AST Transformations: Code Generation & Class Design.
Monadic comprehensions with the DO macro
Chains of flatMap over Optional, Stream or CompletableFuture nest quickly. Groovy 6.0 adds the DO macro (incubating,
since 6.0, so it may change) to write them flat, in the style of a Scala for-comprehension or Haskell do-notation. It lives in
the groovy-macro-library module, which is on the classpath of the groovy command and needs to be a compile-time
dependency only in a project. A DO takes name in source generators followed by a body closure, and the body must itself
produce a value of the carrier type (there is no implicit lifting). Each generator becomes a nested bind, a name bound by an
earlier generator is visible to later ones, and an empty carrier short-circuits the rest.
def result = DO(a in Optional.of(2), b in Optional.of(3)) { Optional.of(a + b) }
println result
def chained = DO(a in Optional.of(10), b in Optional.of(a * 2)) { Optional.of(a + b) }
println chained
def none = DO(a in Optional.empty(), b in Optional.of(3)) { Optional.of(b) }
println none
import java.util.stream.Stream
def pairs = DO(x in Stream.of(1, 2), y in Stream.of('a', 'b')) { Stream.of("$x$y".toString()) }
println pairs.toList()
Optional[5]
Optional[30]
Optional.empty
[1a, 1b, 2a, 2b]
A realistic use is combining lookups that may miss. Here a plant and a reading are both optional; a missing reading
produces an empty result without any null checks. The same shape works for CompletableFuture, which composes two
asynchronous values without blocking on each.
import groovy.transform.Field
import groovy.transform.CompileStatic
import java.util.concurrent.CompletableFuture
record Plant(String name, double minTemp) {}
@Field Map<String, Plant> plants = [t: new Plant('tomato', 12.0d), b: new Plant('basil', 10.0d)]
@Field Map<String, Double> readings = [t: 18.5d]
Optional<Plant> findPlant(String id) { Optional.ofNullable(plants[id]) }
Optional<Double> reading(String id) { Optional.ofNullable(readings[id]) }
def ok = DO(p in findPlant('t'), r in reading('t')) { Optional.of("${p.name()}: ${r - p.minTemp()} above minimum") }
println ok
def missing = DO(p in findPlant('b'), r in reading('b')) { Optional.of("${p.name()}: ${r - p.minTemp()}") }
println missing
def f = DO(a in CompletableFuture.supplyAsync { 20 }, b in CompletableFuture.supplyAsync { 22 }) { CompletableFuture.completedFuture(a + b) }
println f.get()
@CompileStatic(extensions = 'groovy.typecheckers.MonadicChecker')
class Calc {
static int sum() { DO(a in Optional.of(2), b in Optional.of(3)) { Optional.of(a + b) }.get() }
}
println Calc.sum()
Optional[tomato: 6.5 above minimum]
Optional.empty
42
5
Which types can be a carrier:
-
the types on the standard allow-list:
Optional,Stream,CompletionStage(soCompletableFuture) andgroovy.concurrent.Awaitable(soDataflowVariable); -
any type with a one-argument
flatMap(andmap); -
a class annotated
@groovy.transform.Monadic, naming its own bind and map methods.
The macro does not enforce the monad laws; a user-defined carrier is responsible for them. Under @CompileStatic or
@TypeChecked use the extension groovy.typecheckers.MonadicChecker, as in the Calc class above: without it the type of
each bound name and of the whole expression falls back to Object. The companion checker MonadicShapeChecker lints
hand-written flatMap chains. Both are described on
Built-in Type Checkers. Use DO when two or more dependent
flatMap steps would nest; for a single map or flatMap the plain method is shorter.
Where the book is outdated
The Manning book Groovy in Action (publisher page in the references) teaches curry and closure composition, and those
descriptions still hold. What to check against the current documentation instead of the book:
-
rcurryandncurry, memoization (memoizeand@Memoized),trampolineand@TailRecursive, all added after the book was written. -
Method references with
::and thejava.util.functionoverloads, which need a JDK with lambdas; the book predates both. -
@Immutableand records, and the 6.0DOmacro: the book has nothing on either.
For where these features appeared see What’s New & Migrating from Older Versions.