Полиморфизм в ООП: виды и примеры на Java

Полиморфизм в ООП: виды и примеры на Java Полезное

Полиморфизм — это возможность работать с объектами разных типов через один общий интерфейс: код вызывает send(), а что именно произойдет, зависит от типа данных. Под этим словом прячутся три разных механизма, и их важно не смешивать:

Вид Как выглядит в Java Кто выбирает реализацию
Подтиповой (полиморфизм подтипов) переопределение метода в наследнике или реализации интерфейса JVM во время выполнения, по реальному объекту
Ad hoc перегрузка: несколько методов с одним именем и разными параметрами компилятор, по объявленным типам аргументов
Параметрический дженерики: List<T>, <T> T max(...) один код для многих типов, проверка при компиляции

Когда в ООП говорят «полиморфизм» без уточнения, обычно имеют в виду первый вид. Ниже разбираем все три на запускаемых примерах. Все листинги проверены на Eclipse Temurin 25.0.4 (Java 25 LTS), 23.09.2026.

Подтиповой полиморфизм: один вызов, разное поведение

Аналогия: кнопка «Поделиться» в соцсети одна, но текст уходит сообщением, картинка — файлом, видео — ссылкой. Сначала полный пример, потом разбор.

import java.util.List;

abstract class Content {
    private final String title;

    Content(String title) {
        this.title = title;
    }

    String title() {
        return title;
    }

    // общий контракт: каждый вид контента обязан уметь отправляться
    abstract String send(String to);
}

class Text extends Content {
    Text(String title) { super(title); }

    @Override
    String send(String to) {
        return "текст '" + title() + "' -> " + to + ": отправлен сообщением";
    }
}

class Image extends Content {
    Image(String title) { super(title); }

    @Override
    String send(String to) {
        return "картинка '" + title() + "' -> " + to + ": сжата и приложена файлом";
    }
}

class Video extends Content {
    Video(String title) { super(title); }

    @Override
    String send(String to) {
        return "видео '" + title() + "' -> " + to + ": отправлена ссылка на поток";
    }
}

public class Main {
    public static void main(String[] args) {
        List<Content> feed = List.of(
                new Text("Привет"),
                new Image("cat.png"),
                new Video("demo.mp4"));

        for (Content c : feed) {
            System.out.println(c.send("anna"));
        }
    }
}

Сохраните как Main.java, выполните javac Main.java и java Main. В консоли появятся три разные строки из одного и того же цикла:

текст 'Привет' -> anna: отправлен сообщением
картинка 'cat.png' -> anna: сжата и приложена файлом
видео 'demo.mp4' -> anna: отправлена ссылка на поток

Что здесь произошло:

  1. Content задает контракт: метод send(String) есть у любого контента. Метод абстрактный, поэтому создать «просто контент» через new Content(...) нельзя.
  2. Наследники переопределяют (override) метод: та же сигнатура, своя реализация. Аннотация @Override просит компилятор проверить, что переопределение действительно есть.
  3. Переменная c объявлена как Content, но в ней лежат Text, Image, Video. JVM выбирает реализацию по реальному объекту в момент вызова. Это называют динамической диспетчеризацией или поздним связыванием.

Главная польза: цикл не знает о конкретных классах. Добавите Audio — цикл менять не придется, достаточно нового наследника. Это и есть расширяемость, ради которой полиморфизм используют.

Наследование не обязательно

Частое упрощение звучит так: «для полиморфизма нужны абстрактный класс и наследование». Это не так. В Java достаточно интерфейса: interface Sendable { String send(String to); } могут реализовать классы из совершенно разных иерархий, и работать с ними можно через тип Sendable. В языках с утиной типизацией (Python) не нужен даже общий интерфейс — важен только метод с нужным именем.

Ошибка: забыли реализовать метод

Добавим наследника без send:

class Audio extends Content {
    Audio(String title) { super(title); }
}

Результат — код не компилируется (javac из JDK 25):

Main.java:45: error: Audio is not abstract and does not override abstract method send(String) in Content
class Audio extends Content {
^
1 error

Исправление: реализовать String send(String to) с @Override или объявить Audio абстрактным, если он сам служит базой для других классов. Абстрактный метод полезен именно этим: забыть реализацию невозможно, компилятор не даст.

Ошибка: сигнатура не совпала

Если в наследнике написать String send(String to, boolean silent), это уже не переопределение, а новый метод с другими параметрами. С @Override javac сразу это ловит (минимальный файл Err2.java, первые строки вывода):

Err2.java:5: error: Text is not abstract and does not override abstract method send(String) in Content
Err2.java:6: error: method does not override or implement a method from a supertype

Без абстрактного метода в базе и без @Override такой промах пройдет молча: вызов через базовый тип выполнит родительскую версию. Поэтому @Override стоит ставить на каждое переопределение.

Что не участвует в полиморфизме

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

class Base {
    String kind = "base";
    String name() { return "Base.name"; }
}

class Child extends Base {
    String kind = "child";                        // поле скрывает, а не переопределяет
    @Override
    String name() { return "Child.name"; }
}

public class Hide {
    public static void main(String[] args) {
        Base b = new Child();
        System.out.println(b.name());   // выбор по объекту
        System.out.println(b.kind);     // выбор по типу переменной
    }
}
Child.name
base

Статические методы с той же сигнатурой ведут себя так же: наследник их скрывает, вызов привязан к классу. Также не переопределяются private и final методы.

Ad hoc полиморфизм: перегрузка методов

Перегрузка (overloading) — несколько методов с одним именем и разными списками параметров. Какой из них вызвать, решает компилятор по объявленным типам аргументов. Поэтому перегрузку называют статическим полиморфизмом, а переопределение — динамическим.

public class Overload {
    static String describe(Object o)  { return "Object: " + o; }
    static String describe(String s)  { return "String длиной " + s.length(); }
    static String describe(int n)     { return "int, квадрат = " + n * n; }

    public static void main(String[] args) {
        System.out.println(describe("hello"));
        System.out.println(describe(7));
        System.out.println(describe(3.5));

        Object o = "hello";            // объект String, но тип переменной Object
        System.out.println(describe(o));
        System.out.println(o.toString().length()); // а вот toString() - переопределение
    }
}
String длиной 5
int, квадрат = 49
Object: 3.5
Object: hello
5

Четвертая строка — типичная ловушка. В o лежит строка, но перегрузка выбрана по типу переменной Object. Для 3.5 подходящей версии нет, и double упаковывается в Double, а тот подходит под Object. Вызов o.toString() при этом работает по реальному объекту: это переопределение, а не перегрузка.

Исправление, если нужен выбор по реальному типу во время выполнения: один метод и switch с шаблонами типов (финальная возможность языка с Java 21).

static String describe(Object o) {
    return switch (o) {
        case String s  -> "String длиной " + s.length();
        case Integer n -> "int, квадрат = " + n * n;
        default        -> "Object: " + o;
    };
}

Для Object o = "hello" теперь печатается String длиной 5. Если же типы ваши собственные, чаще правильнее не разбирать их через switch, а вынести поведение в переопределяемый метод, как в примере с Content.

Параметрический полиморфизм: дженерики

Параметрический полиморфизм — один и тот же код для многих типов, где тип передается параметром. В Java это дженерики. Метод ниже находит максимум в списке чисел, строк и дат:

import java.time.LocalDate;
import java.util.List;

public class Generic {
    // один код для любого T, у которого есть порядок сравнения
    static <T extends Comparable<? super T>> T max(List<T> items) {
        if (items.isEmpty()) {
            throw new IllegalArgumentException("пустой список");
        }
        T best = items.get(0);
        for (T item : items) {
            if (item.compareTo(best) > 0) {
                best = item;
            }
        }
        return best;
    }

    public static void main(String[] args) {
        System.out.println(max(List.of(3, 11, 7)));
        System.out.println(max(List.of("java", "kotlin", "c")));
        System.out.println(max(List.of(LocalDate.of(2025, 9, 16), LocalDate.of(2026, 3, 17))));
    }
}
11
kotlin
2026-03-17

Строки сравниваются лексикографически, поэтому «kotlin» больше «java». Ограничение T extends Comparable<? super T> — это граница: код не для абсолютно любого типа, а для типов с порядком. Передадим список Object:

GenErr.java:7: error: method max in class GenErr cannot be applied to given types;
  required: List<T>
  found:    List<Object>

Ошибка ловится при компиляции, а не падением программы. Оговорка: дженерики в Java реализованы через стирание типов, поэтому во время выполнения List<String> и List<Integer> — один и тот же класс ArrayList, а примитивы (int) в параметр типа не подставить, только обертки (Integer).

Полиморфизм среди принципов ООП

В примере с Content видны все четыре принципа: инкапсуляция прячет поле title за методом, абстракция выделяет контракт send, наследование задает отношение «Video является Content», а полиморфизм позволяет вызывать контракт, не зная конкретного класса.

Связь не жесткая: полиморфизм подтипов работает и через интерфейсы без наследования реализации. Глубокое наследование ради полиморфизма — частая причина хрупкого кода (изменение базы ломает наследников), поэтому в Java-проектах часто предпочитают интерфейсы и композицию.

Как выбрать механизм

Задача Что взять Когда иначе
Много видов объектов с общим действием, список видов будет расти интерфейс или абстрактный класс + переопределение если виды фиксированы и их мало, подойдет sealed interface и switch
Одно действие для разных входных типов, удобство вызова перегрузка если тип известен только во время выполнения, перегрузка не поможет
Одинаковый алгоритм для многих типов (коллекции, поиск, сортировка) дженерики с нужной границей extends если логика отличается по типам, это уже переопределение

Выводы

  • Полиморфизм в ООП — работа с разными типами через общий интерфейс; в Java он бывает подтиповым, ad hoc и параметрическим.
  • Переопределение выбирается JVM по реальному объекту во время выполнения, перегрузка — компилятором по объявленным типам.
  • @Override и абстрактные методы переводят ошибки полиморфизма в ошибки компиляции.
  • Поля, статические, private и final методы не переопределяются.
  • Наследование не обязательно: интерфейсы дают полиморфизм без общей иерархии реализации.

Где применяется / связь с практикой

Освойте тему на практике

Полиморфизм встречается в любом Java-проекте: коллекции работают через List и Map, веб-фреймворки вызывают ваши реализации интерфейсов, в тестах зависимости подменяют тестовыми реализациями. Чтобы уверенно проектировать классы и интерфейсы и разбирать такой код, пригодится базовый курс Java Developer. Basic. Попробовать формат и задать вопросы преподавателям можно на бесплатных открытых уроках Otus.

Смежные темы: Java для новичков: class и object, отношения между классами в Java.

FAQ

Можно ли переопределить конструктор?
Нет. Конструкторы не наследуются, их можно только перегружать и вызывать родительский через super(...), как в классах Text и Image.

Замедляет ли динамическая диспетчеризация программу?
Накладные расходы есть, но JIT-компилятор HotSpot часто убирает их, когда видит, что в месте вызова встречается один или два типа. Для обычного прикладного кода это редко узкое место, решать стоит по профилировщику.

Есть ли полиморфизм в языке C?
Встроенного нет: в C нет классов и перегрузки. Похожее поведение собирают вручную через структуры с указателями на функции, как устроены таблицы виртуальных методов в C++.

OTUS Журнал