Разберите последствия объявления метода базового класса как final для полиморфного вызова.
Метод, объявленный как final, нельзя переопределить в классе-наследнике. Поэтому при вызове такого метода через ссылку базового типа всегда используется реализация, объявленная в базовом классе; полиморфная замена этой реализации невозможна.
Наследник всё ещё может унаследовать такой метод и вызывать его, но не может объявить метод с той же сигнатурой как замену. При этом final не запрещает перегрузку метода и не делает неизменяемым состояние объектов, с которым метод работает.
Механизм final появился как средство явно ограничивать расширение классов и сохранять гарантии базового класса. Без такого ограничения наследник мог бы изменить поведение метода, на которое опираются инварианты, проверка безопасности или алгоритм базового класса.
final также позволяет автору API отделить точки расширения от фиксированной части поведения. Это не следует путать с оптимизацией: виртуальная машина может оптимизировать вызов, но семантический смысл final — запрет переопределения, а не обещание конкретной реализации оптимизатору.
Если базовый класс вызывает метод, предполагая, что он всегда выполняет определённую проверку или поддерживает внутреннее состояние, переопределение этой операции может нарушить его контракт. Особенно опасна ситуация, когда внешний код видит объект через тип базового класса, но фактически получает экземпляр наследника с изменённым поведением.
Неверное понимание final приводит к двум типичным ошибкам: разработчик пытается переопределить такой метод и получает ошибку компиляции либо считает, что объявление одного метода final защищает все методы и состояние класса от изменений.
В Java переопределение требует совместимой сигнатуры метода базового класса. Для экземплярного метода final такая замена запрещена на этапе компиляции. Следовательно, динамический тип объекта уже не может выбрать другую реализацию именно этого метода.
В примере withdraw нельзя переопределить, но его поведение всё ещё может зависеть от вызова doWithdraw, который остаётся расширяемым. Это распространённый шаблон неизменяемого алгоритма: базовый класс фиксирует порядок и обязательные проверки, а наследник предоставляет отдельный шаг расширения.
final не запрещает перегрузку: наследник может объявить метод с тем же именем, но другим списком параметров. Это будет новый метод, а не замена исходного. Нельзя также объявить в наследнике статический метод или метод экземпляра с той же сигнатурой в попытке обойти запрет: такая декларация конфликтует с final-методом базового класса.
При вызове withdraw через ссылку типа Account и при фактическом объекте AuditedAccount будет выполнена реализация Account.withdraw. Динамическая диспетчеризация не выбирает альтернативу для final-метода, потому что допустимой переопределяющей реализации не существует.
Ограничение имеет компромисс: final защищает контракт, но уменьшает гибкость наследования и усложняет подмену поведения в тестах. Если классу не требуется наследование вообще, иногда точнее сделать final весь класс; если нужна гибкость отдельных частей, следует оставить расширяемыми только явно предназначенные для этого методы.
В библиотеке платежей базовый класс выполняет списание средств и обязан проверять положительную сумму, лимит и журналировать операцию. Команда обнаружила, что один наследник переопределяет метод списания и пропускает журналирование, из-за чего аудит становится неполным.
Рассматривались три варианта. Запретить наследование всего класса — надёжно, но лишает систему допустимых специализаций. Описать правило только в документации — гибко, но компилятор не защищает контракт. Сделать основной метод final, а отдельный защищённый шаг обработки оставить расширяемым — сохраняет обязательный алгоритм и контролируемую точку настройки.
Выбран третий вариант. В результате наследники могут менять конкретный этап проведения платежа, но не могут удалить проверки и журналирование. Дополнительно отдельно документируется, какие защищённые методы разрешено переопределять и какие условия они обязаны соблюдать.
Означает ли final, что вызов метода всегда будет статически связан на уровне JVM?
На уровне языка это означает, что метод нельзя переопределить, поэтому результат вызова не зависит от динамического типа объекта. На уровне JVM компилятор или JIT-компилятор может выбрать оптимизированный способ вызова, но конкретная техника связывания не является тем, что должен определять исходный код. Нельзя строить корректность программы на деталях текущей оптимизации.
Можно ли обойти final через перегрузку метода в наследнике?
Нет, если параметры остаются теми же: это будет попытка переопределения и она запрещена. Если параметры отличаются, возникает перегрузка — новый метод, который не заменяет final-метод. Поэтому вызов с другим набором аргументов может попасть в перегруженный метод, а вызов с исходной сигнатурой по-прежнему обращается к реализации базового класса.
Защищает ли final-метод базовый класс от полиморфного поведения полностью?
Нет. Сам final-метод нельзя заменить, но он может вызывать обычные переопределяемые методы. В таком случае внутри фиксированного алгоритма часть поведения всё равно выбирается по динамическому типу объекта. Если базовый класс вызывает расширяемый метод из конструктора, это дополнительно опасно: поля наследника могут быть ещё не инициализированы. Поэтому final обычно фиксирует критический алгоритм, а точки расширения должны быть небольшими, явно обозначенными и безопасными для вызова в соответствующем жизненном цикле.