Полиморфизъм и абстрактни класове

Полиморфизмът е принцип в обектно-ориентираното програмиране, при който една и съща операция може да има различна реализация според конкретния тип на обекта. В Java това се проявява чрез претоварване на методи и конструктори и чрез предефиниране на методи. Предефинирането на метод се среща и като презаписване на метод.

Абстрактният клас е клас, който не е предназначен за директно създаване на обекти. Той описва обща структура и общо поведение, което трябва да бъде наследено и довършено от конкретни класове.

Видове полиморфизъм

В Java полиморфизмът може да се разглежда според момента, в който се избира конкретната реализация на операцията.

Полиморфизъм по време на компилация се получава, когато компилаторът избира кой метод или конструктор да бъде извикан според подадените аргументи. Това е свързано с претоварване.

Полиморфизъм по време на изпълнение се получава, когато изборът на предефиниран, или презаписан, метод зависи от реалния тип на обекта. Това е свързано с наследяване и предефиниране на методи.

Вид Механизъм Кога се избира реализацията
Полиморфизъм по време на компилация претоварване на методи и конструктори при компилация
Полиморфизъм по време на изпълнение предефиниране (презаписване) на методи по време на изпълнение

Претоварване на методи

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

class Calculator {

    int sum(int first, int second) {
        return first + second;
    }

    double sum(double first, double second) {
        return first + second;
    }

    int sum(int first, int second, int third) {
        return first + second + third;
    }
}

При извикване на претоварен метод компилаторът избира коя версия да използва според подадените аргументи.

Calculator calculator = new Calculator();

int firstResult = calculator.sum(2, 3);
double secondResult = calculator.sum(2.5, 3.5);
int thirdResult = calculator.sum(2, 3, 4);

Претоварването не създава ново име за операцията. То позволява една операция да бъде представена с едно име, когато може да работи с различни параметри.

Претоварването е форма на полиморфизъм по време на компилация, защото конкретната версия на метода се избира преди изпълнение на програмата.

Претоварване на конструктори

Конструкторите могат да бъдат претоварвани по същото правило като методите. В един клас може да има няколко конструктора със същото име, защото името на конструктора винаги съвпада с името на класа. Разликата между тях се определя от броя, типа и реда на параметрите.

class Product {

    private String name;
    private double price;

    public Product() {
        this("Unknown", 0.0);
    }

    public Product(String name) {
        this(name, 0.0);
    }

    public Product(String name, double price) {
        this.name = name;
        this.price = price;
    }
}

При създаване на обект компилаторът избира конструктора според аргументите в израза с new.

Product first = new Product();
Product second = new Product("Keyboard");
Product third = new Product("Monitor", 250.0);

Това е форма на полиморфизъм по време на компилация, защото изборът на конкретен конструктор се извършва преди стартиране на програмата.

Предефиниране на метод

Предефиниране на метод се получава, когато клас наследник дефинира метод със същата сигнатура като метод от родителския клас. Същото понятие може да бъде наречено и презаписване на метод.

class Animal {

    public String sound() {
        return "Unknown sound";
    }
}

class Dog extends Animal {

    @Override
    public String sound() {
        return "Bark";
    }
}

Методът sound() съществува в Animal, но класът Dog предоставя собствена реализация. Анотацията @Override указва, че методът трябва да предефинира наследен метод.

Изисквания за предефиниране на метод

За да бъде един метод предефиниран коректно, трябва да са изпълнени няколко условия:

  • методът в класа наследник трябва да има същото име;
  • списъкът с параметри трябва да съвпада;
  • върнатият тип трябва да бъде същият или съвместим;
  • видимостта не трябва да бъде по-ограничена от тази в родителския клас;
  • методът от родителския клас не трябва да бъде деклариран като final, static или private.
class Parent {

    public String getInfo() {
        return "Parent";
    }
}

class Child extends Parent {

    @Override
    public String getInfo() {
        return "Child";
    }
}

Анотацията @Override не е задължителна, но позволява на компилатора да провери дали действително се предефинира наследен метод.

Методи, наследени от Object

Всеки клас в Java наследява класа Object. Затова всеки обект разполага с методи, дефинирани в Object.

Основни методи, наследени от Object, са:

  • toString() - връща текстово представяне на обекта;
  • equals(Object other) - сравнява текущия обект с друг обект;
  • hashCode() - връща числова стойност, свързана с логическата идентичност на обекта;
  • getClass() - връща обект, който описва реалния клас на текущия обект.

Тези методи могат да бъдат предефинирани в собствен клас.

class Student {

    private String name;
    private int facultyNumber;

    public Student(String name, int facultyNumber) {
        this.name = name;
        this.facultyNumber = facultyNumber;
    }

    @Override
    public String toString() {
        return name + " " + facultyNumber;
    }
}

Методът toString() се използва, когато обектът трябва да бъде представен като текст.

Student student = new Student("Ivan", 12345);
System.out.println(student);

При извеждане на обекта се използва предефинираният toString().

Метод getClass()

Методът getClass() е наследен от Object. Той връща информация за реалния клас на обекта по време на изпълнение.

Animal animal = new Dog();

System.out.println(animal.getClass().getSimpleName());

В примера типът на променливата е Animal, но реалният обект е Dog. Затова getClass().getSimpleName() връща името Dog.

getClass() не заменя полиморфизма. Той се използва само когато е необходима информация за реалния клас на обекта.

Оператор instanceof

Операторът instanceof проверява дали дадена референция сочи към обект от определен тип.

Animal animal = new Dog();

if (animal instanceof Dog) {
    System.out.println("The object is Dog.");
}

Проверката чрез instanceof се използва преди явно преобразуване, когато не е сигурно какъв е реалният тип на обекта. Тя се използва и при предефиниране на equals(), защото параметърът на метода е от тип Object.

equals() и hashCode()

Методът equals() се използва за логическо сравнение на обекти. Ако два различни обекта трябва да се считат за равни според стойностите на полетата си, equals() трябва да бъде предефиниран.

class Student {

    private int facultyNumber;

    public Student(int facultyNumber) {
        this.facultyNumber = facultyNumber;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }

        if (!(other instanceof Student)) {
            return false;
        }

        Student student = (Student) other;
        return facultyNumber == student.facultyNumber;
    }

    @Override
    public int hashCode() {
        return Integer.hashCode(facultyNumber);
    }
}

Когато се предефинира equals(), трябва да се предефинира и hashCode(). Двата метода трябва да бъдат съгласувани: ако два обекта са равни според equals(), те трябва да връщат една и съща стойност от hashCode().

Полиморфна референция

Променлива от родителски тип може да сочи към обект от клас наследник.

Animal animal = new Dog();

System.out.println(animal.sound());

Типът на променливата е Animal, но реалният обект е Dog. При извикване на sound() се изпълнява реализацията от Dog. Изборът на метода се извършва по време на изпълнение според реалния тип на обекта.

Преобразуване при наследяване

Обект от клас наследник може да бъде записан в променлива от родителски тип. Това преобразуване се извършва автоматично.

Dog dog = new Dog();
Animal animal = dog;

Обратното преобразуване от родителски тип към тип на наследник трябва да бъде явно.

Animal animal = new Dog();
Dog dog = (Dog) animal;

Явното преобразуване е коректно само ако реалният обект е от съответния тип. Ако реалният обект не е от този тип, при изпълнение възниква ClassCastException.

Масив от родителски тип

Полиморфизмът позволява различни наследници да се обработват чрез общ родителски тип.

Animal[] animals = new Animal[3];

animals[0] = new Dog();
animals[1] = new Cat();
animals[2] = new Bird();

for (Animal animal : animals) {
    System.out.println(animal.sound());
}

Всеки елемент от масива има тип Animal, но може да съдържа различен конкретен наследник. При обхождане се извиква подходящата реализация на sound().

Абстрактен клас

Абстрактен клас се декларира с ключовата дума abstract.

abstract class Shape {

    private String color;

    public Shape(String color) {
        this.color = color;
    }

    public String getColor() {
        return color;
    }

    public abstract double calculateArea();
}

Класът Shape съдържа поле, конструктор, обикновен метод и абстрактен метод. Абстрактният метод calculateArea() няма тяло. Всеки конкретен наследник трябва да предостави реализация.

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

В примера всяка фигура има цвят, затова полето color и методът getColor() са дефинирани в Shape. Изчисляването на лице зависи от конкретната фигура, затова calculateArea() е абстрактен метод и трябва да бъде реализиран от наследниците.

Наследяване на абстрактен клас

class Rectangle extends Shape {

    private double width;
    private double height;

    public Rectangle(String color, double width, double height) {
        super(color);
        this.width = width;
        this.height = height;
    }

    @Override
    public double calculateArea() {
        return width * height;
    }
}

Класът Rectangle наследява Shape, извиква конструктора на родителския клас чрез super(color) и реализира абстрактния метод calculateArea().

Правила за абстрактни класове

От абстрактен клас не може да се създава директен обект.

// Shape shape = new Shape("red"); // не се компилира

Абстрактен клас може да съдържа полета, конструктори, обикновени методи и абстрактни методи. Ако един клас съдържа поне един абстрактен метод, самият клас трябва да бъде деклариран като abstract.

Предефиниране и претоварване

Предефинирането не е същото като претоварване. Претоварването се избира при компилация според параметрите на метода или конструктора.

Предефинирането е свързано с наследяване. При него клас наследник предоставя нова реализация на метод със същата сигнатура като метод от родителския клас. Изборът на предефиниран метод се извършва по време на изпълнение според реалния тип на обекта.

Основни понятия

Понятие Предназначение
Претоварване Един метод или конструктор има няколко версии с различни параметри
Предефиниране Клас наследник предоставя нова реализация на наследен метод
Полиморфизъм Различни обекти се обработват чрез общ родителски тип
Абстрактен клас Базов клас, който не може да бъде инстанциран и може да съдържа абстрактни методи

This site uses Just the Docs, a documentation theme for Jekyll.