This lesson is not translated yet – the Russian original is below.
TODO: отвалидировать – текст собран автоматически из материалов курса на Google Drive, проверьте формулировки и код.
Полиморфизм – это свойство, а не конкретная конструкция языка. Записать его можно по-разному, и набор способов у каждого языка свой: где-то их пара, где-то с десяток, а что-то вроде классов типов или мультиметодов встречается всего в нескольких языках.
Разберём самые распространённые – те, что встретятся почти в любом проекте: где такой способ есть, как выглядит и что именно в нём полиморфного.
Интерфейс
Полиморфизм статический динамический
Академическое название – полиморфизм подтипов, также известен как полиморфизм включения (subtype, inclusion polymorphism).
Договор объявлен отдельно от тех, кто его выполняет. Вызывающий код видит только договор, а какая реализация подставится – решается при создании объекта.
public interface IStorage { void Save(string data); }
// Метод принимает договор, а не конкретный класс
void Backup(IStorage storage) => storage.Save("data");
// В C++ роль интерфейса играет абстрактный класс без данных
class IStorage {
public:
virtual void Save(const std::string& data) = 0;
virtual ~IStorage() = default;
};
void Backup(IStorage& storage) { storage.Save("data"); }
// В Go интерфейс реализуется молча: объявлять родство не нужно
type Storage interface{ Save(data string) error }
func Backup(s Storage) error { return s.Save("data") }
from typing import Protocol
# Protocol описывает договор, наследоваться от него не обязательно
class Storage(Protocol):
def save(self, data: str) -> None: ...
def backup(storage: Storage) -> None:
storage.save("data")
interface Storage {
save(data: string): void;
}
function backup(storage: Storage): void {
storage.save("data");
}
public interface Storage {
void save(String data);
}
static void backup(Storage storage) {
storage.save("data");
}
interface Storage {
fun save(data: String)
}
fun backup(storage: Storage) = storage.save("data")
Наследование и виртуальные методы
Полиморфизм статический динамический
Это тот же полиморфизм подтипов, только договором служит не интерфейс, а базовый класс.
Метод объявлен у родителя, а наследник его подменяет. Вызывающий код работает с родителем и не знает, чей код выполнится на самом деле.
Работает это через позднее связывание: конкретная реализация выбирается не при
компиляции, а в момент вызова – по фактическому типу объекта, а не по типу переменной.
Методы, которые так себя ведут, называют виртуальными: в C# и C++ их помечают словом
virtual, в Kotlin – open, а в Java, Python и TypeScript виртуальны все методы по
умолчанию.
Не путайте с виртуальным наследованием в C++ (class B : virtual public A) – это про общий подобъект при ромбовидной иерархии, к полиморфизму отношения не имеет.
public class Notifier
{
// virtual разрешает наследнику подменить реализацию
public virtual void Send(string text) => Console.WriteLine($"log: {text}");
}
public class EmailNotifier : Notifier
{
public override void Send(string text) => Console.WriteLine($"email: {text}");
}
Notifier notifier = new EmailNotifier();
notifier.Send("hi"); // email: hi – тип переменной ни при чём
class Notifier {
public:
// Без virtual подмены не будет: вызовется метод родителя
virtual void Send(const std::string& text) { std::cout << "log: " << text << "\n"; }
virtual ~Notifier() = default;
};
class EmailNotifier : public Notifier {
public:
void Send(const std::string& text) override { std::cout << "email: " << text << "\n"; }
};
std::unique_ptr<Notifier> notifier = std::make_unique<EmailNotifier>();
notifier->Send("hi"); // email: hi
Наследования в Go нет, а встраивание методы не подменяет: встроенный тип всегда вызывает свой собственный метод. Полиморфизм в Go делают интерфейсами.
class Notifier:
def send(self, text):
print(f"log: {text}")
class EmailNotifier(Notifier):
# Ключевых слов не нужно: любой метод можно переопределить
def send(self, text):
print(f"email: {text}")
notifier = EmailNotifier()
notifier.send("hi") # email: hi
class Notifier {
send(text: string): void {
console.log(`log: ${text}`);
}
}
class EmailNotifier extends Notifier {
send(text: string): void {
console.log(`email: ${text}`);
}
}
const notifier: Notifier = new EmailNotifier();
notifier.send("hi"); // email: hi
public class Notifier {
public void send(String text) {
System.out.println("log: " + text);
}
}
public class EmailNotifier extends Notifier {
@Override
public void send(String text) {
System.out.println("email: " + text);
}
}
Notifier notifier = new EmailNotifier();
notifier.send("hi"); // email: hi
// open разрешает наследование и переопределение
open class Notifier {
open fun send(text: String) = println("log: $text")
}
class EmailNotifier : Notifier() {
override fun send(text: String) = println("email: $text")
}
val notifier: Notifier = EmailNotifier()
notifier.send("hi") // email: hi
Перегрузка
Полиморфизм статический динамический
Академическое название – ad hoc полиморфизм, также известен как специальный, или полиморфизм перегрузки.
Одно имя метода на несколько наборов параметров. Какой из них вызовется, решает компилятор по типам аргументов – ещё до запуска программы.
void Print(int value) => Console.WriteLine($"число {value}");
void Print(string value) => Console.WriteLine($"строка {value}");
Print(42); // число 42
Print("hello"); // строка hello
void Print(int value) { std::cout << "число " << value << "\n"; }
void Print(const std::string& value) { std::cout << "строка " << value << "\n"; }
Print(42); // число 42
Print(std::string("hello")); // строка hello
Перегрузки в Go нет вовсе: два метода с одинаковым именем – ошибка компиляции. Вместо неё пишут разные имена или принимают интерфейс.
Перегрузки в Python нет: второе определение просто затрёт первое. Ту же задачу решают значения по умолчанию и functools.singledispatch.
// Сигнатур несколько, реализация одна – типы проверяются на этапе компиляции
function print(value: number): void;
function print(value: string): void;
function print(value: number | string): void {
console.log(typeof value === "number" ? `число ${value}` : `строка ${value}`);
}
static void print(int value) {
System.out.println("число " + value);
}
static void print(String value) {
System.out.println("строка " + value);
}
fun print(value: Int) = println("число $value")
fun print(value: String) = println("строка $value")
Обобщения
Полиморфизм статический динамическийЗависит от языка. В C++ и Rust шаблон разворачивается под каждый тип при сборке – это чистая статика. В C# дженерики для значимых типов тоже разворачиваются, а для ссылочных используется общий код. В Java обобщения стираются при компиляции, и в рантайме остаётся приведение типов, то есть работа перекладывается на выполнение.
Академическое название – параметрический полиморфизм, также известен как дженерики или шаблоны.
Один и тот же код работает с разными типами, а конкретный тип подставляется при использовании.
// T подставится при вызове: Box<int>, Box<string>, Box<Order>
public class Box<T>
{
public T Value { get; set; }
}
// Шаблон разворачивается компилятором под каждый использованный тип
template <typename T>
class Box {
public:
T value;
};
// Дженерики появились в Go 1.18. any – ограничение «любой тип»
type Box[T any] struct {
Value T
}
# Аннотации типов проверяет не рантайм, а внешний анализатор вроде mypy
class Box[T]:
def __init__(self, value: T) -> None:
self.value = value
class Box<T> {
constructor(public value: T) {}
}
public class Box<T> {
private T value;
public T getValue() {
return value;
}
}
class Box<T>(val value: T)
Утиная типизация
Полиморфизм статический динамический
Академическое название – структурная типизация, также известна как duck typing или латентная типизация.
Договор нигде не объявлен: объект подходит, если у него просто есть нужный метод. Название из поговорки – если оно ходит как утка и крякает как утка, то это утка.
Обратите внимание, где это встречается: в языках, которые к строгому ООП не относятся – Python, JavaScript, Ruby, отчасти Go с его неявными интерфейсами. В классических объектных языках вроде C#, Java и Kotlin договор требуют объявлять явно, и это осознанное решение.
C# требует объявлять договор: тип должен реализовать интерфейс явно. Похожее поведение даёт только dynamic, и проверок при этом не остаётся никаких.
В C++ утиной типизации нет в обычном коде, но она есть в шаблонах: подойдёт любой тип, у которого нашёлся нужный метод, – только проверяется это при компиляции.
// Тип реализует интерфейс, если у него есть нужные методы.
// Писать «implements» негде и не нужно – связь находит компилятор.
type Speaker interface{ Say() }
type Dog struct{}
func (d Dog) Say() { fmt.Println("Woof") }
var s Speaker = Dog{} // подходит, потому что метод есть
# Никаких объявлений: лишь бы у объекта нашёлся метод say
def speak(animal):
animal.say()
class Dog:
def say(self):
print("Woof")
speak(Dog())
// Типы сравниваются по структуре, а не по имени:
// объектный литерал подойдёт, если поля и методы совпали
type Speaker = { say(): void };
const dog = { say: () => console.log("Woof") };
function speak(s: Speaker): void {
s.say();
}
speak(dog); // подходит, хотя Speaker нигде не упомянут
Java требует объявить implements явно: тип с подходящими методами интерфейсом не считается.
В Kotlin договор тоже объявляется явно. Ближайший аналог – функциональные типы: передаём функцию, а не объект.
Причина такой осторожности в минусах, и они существенные:
- ошибки находит пользователь, а не компилятор. Опечатались в имени метода – узнаете в момент вызова, в проде, а не при сборке;
- договор нигде не записан. Чтобы понять, что должен уметь объект, приходится читать тело функции целиком, а не одну строку с типом;
- совпадение имён случайно. Метод
drawесть и у фигуры на холсте, и у ковбоя с револьвером – язык не различает эти дваdrawникак; - среда почти не помогает. Автодополнение и переименование работают вслепую: IDE не знает, какие объекты сюда попадут;
- рефакторинг опасен. Переименовали метод – и все места, где на него полагались, молча ломаются, потому что связь нигде не объявлена.
Часть этого лечится сверху: аннотации типов и Protocol в Python, TypeScript поверх
JavaScript. По сути это возвращение договора, только не в рантайм, а во внешний
анализатор.
Динамическая типизация
Полиморфизм статический динамический
Академическое название – динамическая типизация, она же позднее определение типа.
Тип у значения есть, но проверяется он не при сборке, а в момент обращения: переменная может держать что угодно, и что именно в ней лежит, выясняется по ходу дела. Полиморфным здесь становится любой вызов – никаких объявлений для этого не требуется.
C# статически типизирован, но лазейка есть: тип dynamic откладывает проверку до выполнения. Компилятор пропускает любой вызов, а несуществующий метод даст RuntimeBinderException.
В C++ динамической типизации нет. Ближайшее – std::any и std::variant: они хранят значение неизвестного заранее типа, но достать его можно только явным запросом.
// any (он же interface{}) хранит значение любого типа,
// но чтобы что-то с ним сделать, тип придётся запросить обратно
var value any = 42
n, ok := value.(int) // ok == true, n == 42
# Одна и та же переменная спокойно меняет тип
value = 42
value = "теперь строка"
value = Dog()
# Метод ищется у объекта в момент вызова
value.say()
// any выключает проверки, unknown требует уточнить тип перед использованием
let value: any = 42;
value = "теперь строка";
value.whatever(); // компилятор молчит, упадёт при выполнении
Java статически типизирована. Роль «любого типа» играет Object, но вызвать у него чужой метод без приведения типа или рефлексии нельзя.
В Kotlin то же самое: Any хранит что угодно, но методы конкретного типа появятся только после проверки is и умного приведения.
Плюс тут один и большой: код получается коротким, а полиморфизм – бесплатным, ничего объявлять не нужно. Минусы – те же, что у утиной типизации: ошибки находит пользователь, договор нигде не записан, а среда разработки помогает вслепую.
Выбор по типу
Полиморфизм статический динамический
Академическое название – type switching, в обиходе – проверка типа, сопоставление с образцом, приведение типа.
Самый прямолинейный способ: спросить у объекта, кто он такой, и разветвить код руками.
Формально это тоже полиморфизм – одно обращение, разное поведение, – только выбор делает не
язык, а ваш switch.
// Сопоставление с образцом: ветка выбирается по фактическому типу
decimal Price(Shape shape) => shape switch
{
Circle c => c.Radius * 2,
Square s => s.Side * 4,
_ => throw new ArgumentException("неизвестная фигура"),
};
// Через dynamic_cast: приведение вернёт nullptr, если тип не тот
double Price(Shape* shape) {
if (auto* c = dynamic_cast<Circle*>(shape)) return c->radius * 2;
if (auto* s = dynamic_cast<Square*>(shape)) return s->side * 4;
throw std::invalid_argument("неизвестная фигура");
}
// Type switch – штатная конструкция языка
func Price(shape any) (float64, error) {
switch s := shape.(type) {
case Circle:
return s.Radius * 2, nil
case Square:
return s.Side * 4, nil
default:
return 0, errors.New("неизвестная фигура")
}
}
def price(shape):
if isinstance(shape, Circle):
return shape.radius * 2
if isinstance(shape, Square):
return shape.side * 4
raise ValueError("неизвестная фигура")
// Проверка сужает тип: внутри ветки TypeScript уже знает, что это Circle
function price(shape: Shape): number {
if (shape instanceof Circle) return shape.radius * 2;
if (shape instanceof Square) return shape.side * 4;
throw new Error("неизвестная фигура");
}
// switch с образцами – с Java 21
static double price(Shape shape) {
return switch (shape) {
case Circle c -> c.radius() * 2;
case Square s -> s.side() * 4;
default -> throw new IllegalArgumentException("неизвестная фигура");
};
}
// when с is: умное приведение делает проверку и каст за один раз
fun price(shape: Shape): Double = when (shape) {
is Circle -> shape.radius * 2
is Square -> shape.side * 4
else -> throw IllegalArgumentException("неизвестная фигура")
}
Этот способ чаще других оказывается лишним. Появилась новая фигура – придётся найти и дописать каждый такой switch в проекте, а забытая ветка молча упадёт в рантайме. Обычно то же самое решается методом на самом объекте: пусть фигура сама считает свою цену. Подробнее – в уроках GRASP: Polymorphism и OCP.
Но и полезные случаи есть: разбор входящих сообщений разных форматов, обработка ошибок по типу исключения, работа с чужими типами, к которым метод не приделать.
Итоги
Полиморфизму, в отличие от двух других базовых концепций, не досталось единой реализации. Инкапсуляцию тоже записывают по-разному, но внутри одного языка механизм обычно один, ну два: модификаторы доступа – и, может быть, свойства. Наследование и вовсе сводится к одной конструкции.
С полиморфизмом иначе. Возьмите тот же C#: перегрузка, дженерики, интерфейсы, виртуальные
методы, dynamic – пять разных механизмов в одном языке, каждый со своим синтаксисом,
своим моментом выбора и своей ценой. И это не избыточность: каждый закрывает свой случай,
поэтому ни один не вытеснил остальные.
Услышали «здесь полиморфизм» – уточните, когда делается выбор и чем он записан. От этого зависит и скорость, и то, где всплывёт ошибка.