В какой момент Java выбирает перегруженный метод: при компиляции или во время выполнения?
Перегруженный метод Java выбирается во время компиляции. Компилятор анализирует имя метода, количество и типы аргументов, а также их статические типы; во время выполнения выбор между перегрузками заново не выполняется.
Это отличается от переопределения, при котором реализация метода может определяться во время выполнения по фактическому типу объекта.
Перегрузка позволяет использовать одно имя для операций с разными типами параметров. В статически типизированном языке такой выбор удобно выполнять на этапе компиляции: вызов проверяется заранее, а байткод уже содержит конкретную сигнатуру метода.
Отдельно Java поддерживает полиморфизм через переопределение методов. Поэтому язык разделяет две задачи: перегрузка определяется по доступной компилятору информации, а переопределение — по классу объекта во время выполнения.
Ошибка возникает, когда разработчик рассуждает о перегрузке по фактическому значению переменной, игнорируя её объявленный тип. В результате вызывается не тот вариант метода, который ожидается по содержимому объекта.
Особенно заметно это при передаче объекта через переменную базового типа, использовании null, обобщений, упаковки примитивов или varargs. Неверное ожидание может привести к неожиданному поведению либо к ошибке компиляции ещё до запуска программы.
Для перегруженного вызова компилятор выполняет разрешение метода. Он учитывает статический тип каждого выражения, доступность методов, совместимость типов и правила выбора наиболее подходящей сигнатуры.
Статический тип — это тип, указанный для переменной или выражения в исходном коде. Динамический тип — фактический класс объекта во время выполнения. Для выбора перегрузки используется первый, а не второй.
Здесь будет выбран print(Object), потому что статический тип переменной value — Object. Сам объект фактически содержит строку, но это не изменяет уже выбранную компилятором перегрузку.
Если у класса-получателя есть переопределённый метод с той же сигнатурой, сначала определяется перегруженная сигнатура, а затем для неё может применяться динамический выбор реализации. Поэтому в одном вызове могут последовательно участвовать оба механизма: компиляция выбирает сигнатуру, выполнение — переопределённую реализацию этой сигнатуры.
Основной компромисс перегрузки — удобный единый API против потенциальной неоднозначности. Чем больше близких по типам перегрузок, тем выше риск неожиданных преобразований или ошибок при добавлении нового метода.
В библиотеке логирования были перегрузки для String и Object. Клиентский код хранил сообщения в переменной типа Object, хотя фактические значения обычно были строками. Разработчики ожидали строковую обработку, но компилятор стабильно выбирал вариант для Object, из-за чего форматирование сообщений отличалось.
Рассматривались два варианта. Первый — добавлять приведение типа в каждом месте вызова: это сохраняло существующий API, но увеличивало количество небезопасных проверок. Второй — переименовать операции в явно разные методы, например для текста и произвольных объектов: это делало намерение понятнее, но требовало изменений в коде клиентов.
Выбрали явные методы на границе подсистемы, где тип сообщения был известен заранее, а внутри сохранили одну нормализованную операцию. Это устранило зависимость поведения от статического типа переменной и сделало контракт вызова очевидным.
Что произойдёт при вызове перегруженных методов с аргументом null, если существуют варианты для String и Integer?
Вызов будет неоднозначным и не скомпилируется. null совместим с обоими ссылочными типами, но String и Integer не находятся в отношении наследования друг с другом, поэтому компилятор не может выбрать более специфичный вариант.
Если одна перегрузка принимает Object, а другая — String, будет выбрана перегрузка с String, поскольку String является более специфичным типом. Явное приведение null к нужному типу также устраняет неоднозначность, но должно отражать действительно желаемый контракт.
Как перегрузка выбирается при наличии примитивного расширения и упаковки?
Компилятор применяет определённые фазы поиска и обычно предпочитает вызов без упаковки перед вызовом, требующим упаковки, если подходящий вариант найден на более ранней фазе. Например, перегрузка с long может быть выбрана для значения типа int раньше варианта с Integer, потому что расширение примитива не требует упаковки.
Поэтому добавление перегрузки для wrapper-типа способно изменить результат только в конкретных наборах аргументов и создать неоднозначность. Надёжнее не полагаться на неочевидные преобразования в публичном API, а использовать явный тип аргумента или отдельное имя метода.
Почему перегрузка с varargs обычно проигрывает обычной перегрузке?
Varargs рассматривается как более поздний вариант, когда подходящий вызов фиксированного числа параметров не найден. Поэтому при наличии обычной перегрузки и varargs компилятор обычно выбирает обычную, поскольку она не требует упаковки аргументов в массив.
Это удобно для совместимости, но добавление varargs-перегрузки может повлиять на вызовы, которые раньше не имели подходящего фиксированного варианта. Кроме того, вызов с null может требовать дополнительного внимания, поскольку null способен соответствовать ссылочному массиву varargs и другим ссылочным параметрам.