Java Challengers #1: Перегрузка методов в JVM


Всем доброго дня.


У нас уже запустился очередной поток курса "Разработчик Java", но у нас ещё осталось немного материалов, которыми бы хотели с вами поделиться.


Добро пожаловать в серию статей Java Challengers! Этот серия статей посвящена особенностям программирования на Java. Их освоение — это ваш путь к становлению высококвалифицированным программистом на Java.


Освоение техник, рассматриваемых в этой серии статей требует некоторых усилий, но они будут иметь большое значение в вашем повседневном опыте в качестве java — разработчика. Избежать ошибок проще когда вы знаете как правильно применять основные техники программирования Java и отслеживать ошибки намного проще, когда вы точно знаете, что происходит в вашем java — коде.


Готовы ли вы приступить к освоению основных концепций программирования на Java? Тогда давайте начнем с нашей первой задачки!


widening-boxing-varargs


Термин "Перегрузка методов"

Про термин перегрузка разработчики склонны думать, что речь идет о перезагрузке системы, но это не так. В программировании, перегрузка метода означает использование одинакового имени метода с разными параметрами.

Что такое перегрузка методов?


Перегрузка методов — это приём программирования, который позволяет разработчику в одном классе для методов с разными параметрами использовать одно и то же имя. В этом случае мы говорим, что метод перегружен.


В Листинге 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) { }
}

Перегрузка методов и примитивные типы


В Листинге 1 вы видели примитивные типы int и double. Давайте отвлечёмся на минуту и вспомним примитивные типы в Java.


Таблица 1. Примитивные типы в Java


Тип Диапазон Значение по умолчанию Размер Примеры литералов
boolean true или false false 1 бит true, false
byte -128… 127 0 8 бит 1, -90, 128
char Символ юникода или от 0 до 65 536 \u0000 16 бит 'a', '\u0031', '\201', '\n', 4
short -32,768… 32,767 0 16 бит 1, 3, 720, 22,000
int -2 147 483 648… 2 147 483 647 0 32 бит -2, -1, 0, 1, 9
long -9 223 372 036 854 775 808 до 9 223 372 036 854 775 807 0 64 бит -4000L, -900L, 10L, 700L
float 3.40282347 x 1038, 1.40239846 x 10-45 0.0 32 бит 1.67e200f, -1.57e-207f, .9f, 10.4F
double 1.7976931348623157 x 10308, 4.9406564584124654 x 10-324 0.0 64 бит 1.e700d, -123457e, 37e1d

Зачем мне использовать перегрузку методов?


Использование перегрузки делает ваш код чище и проще для чтения, а также помогает избежать ошибок в программе.


В противоположность Листингу 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"; }
}

Хорошо. Вы изучили код. Какой будет вывод?


  1. befe
  2. bfce
  3. efce
  4. aecf

Правильный ответ приведён в конце статьи.


Что сейчас произошло? Как JVM компилирует перегруженные методы


Для того чтобы понять что произошло в Листинге 2, вам нужно знать несколько вещей о том, как JVM компилирует перегруженные методы.


Прежде всего, JVM разумно ленива: она всегда будет прилагать наименьшие усилия для выполнения метода. Таким образом, когда вы думаете о том, как JVM обрабатывает перегрузку, имейте в виду три важных особенности компилятора:


  1. Расширение (widening)
  2. Упаковка (autoboxing and unboxing)
  3. Аргументы переменной длины (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.



Изучите ограничения и недостатки перегрузки методов, а также способы их устранения путем использования пользовательских типов и объектов параметров.

Комментарии (2)


  1. shishmakov
    01.11.2018 11:34

    Скажите пожалуйста, а где вы взяли, что у boolean размер 1 бит? Не вижу такого в JLS: docs.oracle.com/javase/specs/jls/se11/html/jls-4.html
    Возможно это дело JVM?


    1. rjhdby
      01.11.2018 13:25

      Подозреваю, что имелся в виду «значащий» размер. Но так-то конечно да — в обычной жизни меньше байта не целесообразно.