[recovery mode] Полиморфизм: подавать холодным

4b60aa6365e9958e38504bb659bd498c.jpg

Полиморфизм («истинный», параметрический) — свойство, позволяющее обрабатывать данные разных типов одним образом.

Полиморфизм является одним из трёх столпов ООП, наравне с наследованием и инкапсуляцией, да и в целом краеугольным камнем современного программирования. Думаю, большинство читателей не представляет своей жизни без полиморфизма, за что я, конечно, это большинство никак не осуждаю, ибо сам к нему принадлежу. Дело, однако, в том, что многие не задумываются об устройстве этого полиморфизма, ведь любой принцип программирования, по сути, представляет из себя математическую матрёшку.

Реализуем функции print_static, print_dynamic и print_enum для демонстрации различных методов реализации полиморфизма.

Статический полиморфизм

Грубо говоря, статическим называется такой полиморфизм, который не оказывает влияния на работу функции во время выполнения, но реализуется во время компиляции, следовательно, статический полиморфизм наиболее эффективен в отношении производительности.

Статическая диспетчеризация

Статическая диспетчеризация — единственный способ реализации статического полиморфизма (перегрузку, напомню, мы истинным полиморфизмом не считаем). Суть этого механизма заключается преимущественно в использовании т.н. типовых параметров, которые часто также называют дженериками (что, впрочем, не совсем верно, ведь типовые параметры — подкатегория дженериков). В некоторых языках существуют несколько примитивные реализации дженериков, называемые шаблонами. В основе работы дженериков лежит принцип мономорфизации — процесса генерации нескольких инстанций функции для разных типовых параметров (мономорфизация также сопровождается манглингом символов, т.е. спутыванием имён в бинаре, во избежание символьных конфликтов при линковке). Зачастую дженериками могут называть вещи, дженериками не являющиеся.

Rust

В Rust статическая диспетчеризация реализуется с помощью типовых параметров или ключевого слова impl (по сути, являющего собой анонимные типовые параметры). Напишем функцию print_static:

fn print_static(to: T) {
    println!("{to}");
}

print_static(123);
print_static("abc")

Типовый параметр объявляется внутри <>, названием его служит T, а Display — трейт, позволяющий форматировать значения. В Rust вы не сможете отформатировать значение с помощью {}, пока не укажете, что типовый параметр реализует Display.

C++

В C++ статическая диспетчеризация реализуется с помощью шаблонов, декларируемых с помощью ключевого слова template. Функция:

template
auto print_static(T to) -> void {
    std::cout << to << std::endl;
}

print_static(123);
print_static("abc");

Типовый параметр T объявлен с помощью ключевого слова typename, обозначающего произвольный тип. В C++ вам не нужно ограничивать область типов для функции, ошибку вы получите только в случае передачи аргумента типа, который нельзя записать в std::ostream. Стоит отметить, что в данном случае можно было обойтись без шаблона, вместо типового параметра T использовав auto.

Go

Go не поддерживает статический полиморфизм. В этом языке есть концепт, называемый типовыми параметрами, но на деле ими не являющийся, который я упомяну позже.

Динамический полиморфизм

Суть динамического полиморфизма заключается в определении типов и их поведения во время выполнения. Это позволяет сделать полиморфные функции более гибкими, однако часто бьёт по производительности.

Динамическая диспетчеризация

Динамическая диспетчеризация реализуется как передача вместе со значением т.н. виртуальной таблицы, хранящей информацию о типе и нужных для работы с ним методов.

Rust

В Rust динамическая диспетчеризация реализуется с помощью ключевого слова dyn. Динамические типы считаются безразмерными, потому их нужно передавать посредством указателя, ссылки или умного указателя (всё далее — указатель). Указатели на динамические типы являются толстыми, на деле представляющими из себя два указателя — на значение и на динамическую таблицу.

fn print_dynamic(to: &dyn Display) {
    println!("{to}");
}

print_dynamic(&123);
print_dynamic(&"abc");

В данном случае мы передаём динамическое значение по ссылке, потому при сообщении аргумента требуется добавить амперсанд.

C++

В C++ не существует идентичного dyn Displayмеханизма, однако сам этот язык гораздо более приспособлен к использованию динамической диспетчеризации, так как является объектно-ориентированным, потому мы реализуем такой механизм с помощью наследования и ключевых слов virtual и override:

class Display {
    public:
        virtual auto writeln(std::ostream& to) -> void {};
};

class DisplayInt: public Display {
    public:
        int self;
        
        DisplayInt(int val) {
            self = val;
        };

        auto writeln(std::ostream& to) -> void override {
            to << this->self << std::endl;
        };
};

class DisplayStr: public Display {
    public:
        char* self;
        
        DisplayStr(char* val) {
            self = val;
        };
        
        auto writeln(std::ostream& to) -> void override {
            to << this->self << std::endl;
        };
};

auto display(int val) {
    return DisplayInt(val);
}

auto display(char* val) {
    return DisplayStr(val);
}

auto print_dynamic(Display&& to) -> void {
    to.writeln(std::cout);
}

print_dynamic(display(123));
print_dynamic(display("abc"));

В данном случае мы реализуем метод writeln для наследников базового класса Display. Переопределяемая display сугубо для схожести инициализации.

Go

В Go за динамическую диспетчеризацию отвечает механизм интерфейсов, их и используем:

func print_dynamic(to any) {
    fmt.Println(to)
}

print_dynamic(123);
print_dynamic("abc");

В данном случае Go не требует наложения дополнительных ограничений на принимаемый тип, потому мы можем использовать интерфейс any, являющийся менее строгим аналогом any в C++ и dyn Any в Rust.

«Энамная» диспетчеризация

Энамная диспетчеризация — способ реализации динамического полиморфизма, характеризующийся передачей тэга, зачастую называемого дискриминантом, и юниона возможных типов. Этот метод превосходит динамическую диспетчеризацию в производительности, но уступает ей в гибкости, уступает также статической диспетчеризации в производительности, но превосходит её в гибкости. Словом, позволяет избежать некоторых недостатков динамической диспетчеризации там, где нельзя использовать статическую.

Rust

В Rust нет встроенного механизма для реализации энамной диспетчеризации, однако существует замечательный крейт, предоставляющий подобный механизм — enum_dispatch. Пользоваться сторонними библиотеками мы, однако, конечно же не будем, потому реализуем тип Displayable сами:

enum Displayable {
    Str(&'static str),
    I32(i32),
}

impl From for Displayable {
    fn from(value: i32) -> Self {
        Self::I32(value)
    }
}

impl From<&'static str> for Displayable {
    fn from(value: &'static str) -> Self {
        Self::Str(value)
    }
}

impl Display for Displayable {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Str(s) => write!(f, "{s}"),
            Self::I32(i) => write!(f, "{i}")
        }
    }
}

fn print_enum(to: Displayable) {
    println!("{to}");
}

print_enum(123.into());
print_enum("abc".into());

На строках 1 — 4 реализуется сам тип, на строках 6 — 16 — конверсия в него (используется на строках 31 и 32 путём вызова метода into), на строках 18 — 25 — возможность форматирования типа. В функции мы без проблем принимаем готовую инстанцию.

C++

Как и Rust, C++ не предоставляет встроенной энамной диспетчеризации, потому реализуем сами:

struct Displayable {
    enum {
        STR,
        I32
    } tag;
    union {
        char* str;
        int i32;
    };
    
    Displayable(int val) {
        tag = I32;
        i32 = val;
    };
    
    Displayable(char* val) {
        tag = STR;
        str = val;
    };
    
    auto writeln(std::ostream& to) -> void {
        switch (this->tag) {
            case STR:
                to << this->str << std::endl;
                break;
            case I32:
                to << this->i32 << std::endl;
                break;
        }
    }
};

auto print_enum(Displayable to) -> void {
    to.writeln(std::cout);
}

print_enum(Displayable(123));
print_enum(Displayable("abc"));

И так, на строках 1 — 9 мы описываем сам тип, на 11 — 19 — конверсию, а на 21 — 30 — метод writeln, идентичный методу fmt в версии на Rust. В функции print_enum мы также принимаем готовую инстанцию. Стоит отметить, что похожего эффекта можно добиться при помощи std::variant.

Go

В Go существует встроенный механизм реализации энамной диспетчеризации — т.н. словари, которые часто неверно называют дженериками даже сами разработчики языка (не делайте так, пожалуйста). Поглядим на них в деле:

type Displayable interface {
    int | string
}

func print_enum[T Displayable](to T) {
    fmt.Println(to)
}

print_enum(123);
print_enum("abc");

Словарь требует интерфейса-ограничителя, возможные типы для нашего интерфейса Displayable указаны на строке 2. В качестве ограничителя также может выступать интерфейс any, однако в данном случае я избежал его использования для более точного раскрытия этого механизма в сравнении с предыдущими вариантами и возможной оптимизации.

Стоит упомянуть — C

В стандарте C11 в систему препроцессорных констант (#define) была встроена директива _Generic, позволяющая вручную осуществлять статическую диспетчеризацию.

#define print_static(VAL) _Generic((VAL), int: print_int, default: print_str)(VAL)

print_int(int val) {
    printf("%d\n", val);
}

print_str(char* val) {
    puts(val);
}

print_static(123);
print_static("abc");

Механизм действительно интересен и прогрессивен для си, хоть и не является полноценной реализацией типовых параметров. С другой стороны, местами такая реализация может быть даже более гибкой.

Заключения

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

Miiao.

© Habrahabr.ru