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

Переопределение метода в Java

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

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

Вступление

Объектно-ориентированное программирование (ООП) побуждает нас моделировать объекты реального мира в коде. И дело с объектами в том, что некоторые из них имеют общую внешнюю видимость. Кроме того, группа из них может демонстрировать аналогичное поведение.

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

Когда класс Java расширяет другой, мы называем его подклассом . Тот, из которого расширен, становится суперклассом . Теперь основная причина этого заключается в том, что подкласс может использовать процедуры из суперкласса. Тем не менее, в других случаях подкласс может захотеть добавить дополнительную функциональность к тому, что уже есть у суперкласса.

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

Что такое Переопределение метода?

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

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

  1. То же самое имя
  2. Одинаковое количество параметров
  3. Один и тот же тип параметров
  4. Тот же или ковариантный тип возврата

Чтобы лучше понять эти условия, возьмите класс Shape . Это геометрическая фигура, которая имеет вычисляемую площадь:

abstract class Shape {
    abstract Number calculateArea();
}

Затем давайте расширим этот базовый класс на пару конкретных классов — a Треугольник и Квадрат :

class Triangle extends Shape {
    private final double base;
    private final double height;

    Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }

    @Override
    Double calculateArea() {
        return (base / 2) * height;
    }

    @Override
    public String toString() {
        return String.format(
                "Triangle with a base of %s and height of %s",
                new Object[]{base, height});
    }
}

class Square extends Shape {
    private final double side;

    Square(double side) {
        this.side = side;
    }

    @Override
    Double calculateArea() {
        return side * side;
    }

    @Override
    public String toString() {
        return String.format("Square with a side length of %s units", side);
    }
}

Помимо переопределения метода calculateArea () , два класса также переопределяют Object ‘s toString () . Также обратите внимание, что эти два аннотируют переопределенные методы с помощью @Override .

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

Тем не менее, мы также добавили переопределение toString () . Метод доступен для всех объектов. И поскольку две фигуры являются объектами, они могут переопределять toString() . Хотя это не обязательно, это делает распечатку сведений о классе удобной для человека.

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

void printAreaDetails(Shape shape) {
    var description = shape.toString();
    var area = shape.calculateArea();

    // Print out the area details to console
    LOG.log(Level.INFO, "Area of {0} = {1}", new Object[]{description, area});
}

Итак, когда вы запускаете такой тест, как:

void calculateAreaTest() {
    // Declare the side of a square
    var side = 5;

    // Declare a square shape
    Shape shape = new Square(side);

    // Print out the square's details
    printAreaDetails(shape);

    // Declare the base and height of a triangle
    var base = 10;
    var height = 6.5;

    // Reuse the shape variable
    // By assigning a triangle as the new shape
    shape = new Triangle(base, height);

    // Then print out the triangle's details
    printAreaDetails(shape);
}

Вы получите этот вывод:

INFO: Area of Square with a side length of 5.0 units = 25
INFO: Area of Triangle with a base of 10.0 and height of 6.5 = 32.5

Как видно из кода, при переопределении рекомендуется включать обозначение @Override . И, как объясняет Oracle , это важно, потому что это:

…указывает компилятору, что вы собираетесь переопределить метод в суперклассе. Если по какой-либо причине компилятор обнаружит, что метод не существует в одном из суперклассов, он выдаст ошибку.

Как и когда переопределить

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

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

Методы из интерфейсов и абстрактных классов

Возьмите интерфейс, Идентифицируемый , который определяет поле id объекта:

public interface Identifiable {
    T getId();
}

T представляет тип класса, который будет использоваться для идентификатора . Итак, если мы используем этот интерфейс в приложении базы данных, T может иметь , например, тип Целое число . Еще одна примечательная вещь заключается в том, что T является Сериализуемым .

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

Затем, скажем, мы создаем класс Первичный ключ , который реализует Идентифицируемый :

class PrimaryKey implements Identifiable {
    private final int value;

    PrimaryKey(int value) {
        this.value = value;
    }

    @Override
    public Integer getId() {
        return value;
    }
}

Первичный ключ должен переопределить метод getId() из Идентифицируемого . Это означает, что Первичный ключ обладает свойствами Идентифицируемого . И это важно, потому что Первичный ключ может реализовывать несколько интерфейсов.

В таком случае он будет обладать всеми возможностями интерфейсов, которые он реализует. Вот почему такая связь называется “имеет-а” отношения в иерархиях классов.

Давайте рассмотрим другой сценарий. Может быть, у вас есть API, который предоставляет абстрактный класс, Человек :

abstract class Person {
    abstract String getName();
    abstract int getAge();
}

Поэтому, если вы хотите воспользоваться некоторыми процедурами, которые работают только с типами Person , вам придется расширить класс. Возьмем, к примеру, этот класс Customer :

class Customer extends Person {
    private final String name;
    private final int age;

    Customer(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    String getName() {
        return name;
    }

    @Override
    int getAge() {
        return age;
    }
}

Расширяя Person с помощью Customer , вы вынуждены применять переопределения. Тем не менее, это означает только то, что вы ввели класс, который относится к типу Человек . Таким образом, вы ввели “есть-а” отношения. И чем больше вы на это смотрите, тем больше смысла в таких заявлениях.

Потому что, в конце концов, клиент – это человек.

Расширение класса, не являющегося окончательным

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

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

class Coach {
    void motivateTeam() {
        throw new UnsupportedOperationException();
    }
}

Если Тренер не объявлен окончательным, вам повезло. Вы можете просто расширить его, чтобы создать Тренера по крикету , который может как анализировать игру () , так и мотивировать команду() :

class CricketCoach extends Coach {
    String analyzeGame() {
        throw new UnsupportedOperationException();
    }

    @Override
    void motivateTeam() {
        throw new UnsupportedOperationException();
    }
}

Расширение заключительного класса

Наконец, что произойдет, если мы расширим класс final ?

final class CEO {
    void leadCompany() {
        throw new UnsupportedOperationException();
    }
}

И если бы мы попытались воспроизвести функциональность генерального директора с помощью другого класса, скажем, Инженера-программиста :

class SoftwareEngineer extends CEO {}

Git Essentials

Ознакомьтесь с этим практическим руководством по изучению Git, содержащим лучшие практики и принятые в отрасли стандарты. Прекратите гуглить команды Git и на самом деле изучите это!

Нас бы встретила неприятная ошибка компиляции. Это имеет смысл, поскольку ключевое слово final в Java используется для указания на вещи, которые не должны меняться.

Вы не можете расширить окончательный класс.

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

Создание класса-оболочки, содержащего экземпляр класса final , который предоставляет вам методы, которые могут изменять состояние объекта. Однако это работает только в том случае, если в обертываемом классе реализован интерфейс, который означает, что мы можем предоставить оболочку вместо класса final .

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

Популярным примером класса final является класс String . Это окончательное и, следовательно, неизменное . Когда вы выполняете “изменения” в строке с помощью любого из встроенных методов, создается и возвращается новая Строка , создавая иллюзию изменения:

public String concat(String str) {
    int otherLen = str.length();
    if (otherLen == 0) {
        return this;
    }

    int len = value.length;
    char buf[] = Arrays.copyOf(value, len + otherLen);
    str.getChars(buf, len);
    return new String(buf, true);
}

Переопределение метода и полиморфизм

Словарь Merriam-Webster определяет полиморфизм как:

Качество или состояние существования или принятия различных форм

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

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

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

Условные обозначения

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

Рассмотрим два класса ниже, которые описывают звуки, которые издают Собака и Кошка :

class Dog {
    String bark() {
        return "Bark!";
    }

    @Override
    public String toString() {
        return "Dog";
    }
}

class Cat {
    String meow() {
        return "Meow!";
    }

    @Override
    public String toString() {
        return "Cat";
    }
}

Затем мы создаем метод makeSound () , чтобы заставить этих животных издавать звуки:

void makeSound(Object animal) {
    switch (animal.toString()) {
        case "Dog":
            LOG.log(Level.INFO, ((Dog) animal).bark());
            break;
        case "Cat":
            LOG.log(Level.INFO, ((Cat) animal).meow());
            break;
        default:
            throw new AssertionError(animal);
    }
}

Теперь типичным тестом для make Sound() будет:

void makeSoundTest() {
    var dog = new Dog();
    var cat = new Cat();

    // Create a stream of the animals
    // Then call the method makeSound to extract
    // a sound out of each animal
    Stream.of(dog, cat).forEach(animal -> makeSound(animal));
}

Который затем выводит:

INFO: Bark!
INFO: Meow!

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

abstract class Animal {
    // Assign the sound-making
    // to the concrete implementation
    // of the Animal class
    abstract void makeSound();
}

class Dog extends Animal {
    @Override
    void makeSound() {
        LOG.log(Level.INFO, "Bark!");
    }
}

class Cat extends Animal {
    @Override
    void makeSound() {
        LOG.log(Level.INFO, "Meow!");
    }
}

Приведенный ниже тест показывает, насколько простым стало использование класса:

void makeSoundTest() {
    var dog = new Dog();
    var cat = new Cat();

    // Create a stream of animals
    // Then call each animal's makeSound method
    // to produce each animal's unique sound
    Stream.of(dog, cat).forEach(Animal::makeSound);
}

У нас больше нет отдельного метода makeSound , как раньше, для определения того, как извлечь звук из животного. Вместо этого каждый конкретный Животное класс переопределен издает звук для введения полиморфизма. В результате код становится читабельным и кратким.

Если вы хотите узнать больше о лямбда-выражениях и ссылках на методы, показанных в приведенных выше примерах кода, мы вам поможем!

Классы полезности

Классы утилит часто используются в проектах Java. Обычно они выглядят примерно как java.lang.Математика ‘s min() метод:

public static int min(int a, int b) {
    return (a <= b) ? a : b;
}

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

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

Возьмем, к примеру, метод min() в классе утилит Math . Эта процедура стремится вернуть значение int . Он также принимает два значения int в качестве входных данных. Затем он сравнивает эти два, чтобы найти меньший.

Итак, по сути, min() показывает нам, что нам нужно создать класс типа Число – для удобства, названный Минимум .

В Java класс Number является абстрактным. И это хорошо. Потому что это позволит нам переопределить методы, которые имеют отношение только к нашему делу.

Это, например, даст нам возможность представить минимальное количество в различных форматах. В дополнение к int мы также могли бы предложить минимум в виде long , float или double . В результате класс Minimum может выглядеть следующим образом:

public class Minimum extends Number {

    private final int first;
    private final int second;

    public Minimum(int first, int second) {
        super();
        this.first = first;
        this.second = second;
    }

    @Override
    public int intValue() {
        return (first <= second) ? first : second;
    }

    @Override
    public long longValue() {
        return Long.valueOf(intValue());
    }

    @Override
    public float floatValue() {
        return (float) intValue();
    }

    @Override
    public double doubleValue() {
        return (double) intValue();
    }
}

В реальном использовании разница в синтаксисе между Math ‘s min и Minimum значительна:

// Find the smallest number using
// Java's Math utility class
int min = Math.min(5, 40);

// Find the smallest number using
// our custom Number implementation
int minimumInt = new Minimum(5, 40).intValue();

Тем не менее, аргумент, который можно привести против приведенного выше подхода, заключается в том, что он более многословен. Правда, мы, возможно, значительно расширили метод утилиты min () . На самом деле мы превратили его в полноценный класс!

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

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

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

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

Еще одно отличительное различие между ними заключается в том, как компиляторы относятся к ним. Компиляторы выбирают между перегруженными методами при компиляции и разрешают переопределенные методы во время выполнения. Вот почему перегрузка также известна как время компиляции полиморфизм. И мы также можем ссылаться на переопределение как полиморфизм времени выполнения .

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

Таким образом, перегрузка и переопределение различаются следующими способами:

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

Вывод

Переопределение метода является неотъемлемой частью представления ООП-мускула Java. Он цементирует иерархии классов, позволяя подклассам обладать и даже расширять возможности своих суперклассов.

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

Например, рекомендуется переопределить метод toString() из класса Объекта . И эта статья продемонстрировала такую практику, когда она переопределила toString() для Формы типов – Треугольник и Квадрат .

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

Как всегда, вы можете найти весь код на GitHub .