Перейти к содержанию

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

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

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

Перегрузка метода (overloading)

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

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

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

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

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

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

Класс 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;
    }
}

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

Можно перегрузить метод 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() вызывать.

Преобразование типов

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

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

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

Статическое связывание

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

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

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

Переопределение метода (overriding)

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

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

Посмотрим, как использовать переопределение метода, создав простое отношение на основе наследования («является»).

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

public class Vehicle {
    
    public String accelerate(long kph) {
        return "Автомобиль разгоняется до: " + kph + " километров в час.";
    }
    
    public String stop() {
        return "Автомобиль остановился.";
    }
    
    public String run() {	
        return "Автомобиль едет.";
    }
}

А вот придуманный подкласс:

public class Car extends Vehicle {

    @Override
    public String accelerate(long kph) {
        return "Автомобиль разгоняется до: " + kph + " километров в час.";
    }
}

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

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

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

@Test
public void whenCalledAccelerate_thenOneAssertion() {
    assertThat(vehicle.accelerate(100))
      .isEqualTo("Автомобиль разгоняется до: 100 километров в час.");
}
    
@Test
public void whenCalledRun_thenOneAssertion() {
    assertThat(vehicle.run())
      .isEqualTo("Автомобиль едет.");
}
    
@Test
public void whenCalledStop_thenOneAssertion() {
    assertThat(vehicle.stop())
      .isEqualTo("Автомобиль остановился.");
}

@Test
public void whenCalledAccelerate_thenOneAssertion() {
    assertThat(car.accelerate(80))
      .isEqualTo("Автомобиль разгоняется до: 80 километров в час.");
}
    
@Test
public void whenCalledRun_thenOneAssertion() {
    assertThat(car.run())
      .isEqualTo("Автомобиль едет.");
}
    
@Test
public void whenCalledStop_thenOneAssertion() {
    assertThat(car.stop())
      .isEqualTo("Автомобиль остановился.");
}

Теперь давайте посмотрим на некоторые модульные тесты, которые показывают, как методы 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());
}

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

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

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

Взаимозаменяемый тип

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

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

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

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

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

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

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

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

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

Заключение

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

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

Оригинал