«1. Обзор

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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. , float или double int могут быть повышены до 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.";
    }
}

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

Здесь ясно видно, что если приложение использует экземпляры класса 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 и вызов метода Acceler( ) для экземпляра 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.