Привет, Хабр!

Сегодня мы рассмотрим closures в Groovy: как они устроены, зачем нужны и чем принципиально отличаются от лямбд в Java.

Анатомия Closure

Первое, что важно понять: Closure — это полноценный объект, наследник groovy.lang.Closure, а не синтаксический сахар, как Java‑лямбда, которая компилятором превращается в скрытый статический метод + invokedynamic.

def adder = { int a, int b -> a + b }
println adder.getClass().name

Запустите, и в консоли всплывёт нечто вроде:

foo$_run_closure1

Если через javap -c вскрыть класс, найдёте метод doCall, не call. Именно его Groovy генерит, передавая параметры строго так, как вы их объявили. А метод call в базовом классе просто проксирует внутрь doCall, добавляя гибкости.

Контейнер outer-this

Каждый сгенерированный closure‑класс содержит приватное финальное поле this$0 — ссылку на внешнюю (enclosing) инстанцию скрипта или класса. Отсюда и магия доступа к приватным переменным уровнем выше:

class Counter {
    private int state = 0
    Closure next = { ++state }   // лаконичный инкремент
}
assert new Counter().next() == 1

Компилятор просто тащит ссылку на Counter внутрь анонимного Closure‑класса. Отсюда главный риск: если замыкание уходит жить дольше, чем владелец, можете нечаянно удержать кучу тяжёлых структур. Java‑лямбды в этом плане немножко легковеснее: они не цепляются за полный this, а захватывают только реально используемые переменные, причём финализированные (или effectively final).

delegate / owner / this

Спецификатор

Что хранит

Тип данных

Когда меняется

this

Enclosing объект (outer класс)

Object

Никогда: жёстко прошит в конструктор

owner

Тот, кто создал замыкание

Object

Никогда

delegate

Внешняя точка разрешения методов

Object (по умолчанию = owner)

Можно менять на лету

Замыкание сначала ищет методы/поля в delegate, потом в owner, потом в this, и лишь затем падает в NPE. Установить делегата проще простого:

class Greeter { String prefix = '>> ' }
def cl = { "Hello $name" }
cl.delegate = new Greeter()
cl.resolveStrategy = Closure.DELEGATE_FIRST
println cl.call(name: 'Groovy')   // >> Hello Groovy

Под капотом Closure вызывает this.getDelegate(), а getDelegate() — обычный геттер.

Стратегии разрешения вызовов

  • OWNER_FIRST (дефолт) — любит стабильность, ищет у создателя.

  • DELEGATE_FIRST — фаворит DSL‑щиков.

  • DELEGATE_ONLY — железная гарантия, что забудете this и не вызовете что‑то лишнее.

  • OWNER_ONLY — эквивалент this. в Java.

  • TO_SELF — редкий зверь, полезен в methodMissing.

Часто используют Builder‑миксер — переключают стратегию на время конфигурации, потом откатывают, чтобы случайно не затащить левые методы:

def withDelegate(Closure<?> cl, Object delegate, int stg = Closure.DELEGATE_FIRST) {
    def oldDel = cl.delegate
    def oldStg = cl.resolveStrategy
    try {
        cl.delegate = delegate
        cl.resolveStrategy = stg
        cl.call()
    } finally {
        cl.delegate = oldDel
        cl.resolveStrategy = oldStg
    }
}

Способы передачи параметров

it

def odds = (1..10).findAll { it & 1 }

Компилятор создаёт метод doCall(Object it) (ровно один арг). Всё хорошо, пока не надо цеплять типизацию — в @CompileStatic это превращается в Object, теряется строгая проверка, и ловите ClassCastException в рантайме.

Явные имена

def odds = (1..10).findAll { Integer num -> (num & 1) }

Теперь doCall(Integer num), и static‑компиляция проверит типы ещё на build‑сервере.

Вариативка

def sum = { int... xs -> xs.sum() }
assert sum(1, 2, 3) == 6

Да, Groovy оборачивает varargs в int[], как и Java. Но прячьте такую конструкцию под @CompileStatic, иначе рекурсивный вызов при каррировании может получить Object[], и снова проблемы.

Каррирование и частичное применение (curry / rcurry / ncurry)

curryочень полезно, когда нужно было быстро соорудить параметризованный генератор задач.

Closure htmlWrap = { pre, post, body -> "$pre$body$post" }
def bold = htmlWrap.curry('<b>', '</b>')
println bold('Hi')   // <b>Hi</b>

curry создаёт новый объект Closure, ссылка на исходный остаётся; собирайте мусор вовремя, не плодите миллионы полуфабрикатов внутри циклов.

Trampoline

Groovy умеет делать TCO через прокси‑метод trampoline(). Работает и с Closure, и с обычными методами:

def fib
fib = { long n, long a = 0, long b = 1 ->
    n ? fib.trampoline(n - 1, b, a + b) : a
}.trampoline()

println fib(92)          // не падаем, хотя рекурсия толстая

JVM‑стек остаётся тонким, потому что каждое вложение возвращает новую closure‑заготовку, а trampoline крутит итеративный while‑loop.

Builders — MarkupBuilder, JsonBuilder, StreamingMarkupBuilder

На Builder‑DSL‑ах замыкание раскрывает весь потенциал delegate. Например:

def report = new StringWriter()
def html = new groovy.xml.MarkupBuilder(report)

html.html {
    head {
        title 'Health Check'
    }
    body {
        h1(class: 'title', 'Service Status')
        table {
            services.each { svc ->
                tr {
                    td svc.name
                    td svc.status
                }
            }
        }
    }
}
println report.toString()

Каждая вложенная closure получает нового делегата — так MarkupBuilder меняет контекст, не подменяя owner, что позволяет свободно писать циклы или вызывать методы скрипта, не боясь конфликтов имён.

Безопасность

Когда исполняете пользовательский Groovy‑скрипт внутри сервиса, можно включать SecureASTCustomizer. Он способен запретить:

  • доступ к System.exit, Runtime.exec и т.п;

  • создание новых классов;

  • прямое обращение к java.io.

А closure в GroovyShell можно обернуть в CompilationCustomizer с phase = Conversion, чтобы выдать кастомную ошибку раньше runtime‑части.

Конкурентность

Если нужен Map‑Reduce‑стиль обработки, но хочется остаться в Groovy:

@Grab('org.codehaus.gpars:gpars:1.3.1')
import static groovyx.gpars.GParsPool.withPool

withPool(8) {
    def primes = (2..1_000_000).findAllParallel { n ->
        (2..Math.sqrt(n)).every { n % it != 0 }
    }
    println primes.size()
}

findAllParallel делит коллекцию, сериализует closure и исполняет в пуле акторов. Именно сериализует: все захваченные переменные должны быть Serializable, иначе получите NotSerializableException. С Java‑лямбдой такое не пройдёт, она по дефолту не Serializable, поэтому Groovy всё равно удобнее для быстрого прототипа.


Итог

Closure в Groovy — гибок, умеет замыкаться на контекст, подменять делегатов, каррироваться, работать в DSL и даже оптимизироваться trampoline‑рекурсией, но за каждую эту фичу вы платите производительностью и сложностью отладки. Чем чётче вы понимаете, как устроен его bytecode, какие ссылки он тянет и как управлять resolveStrategy, тем безопаснее и чище будет ваш код. Уверен, у многих из вас есть свой опыт работы с замыканиями — делитесь опытом в комментариях.

Если вы работаете с Groovy или только начинаете разбираться в его возможностях — обратите внимание на курс Groovy Developer. Он поможет структурировать понимание языка и лучше разобраться в таких темах, как closures, делегаты и построение DSL.

Также приглашаем вас заглянуть в каталог курсов по программированию, где собраны курсы по работе с языками, фреймворками и инструментами — от общего синтаксиса до узкоспециализированных решений.

Чтобы оставаться в курсе самых актуальных технологий и трендов, подписывайтесь на Telegram-канал OTUS.

Комментарии (2)


  1. vital_pavlenko
    17.07.2025 13:49

    любимые нейронки или что с наушниками у котика на превью


    1. MaxRokatansky
      17.07.2025 13:49

      Ну, съехали немного. Увлекся работой и не заметил)