1. Введение
Полиморфизм — это одна из трёх китовых концепций объектно-ориентированного программирования (наряду с наследованием и инкапсуляцией). Если дословно, то слово происходит от греческого "poly" (много) и "morph" (форма). В программировании это означает: один интерфейс — много реализаций.
Определение
Полиморфизм — это способность объектов разных классов реагировать на одинаковые сообщения (вызовы методов) по-разному.
То есть, если у вас есть метод makeSound(), то вы можете вызвать его у любого животного, но кошка замяукает, собака залает, а корова замычит. Для программиста — это просто вызов animal.makeSound(), а вот что произойдёт на самом деле — зависит от того, какой именно объект стоит за этой переменной.
Аналогия из жизни
Представьте, что у вас дома есть пульт от телевизора, и этим же пультом можно управлять колонками, проектором и даже кофемашиной. Вы нажимаете кнопку «Включить» — turnOn(), и каждый прибор реагирует по-своему. Главное — у всех есть «кнопка» включения, но реализация разная.
Пример на Java
class Animal {
void makeSound() {
System.out.println("Какой-то звук...");
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Гав!");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("Мяу!");
}
}
class Cow extends Animal {
@Override
void makeSound() {
System.out.println("Мууу!");
}
}
Теперь мы можем сделать так:
Animal animal1 = new Dog();
Animal animal2 = new Cat();
Animal animal3 = new Cow();
animal1.makeSound(); // Гав!
animal2.makeSound(); // Мяу!
animal3.makeSound(); // Мууу!
Обратите внимание: все переменные имеют тип Animal, но результат вызова зависит от фактического типа объекта.
2. Виды полиморфизма
В Java (и в большинстве ООП-языков) различают два основных вида полиморфизма:
Компиляторный (статический) полиморфизм — перегрузка методов (overloading)
Это когда в одном классе есть несколько методов с одинаковым именем, но разными параметрами. Компилятор сам решает, какой метод вызвать, исходя из переданных аргументов.
Пример (заглянем вперёд — подробнее в следующей лекции):
class Printer {
void print(int x) {
System.out.println("Число: " + x);
}
void print(String s) {
System.out.println("Строка: " + s);
}
}
Исполнительный (динамический) полиморфизм — переопределение методов (overriding)
Это когда метод определяется в базовом классе, а затем переопределяется в подклассах. Какой именно метод будет вызван — решается во время выполнения программы (runtime), в зависимости от фактического типа объекта.
Пример — см. выше с животными.
3. Зачем нужен полиморфизм?
Полиморфизм — это не просто красивое слово для собеседования. Это инструмент, который делает ваш код гибким, расширяемым и удобным для поддержки.
Универсальность кода
Вы можете писать код, который работает с объектами базового типа, не заботясь о деталях их реализации. Например, если у вас есть список животных, вы можете пройтись по нему и вызвать у каждого makeSound(), не думая о том, кошка это или собака.
Animal[] animals = { new Dog(), new Cat(), new Cow() };
for (Animal animal : animals) {
animal.makeSound(); // Каждый раз вызовется "правильный" метод
}
Лёгкость расширения
Если завтра к вам придёт начальник и скажет: «А давай добавим попугая!», вы просто пишете новый класс Parrot extends Animal и добавляете его в массив. Весь остальной код останется прежним. Это — открытость для расширения и закрытость для изменений (принцип OCP из SOLID).
Упрощение архитектуры
Вы можете строить сложные системы, где отдельные части взаимодействуют друг с другом через абстракции (базовые классы или интерфейсы), не заботясь о конкретных реализациях. Это экономит время, нервы и кофе.
4. Ключевые понятия: ссылочный и фактический тип
Ссылочный тип переменной
Когда вы пишете Animal animal = new Dog();, переменная animal имеет ссылочный тип Animal, то есть компилятор «думает», что это животное, и разрешает только те методы, которые объявлены в классе Animal.
Фактический (реальный) тип объекта
Но в памяти у вас реально лежит объект типа Dog. Именно он определяет, какой метод будет вызван при обращении к makeSound().
Иллюстрация
Animal animal = new Dog();
animal.makeSound(); // Вызовет Dog.makeSound(), а не Animal.makeSound()
Важно! Через ссылку базового типа (Animal) вы не сможете вызвать методы, которые есть только у Dog, если они не объявлены в базовом классе.
Позднее (динамическое) связывание
Это магия, которая происходит во время выполнения: когда вы вызываете метод через ссылку базового типа, JVM смотрит на фактический тип объекта и вызывает «правильную» реализацию. Это и есть полиморфизм в действии.
5. Практический пример: полиморфизм в приложении
Давайте продолжим развитие нашего учебного приложения. Допустим, мы пишем простую симуляцию зоопарка. У нас есть базовый класс Animal и несколько его потомков. Мы хотим, чтобы все животные могли «издавать звук», но не хотим каждый раз писать отдельный код для каждого типа животного.
Шаг 1: Базовый класс и потомки
class Animal {
void makeSound() {
System.out.println("Какой-то звук...");
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Гав!");
}
}
class Cat extends Animal {
@Override
void makeSound() {
System.out.println("Мяу!");
}
}
Шаг 2: Массив животных
Animal[] zoo = { new Dog(), new Cat(), new Animal() };
Шаг 3: Перебор и вызов метода
for (Animal animal : zoo) {
animal.makeSound();
}
Результат выполнения:
Гав!
Мяу!
Какой-то звук...
Обратите внимание: мы не знаем заранее, кто именно находится в массиве — но программа сама разбирается и вызывает нужный метод.
6. Схематическое изображение полиморфизма
. Animal (makeSound)
/ \
Dog Cat
(makeSound) (makeSound)
Animal animal = new Dog();
animal.makeSound(); // --> Dog.makeSound()
Animal animal = new Cat();
animal.makeSound(); // --> Cat.makeSound()
7. Ещё пример: полиморфизм в реальной задаче
Допустим, вы пишете программу для управления сотрудниками компании. У вас есть базовый класс Employee и два потомка: Manager и Developer. Все сотрудники могут работать — work(), но делают это по-разному.
class Employee {
void work() {
System.out.println("Сотрудник работает.");
}
}
class Manager extends Employee {
@Override
void work() {
System.out.println("Менеджер проводит совещание.");
}
}
class Developer extends Employee {
@Override
void work() {
System.out.println("Разработчик пишет код.");
}
}
Теперь вы можете сделать так:
Employee[] staff = { new Manager(), new Developer(), new Employee() };
for (Employee emp : staff) {
emp.work();
}
Результат:
Менеджер проводит совещание.
Разработчик пишет код.
Сотрудник работает.
8. Когда полиморфизм НЕ работает
Полиморфизм работает только для методов, объявленных в базовом классе. Если в подклассе есть свой уникальный метод, через ссылку базового типа вы его не увидите.
class Dog extends Animal {
void fetchStick() {
System.out.println("Собака приносит палку!");
}
}
Animal animal = new Dog();
// animal.fetchStick(); // Ошибка компиляции! Такой метод не виден через Animal
Чтобы вызвать специфичный метод, нужно привести переменную к нужному типу:
if (animal instanceof Dog) {
((Dog) animal).fetchStick();
}
Но это уже другая история — главное: через полиморфизм доступны только методы, объявленные в базовом классе.
9. Типичные ошибки при работе с полиморфизмом
Ошибка № 1: Ожидание, что через ссылку базового типа будут доступны все методы подкласса. На самом деле, доступны только те, что объявлены в базовом классе.
Ошибка № 2: Неиспользование аннотации @Override при переопределении метода. Без неё можно случайно написать метод с неправильной сигнатурой, и тогда полиморфизм не сработает (метод базового класса не будет переопределён).
Ошибка № 3: Попытка вызвать специфичный метод подкласса без приведения типа. Компилятор не позволит, потому что не знает, кто именно сидит за ссылкой базового типа.
Ошибка № 4: Путаница между перегрузкой (overloading) и переопределением (overriding). Перегрузка — это несколько методов с одним именем и разными параметрами в одном классе. Переопределение — это изменение поведения метода в подклассе.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ