Рубрики
Без рубрики

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

Изучите основы перегрузки и переопределения методов в Java.

Автор оригинала: baeldung.

1. Обзор

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

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

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

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

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

Если мы дали методам вводящие в заблуждение или двусмысленные имена, такие как multiply 2() , multiply 3() , multiply 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. Аргументы различных типов

Аналогично, мы можем перегрузить метод 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. Тип Продвижения

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

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

Чтобы более четко понять, как работает продвижение типов, рассмотрим следующие реализации метода 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 , так как в этом случае нет соответствующей реализации метода с двумя взаимозаменяемыми аргументами.

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

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

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

  • байт может быть повышен до short, int, long, float, или double
  • short может быть повышен до int, long, float, или double
  • char может быть повышен до int, long, float, или double
  • он может быть повышен до long, float, или double
  • long может быть повышен до float или double
  • float может быть повышен до double

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

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

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

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

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

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

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

Теперь давайте посмотрим, как использовать переопределение метода, создав простое отношение на основе наследования (“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. Взаимозаменяемость Типов

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

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

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

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

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

3.2. Динамическая привязка

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

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

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

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

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

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