Pull to refresh

Comments 47

Так и не понял, в чём элегантность и "расширяемость" по сравнению, к примеру, с вариантом:

#include <iostream>
#include <variant>

struct circle_t {};
struct square_t {};

struct draw_t
{
    void operator () (const circle_t &) const
    {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
    }

    void operator () (const square_t &) const
    {
        std::cout << __PRETTY_FUNCTION__ << std::endl;
    }
};

int main ()
{
    using shape_t = std::variant<circle_t, square_t>;

    shape_t circle(circle_t{});
    shape_t square(square_t{});

    std::visit(draw_t{}, circle);
    std::visit(draw_t{}, square);
}

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

А за виртуальный метод clone в плюсах полагается расстрел на месте.

Как делается настоящее стирание типа без виртуальной шляпы — см. std::function.
Более продвинуто — Boost.TypeErasure.

Да я регулярно здесь читаю статьи таких вот клоунов. Невооружённым глазом видно, что человек не имеет опыта программирования на плюсах, но уже учит других.

Из говномёта! Прямо под стенами здания вместе с группой коллег, пропустивших на рецензии! :-)

Почему? Объясните, пожалуйста.

А если все возможные объекты наперёд заранее не известны? Т.е. в момент линковки-то они конечно известны, но сделать так, чтоб был такой вариант который знает все типы -- не вариант.

Автору следовало уточнить, что паттерн имеет смысл, когда создание и использование Shape делается кодом разных владельцев (скажем, создание в плагине, а вызов draw — в плагин-хосте). Иначе и городить ничего не надо, достаточно


class Circle {};
class Square {};

void draw(const Circle&) {}
void draw(const Square&) {}

Иначе и городить ничего не надо, достаточно

Согласен. А то, что вы говорите, слегка упомянуто мной:

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

См. ссылка

std::variant основан на фиксированном наборе типов и предоставляет открытый набор операций над ними. Мы же пытаемся достигнуть обратного - открытого для расширения набора типов и фиксированного набора операций над ними.

std::function, Boost.TypeErasure, Boost.Polycollection

Как минимум, код становится красивее и читабельнее! А с std::variant могут возникнуть трудности, например: https://godbolt.org/z/633eh11rc

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

Полностью НЕвиртуальный интерфейс...

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

UFO just landed and posted this here

Насколько я понял пока изучал ассемблерный код примеров на godbolt.org, в этом деле нам очень сильно могут помочь отпимизации компилятора ("девиртуализация", кажется). (Не ручаюсь за свои слова здесь на 100%, я не эксперт ассемблера).

Девиртуализация в нетривиальной программе делается только локально.

Спасибо, интересная статья. А почему мы не можем абстрагировать сам рендерер (IVulkanRenderer, IDxRenderer, IOpenGlRenderer), и сделать то же самое с сериализацией? К тому же, composition over inheritance это один из базовых принципов, поэтому примеры выглядят с этой позиции немного надуманно - никто не будет так делать, как вы и сказали.

Не понимаю. Буквально несколько дней назад вы писали в моём посте описывающем собственно type erasure и готовые решения описанных в статье проблем и отвечали, что нужно сделать перегрузку на макросах в С как будто это то же самое(спойлер - нет)
https://habr.com/ru/post/703846/comments/#comment_25020336
А сейчас статью про стирание типов...
https://github.com/kelbon/AnyAny
вот держите блин, реализовано и делает гораздо лучше чем описанное вами стирание типов, с более удобным интерфейсом, убиранием лишних аллокаций и всякими крайне удобными и полезными фишками

Ваш код это буквально первый пример оттуда

Она в момент конструирования any_drawable имеет такой же шаблонный конструктор, как я понимаю, в котором сохраняет указатель на Draw::do_invoke() в себе (условно, скорей там статический класс из шаблона инстанцируется, в нём все указатели, а на него ссылка в объекте) и использует при вызове invoke. Та же таблица виртуальных функций, но сделанная вручную.

??? Во первых не вручную, а шаблон

Во вторых у вас есть какие то другие варианты как это сделать?
В третьих, в статье описано то же самое, только хуже

Почему хуже, если других вариантов, как это сделать, нет?

в рамках одного варианта можно написать разный код

Можно, но хуже то он в чем? А то пока выглядит так, что сделал не я — значит гамно.

в интерфейсе, расширяемости и выделении лишней памяти

Ну, память возможно действительно можно не выделять, а что за проблемы с интерфейсом и расширяемостью?

попробуйте добавить новый тип или новый метод в интерфейс из статьи

Так вроде же в последнем листинге написано. Новый тип:


struct NewType {};
void draw(const NewType &s) {
    std::cout << "I am NewType" << std::endl;
}

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


Кстати, а в вашем варианте как решаются аналогичные вопросы?


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

В случае добавления нового типа с нужным интерфейсом всё тривиально - просто пишете этот тип.

Если нужно добавить метод в интерфейс, вы просто пишете этот метод и вставляете в собственно интерфейс, например

template<typename T>
struct Foo { ... };

using any_xxx = any_with<СтарыеМетоды..., Foo>;

Решение из статьи уже в этом моменте ломается, нужно менять реализацию на уровне ShapeConcept и проч.
В этот момент метод Foo начинает требоваться для всех типов, где использовано any_xxx.
Но можно сделать интереснее.
Если у вас раньше функция требовала

int bar(any_with<OldMethods...>::ref x);

То теперь изменения сигнатуры не нужны. Потому что any_with<OldMethods..., Foo>::ref неявно приводится к any_with<OldMethods...>

Таким образом типам отправленным в функцию bar не требуется реализовывать новый метод из интерфейса. ABI и API брейка нет.

Этого не добиться ни с каким решением с монолитным интерфейсом или любым тайп ерайзом использущим в реализации virutal.

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

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

При создании any_with<...>::ref или any_with<...>::ptr не происходит никакого выделения памяти вовсе, компилятор отлично это оптимизирует(лучше, чем виртуальные функции)

При создании самого объекта any_with<...> тоже скорее всего аллокация не будет сделана, потому что там оптимизация аналогичная SSO в строках

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

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

На основе _Generic становятся возможными некоторые трюки, что гораздо более полезны, чем собственно switch-case времени компиляции (который всегда был в виде __builtin_choose_expr()). Например, можно отличать константные и не константные указатели, можно отличать константы времени компиляции от переменных. Можно вообще как-то программировать компилятор (собственно это единственный оператор выбора доступный в стандартном ISO C в момент компиляции). Но для реализации полиморфных функций, _Generic -- достаточно бесполезная сущность, повторюсь, из-за необходимости знания всех типов наперёд и постоянного их перечисления.

я знаю что это такое

Во, вот это правильная реализация.

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

У вас по-прежнему используется динамическая диспетчеризация.

Задача расширения кода на новые типы объектов и новые методы без нелокальных изменений называется expression problem. Её 15 лет назад уже решили, почитать можно, например здесь. На С++ это будет выглядеть как CRDT + инстанцирования классов с статическими методами.

На С++ это будет выглядеть как CRDT + инстанцирования классов с статическими методами

Можно пример?

Статья хорошая, но почему перепутаны паттерны стратегия и визитор?

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

А визитор вызывает отдельный алгоритм в зависимости от типа класса.

Пример реализации паттерна
struct ShapeVisitor;

struct Shape {
  void Accept(const ShapeVisitor&) = 0;
};
struct Circle;
struct Square;

struct ShapeVisitor {
  void Visit(const Circle&) = 0;
  void Visit(const Square&) = 0;
};

struct Square : public Shape {
  void Accept(const ShapeVisitor& v) final {
    v.Visit(*this);
  }
}

struct DrawVisitor : public ShapeVisitor {
  void Visit(const Circle& c) final {
    ...
  }
};

struct SerializeVisitor : public ShapeVisitor {
  void Visit(const Circle& c) final {
    ...
  }
};

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

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

Шаблон стратегия позволяет переключаться между алгоритмами (стратегиями) в зависимости от ситуации. Вот мы и пользуемся этим паттерном:

struct Circle : public Shape {
    std::unique_ptr<DrawStrategy> drawStrategy;
    explicit Circle(DrawStrategy *drawStrategy) 
        : drawStrategy(drawStrategy) { }
    void draw() override { drawStrategy->draw(this); }
};

Таким образом мы выделили в отдельный класс метод рисования.

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

И главное - мы их больше никогда не изменим!

В этот момент начинается рассказ о паттерне external polymorphism, поэтому визитор слегка отходит на второй план.

Я примерно 10 лет назад с таким упражнялся вот тут: https://habr.com/ru/post/151504/
Делал нечто похожее, но с помощью std::function. Никакой практической ценности я тогда из этого не извлек, но как упражнение для мозгов сгодилось.

Я новичок, могу ошибаться, но реализация паттерна стратегия такая:

Код
template<class PrintStrategy>
class PrintStringTest {   
	using PrintStrategy::printImpl;   
	
	public:
		void print() {           
			printImpl();        
		}
};

class Test {
	public:
		void printImpl() {
			std::setlocale(LC_ALL, "");
			
			std::wcout << L"Тест паттерна стратегия\n";
		}
};

int main() {
	PrintStringTest<Test> printer;
	
	printer.print();
}

Ваша реализация еще зачем-то использует умные указатели и виртуальные функции.

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

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

Так что все варианты имеют место быть, просто у каждого свое назначение(см. ссылка), свои плюсы и минусы.

Спасибо. Кстати, у меня есть еще один вопрос: почему бы вместо того, чтобы выносить функцию, которая в нашем случае рисует объект, не сделать вот так, основываясь на этом паттерне (Policy):

Код
template<typename T>
struct ShapeModel : public ShapeConcept {
    using T::drawImpl;
  
    T object;
  
    explicit ShapeModel(T &&shape) : object(std::move(shape)) { }
    
    explicit ShapeModel(const T &shape) : object(shape) { }
    
    void draw() const override {
        drawImpl(object); 
    }
};


Реализация класса Circle в таком случае:

Код
class Circle {
  public:
    // ...
    
    static void drawImpl(const Circle &circle) {
      // ...
    }

  private:
    // ...
};

Здесь неплохо бы использовать концепты из C++20, ведь если бы наш класс не имел функции drawImpl, компилятор выдавал бы более понятные сообщения об ошибках.

Или так:

template<typename T>
struct ShapeModel : public ShapeConcept {
    T object;
  
    explicit ShapeModel(T &&shape) : object(std::move(shape)) { }
    
    explicit ShapeModel(const T &shape) : object(shape) { }
    
    void draw() override final /* Для девиртуализации */ {
        object->drawImpl(); 
    }
};
class Circle {
  public:
    // ...
    
    void drawImpl() {
      // ...
    }

  private:
    // ...
};

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

Один из примеров из статьи про external polymorphism. Представьте себе, что мы хотим реализовать сериализацию для классов программы (и своих и библиотечных). Мы не можем взять и добавить им в иерархию общего предка с методом serialize(), тк библиотечные классы мы не можем менять. Но зато мы можем создать свою оболочку со стиранием типа для них и отдельно функции, которые уже умеют обрабатывать каждый из желаемых классов.

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

Концепты - отличная штука, согласен, можно с их помощью доработать всё так, чтобы ошибки компиляции выглядели приятнее. А с помощью еще некоторых манипуляций (см. комментарии от пользователя @Kelbon) можно достичь той же гибкости в рантайме.

Sign up to leave a comment.

Articles

Change theme settings