Мы пройдем по следующим шагам: Посмотрим на проблему. Решим ее обычным полиморфизмом. Пройдем небольшими шагами к более элегантному решению Strategy External Polymorphism Type Erasure Немного философии. Изменения - основная проблема создания программного обеспечения. Большая задача в разработке ПО - сделать его таким, чтобы его можно было легко изменять. И одной из основных проблем в изменении существующего кода - его зависимости. Привязанность кода к внешним зависимостям очень сильно "сковывает" движения программиста при внесении чего-то нового в код. Посмотрим на задачу: хотим рисовать разные фигуры. Задача искусственная, но очень хорошо иллюстрирующая всё, что я собираюсь показать. Но не заостряйте свое внимание именно на фигурах, все-таки паттерн, про который я собираюсь рассказать, применим еще много к чему. Мы хотим рисовать разные фигуры. Сперва создадим базовый абстрактный класс Shape с методом его рисования на экран Теперь определим парочку конкретных фигур, например квадрат и круг унаследовав их классы Square и Circle от Shape. Также в них определим виртуальный метод draw. Типов фигур может быть очень много, каждый из классов фигур обязан давать определение методу draw. Но такой подход может очень быстро привести нас к проблемам. Рассмотрим конкретный класс Circle - в нашей текущей реализации он сильно связан с деталями реализации механизма рисования фигур на экран, а это плохо. Например, реализация рисования фигур может быть зашита где-то глубоко в используемой библиотеке или написана в другом месте проекта. К тому же такой подход позволяет иметь только одну реализацию рисования фигур. Допустим, мы пишем приложение, используя OpenGL для рисования разной информации на экран, но вдруг нам понадобилось портировать весь рисующий функционал ещё и на Vulkan/Metal/DirectX. Что делать в таком случае? К решению этой проблемы можно подойти с разных сторон. Первый подход - добавить новые методы рисования: Тогда при использовании данного класса нам нужно будет делать выбор, какой из методов использовать: Второй подход - создать новые классы для каждого из графических движков. Тогда при создании новых объектов фигур нужно будет откуда-то узнавать, поддержка какого графического движка есть на текущей машине и создавать объект соотвествующего класса: И аналогично для Square. Да, оба подхода сработают. Для маленьких проектов, возможно, даже ничего страшного не произойдёт. Но проекты развиваются. Представим, что через какое-то время нам потребовалось сохранять существующие в программе фигуры в постоянную память. Требуется создать новый метод serialize. Добавляем его в базовый класс: И вдруг понимаем, что делать сериализацю объектов можно в множество разных форматов (JSON, toml, XML, ...). И опять та же история, что и с разными графическими движками. Я уже не буду описывать, что иметь в своей программе подобные классы - плохо: Стоит отметить, что добавление нового метода в базовый класс привело нас также к дублированию кода. Метод draw будет одинаковым у классов CircleOpenGL_JSON и CircleOpenGL_XML, а метод serialize будет одинаковым у классов CircleOpenGL_JSON и CircleVulkan_JSON. Иерахия классов становится всё глубже и запутаннее. А если нам понадобится ещё один метод в базовом классе? Диаграмма этого ужаса: Результаты такого подхода: Очень много наследования Нелепые имена классов Огромные иерархии классов Дублирования кода (DRY) Невероятно сложное добавления нового функционала Сложность сопровождения кода В попытках справиться с недостатками такого подхода разработчик может сделать всё ещё хуже. Предыдущий подход был наивным, не имеющим возможности жить в больших проектах. Рассмотрим более современный подход - использование паттернов. Паттерн: Имеет имя Одним лишь своим именем объясняет уже многое Нацелен на уменьшение связности Предоставляет своего рода абстракцию Проверен временем В тот момент, когда мы добавляли в классы фигур уже второй метод (serialize),  этот паттерн уже витал где-то поблизости. И это паттерн стратегия. Суть паттерна: Источник Стратегия — это поведенческий паттерн проектирования, который определяет семейство схожих алгоритмов и помещает каждый из них в собственный класс, после чего алгоритмы можно взаимозаменять прямо во время исполнения программы. Создадим отдельный класс, определяющий поведение при рисовании фигуры: Теперь мы можем определить несколько разных методов рисования фигур, унаследовавшись от DrawStrategy: А в классе Circle теперь добавим поле, хранящее метод его рисования. Применение: Таким образом мы выделили в отдельный класс метод рисования. В общем случае - тот метод, который может в разных обстоятельствах меняться. Теперь, если потребуется добавить поддержку нового графического движка. то нужно будет всего лишь добавить новый класс, унаследованный от DrawStrategy. Не придется менять уже существующий код. Диаграмма: В целом применение паттерна стратегия позволило нам разделить нашу программу на 3 разных уровня. Вот они, отсортированные по их абстрактности: Класс Shape - высокоуровневый интерфейс Классы Circle и Square - реализации интерфейса (средний уровень) Абстрактный класс DrawStrategy и его наследники - вынесенное в отдельное место поведение (низкоуровневая реализация) Данный паттерн не ограничивается лишь тем, что наш класс содержит какой-то внешний объект, содержащий в себе реализацю.метода. Стратегии также распространены и вне мира ООП, например: Стратегии в стандартной библиотеке шаблонов: Итоги применения паттерна стратегия: Вынесение деталей реализации в отдельные классы. (Принцип единственной ответственности/Single responsibility principle) Создали возможность легкого расширения (Принцип открытости/закрытости. OCP) Разделили интерфейсы (Interface segregation principle) Избавились от дублирования кода (DRY) Избавились от глубины иерархии Упростили сопровождение кода. Легче понимать, легче писать Но! Минусы: Производительность с точки зрения вызовов. При вызове draw происходит на самом деле два вызова: main -> Circle::draw -> DrawStrategy::draw(Circle*) Производительность с точки зрения памяти. Множество маленьких выделений памяти для стратегий. Производительность с точки зрения указателей. Много указателей, мы переходим по их адресам. Нужно создавать отдельные абстрактные классы-стратегии для другой функциональности, например SerializeStrategy для сериализации. Если отказаться от умных указателей в пользу производительности, то придётся вручную управлять временем жизни объектов. См. Интересная лекция про цену абстракций. Circle и Square всё еще знают про то, что их нужно как-то рисовать. Они всё еще несут некоторую ответственность за эти операции. Что-то в этом чувствуется не так. Операция рисования, конечно, зависит от фигуры, которая рисуется в данный момент. Но по идее самой фигуре не должно быть дела, рисуют ли её или делают что-то другое. Это слегка размывает абстракцию фигуры. Существует решение лучше! Вы уже могли слышать что-то про стирание типа, поэтому уточню: Это НЕ про void* указатели на базовый класс std::variant. std::variant основан на фиксированном наборе типов и предоставляет открытый набор операций над ними. Мы же пытаемся достигнуть обратного - открытого для расширения набора типов и фиксированного набора операций над ними. Это про Шаблонный конструктор Интерфейс без единого слова virtual Смесь паттернов External Poymorphism, Bridge, Prototype Посмотрим на класс Circle, ещё не испорченный разными не относящимися к фигурам методами, а также наследованием: И аналогично может быть определён класс Square. Этим классам не нужен базовый класс Им не нужно знать друг о друге Они не должны заботиться о том, что с ними можно сделать Это очень удобно. Такими классами максимально просто пользоваться. У них нет никаких зависимостей. И главное - мы их больше никогда не изменим! Теперь перейдем к решению проблемы их рисования. Описание задачи этого паттерна из исходного документа, описывающего его: Allow classes that are not related by inheritance and/or have no virtual methods to be treated polymorphically. И мой вольный перевод: Дать возможность классам, не связанным наследованием и/или не имеющим виртуальных методов, быть обработанными так, как будто они полиморфные. Посмотрим на код и разберем, что к чему. Чуть позже станет ясно, почему здесь написано struct, а не class. Конструктор ShapeModel принимает объект любого класса и сохраняет его в своё поле. Этим классом может быть Circle или Square, или любая другая фигура, которую мы создадим. В то же время ShapeModel наследуется от ShapeConcept, чуть позже станет ясно, почему. Теперь в ShapeConcept добавим все функции-операции над фигурами, которые могут быть нужны нам (оставлю в будущем только draw для краткости). И особенным образом дадим определение этим функциям в производном классе ShapeModel: Что же за вызов функции draw(object)? Когда мы пишем такое, мы утверждаем, что где-то вне классов, только что созданных нами, существует функция draw, которая в качестве аргумента сможет принять object типа T. Например: void draw(const Circle &c) { ... } Это уже наложило ограничения на то, какие типы могут быть подставлены вместо T во время инстанциации шаблона ShapeModel. А именно: для этих типов обязательно должна существовать функция draw, принимающая их в качестве аргумента. ShapeModel наследуется от ShapeConcept. Таким образом, с помощью задания чисто виртуальных функций в классе ShapeConcept мы говорим, какие функции-обработчики должны обязательно существовать для объектов, которые мы будем в будущем хранить внутри ShapeModel. В этом и заключается паттерн External Polymorphism. Мы извлекли полиморфную часть классов иерархии(которая теперь уже и не нужна) в отдельное место Мы всё еще можем строго задавать функции-обработчики наших классов Всё еще могут существовать абстрактные классы (для которых нет полного набора функций-обработчиков) В итоге данный паттерн: Позволяет обрабатывать любой объект так, как будто он полиморфный. Можно даже создать видимость полиморфизма для фундаментальных типов (например int) Позволяет вынести детали реализации из класса Позволяет классам не заботиться (и даже знать) о тех операциях, которые над ними совершаются Открывает возможность лёгкого расширения функциональности Покажу пример работы: Из оригинального документа, описывающего данный паттерн есть ещё один пример. Допустим, мы работаем с несколькими библиотеками и хотим написать сериализацию для объектов из этих библиотек. Взять и создать для них общий базовый класс не представляется возможным. С помощью данного паттерна мы можем решить эту проблему. Создаем класс SerializableConcept, который описывает, какие внешние функции должны существовать (serialize в данном случае). От этого класса наследуем класс SerializableModel (аналогично ShapeModel). И получаем возможность создать SerializableModel над любым классом, для которого существует функция serialize, принимающая этот класс как аргумент. И, соответственно, теперь мы можем сериализовать любой класс. И... Ура! Это работает! Но. Подведём итоги применения паттерна External Polymorphism: Много указателей Много std::make_unique И много других вещей, которые мы не хотим делать вручную Давайте улучшим этот паттерн, чтобы избавиться от данных проблем. Возьмём и обернем ShapeConcept и ShapeModelв класс Shape, в его секцию private (становится понятно, зачем было объявлять их struct - теперь они всё равно спрятаны). Таким образом мы прячем эти относительно искусственные два класса с не очень говорящими именами внутрь уже довольно очевидной оболочки. Теперь предоставим пользователю возможность создать любую фигуру. Добавим в класс Shape поле, хранящее конкретную фигуру и шаблонный конструктор: А теперь присмотритесь. Что делает этот новый конструктор? Он создает для переданного объекта соответсвующую ему ShapeModel Заметьте, новое поле имеет тип std::unique_ptr<ShapeConcept> и конструктор сохраняет в него указатель на ShapeModel. А теперь посмотрим на то, что происходит с типами во время всех этих действий: Объект передан в конструктор Shape - тип известен - T Создан объект класса ShapeModel<T> - тип исходного объекта всё еще здесь Указатель на ShapeModel<T> сохранен внутри указателя на ShapeConcept. Так можно сделать, ведь они связаны наследованием. Но! Указатель на ShapeConcept уже не содержит в себе типа, который лежит внутри него. На этом шаге произошло "стирание типа". Отсюда и название данного паттерна. Но несмотря на то, что тип кажется утерянным, мы всё еще можем пользоваться объектом, который только что сохранили. Всё, что нужно для его обработки уже написано в классах ShapeConcept и ShapeModel. Нужна лишь внешняя функция-обработчик. Этот шаг с созданием шаблонного конструктора Shape и есть проявляение паттерна Bridge. Для справки (wikipedia): Мост - структурный шаблон проектирования, используемый в проектировании программного обеспечения чтобы «разделять абстракцию и реализацию так, чтобы они могли изменяться независимо» Теперь мы можем создать сколько нам угодно классов для разных фигур, создать перегрузки функции draw, способные принимать объекты их типов в качестве аргументов. А красота в том, что компилятор инстанциирует нужные шаблоны и предоставит нам возможность пользоваться ими, как полиморфными! Нам самим не нужно писать этот код. И последнее, что нужно для пользования классом Shape. Все еще нет возможности нарисовать сохраненную фигуру. Для решения этой проблемы создадим функцию: Новая функция должна быть объявлена со словом friend, чтобы она могла внутри себя обратиться к полю shapePtr класса Shape. Пример применения: Теперь этим можно пользоваться, рисовать разные фигуры. Но, скорее всего, в мы столкнемся с проблемой. Как копировать объекты класса Shape? Ведь этот класс не знает тип объекта, который он хранит внутри себя. С решением этой проблемы поможет паттерн Прототип. Суть паттерна (wikipedia): Прототип — это порождающий паттерн проектирования, который позволяет копировать объекты, не вдаваясь в подробности их реализации. Просто добавим новый чисто виртуальный метод clone в ShapeConcept: Теперь этот новый метод отвечает за копирование (клонирование) объектов. Дадим этому методу определение в ShapeModel: Конечно же, дадим пользователю возможность использовать этот метод, добавим соотвествующую функцию в Shape: Теперь можно написать так: Анализ данного решения: Shape - высокоуровневая абстракция "контейнера" для фигур, который позволяет хранить в себе только те фигуры, которые соответствуют требованиям ShapeConcept. Circle, Square и др. - содержат только нужную информацию. Не знают о классе Shape. Не знают об операциях над ними. Не связаны полиморфизмом. Среднеуровневая абстракция. Функции draw, serialize и др. Делают некие операции над нужными фигурами - низкоуровневая абстракция. Класс ShapeModel - хранит в себе конкретную фигуру, связывает Shape с конкретными функциями-обработчиками фигур (Мост). Сгенерирован компилятором. Что мы получили: Извлекли детали реализации Предоставили возможность лёгкого расширения функциональности Разделение интерфейсов Отсутствие дублирования кода Классы, с которыми мы работаем, больше не отвечают за операции, которые производятся над ними. Они не обязаны знать о них. Нет больших иерархий наследования. Нет указателей (для пользователя) Нет ручного управления памятью (для пользователя) Улучшили производительность И это всё в private секции нового класса Shape! Шаблонный конструктор Полностью НЕвиртуальный интерфейс (спасибо, External Polymorphism) External Polymorphism + Bridge + Prototype Очень элегантный современный паттерн :) Что Type Erasure позволяет сделать? Избавиться от зависимостей Избавиться от указателей Улучшить производительность Улучшить читабельность и "понимательность" кода Упростить сопровождение кода Диаграмма паттерна Type Erasure (в данном примере): Breaking Dependencies: Type Erasure - A Design Analysis - Klaus Iglberger - CppCon 2021 External Polymorphism. An Object Structural Pattern for Transparently Extending C++ Concrete Data Types. Chris Cleeland and Douglas C. Schmidt Abstraction Can Make Your Code Worse  - про coupling (связность). CppCon 2017: Nicolai Josuttis “The Nightmare of Move Semantics for Trivial Classes”.(См. конструктор Shape) CppCon 2019: Chandler Carruth “There Are No Zero-cost Abstractions” - про производительность умных указателей, да и в целом про производительность разных абстракций. Back to Basics: Type Erasure - Arthur O'Dwyer - CppCon 2019 CppCon 2014: Zach Laine "Pragmatic Type Erasure: Solving OOP Problems w/ Elegant Design Pattern" Jason Turner. C++ Weekly - Ep 343 - Digging Into Type Erasure Блог Andrzej Krzemieński. Type erasure — Part I Блог Andrzej Krzemieński. Type erasure — Part II Блог Andrzej Krzemieński. Type erasure — Part III Блог Andrzej Krzemieński. Type erasure — Part IV C++ type erasure. cplusplus.com Design Patterns: Elements of Reusable Object-Oriented Software / Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. - United States : Addison-Wesley, 1994. - 395 c. - ISBN 0-201-63361-2. Alexander Shvets. Dive Into Design Patterns. - электронная книга : Refactoring.Guru, 2018. - 406 c.  Студент МГТУ им. Н. Э. Баумана