Java Challengers #1: Перегрузка методов в JVM
Перегрузка методов в JVM: рассмотрим основные моменты с примерами, разберемся в назначении, пройдемся по примитивным типам и решим задачу!
Всем доброго дня.
Совсем скоро старт нового потока курса "Разработчик Java", в преддверии которого мы хотим поделиться полезными материалами.
☕ Подтянуть свои знания по Java вы можете на нашем телеграм-канале «Библиотека Java для собеса»
Добро пожаловать в серию статей Java Challengers! Эта серия статей посвящена особенностям программирования на Java. Их освоение – ваш путь к становлению в качестве высококвалифицированного программиста на Java.
Освоение техник, рассматриваемых в этой серии статей, требует некоторых усилий, но они будут иметь большое значение в вашем повседневном опыте в качестве java-разработчика. Избежать ошибок проще, когда вы знаете, как правильно применять основные техники программирования Java, и отслеживать ошибки намного проще, когда вы точно знаете, что происходит в вашем java-коде.
Готовы ли вы приступить к освоению основных концепций программирования на Java? Тогда давайте начнем с нашей первой задачки!
Термин "Перегрузка методов"
Про термин перегрузка разработчики склонны думать, что речь идет о перезагрузке системы, но это не так. В программировании, перегрузка метода означает использование одинакового имени метода с разными параметрами.
Что такое перегрузка методов?
Перегрузка методов – это приём программирования, который позволяет разработчику в одном классе для методов с разными параметрами использовать одно и то же имя. В этом случае мы говорим, что метод перегружен.
В Листинге 1 показаны методы с разными параметрами, которые различаются количеством, типом и порядком.
Листинг 1. Три варианта перегрузки методов
// Количество параметров public class Calculator { void calculate(int number1, int number2) { } void calculate(int number1, int number2, int number3) { } } // Типы параметров public class Calculator { void calculate(int number1, int number2) { } void calculate(double number1, double number2) { } } // Порядок параметров public class Calculator { void calculate(double number1, int number2) { } void calculate(int number1, double number2) { } }
🧩☕ Интересные задачи по Java для практики можно найти на нашем телеграм-канале «Библиотека задач по Java»
Перегрузка методов и примитивные типы
В Листинге 1 вы видите примитивные типы int
и double
. Давайте отвлечёмся на минуту и вспомним примитивные типы в Java.
Таблица 1. Примитивные типы в Java
Зачем мне использовать перегрузку методов?
Использование перегрузки делает ваш код чище и проще для чтения, а также помогает избежать ошибок в программе.
В противоположность Листингу 1 представьте программу, где у вас будет много методов calculate()
с именами, похожими на calculate1
, calculate2
, calculate3
… не хорошо, правда? Перегрузка метода calculate()
позволяет использовать одно и то же имя и изменять только то, что необходимо, – параметры. Также очень легко найти перегруженные методы, поскольку они сгруппированы в коде.
Чем перегрузка не является
Помните, что изменение имени переменной не является перегрузкой. Следующий код не скомпилируется:
public class Calculator { void calculate(int firstNumber, int secondNumber){} void calculate(int secondNumber, int thirdNumber){} }
Вы также не можете перегрузить метод, изменяя возвращаемое значение в сигнатуре метода. Этот код также не скомпилируется:
public class Calculator { double calculate(int number1, int number2){return 0.0;} long calculate(int number1, int number2){return 0;} }
Перегрузка конструктора
Вы можете перегрузить конструктор таким же способом, как и метод:
public class Calculator { private int number1; private int number2; public Calculator(int number1) { this.number1 = number1; } public Calculator(int number1, int number2) { this.number1 = number1; this.number2 = number2; } }
Решите задачку по перегрузке методов
Готовы ли вы к первому испытанию? Давайте выясним!
Начните с внимательного изучения следующего кода.
Листинг 2. Сложная задача по перегрузке методов
public class AdvancedOverloadingChallenge3 { static String x = ""; public static void main(String... doYourBest) { executeAction(1); executeAction(1.0); executeAction(Double.valueOf("5")); executeAction(1L); System.out.println(x); } static void executeAction(int ... var) {x += "a"; } static void executeAction(Integer var) {x += "b"; } static void executeAction(Object var) {x += "c"; } static void executeAction(short var) {x += "d"; } static void executeAction(float var) {x += "e"; } static void executeAction(double var) {x += "f"; } }
Хорошо. Вы изучили код. Какой будет вывод?
- befe
- bfce
- efce
- aecf
Правильный ответ приведён в конце статьи.
Что сейчас произошло? Как JVM компилирует перегруженные методы?
Чтобы понять, что произошло в Листинге 2, вам нужно знать несколько вещей о том, как JVM компилирует перегруженные методы.
Прежде всего, JVM разумно ленива: она всегда будет прилагать наименьшие усилия для выполнения метода. Таким образом, когда вы думаете о том, как JVM обрабатывает перегрузку, имейте в виду три важных особенности компилятора:
- Расширение (widening).
- Упаковка (autoboxing and unboxing).
- Аргументы переменной длины (varargs).
Если вы никогда не сталкивались с этими техниками, то несколько примеров должны вам помочь их понять. Обратите внимание, что JVM выполняет их в том порядке, в котором они указаны.
Вот пример расширения:
int primitiveIntNumber = 5; double primitiveDoubleNumber = primitiveIntNumber;
Это порядок расширения примитивных типов:
(Прим. переводчика – В JLS расширение примитивов описано с большими вариациями, например, long может быть расширен во float или в double.)
Пример автоупаковки:
int primitiveIntNumber = 7; Integer wrapperIntegerNumber = primitiveIntNumber;
Обратите внимание, что происходит за кулисами при компиляции кода:
Integer wrapperIntegerNumber = Integer.valueOf(primitiveIntNumber);
А вот пример распаковки:
Integer wrapperIntegerNumber = 7; int primitiveIntNumber= wrapperIntegerNumber;
Вот что происходит при компиляции этого кода:
int primitiveIntNumber = wrapperIntegerNumber.intValue();
И пример метода с аргументами переменной длины. Обратите внимание, что методы переменной длины всегда являются последними для выполнения.
execute(int... numbers){}
Что такое аргументы переменной длины?
Аргументы переменной длины – это просто массив значений, заданный тремя точками (...). Мы можем передать сколько угодно чисел int
этому методу.
Например:
execute(1,3,4,6,7,8,8,6,4,6,88...); // Можно продолжать...
Аргументы переменной длины (varargs) очень удобны тем, что значения могут передаваться непосредственно в метод. Если бы мы использовали массивы, нам пришлось бы создать экземпляр массива со значениями.
Расширение: практический пример
Когда мы передаем число 1 прямо в метод executeAction()
, JVM автоматически интерпретирует его как int
. Вот почему это число не будет передано в метод executeAction(short var)
.
Если мы передаём число 1.0
, JVM также автоматически распознает, что это double
.
Конечно, число 1.0
может быть и float
, но тип таких литералов предопределен. Поэтому в Листинге 2 выполняется метод executeAction(double var)
.
Когда мы используем обёртку Double
, есть два варианта: либо число может быть распаковано в примитивный тип, либо оно может быть расширено в Object
(помните, что каждый класс в Java расширяет класс Object
). В этом случае JVM выбирает расширение типа Double
в Object
, потому что это требует меньше усилий, чем распаковка.
Последним мы передаём 1L
, и так как мы указали тип – это long
.
Распространенные ошибки с перегрузкой
К настоящему времени вы уже поняли, что с перегрузкой методов всё может быть запутано, поэтому давайте рассмотрим несколько проблем, с которыми вы, вероятно, столкнетесь.
Автоупаковка с обёртками (autoboxing with wrappers)
Java – это строго типизированный язык программирования и, когда мы используем автоупаковку с обёртками, есть несколько вещей, которые мы должны учитывать. Во-первых, следующий код не компилируется:
int primitiveIntNumber = 7; Double wrapperNumber = primitiveIntNumber;
Автоупаковка будет работать только с типом double
потому что, когда вы скомпилируете код, он будет эквивалентен этому:
Double number = Double.valueOf(primitiveIntNumber);
Этот код скомпилируется. Первый int
будет расширен до double
и потом будет упакован в Double
. Но при автоупаковке нет расширения типов, и конструктор Double.valueof
ожидает double
, а не int
. В этом случае автоупаковка будет работать, если мы сделаем явное приведение типа, например:
Double wrapperNumber = (double) primitiveIntNumber;
Помните, что Integer
не может быть Long
и Float
и не может быть Double
. Здесь нет наследования. Каждый из этих типов (Integer
, Long
, Float
, и Double
) – Number
и Object
.
Если Вы сомневаетесь, просто помните, что обёртки чисел (wrapper numbers) могут быть расширены до Number
или Object
(есть еще много чего, что можно сказать про обёртки, но оставим это для другой статьи).
Литералы чисел в коде
Когда мы не указываем тип числа-литерала, JVM вычислит тип за нас. Если напрямую используем число 1
в коде, то JVM создаст его как int
. Если мы попытаемся передать 1
напрямую в метод, который принимает short
, то он не скомпилируется.
Например:
class Calculator { public static void main(String... args) { // Вызов этого метода не скомпилируется // Да, может быть char, short, byte, но JVM создает его как int calculate(1); } void calculate(short number) {} }
Такое же правило будет применяться, когда используется число 1.0
. Хотя это может быть и float
, JVM будет считать его double
.
class Calculator { public static void main(String... args) { // Вызов этого метода не скомпилируется // Да, может быть float, но JVM создает его как double calculate(1.0); } void calculate(float number) {} }
Другой распространенной ошибкой является предположение, что Double
или любая другая обертка лучше подойдет для метода, получающего double
.
Факт в том, что JVM требуется меньше усилий для расширения обертки Double
в Object
вместо её распаковки в примитивный тип double
.
Подводя итог, при использовании непосредственно в java-коде, 1
будет int
и 1.0
будет double
. Расширение – это самый лёгкий путь к выполнению, далее идёт упаковка или распаковка и последней операцией всегда будут методы переменной длины.
Как любопытный факт. Знаете ли вы, что тип char
принимает числа?
char anyChar = 127; // Да, это странно, но это компилируется
Что необходимо помнить о перегрузке
Перегрузка – это очень мощная техника для случаев, когда вам нужно одинаковое имя метода с разными параметрами. Это полезная техника, потому что использование правильных имён делает код более удобным для чтения. Вместо того, чтобы дублировать имя метода и добавлять беспорядок в ваш код, вы можете просто перегрузить его.
Это позволяет сохранять код чистым и удобным для чтения, а также снижает риск того, что дублирующие методы сломают часть системы.
Что следует иметь в виду: при перегрузке метода JVM сделает наименьшее усилие из возможных.
Вот порядок самого ленивого пути к исполнению:
- Первое – расширение (widening).
- Второе – упаковка (boxing).
- Третье – аргументы переменной длины (varargs).
Что следует учитывать: сложные ситуации возникают при объявлении чисел напрямую: 1
будет int
и 1.0
будет double
.
Также помните, что вы можете объявить эти типы явно, используя синтаксис 1F
или 1f
для float
и 1D
или 1d
для double
.
На этом мы закончим о роли JVM в перегрузке методов. Важно понимать, что JVM по своей сути ленива, и всегда будет следовать по самому ленивому пути.
Ответ
Ответ к Листингу 2 – Вариант 3. efce.
Подробнее о перегрузке методов в Java
Введение в классы и объекты для абсолютных новичков, включая небольшие разделы о методах и перегрузке методов.
Узнайте больше о том, почему важно, что Java является строго типизированным языком, и изучите примитивные типы Java.
Изучите ограничения и недостатки перегрузки методов, а также способы их устранения путем использования пользовательских типов и объектов параметров.