Как стирание типов обеспечивает бинарную совместимость старого байткода с библиотекой, в которой API позже параметризовали?
Стирание типов сохраняет в байткоде прежние JVM-сигнатуры: параметры типов обычно удаляются, а параметризованные типы заменяются своими raw-типами или верхними границами. Поэтому старый байткод может вызывать обновлённые методы, если их стёртые дескрипторы остались прежними. Параметризация при этом записывается главным образом в атрибуте Signature, который используется компилятором и reflection, но не участвует в обычном разрешении вызовов JVM.
Обобщения появились в Java 5, когда уже существовало большое количество библиотек и скомпилированных приложений. Изменение формата JVM или обязательная специализация классов для каждого аргумента типа нарушили бы совместимость с этим кодом.
Стирание типов позволило добавить статическую проверку типов на этапе компиляции, сохранив модель выполнения, рассчитанную на неparameterизованные классы и методы. Это компромисс: совместимость была сохранена, но информация о конкретных аргументах типа обычно недоступна во время выполнения.
Предположим, библиотека раньше принимала обычный List, а затем её исходный код изменили на List<String>. Для исходного байткода важно не написание исходного кода, а JVM-дескриптор метода. Если после стирания он остался прежним, вызов можно связать с новой реализацией.
Опасность возникает, если изменение меняет стёртую сигнатуру: например, параметр заменяют на другой класс или метод получает конфликтующий дескриптор. Тогда старый клиент может завершиться ошибкой связывания, несмотря на то что новый исходный код библиотеки успешно компилируется.
Кроме того, компилятор новых клиентов вставляет проверки типов при чтении значений. Поэтому бинарная совместимость не означает, что старый клиент автоматически получает compile-time-проверки, добавленные параметризацией.
Для JVM параметризация List<String> и List<Integer> не создаёт два разных runtime-типа: после стирания обе формы соответствуют List. Аналогично, параметр типа без явной границы обычно стирается до Object, а параметр с верхней границей — до этой границы.
В class-файле отдельно может храниться generic-сигнатура. Компилятор использует её, чтобы проверять новые исходники, выводить типы и вставлять необходимые приведения. Но при разрешении обычного вызова JVM ориентируется на дескриптор метода, в котором параметры типов уже стёрты.
Если старый клиент был скомпилирован против метода со стёртым параметром List, он сможет вызвать метод новой библиотеки с параметром List<String>, поскольку на уровне байткода это тот же тип. Новый клиент уже увидит более точный контракт и получит статическую проверку элементов.
При переопределении стирание иногда создаёт несовместимость между ожидаемой и фактической сигнатурами. Тогда компилятор добавляет bridge method — синтетический метод с нужной стёртой сигнатурой, делегирующий вызов параметризованной реализации.
Главное ограничение: параметризацию нельзя считать частью JVM-дескриптора. Изменение List<String> на Set<String> меняет стёртый тип и может нарушить бинарную совместимость. Поэтому при эволюции публичного API важно сохранять не только логический контракт, но и стёртые сигнатуры методов.
В библиотеке метод загрузки данных первоначально возвращал обычный List. В новой версии разработчики объявили его как List<String>, чтобы запретить добавление объектов других типов в новом клиентском коде.
Вариант с изменением только параметризации сохраняет прежний стёртый тип результата. Старые клиенты продолжают загружаться и вызывать метод, а новые получают более безопасный compile-time-контракт. Минус — старый клиент по-прежнему работает с raw-типом и может самостоятельно нарушить типовую дисциплину.
Вариант с заменой результата на Collection<String> может быть логически приемлем, но меняет JVM-дескриптор метода с List на Collection. Старый байткод, ожидающий прежний дескриптор, способен получить NoSuchMethodError при связывании.
Практическое решение — сохранять старый метод с прежней стёртой сигнатурой, при необходимости добавить новый перегруженный или делегирующий метод и проверить совместимость инструментами анализа бинарного API. В результате новые клиенты получают параметризацию, а старые продолжают работать без перекомпиляции, если сохранены необходимые дескрипторы.
Вопрос: Достаточно ли сохранить одинаковый исходный текст метода для бинарной совместимости?
Нет. JVM связывает вызовы по имени, типам параметров и возвращаемому типу в байткодном дескрипторе, а не по внешнему сходству исходного текста. Даже небольшое изменение стёртого типа может привести к ошибке связывания. Параметризация обычно безопасна именно потому, что не меняет стёртый тип.
Вопрос: Видит ли JVM аргументы типа, записанные в Signature?
Для обычного разрешения вызова — нет. Signature является метаданными, которые читают компилятор и reflection; JVM использует его ограниченно и не превращает разные параметризации в разные runtime-классы. Поэтому наличие List<String> в generic-сигнатуре не позволяет перегрузить метод отдельно для List<Integer>.
Вопрос: Почему старый клиент может получить ClassCastException, если бинарная совместимость сохранена?
Бинарная совместимость означает, что вызов удалось связать, но не гарантирует корректность данных. Старый или raw-клиент может поместить объект неподходящего типа в параметризованную структуру, а новый код при чтении выполнит вставленное компилятором приведение. Если фактический объект не соответствует ожидаемому типу, ошибка возникнет во время выполнения, обычно в точке чтения значения.