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

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

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

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

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

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

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

Rust

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

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

print_static(123);
print_static("abc")

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

C++

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

template<typename T>
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<i32> 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.

Комментарии (31)


  1. Kelbon
    00.00.0000 00:00

    Существует...

    using any_printable = aa::any_with<Print>;
    
    print_one(any_printable::const_ref p) {  p.print();}



    https://github.com/kelbon/AnyAny/blob/main/examples/polyref.hpp


    1. ReadOnlySadUser
      00.00.0000 00:00

      Сказать что это страшно - ничего не сказать :) А я пишу на плюсах уже 8 лет)


      1. Kelbon
        00.00.0000 00:00

        что страшно?


      1. tzlom
        00.00.0000 00:00

        За 8 лет можно бы CRTP и выучить уже


        1. Kelbon
          00.00.0000 00:00

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


          1. tzlom
            00.00.0000 00:00

            CRTP там единственная не тривиальная часть реализации. И я не вижу как она может быть опциональна, но все равно, идея весьма прозрачная так что не понятно что там сложного.


            1. Kelbon
              00.00.0000 00:00

              template <typename T>
              struct Print {
                static void do_invoke(const T& self) {
                  std::cout << self << std::endl;
                }
              };
              using any_printable = aa::any_with<Print>;
              
              void print_one(any_printable::const_ref p) {
                aa::invoke<Print>(p);
              }

              Вот так без CRTP это бы выглядело


  1. domix32
    00.00.0000 00:00
    +1

    Из пропущенного

    1. В Rust имеется статический вариант dyn Traits - impl Trait.

    2. Вариант enum dispatching в C++ так и просит обмазать его повсеместно constexpr.


    1. Miiao Автор
      00.00.0000 00:00
      +1

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


      1. domix32
        00.00.0000 00:00
        +1

        Только примера с ним в статье так и не появилось.


  1. ksbes
    00.00.0000 00:00
    +2

    А как же Java? Да, вы можете сказать что там дженериков нет, но в не можете сказать, что там нет полиморфизма!
    Полиморфизм — он не шаблонами/джененриками едиными обеспечивается. Более того я бы даже сказал, что полиморфизм он вообще не про это. А дженерики, рефлекшены или просто банальные фабрики — это различные механизмы реализации данной концепции.
    Так что — сабж в статье не раскрыт. Полиморфизм не подан — ни холодным, ни горячим. Кушать нечего!


    1. Lewigh
      00.00.0000 00:00
      +2

      А как же Java? Да, вы можете сказать что там дженериков нет, но в не можете сказать, что там нет полиморфизма!

      А зачем здесь нужна Java? Тут показаны несколько видов реализации полиморфизма.
      Автор статьи намекнул что ADHOC полиморфизм рассматривать не будет.
      Следовательно, если брать Java, остаются два вида универсального полиморфизма:
      параметрический и полиморфизм подтипов.
      Оба они реализованы через динамическую диспетчеризацию а об этом в статье есть.

      Просто статья именно про различные виды реализации полиморфизма а не про абстракциию.


    1. Miiao Автор
      00.00.0000 00:00

      Жду вопросы типа «а как же J?», «а как же BQN?», «а как же brainfuck?» :)


      1. ksbes
        00.00.0000 00:00

        А как же вникнуть в смысл критики не цепляясь к первому предложению?


        1. Miiao Автор
          00.00.0000 00:00

          Критики не вижу, вижу бессмысленное, извините за грубость, нытьё, не имеющее совершенно никакого отношения к теме. Если вы не увидели в статье полиморфизма, вам стоит для начала её прочесть. Надеюсь, не оскорбил - не имел такой цели.


          1. ksbes
            00.00.0000 00:00

            Не оскорбили, просто испортили о себе мнение. Я ж по делу. С добрыми намерениями. А вы даже понять ленитесь и/или объяснить. Ещё и требуете чтоб вас понимали. Не по Канту...


            1. Miiao Автор
              00.00.0000 00:00
              -1

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


              1. ksbes
                00.00.0000 00:00

                Ладно, скажу грубо. Вы, недалёкий человек, неотличаете полиморфизм от механизмов его реализации. Не отличаете "шашечки" от "ехать". До вас даже не дошло, что джаву я упомянул только как пример, иллюстрацию моей основной претензии: что ваше название не соответсвует тексту статьи. От полиморфизма у вас в статье только названия двух его типов. А что, это, а как это, с чем его едят — у вас нет. Зато есть много, довольно хороших — мне понравилось, детальных разборов как это устроено в разных языковых средах. Но про сам полиморфизм, что собственно, я и ожидал, когда начинал читать эту статью — ни слова.


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


                P.S. Нравится так общaться? Вот не общайтесь так. Будте вежливыми.


  1. fk0
    00.00.0000 00:00
    +2

    "энамная диспетчеризация"...

    Что за слово-то такое. Но это же примитивнейшая форма динамической диспетчеризации. И ни разу она не эффективней чем работа с таблицами виртуальных функций. В последней нет идиотского switch-case и прямо из таблицы за О(1) выбирается нужный вариант, и switch-case не нужно переписывать при добавлении нового типа.

    Кроме того, не упомянута перегрузка функций, в C++ например. В этом смысле статическая диспетчеризация делается и без шаблонов. И вообще механизм ADL (argument dependent lookup), который позволяет выбрать в момент компиляции функцию в зависимости от типа аргумента.

    Ключевое слово _Generic в голом C -- абсолютно бесполезная вещь в том применении, для чего его предлагалось использовать. Потому, что все возможные типы нужно заранее перечислить. Этот тот же switch-case но в момент компиляции. В итоге что только на этом _Generic не делают, у него масса применений, но для полиморфных функций он не пригоден практически вообще.

    Без перечисления возможных типов вообще никуда не деться, но по крайней мере нужно только само перечисление, для получения некоторого порядкового номера, не более того. Декларировать функции и все подробности типа наперёд нужды нет, статический полиморфизм на голом C может быть реализован примерно следующим образом: https://www.onlinegdb.com/ncQeDOcPX (в одном файле: https://coliru.stacked-crooked.com/a/68011ec1308d8fe8).

    Отмечу, что диспетчеризация времени компиляции случается только либо при компиляции в одном модуле, либо при использовании Link Time Optimization опции (-flto). Иначе всё скатывается обратно к диспетчеризации времени исполнения. Т.е. расчёт на то, что компилятор имеет возможность девиртуализировать вызовы (т.к. таблица виртуальных функций -- константа времени компиляции).

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


    1. mayorovp
      00.00.0000 00:00
      +3

      Ну, нужный case для перечисления тоже за O(1) выбирается обычно. И этот switch может запросто оказаться быстрее таблицы виртуальных функций за счёт большей локальности обращений к памяти.


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


      Если нужно расширяться по операциям — типы-суммы эффективнее, если ожидается расширение по типам — эффективнее интерфейсы. Если ожидается расширение в обоих направлениях — поздравляю, вы прокляты.


      1. fk0
        00.00.0000 00:00
        -3

        При добавлении новых операций вовсе не обязательно "всё переписывать". В C++ может использоваться реализация по-умолчанию унаследованная от базового класса. Либо вовсе могут быть множественные интерфейсы и таблицы, тогда добавление нового интерфейса никак не затрагивает уже существующие объекты. И в конце концов добавить новую функцию в класс не проблема, тем более, что компилятор заставит так сделать для абстрактного базового класса.

        А вот забыть добавить что-то в switch-case -- запросто. И никто не напомнит. Это сомнительная оптимизация. Я допускаю, что она выигрывает какие-то копейки с очень легковесными операциями в каком-то очень узком случае когда статическая диспетчеризация времени компиляции и/или развиртуализация ещё не срабатывают, а обращение к таблицам функций на сколько-то тактов тяжелее. Может быть в случае очень легковесных объектов, когда аж целый указатель на объект (для таблицы функций) -- дорого. Если не дорого, то наверное сделать ту же таблицу функций вручную, на шаблонах, и положить её куда-то в .text или выделенную секцию, если очень неймётся -- лучшее решение. И уж если так кроить, то можно не указатели хранить, а смещения относительно какой-то базы, там могут запросто быть 16-битные числа. Разница всё в том, что в варианте с switch-case содержимое таблицы контролируется только программистом, а в варианте с таблицами виртуальных функций компилятор не позволит что-то забыть (таблица может формироваться шаблоном из реально существующих функций классов, по заданному шаблону требующему наличия определённых функций с определёнными свойствами).

        PS: хотя если типы -- это enum, то современные компиляторы умеют проверять, что в switch-case перебрали все возможные значения. Так что ошибку всё же можно задетектить.


        1. DarkEld3r
          00.00.0000 00:00
          +3

          А вот забыть добавить что-то в switch-case — запросто. И никто не напомнит.

          Это смотря где.


    1. fk0
      00.00.0000 00:00
      -1

      Нужно наверное добавить какой смысл в статической диспетчеризации времени компиляции. Казалось бы бессмысленная вещь: чтоб применить к объекту функцию, нужно знать тип объекта. Но если мы знаем тип, то можем без всяких трюков прямо непосредсвенно вызвать нужную функцию... То ли дело динамическая диспетчеризация времени исполнения: там неизвестно какая ещё функция будет вызвана, и тип может быть неизвестно какой. Но из объекта достаются свойства его типа и вызывается нужная функция.

      Статическая диспетчеризация в явном виде скорей нужна только для реализации шаблонных функций. Но в не явном виде может использоваться для реализации "обобщённых" функций, способных принимать объекты множества различных типов и оперировать с ними единообразным образом. При этом сами объекты могут не нести информацию о своём типе (как это делается в ООП, при использовании таблиц виртуальных функций). Информация о типе может быть отделена от объектов и передаваться отдельно. В виде той же таблицы виртуальных функций, например. Важно, что компилятор в момент компиляции может для объекта известного типа породить информацию о типе, например, при необходимости передачи объекта в обобщённую функцию. Более того, в этот момент сам объект и относящаяся к нему информацию о типе могут быть упакованы в некий единый объект-контейнер, который дальше будет восприниматься как объект поддерживающий динамическую диспетчеризацию. Но в случаях, когда тип заранее известен, компилятор может развиртуализировать операции над объектом. В разных языках это всё может делаться по разному, важна сама идея: выделение свойств объекта в отдельную сущость позволяет реализовать обобщённые функции.


  1. JekaMas
    00.00.0000 00:00

    А к чему у автора относится golang:

    package main
    
    import (
    	"bytes"
    	"fmt"
    	"io"
    )
    
    func Print[T io.Writer](b []byte, w T) error {
    	_, err := w.Write(b)
    	return err
    }
    
    func main() {
    	buf := bytes.NewBuffer(nil)
    	Print([]byte("12345"), buf)
    	fmt.Println(buf.String())
    }


    1. Miiao Автор
      00.00.0000 00:00

      К «энамной», словари ведь используются :)


      1. JekaMas
        00.00.0000 00:00

        Отлично, тогда "В качестве ограничителя также может выступать интерфейс any" у вас некорректно. Верно будет любой интерфейс или их объединение.


  1. khe404
    00.00.0000 00:00

    Программисты умеют использовать полиморфизм и понимают что это такое. Я любитель и мое понимание полиморфизма очень искажено. Посему у меня вопрос, пожалуйста растолкуйте.

    В примере динамического полиморфизма на с++ автор определяет сначала базовый класс

    Display далее реализует два класса наследника DisplayInt и DisplayStr

    Дальше реализует функцию display которая умеет создавать из int DisplayInt а из char* DisplayStr

    Далее реализована функция print_dynamic которая запускает механизм writeln базового класса Display.

    Как результат мы получили конструкцию вида :

    print_dynamic(display(some_var));

    НО у нас ведь была альтернатива реализовать две функции

    print_dynamic(int); и print_dynamic(char*)

    И случись изменения в формате отображения мы бы модифицировали эти функции, а не аналогичные DisplayInt::writeln DisplayStr::writeln.

    По какой причине использованный подход лучше и удобнее подхода реализации в лоб.

    Появись у нас еще тип нам все одно нужно будет либо создавать конвертер в имеющийся либо писать DisplaySomethingNew::writeln

    Появись у нас новый функционал, скажем Display::write нам нужно будет пройтись по каждому классу и реализовать его.

    Все это вопрос вкуса или все же за созданием такой структуры стоит определенная задача, которая дает некое преимущество ?


    1. Miiao Автор
      00.00.0000 00:00

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

      К тому же, я не плюсовик, не пытайтесь перенять у меня какие, как их принято назвать, «паттерны», плохому научить могу, вместо этого просто смотрите на главную мысль и подход к реализации.


      1. khe404
        00.00.0000 00:00

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


    1. mayorovp
      00.00.0000 00:00

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


      Например, можно написать алгоритм вывода массива, и применить его как к массиву чисел, так и к массиву строк.


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


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


      1. khe404
        00.00.0000 00:00

        Да, в таком ключе мне тоже понятно как это работает. В принципе std::ostream& позволяет себя перегрузить. И куча функций в STL работает на этом принципе.

        А в статье именно наследование классов с общим методом + перегрузкой функций.