Программа курса← Объектно ориентированное программирование
1История ООП
2Объект и Класс
3Базовые концепции ООП
4Принцип проектирования GRASP
5Принцип проектирования SOLID
6Паттерны GOF

Способы реализации полиморфизма

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 – пять разных механизмов в одном языке, каждый со своим синтаксисом, своим моментом выбора и своей ценой. И это не избыточность: каждый закрывает свой случай, поэтому ни один не вытеснил остальные.

Услышали «здесь полиморфизм» – уточните, когда делается выбор и чем он записан. От этого зависит и скорость, и то, где всплывёт ошибка.