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

1. Обзор

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

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

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

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

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

Если бы мы дали методам вводящие в заблуждение или неоднозначные имена, такие как multiply2 () , multiply3 () , multiply4 (), , то это было бы плохо разработанным API класса. Вот где перегрузка метода вступает в игру.

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

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

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

аргументы разных типов **

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

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

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. Аргументы разных типов

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

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;
}

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

2.3. Тип Акция

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

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

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

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. Переопределение метода

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

Хотя переопределение методов является мощной функцией, учитывая, что это является логическим следствием использования https://en.wikipedia.org/wiki/Inheritance (object-oriented programming)[inheritance], одного из самых важных компонентов https://en . wikipedia.org/wiki/Object-oriented__programming[OOP]- когда и где его использовать, следует тщательно проанализировать, для каждого конкретного случая использования .

Давайте теперь посмотрим, как использовать переопределение методов, создавая простые, основанные на наследовании («is-a») отношения.

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

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.";
    }
}

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

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

Давайте напишем несколько модульных тестов для проверки классов 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());
}

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

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

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

** 3.1. Заменимость типа

**

Основным принципом в ООП является принцип замещаемости типов, который тесно связан с https://en.wikipedia.org/wiki/Liskov substitution principle[Liskov Принцип замены (LSP)].

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

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

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

  • Если метод в базовом классе принимает аргумент (ы) заданного типа,

переопределенный метод должен принимать тот же тип или супертип (a.k.a.

contravariant аргументы метода) ** Если метод в базовом классе возвращает void , переопределенный метод

должен вернуть void ** Если метод в базовом классе возвращает примитив, переопределенный

метод должен возвращать тот же примитив ** Если метод в базовом классе возвращает определенный тип, переопределенный

Метод должен вернуть тот же тип или подтип (a.k.a. covariant тип возврата) ** Если метод в базовом классе выдает исключение, переопределяется

метод должен выдать то же исключение или подтип исключения базового класса

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

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

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

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

4. Заключение

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

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