Перейти к основному содержимому

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

· 7 мин. чтения

1. Обзор

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

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

2. Перегрузка метода

Перегрузка методов — это мощный механизм, который позволяет нам определять связанные API-интерфейсы классов. Чтобы лучше понять, почему перегрузка методов является такой ценной функцией, давайте рассмотрим простой пример.

Предположим, что мы написали простой служебный класс, реализующий различные методы умножения двух чисел, трех чисел и т. д.

Если мы дали методам вводящие в заблуждение или двусмысленные имена, такие как умножить2() , умножить3() , умножить4() , то это будет плохо спроектированный API класса. Вот где в игру вступает перегрузка методов.

Проще говоря, мы можем реализовать перегрузку методов двумя разными способами:

  • реализация двух или более методов с одинаковыми именами, но с разным количеством аргументов
  • реализация двух или более методов с одинаковыми именами, но принимающих аргументы разных типов

2.1. Разное количество аргументов

Класс Multiplier в двух словах показывает, как перегрузить методmultiplier() , просто определив две реализации, которые принимают разное количество аргументов:

public class Multiplier {

public int multiply(int a, int b) {
return a * b;
}

public int multiply(int a, int b, int c) {
return a * b * c;
}
}

2.2. Аргументы разных типов

Точно так же мы можем перегрузить методmulti() , заставив его принимать аргументы разных типов:

public class Multiplier {

public int multiply(int a, int b) {
return a * b;
}

public double multiply(double a, double b) {
return a * b;
}
}

Кроме того, можно определить класс Multiplier с обоими типами перегрузки методов:

public class Multiplier {

public int multiply(int a, int b) {
return a * b;
}

public int multiply(int a, int b, int c) {
return a * b * c;
}

public double multiply(double a, double b) {
return a * b;
}
}

Однако стоит отметить, что невозможно иметь две реализации методов, которые отличаются только типами возвращаемых значений .

Чтобы понять почему – давайте рассмотрим следующий пример:

public int multiply(int a, int b) { 
return a * b;
}

public double multiply(int a, int b) {
return a * b;
}

В этом случае код просто не скомпилировался бы из-за неоднозначности вызова метода — компилятор не знал бы, какую реализацию multi() вызывать.

2.3. Тип акции

Одной из удобных функций, предоставляемых перегрузкой методов, является так называемое повышение типа, также известное как расширение примитивного преобразования .

Проще говоря, один заданный тип неявно повышается до другого, когда нет соответствия между типами аргументов, переданных в перегруженный метод, и конкретной реализацией метода.

Чтобы лучше понять, как работает преобразование типа, рассмотрим следующие реализации методаmulti() :

public double multiply(int a, long b) {
return a * b;
}

public int multiply(int a, int b, int c) {
return a * b * c;
}

Теперь вызов метода с двумя аргументами int приведет к тому, что второй аргумент будет повышен до long , так как в этом случае нет подходящей реализации метода с двумя аргументами int .

Давайте посмотрим быстрый модульный тест, чтобы продемонстрировать продвижение типа:

@Test
public void whenCalledMultiplyAndNoMatching_thenTypePromotion() {
assertThat(multiplier.multiply(10, 10)).isEqualTo(100.0);
}

И наоборот, если мы вызываем метод с соответствующей реализацией, продвижение типа просто не происходит:

@Test
public void whenCalledMultiplyAndMatching_thenNoTypePromotion() {
assertThat(multiplier.multiply(10, 10, 10)).isEqualTo(1000);
}

Вот сводка правил продвижения типов, которые применяются для перегрузки методов:

  • byte может быть преобразован в short, int, long, float или double
  • Short может быть повышен до int, long, float или double
  • char может быть преобразован в int, long, float или double
  • int может быть повышен до long, float или double
  • long может быть повышен до float или double
  • float можно увеличить до double

2.4. Статическая привязка

Возможность связать конкретный вызов метода с телом метода называется привязкой.

В случае перегрузки метода привязка выполняется статически во время компиляции, поэтому она называется статической привязкой.

Компилятор может эффективно установить привязку во время компиляции, просто проверив сигнатуры методов.

3. Переопределение метода

Переопределение методов позволяет нам предоставлять детализированные реализации в подклассах для методов, определенных в базовом классе.

Несмотря на то, что переопределение методов является мощной функцией, учитывая, что это логическое следствие использования наследования , одного из основных столпов ООП , когда и где его использовать, необходимо тщательно анализировать его для каждого варианта использования .

Теперь давайте посмотрим, как использовать переопределение метода, создав простое отношение на основе наследования ("является").

Вот базовый класс:

public class Vehicle {

public String accelerate(long mph) {
return "The vehicle accelerates at : " + mph + " MPH.";
}

public String stop() {
return "The vehicle has stopped.";
}

public String run() {
return "The vehicle is running.";
}
}

И вот надуманный подкласс:

public class Car extends Vehicle {

@Override
public String accelerate(long mph) {
return "The car accelerates at : " + mph + " MPH.";
}
}

В приведенной выше иерархии мы просто переопределили метод Accel() , чтобы обеспечить более совершенную реализацию подтипа Car.

Здесь ясно видно, что если приложение использует экземпляры класса Vehicle , то оно может работать и с экземплярами Car , поскольку обе реализации метода ускорения() имеют одинаковую сигнатуру и один и тот же тип возвращаемого значения.

Давайте напишем несколько модульных тестов для проверки классов Vehicle и Car :

@Test
public void whenCalledAccelerate_thenOneAssertion() {
assertThat(vehicle.accelerate(100))
.isEqualTo("The vehicle accelerates at : 100 MPH.");
}

@Test
public void whenCalledRun_thenOneAssertion() {
assertThat(vehicle.run())
.isEqualTo("The vehicle is running.");
}

@Test
public void whenCalledStop_thenOneAssertion() {
assertThat(vehicle.stop())
.isEqualTo("The vehicle has stopped.");
}

@Test
public void whenCalledAccelerate_thenOneAssertion() {
assertThat(car.accelerate(80))
.isEqualTo("The car accelerates at : 80 MPH.");
}

@Test
public void whenCalledRun_thenOneAssertion() {
assertThat(car.run())
.isEqualTo("The vehicle is running.");
}

@Test
public void whenCalledStop_thenOneAssertion() {
assertThat(car.stop())
.isEqualTo("The vehicle has stopped.");
}

Теперь давайте посмотрим на некоторые модульные тесты, которые показывают, как методы run() и stop() , которые не переопределены, возвращают одинаковые значения как для Car , так и для Vehicle :

@Test
public void givenVehicleCarInstances_whenCalledRun_thenEqual() {
assertThat(vehicle.run()).isEqualTo(car.run());
}

@Test
public void givenVehicleCarInstances_whenCalledStop_thenEqual() {
assertThat(vehicle.stop()).isEqualTo(car.stop());
}

В нашем случае у нас есть доступ к исходному коду обоих классов, поэтому мы ясно видим, что вызов метода Accel() для базового экземпляра Vehicle и вызов метода Accelerator() для экземпляра Car вернут разные значения для одного и того же аргумента.

Поэтому следующий тест демонстрирует, что переопределенный метод вызывается для экземпляра Car :

@Test
public void whenCalledAccelerateWithSameArgument_thenNotEqual() {
assertThat(vehicle.accelerate(100))
.isNotEqualTo(car.accelerate(100));
}

3.1. Тип взаимозаменяемости

Основным принципом ООП является заменяемость типов, тесно связанная с принципом подстановки Лискова (LSP) .

Проще говоря, LSP утверждает, что если приложение работает с данным базовым типом, то оно должно работать и с любым из его подтипов . Таким образом, заменяемость типов сохраняется должным образом.

Самая большая проблема с переопределением методов заключается в том, что некоторые конкретные реализации методов в производных классах могут не полностью соответствовать LSP и, следовательно, не поддерживать заменяемость типов.

Конечно, допустимо сделать переопределенный метод для приема аргументов разных типов и возврата другого типа, но с полным соблюдением следующих правил:

  • Если метод в базовом классе принимает аргумент(ы) заданного типа, переопределенный метод должен принимать тот же тип или супертип (то есть аргументы контравариантного метода) .
  • Если метод базового класса возвращает void , переопределенный метод должен возвращать void .
  • Если метод в базовом классе возвращает примитив, переопределенный метод должен возвращать тот же примитив.
  • Если метод в базовом классе возвращает определенный тип, переопределенный метод должен возвращать тот же тип или подтип (также известный как ковариантный возвращаемый тип) .
  • Если метод в базовом классе выдает исключение, переопределенный метод должен выдавать такое же исключение или подтип исключения базового класса.

3.2. Динамическое связывание

Учитывая, что переопределение метода может быть реализовано только с наследованием, где существует иерархия базового типа и подтипа (ов), компилятор не может определить во время компиляции, какой метод вызывать, поскольку и базовый класс, и подклассы определяют те же методы.

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

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

4. Вывод

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

Как обычно, все примеры кода, показанные в этой статье, доступны на GitHub .