В каком случае рекурсивная верхняя граница параметра типа позволяет обобщённому API сохранить конкретный тип наследника при цепочке вызовов?
Рекурсивная верхняя граница нужна, когда параметр типа должен ссылаться на сам себя: например, тип базового класса должен совпадать с типом конкретного наследника. Такой приём, называемый F-bound polymorphism, позволяет методам базового обобщённого класса возвращать конкретный тип наследника, а не только базовый тип.
Generics появились в Java 5, чтобы проверять типы на этапе компиляции и уменьшить необходимость в приведениях типов. При этом Java сохранила совместимость с ранее скомпилированным кодом, поэтому обобщения реализованы через стирание типов, а не через создание отдельного класса для каждой параметризации.
Рекурсивные границы стали способом выразить зависимость между параметрами типов, которую обычная верхняя граница вроде T extends Base описать не может. Они особенно полезны для fluent API, иерархий сущностей и алгоритмов, возвращающих тип самого наследника.
Допустим, базовый класс содержит метод, возвращающий this. Если его результат объявлен как базовый тип, после вызова метода теряется информация о конкретном наследнике. Цепочка вызовов с методами, добавленными только в наследнике, тогда требует приведения типа или завершается ошибкой компиляции.
Неверно выбранная граница приводит либо к потере типобезопасности, либо к дублированию методов в каждом наследнике. Универсальное решение должно гарантировать: параметр типа, используемый базовым классом, действительно представляет конкретный тип наследника.
Рекурсивная граница имеет форму T extends Base<T>. Она означает не просто «T является подтипом Base», а «T является подтипом Base, параметризованного самим T».
Минимальный пример:
У UserBuilder параметр B равен UserBuilder, поэтому common() имеет результат UserBuilder. Это сохраняет возможность продолжить цепочку вызовом name, не приводя результат вручную.
Важное ограничение: компилятор проверяет, что UserBuilder является подтипом Builder<UserBuilder>, но рекурсивная граница не запрещает некорректные по смыслу иерархии во всех возможных случаях. Кроме того, приведение this к B в базовом классе обычно нельзя доказать компилятору напрямую; его часто инкапсулируют в методе вроде self() и подавляют локальное предупреждение.
Другой вариант — переопределять каждый fluent-метод в каждом наследнике. Он проще для понимания и не требует unchecked-приведения, но увеличивает дублирование и стоимость сопровождения. Рекурсивная граница компактнее, однако сложнее читается и может затруднить диагностику ошибок типов.
В библиотеке строителей запросов общий строитель добавляет методы фильтрации и сортировки, а специализированные строители добавляют методы для конкретных сущностей. Требование — разрешить цепочку вызовов общего и специализированного API без приведения типов в пользовательском коде.
Рассматривались три решения:
B extends Builder<B> — сохраняет тип наследника в базовом API и обычно требует только одного локального unchecked-приведения.Выбран третий вариант, потому что набор строителей расширяется, а публичный API должен оставаться удобным. Предупреждение ограничили внутренним методом self(), не распространяя небезопасное приведение на вызывающий код.
Чем рекурсивная граница отличается от обычной верхней границы?
При T extends Base известно только, что T совместим с Base. Сам Base не обязан быть параметризован T, поэтому метод базового класса обычно возвращает Base или другой заранее фиксированный тип. При T extends Base<T> базовый класс получает информацию о конкретном типе, который его расширяет, и может использовать её в возвращаемых значениях и параметрах.
Гарантирует ли рекурсивная граница, что объект действительно имеет тип параметра T во время выполнения?
Нет. Из-за стирания типов параметр T не сохраняется как отдельный runtime-тип. Гарантия в основном статическая: компилятор проверяет согласованность объявления наследника, а небезопасное приведение this выполняется без проверки конкретного параметра типа во время выполнения.
Почему нельзя безоговорочно считать рекурсивные границы лучшим решением для fluent API?
Они хорошо сохраняют тип наследника, но усложняют сигнатуры и понимание иерархии. При глубоком наследовании, смешивании нескольких параметров или необходимости строгой runtime-проверки такой дизайн может стать хрупким. Если иерархия небольшая, явное переопределение методов иногда лучше: оно длиннее, зато проще для отладки и не требует unchecked-приведения.