Pull to refresh

Comments 83

Приведу пример того, как я бы сделал изначально:


class Filter {
public:
    virtual ~Filter() = default;
    virtual bool matches(const Txn& txn) const = 0;
};

template<typename Tx>
class TxFilter : public Filter {
public:
    virtual bool matchesTx(const Tx& txn) const = 0;

    bool matches(const Txn& txn) const override {
        return my::visit<Tx>(txn, [](const auto& txn) {
            return matchesTx(txn);
        });
    }
};

class LargeAmountFilter : public Filter {
    bool matches(const Txn& txn) const override {
        return txn.amount() > Money::from_dollars(10'000);
    }
};

class DifferentCustomerFilter : public TxFilter<DepositTxn> {
    bool matchesTx(const DepositTxn& dtxn) const override {
        return dtxn.name_of_customer() != dtxn.name_on_account();
    }
};

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


Вся проблема автора в том, что:


  1. Не используются все возможности языка. Использование шаблонов позволяет существенно сократить код и переиспользовать возможности без засорения интерфейса и без потери гибкости. Если в будущем появятся новые типы транзакций, то TxFilter автоматически добавит нужную реализацию путем использования нужного шаблона. Тот мусор, который приведен в статье в виде do_casewise не расширяем и громоздок.
  2. Не стоит увлекаться догматами. Я часто слышу, что "не надо использовать X, Y". Однако что дается в замен? Обычно обходятся общими фразами. Но жизнь — она более многогранна. И надо пытаться пользоваться всеми возможностями и выразительностью языка. Профессионал знает, как и в каких дозах стоит использовать тот или иной приём. Правило Скотта Майерса, конечно, звучит замечательно, только сам он не писал продакшен кода с миллионами строк кода, поэтому при всём уважении, я отношусь к таким советам несколько скептически, хоть и понимаю важность и ценность. Я бы переформулировать так: стараться использовать по умолчанию подход "Скотта Майерса", однако если есть весомые основания отклонения, то они должны быть обозначены прям в коде и объяснены. С этим советом я могу согласиться, т.к. это не догмат, а вполне вменяемая рекомендация.

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

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

Отсутствие опыта, как правило, не может являться твердым основанием умозаключений.


Имеет смысл посмотреть реализацию std::function, а также посмотреть, что такое type erasure.

очень нагло с вашей стороны предполагать, что любой кто с вами не согласен просто знает с++ хуже вас, имеет меньше опыта, не понимает базовых концепций языка или не знает как устроены базовые классы. В частности учитывая что я предлагаю std::function вместо my::visit, реализованного через цепочку dynamic_cast'ов.

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

Я не делал подобных предположений.


Мое утверждение заключалось в том, что если не было опыта в читаемом и хорошем коде, то из этого мало что следует. Из своего опыта могу сказать, что такой код выглядит ничуть не хуже другого. Это просто возможность языка, которую можно использовать как во благо, так и во вред.

Это всё круто, но…
Часто (очень часто!) возможности языка ограничены возможностями компилятора на конкретной платформе. И многие изящные (и правильные!) подходы разбиваются об ошибки компиляции.
Приходится от "ничего не знаю, у меня всё работает!" переходить и разбираться в том, а почему вот под rhel6 или debian jessie вдруг собрать не получается, и что нужно сделать (да, нужно!), чтобы и там всё работало. Очень часто это приводит к переписыванию "правильного и изящного" кода под предыдущий, или даже ещё более древний стандарт (C++17? Счастье! Но чаще весьма часто даже C++14 уже вводит сборку в ступор. И вот там приходится скрещивать пальцы даже при компиляции/тестировании хотя бы под C++11). Это не субъективная "капля дёгтя", а всего лишь прагматичное "увы". Лямбды с auto в качестве типа параметра передадут большой "привет", и дальше придётся думать уже не о красоте, а чтобы сохранить максимум задуманной архитектуры, исходя из вдруг ставших урезанными возможностей.

casewise здесь никак не 'чувствительность к регистру'. Это или 'от случая к случаю', или 'специфическая проверка', но к регистру оно отношения не имеет.
кажется, filter — сущность, задача которой отвечать на вопрос «хотдог/не хотдог», можно просто запихать в std::function&ltbool(const Txn&)&gt и не морочить голову (кстати, не рекомендую делать сокращения вида Transaction -> Txn).

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


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


Но после ознакомления со статьей главным вопросом, который и заставил написать данный комментарий, остается вот какой: а почему здесь вообще не был применен паттерн Visitor в его (как я понимаю) классическом виде?


class Filter;
class Txn {
public:
   ...
   virtual void filter(Filter & flt) = 0;
};
class DepositTxn : public Txn {
  void filter(Filter & flt) override { flt.on(*this); }
  ...
};
class WithdrawalTxn : public Txn {
  void filter(Filter & flt) override { flt.on(*this); }
  ...
};
...
class Filter {
public:
  virtual void on(DepositTxn & txn) = 0;
  virtual void on(WithdrawalTxn & txn) = 0;
  virtual void on(TransferTxn & txn) = 0;
  ...
};

Соответственно, от Filter уже делаются наследники с нужной функциональностью.


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


ИМХО, такой подход напрашивается изначально и является знакомым для большинства программистов (по крайней мере тех, кто наслышан про паттерн Visitor).

Для решения этой проблемы отлично подходит Entity–component–system (ECS) pattern.

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

Уход от ООП позволяет избавиться от проблем с наследованием, которые решают в этой статье. На хабр есть статьи про ECS, пример: https://habr.com/en/post/441174/

Вместо примера кода, который бы проиллюстрировал ваше утверждение "Для решения этой проблемы отлично подходит Entity–component–system (ECS) pattern" вы отправляете читать объемную статью, в которой обсуждается другая проблема, да еще и говорится следующее:


Спойлер: устранение всех нарушений OOD приводит к улучшениям производительности, аналогичным преобразованиям Араса в ECS, к тому же использует меньше ОЗУ и требует меньше строк кода, чем ECS-версия!

Т.е. вы ссылаетесь на публикацию, которая утверждает, что в рамках ООП можно получить даже лучший результат, чем в ECS?


Как-то аргументация не очень.

Примеры проблем с ООП:
Если в транзакцию добавляем метод filter чтоб решить проблему из статьи, то со временем DepositTxn, WithdrawalTxn и т.д. станет иметь много методов (по аналогии с filter добавится новая логика). Транзакция также теперь зачем-то знает о фильтрации. На практике это приводит к большим классам с кучей логики внутри.


Также одной из проблем наследования является сложность изменения под новые требования. Про эту проблему ООП можно найти во многих вводных статьях про ECS.


Пример решения на ECS библиотеке entt:
Определяем для каждого типа транзакции свой набор компонентов. Т.е. сущность представляем путем композиции компонентов (компонент — только данные без логики обработки) или тегов (entt::tag). Все делается без наследования, следовательно не будет возникать проблем решаемых в статье.
Фильтрация транзакции — обработка определенного набора компонентов.


В документации entt описаны примеры обработки (в нашем случае обрабатывается событие ыильтрации транзакций), нам интересны примеры с использованием entt::view или entt::group. Замечу, что можно обработку делать не только периодически в системе (как во многих примерах использования ECS), но и вызвать ее в нужный момент единожды.


Про производительность.
Если использовать библиотеку entt, то в ее документации можно прочитать о оптимизациях производительности через cache locality (данные упакованы последовательно, что ускоряет итерацию). На практике производительность зачастую ограничена не ЦПУ, а скоростью доступа к RAM (скорость доступа к памяти отстает от прогресса ЦПУ). Поэтому где производительность лучше — отдельный вопрос, зависит от входных данных.


Минусы ECS:
Зависимости между сущностями скорее всего придется описывать связным списком. Блог автора entt содержит пример решения компонентом relationship.


Минусы entt:
Нужно хорошо изучить документацию для избежания ошибок многопоточности и UB. Т.е. программистам нужно выделить время на изучение нового подхода к разработке. При этом ООП обычно все понимают интуитивно.


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

Это уже лучше, но все-таки один пример кода стоил бы тысячи слов в прямом смысле. Так что пока вы не приведете пример кода, демонстрирующий решение проблемы из обсуждаемой статьи (или не дадите ссылку на похожий пример), нормального обсуждения не получится. Т.к. с высокой степени вероятности, спор будет между вашими заблуждениями вокруг ООП и моим незнанием какой-то библиотеки entt.


Если в транзакцию добавляем метод filter чтоб решить проблему из статьи, то со временем DepositTxn, WithdrawalTxn и т.д. станет иметь много методов (по аналогии с filter добавится новая логика).

Суть ООП в том, что у нас есть некая абстракция A и ее конкретные воплощения (B, C, D, E и т.д.). Абстракция A определяет набор свойств (в проторечии методов), которые присущи всем воплощениям абстракции. И, что наиболее важно, код который работает с абстракцией A, автоматически оказывается работоспособным с любым из воплощений A. Т.е., в код мы можем передать B, можем передать C, можем передать D и он все равно будет работать.


Что означает, что в A оказываются только те свойства, которые присущи всем ее воплощениями.


А раз так, то все, что в A оказывается, все это будет вам необходимо. Хоть filter, хоть еще что-то.


Ну а если вы в A добавляете что-то, что нужно не всем воплощениям A, а только какому-то из подмножеств, то вы просто нарушаете ООП. И тут уж вовсе не проблемы ООП.


Определяем для каждого типа транзакции свой набор компонентов. Т.е. сущность представляем путем композиции компонентов (компонент — только данные без логики обработки) или тегов (entt::tag).

Из этого не слишком конкретного описания складывается ощущение, что в подходе ECS вы тупо заведете в каждом типе уникальный tag, а затем в прикладном коде будете делать switch по tag. Правильно?


Если я вас понял не правильно, то приведите, наконец, пример кода (или ссылку на оный), чтобы можно было не заниматься объяснением на пальцах.

Транзакция также теперь зачем-то знает о фильтрации
нет, транзакция лишь предоставляет достаточный для фильтрации интерфейс
Фильтрация транзакции — обработка определенного набора компонентов.
но тогда фильтр должен знать не просто интерфейс транзакции, а про все разновидности транзакций
На практике производительность зачастую ограничена не ЦПУ, а скоростью доступа к RAM (скорость доступа к памяти отстает от прогресса ЦПУ). Поэтому где производительность лучше — отдельный вопрос, зависит от входных данных.
на практике только самые простые и хорошо оптимизированные алгоритмы упираются в память. Иначе разница между базовыми 2433 MHz и топовыми 3600 MHz плашками была бы куда больше чем 10-20% в зависимости от проца. И даже в этом случае многоканальность сама по себе даст больший прирост, чем частота плашки.

Ну и да, сравнивать старый, зарекомендовавший себя и хорошо изученный подход к проектированию ПО с реализацией другого подхода в конкретной библиотеке не очень правильно.
Я про код из комментария с Visitor, там «Транзакция также теперь зачем-то знает о фильтрации» habr.com/en/company/piter/blog/524882/#comment_22227028 Например, объект «пирог» не должен знать о том, что его могут «выбирать» покупатели (мое личное мнение).

«старый, зарекомендовавший себя и хорошо изученный подход к проектированию ПО» — это не означает, что данный подход хорошо подходит для решения проблемы из статьи. ECS, к слову, также давно существует и зарекомендовал себя в индустрии разработки игр (Unity, Unreal Engine, Dava Engine от Wargaming).

Про производительность — зависит от решаемых задач. Как правило производительность достаточна для критического к производительности ПО, например, игровых серверов см. «Overwatch Gameplay Architecture and Netcode» — лекция с GDC. Но ECS зачастую выбирают из-за того, что он во многих случаях не позволит создать ужасную архитектуру (не будет многих проблем, которые на практике не редкость с ООП подходом).

Про ЦПУ или RAM — на сайте gameprogrammingpatterns (бесплатная онлайн книга «Game Programming Patterns») в статье «Data Locality» написано про проблему применимости «Moore’s Law» к RAM. ECS на практике применяют там, где под запретом наследование, виртуальные методы из-за требований производительности (игровые движки). Но вопрос производительности — отдельная и сложная тема.

Замечу также, что я не говорю про идеальность ECS (к сожалению, идеальное решение мне неизвестно). Я говорю, что ECS еще один способ решить проблему из статьи, может кому-то подойдет больше ООП.

Пример кода:

#include <entt/entt.hpp>
#include <entt/entity/registry.hpp>
#include <entt/entity/helper.hpp>
#include <entt/entity/entity.hpp>
#include <entt/core/type_info.hpp>
#include <entt/core/type_traits.hpp>
#include <entt/entity/group.hpp>

#include <boost/algorithm/string.hpp>

#include <cstddef>
#include <cstdint>
#include <sstream>
#include <string>
#include <type_traits>

// на практике проверят валидность данных иначе
const std::string kInvalidStr{"INVALID..."};

struct DebugName
{
  std::string debugName{kInvalidStr};
};

// ECS компонент - данные без логики
struct AccountNumber
{
  int accountId{-1};
};

// ECS компонент - данные без логики
struct AccountName
{
  std::string accountName{kInvalidStr};
};

// ECS компонент - данные без логики
struct MoneyAmount
{
  int moneyAmount{-1};
};

// Метка для любого перевода
using TransactionTag
  = entt::tag<"TransactionTag"_hs>;

// Метка для входящего перевода
using DepositTag
  = entt::tag<"DepositTag"_hs>;

// Метка для исходящего перевода
using TransferTag
  = entt::tag<"TransferTag"_hs>;

// Метка для любого фильтра
using FilterTag
  = entt::tag<"FilterTag"_hs>;

enum class CaseSensitivity
{
  CaseSensitive,
  IgnoreCase
};

enum class ComparisonType
{
  Greater,
  Less
};


// Метка для фильтра Money
struct MoneyAmountFilter
{
  ComparisonType comparisonType;
};

// Метка для фильтра соотв. AccountName
struct ExactAccountNameFilter
{
  CaseSensitivity caseSensitivity;
};

// входные транзакции для примера,
// на практике могут поступать в любой момент
void createTestTransactions(entt::registry& registry)
{
  {
    entt::entity entity
      = registry.create();
    registry.emplace<DebugName>(entity, "Transaction1");
    registry.emplace<TransactionTag>(entity);
    registry.emplace<DepositTag>(entity);
    registry.emplace<MoneyAmount>(entity, MoneyAmount{130});
    registry.emplace<AccountNumber>(entity, AccountNumber{1});
    registry.emplace<AccountName>(entity, AccountName{"Peter"});
  }

  {
    entt::entity entity
      = registry.create();
    registry.emplace<DebugName>(entity, "Transaction2");
    registry.emplace<TransactionTag>(entity);
    registry.emplace<TransferTag>(entity);
    registry.emplace<MoneyAmount>(entity, MoneyAmount{101});
    registry.emplace<AccountNumber>(entity, AccountNumber{2});
    registry.emplace<AccountName>(entity, AccountName{"habrahabr"});
  }
}

// включенные фильтры для примера,
// на практике могут включаться или отключаться в любой момент
void createTestFilters(entt::registry& registry)
{
  {
    entt::entity entity
      = registry.create();
    registry.emplace<DebugName>(entity, "Filter1");
    registry.emplace<FilterTag>(entity);
    MoneyAmountFilter filter{};
    filter.comparisonType = ComparisonType::Greater;
    registry.emplace<MoneyAmountFilter>(entity, std::move(filter));
  }

  {
    entt::entity entity
      = registry.create();
    registry.emplace<DebugName>(entity, "Filter2");
    registry.emplace<FilterTag>(entity);
    ExactAccountNameFilter filter{};
    filter.caseSensitivity = CaseSensitivity::IgnoreCase;
    registry.emplace<ExactAccountNameFilter>(entity, std::move(filter));
  }
}

// Проверка входящей транзакции определенным фильтром
bool filterTransaction(
  entt::registry& registry
  , entt::entity filterEntity
  , entt::entity transactionEntity)
{
  // проверки входных данных...
  {
    CHECK(registry.valid(filterEntity));
    CHECK(registry.has<FilterTag>(filterEntity));
    CHECK(registry.has<DebugName>(filterEntity));
    CHECK(registry.get<DebugName>(filterEntity).debugName != kInvalidStr);

    CHECK(registry.valid(transactionEntity));
    CHECK(registry.has<TransactionTag>(transactionEntity));
    CHECK(registry.has<DebugName>(transactionEntity));
    CHECK(registry.get<DebugName>(transactionEntity).debugName != kInvalidStr);
  }

  MoneyAmount* moneyAmount
    = registry.try_get<MoneyAmount>(transactionEntity);

  AccountNumber* accountNumber
    = registry.try_get<AccountNumber>(transactionEntity);

  AccountName* accountName
    = registry.try_get<AccountName>(transactionEntity);

  const bool isTransfer
    = registry.has<TransferTag>(transactionEntity);

  const bool isDeposit
    = registry.has<DepositTag>(transactionEntity);

  ExactAccountNameFilter* exactAccountNameFilter
    = registry.try_get<ExactAccountNameFilter>(filterEntity);

  MoneyAmountFilter* moneyAmountFilter
    = registry.try_get<MoneyAmountFilter>(filterEntity);

  // Снимать деньги со счета может только владелец этого счета.
  if(exactAccountNameFilter && accountName && isDeposit)
  {
    // тут могут быть доп. проверки входных данных...
    if(accountName->accountName == kInvalidStr) {
      return false;
    }

    const std::string kTestName = "habrahabr";

    if(exactAccountNameFilter->caseSensitivity == CaseSensitivity::IgnoreCase
       // compared case insensitively
       && !boost::iequals(accountName->accountName, kTestName))
    {
      return false;
    }

    if(exactAccountNameFilter->caseSensitivity == CaseSensitivity::CaseSensitive
       // compared case sensitively
       && !boost::equals(accountName->accountName, kTestName))
    {
      return false;
    }
  }

  if(moneyAmountFilter && moneyAmount)
  {
    // тут могут быть доп. проверки входных данных...

    const int kTestAmount = 42;

    if(moneyAmountFilter->comparisonType == ComparisonType::Less
       && moneyAmount->moneyAmount >= kTestAmount)
    {
      return false;
    }

    if(moneyAmountFilter->comparisonType == ComparisonType::Greater
       && moneyAmount->moneyAmount <= kTestAmount)
    {
      return false;
    }
  }

  // Еще проверки...
  // Конечно на практике кучу if в одной функции не пишут,
  // а регистрируют набор обработчиков (например, через плагины).

  return true;
}

// проверка входящих транзакций всеми включенными фильтрами
bool runFiltersOnTransaction(
  entt::registry& registry
  , entt::entity transactionEntity)
{
  auto view
    = registry.view<FilterTag>();

  // если хоть одна из включенных проверок не прошла,
  // то фильтрация вернет false
  for(const entt::entity& filterEntity : view)
  {
    const bool filterOk
      = filterTransaction(
          std::ref(registry)
          , filterEntity
          , transactionEntity);

    LOG(INFO)
      << "==================="
      << "\n"
      << "transactionEntity: "
      << registry.get<DebugName>(transactionEntity).debugName
      << "\n"
      << "filterEntity: "
      << registry.get<DebugName>(filterEntity).debugName
      << "\n"
      << "filterOk: "
      << std::to_string(filterOk)
      << "\n"
      << "===================";

    if(!filterOk) {
      return false;
    }
  }

  return true;
}

// обработка входящих транзакций
void processTransactions(entt::registry& registry)
{
  auto view
    = registry.view<TransactionTag>();

  for(const entt::entity& transactionEntity : view)
  {
    const bool filterOk
      = runFiltersOnTransaction(
          std::ref(registry)
          , transactionEntity);

    if(filterOk)
    {
      // далее логика сохранения транзакций в БД и т.д.
      // ...
    }
  }
}

int main(int argc, char* argv[])
{
  // предметная область — “банковские транзакции.”
  // необходимо фильтровать подозрительные транзакции

  // хранилище данных (ECS)
  entt::registry registry;

  createTestTransactions(std::ref(registry));

  createTestFilters(std::ref(registry));

  // на практике `processTransactions` можно вызывать периодически
  // по мере поступления новых данных
  processTransactions(registry);
  
  return
    EXIT_SUCCESS;
}
Например, объект «пирог» не должен знать о том, что его могут «выбирать» покупатели (мое личное мнение).
хорошо, в вашем варианте транзакция не знает о фильтрации. Но фильтр всё еще должен знать о транзакциях. В то время как классический ООП предлагает полную инкапсуляцию. Которая, между прочим, сильно уменьшает время компиляции, тоже очень важное игроделам.
ECS, к слову, также давно существует и зарекомендовал себя в индустрии разработки игр (Unity, Unreal Engine, Dava Engine от Wargaming)
ну посмотрим на промежутке лет в 20, приживется или нет
Про производительность — зависит от решаемых задач. Как правило производительность достаточна для критического к производительности ПО
так то она и с виртуальными методами как правило достаточна. Более того, говорить о производительности без замеров, еще и вводя свои не бесплатные виды диспатча, очень и очень преждевременно.
Но ECS зачастую выбирают из-за того, что он во многих случаях не позволит создать ужасную архитектуру
ага, именно «не позволит» — там, где в ООП придется делать костыль, в ECS придется рефакторить )
ECS на практике применяют там, где под запретом наследование, виртуальные методы из-за требований производительности (игровые движки)
они же замерили влияние виртуальных методов на производительность прежде, чем их запрещать, да? Или виртуальные методы запрещаются в первую очередь из-за потенциально лишних аллокаций памяти под объекты?

Я так понимаю, что вот здесь:


  AccountName* accountName
    = registry.try_get<AccountName>(transactionEntity);

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


  AccountName* accountName
    = registry.try_get<AccountName>(filterEntity);

а потом долго искать ошибку в run-time?

Замечу также, что я не говорю про идеальность ECS (к сожалению, идеальное решение мне неизвестно). Я говорю, что ECS еще один способ решить проблему из статьи, может кому-то подойдет больше ООП.

Так ведь все ходы записаны:


Для решения этой проблемы отлично подходит Entity–component–system (ECS) pattern.

"отлично подходит" воспринимается довольно-таки однозначно.


Но вот взглянешь на приведенный вами пример кода и складывается ощущение, что у "отлично" здесь какой-то другой оттенок.

« Но вот взглянешь на приведенный вами пример кода и складывается ощущение, что у "отлично" здесь какой-то другой оттенок. »


  • конструктивную критику, пожалуйста. Мое личное мнение — даже набросок кода с ECS выглядит лучше (понятнее, проще изменять и т.д.) примеров с ООП из статьи, мне проблема не ясна и я удивлен такой критикой.

« фильтр всё еще должен знать о транзакциях. В то время как классический ООП предлагает полную инкапсуляцию. » — фильтр должен знать о транзакциях по условию задачи, например, чтобы обработать depositTxt отдельно. В ООП решении метод do_casewise знает о нужной транзакции.


«приживется или нет» — в игрострое уже прижилось


время компиляции затрудняюсь сравнивать с ООП. Считаете ли Вы, что у ECS почему-то хуже с этим?


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


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


«ничто не мешает разработчику опечататься» — от опечаток не спасет и ООП. Конечно, можно создать отдельный объект как обертку над набором компонентов или сделать функцию с проверкой на допустимые компоненты объекта. Проблему не понимаю, то же возможно и с ООП.


"отлично подходит" не значит идеальное решение. У обоих подходов есть куча минусов.

Простите, но вы смешиваете в одном комментарии ответы на комментарии от разных собеседников. Во-первых, не нужно так. Во-вторых, отвечу только на те моменты, которые имеют отношение именно к моим словам.


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

Давайте определимся: мы обсуждаем личное мнение или все-таки нечто более объективное?


Ибо если мы обсуждаем объективное, то "отлично подходит", по моему скромному мнению, означает, что у ECS должны быть очевидные и однозначные преимущества по сравнению с обсуждаемым ООП.


Однако, вместо обозначения очевидных и однозначных преимуществ вы пишете:


"отлично подходит" не значит идеальное решение. У обоих подходов есть куча минусов.

Откуда следует, что если везде есть куча минусов, то "отлично" ни одно из них уж точно не подходит.


«ничто не мешает разработчику опечататься» — от опечаток не спасет и ООП.

Давайте не вводить читателей в заблуждение. У вас в ECS мы имеем динамическую типизацию в чистом виде. Есть некий entity, внутри которого AccountName может быть, а может не быть. И об этом мы узнаем только в run-time.


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

Да, больше проверок будет в run-time или нужно создать несколько view, тогда каждая view будет предоставлять entity с гарантированным набором компонент.


Если же говорить про возможную опечатку с двумя entt::entity — можно решить через strong alias (создать отдельные два типа оборачивающие entt::entity, можно к ним даже добавить проверки на допустимый набор компонент при желании).

Из вашего примера следует, что view определяет коллекцию сущностей, каждая из которых содержит некий тег. Т.е. можно сделать view с ограничением на набор тегов. Но в run-time может оказаться, что этот view пустой.


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


Затем провели рефакторинг сущности "транзакция" и тег X из него убрали. Если фильтры работали с этим тегом через try_get, то ведь ничто не скажет в compile-time, что тега X в "транзакции" больше нет и что написанные подобным образом фильтры нужно переписать. Так ведь?

Да, при добавлении или изменении компонентов нужно быть внимательным. Теперь объект проще изменить через компоненты (композиция), но это порождает новые проблемы (которые, очевидно, можно решить по разному).


Замечу, что и в случае ООП возможны схожие проблемы, особенно если смотреть на пример с dynamic_cast или do_casewise (добавляется или меняется тип транзакции — код меняется).


Да, view с ошибочными компонентами даст пустой результат. Нужно писать тесты и т.д.

Очень часто dynamic_cast — это признак проблем в архитектуре.

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

не понимаю проблему, решение с ECS очевидно проще к модификации и дает интуитивно понятный код. Решение с ООП зачастую сложно, что видно из проблем решаемых в статье.
а можно дискутировать в более объективных категориях?
в игрострое уже прижилось
у пары контор то?
время компиляции затрудняюсь сравнивать с ООП. Считаете ли Вы, что у ECS почему-то хуже с этим?
разумеется — если в ООП сущности должны знать лишь интерфейсы друг друга, то в ECS логика должна знать всё множество возможных типов входных данных
я верно сказал, в игровом цикле слишком накладно постоянно вызывать виртуальный метод, поэтому используют ECS или другие подходы.
так вы замеряли или нет?

«пример кода имеет… другой оттенок. » — вполне логично, что я прошу более конструктивную критику на такие фразы. Также я сказал свое личное мнение, чтобы было понятно почему я не могу понять такую критику.


«у пары контор то» — это целая индустрия разработки ПО активно использующая ECS (и схожие с ним подходы). Я привел в пример только самые узнаваемые компании.


«в ECS логика должна знать всё множество возможных типов входных данных» — по-моему это ошибочное суждение. Я просто для примера все типы поместил в одну функцию с кучей проверок в if (и написал об этом комментарий в коде). Можно вынести проверки в отдельные функции и использовать только нужные типы компонентов или отдельные view с наборами нужных компонентов.


«так вы замеряли или нет» — вычитал из книг вроде Game engine architecture про оптимизацию цикла ААА игр и лекций GDC. На практике мне важнее оказалось удобство изменения архитектуры, чем производительность.

«у пары контор то» — это целая индустрия разработки ПО активно использующая ECS (и схожие с ним подходы)
тем не менее эта «целая индустрия» активнее использует ООП. Который, кстати, моложе чем те подходы, которые легли в основу ECS, но умудрился их вытеснить.
«в ECS логика должна знать всё множество возможных типов входных данных» — по-моему это ошибочное суждение.
да нет же. Вся суть ECS — логика и данные отдельно, соответственно при таком подходе логика должна уметь обрабатывать все виды поступаемых данных, т.е. как минимум знать их структуру.
«так вы замеряли или нет» — вычитал из книг вроде Game engine architecture про оптимизацию цикла ААА игр и лекций GDC
а авторы книг замеряли? Если они утверждают про профит в быстродействии, наверняка у них есть цифры, это подтверждающие?

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


Обычно «ecs система» — небольшой cpp файл (обычно нет в hpp логики системы), который включает только нужные компоненты. В нашем случае отдельный тип фильтра можно считать отдельной системой. Так как система максимально изолирована, то ее можно вызвать методом вида «void runMySystem(entt::registry&)». На практике такая изоляция частей логики друг от друга должна ускорить компиляцию.


Возможно я не так понял проблему с временем компиляции, буду рад примерам кода для сравнения.


Про производительность — прирост от использования кеша вместо RAM примерно 25-100x (цифра из статьи «How to Write Faster Code Than 90% of Programmers» от Jackson Dunstan). Использование указателей и вирт. методов сводит использование кеша на нет. Также на сайте gameprogrammingpatterns (бесплатная онлайн книга «Game Programming Patterns») в статье «Data Locality» написано про это.

Логика не обязана обрабатывать всегда все возможные виды поступаемых данных и включать все заголовочные файлы со всеми компонентами всюду
не всегда, не все, и не всюду, согласен. Но для диспатча понадобится знать обо всех вариантах сущности.
Про производительность — прирост от использования кеша вместо RAM примерно 25-100x
это не «прирост от использования кеша», это примерное соотношение времени доступа к кешу L1 относительно L2 и L3. И это точно не «x25 производительность просто потому что данные лежат рядом».
Использование указателей и вирт. методов сводит использование кеша на нет
я тоже так думал пока не узнал как работает кеш. В общем случае вы получите профит, который нивелируется парой забытых std::move. И даже в очень нагруженных циклах обычно есть простор для куда большей оптимизации, чем удаление виртуальных методов. В общем, профилировщик в помощь.

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


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


Про std::move — исходить из предположения, что программист опечатается всегда — не всегда верный подход.

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

https://habr.com/ru/post/441174/


устранение всех нарушений OOD приводит к улучшениям производительности, аналогичным преобразованиям Араса в ECS, к тому же использует меньше ОЗУ и требует меньше строк кода, чем ECS-версия!

https://habr.com/ru/post/441174/


В той статье пришли к struct RegularObject, там нет ни наследования, ни указателей. Также сравнение производительности идет с ужасной реализацией ecs, в которой каждый компонент уже имел виртуальный метод update (что даже смешно).


Я бы лично назвал получившийся в той статье код не ООП, а data driven development. Это что то между ECS и ООП.


Статья просто говорит, что композицией можно добиться результатов схожих с ecs (в одном конкретном примере при неизменных компонентах). Впрочем, если так сделать — не будет гибкости при разработке, в комментариях к статье говорят про минусы такого подхода по сравнению с более «правильной» реализацией ecs.


Почему "правильный" ecs может быть лучше ООП и data driven development из той статьи говорит статья от автора entt, ее русскоязычная версия https://m.habr.com/en/post/490500/


Замечу, что я не представляю как переписать проблему с транзакциями и фильтрацией в data driven development стиле, но легко могу реализовать с использованием готовой ecs библиотеки, вроде entt (мой код выше)

Почему "правильный" ecs может быть лучше ООП и data driven development из той статьи говорит статья от автора entt, ее русскоязычная версия https://m.habr.com/en/post/490500/

Позвольте ответить вам в вашем же стиле: я лично не увидел ни в одной из статей, на которые вы ссылались, ни одного вменяемого объяснения преимуществ ECS. Да чего уж там, ни одного вменяемого объяснения того, что же такое ECS.


Акцент на "я лично".


Есть ли где-то нормальное описание по типу "было вот так с использованием ООП, а затем стало вот так с использованием ECS"? И, желательно, чтобы вариант ООП не был изначально кривым и пессимизированным.

Что вы понимаете под «обо всех вариантах сущности»? Компонент обычно представлен в одном варианте, без наследования.
вот например задача из этой статьи. Как вы собрались реализовывать фильтр, не знающий о транзакциях, с которыми он будет работать?
т.к. идет в разрез с литературой, что я цитировал ранее.
я в отличие от вас ничего не утверждал, лишь оспаривал однозначность ваших утверждений, аргументируя это тем, что оценка производительности без замеров бесполезна.
Про std::move — исходить из предположения, что программист опечатается всегда — не всегда верный подход.
я говорил про «пару забытых std::move», это точно не «всегда», и даже не «часто», а скорее «изредка». А на практике я натыкаюсь на забытый мув чуть чаще чем «изредка».

Пример "было вот так с использованием ООП, а затем стало вот так с использованием ECS"


https://leatherbee.org/index.php/2019/09/12/ecs-1-inheritance-vs-composition-and-ecs-background/


OOP — "derive Hammerdwarf and Axedwarf from Dwarf and Swordgoblin from Goblin."
ECS — "The bottom six components on both Axedwarf and Axegoblin represent the components that all humanoid NPCs share.


Now it’s easy to reuse all the common code between Axedwarf and Axegoblin. If I want to change an entity while the game is running, it’s a simple as removing and adding components. I can now remove the Axe component from my Axedwarf and give him a Spear component, and everything runs fine."


Конечно Axedwarf плохое ООП и можно иначе. Но ecs защитит от подобного плохого ООП, даже если Вы лично пишете хорошее ООП (что важно для командной работы).


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


Я однажды решил, что ecs достаточно полезный подход для того, чтобы поискать и изучить статьи, видео и книги на эту тему. Результат — сейчас я считаю ecs самым красивым решением многих проблем разработки ПО (из известных мне решений).


Постепенно простота ecs начинает нравится и уже не хочешь думать о «допустим ли тут pattern visitor» или «виртуальные методы с шаблонами, которые не поймет очередной джуниор в команде», а хочешь сразу использовать ecs.


Конечно, (как всегда) лучше поискать разную литературу на тему, сравнить разные библиотеки ecs. Кому-то нравится ecs в смеси с ООП с логикой в компонентах, а кому-то (как мне) больше подходят компоненты только с данными. В общем, в интернете много хороших и не очень подходов к ecs в зависимости от решаемых задач.

Конечно Axedwarf плохое ООП и можно иначе.

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


Но ecs защитит от подобного плохого ООП, даже если Вы лично пишете хорошее ООП (что важно для командной работы).

Другими словами, острый нож плох потому, что он острый и им легко порезаться?


Результат — сейчас я считаю ecs самым красивым решением многих проблем разработки ПО (из известных мне решений).

Хочу спросить, вы игростроением занимаетесь?


Постепенно простота ecs начинает нравится и уже не хочешь думать о «допустим ли тут pattern visitor» или «виртуальные методы с шаблонами, которые не поймет очередной джуниор в команде», а хочешь сразу использовать ecs.

ИМХО, объектно-ориентированный подход (именно подход, который сочетает в себе ОО анализ, ОО проектирование и, собственно, ОО программирование) хорошо работает в случаях, когда:


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

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


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

Если "вы вынуждены строить модели взаимодействия сущностей не имея четкого представления о том, что из себя будут эти сущности представлять"


  • разве ecs плохо работает в такой ситуации? Например, транзакцией можно считать все, что имеет компонент transaction. А какие компоненты дополнительно будут эти сущности предоставлять не важно.

«Вопрос о расширении набора реализаций сущностей, вероятно, даже и не стоит» — ecs лучше ООП если нужно добавить новые сущности или изменить сущ-ие, серия статей и про это. Аналогично и с «добавлять новые реализации уже определенных ранее интерфейсов не меняя ничего в уже существующем коде»

разве ecs плохо работает в такой ситуации? Например, транзакцией можно считать все, что имеет компонент transaction

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


В случае ООП мы можем смоделировать транзакцию как сущность, например, с такими свойствами:


class logger {...};
enum class trx_status {...};

class transaction {
public:
  virtual trx_status() const = 0;
  virtual rollback() = 0;
  virtual retry() = 0;
  virtual describe(logger &) = 0;
};

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


При этом мы вообще понятия не имеем о том, что из себя будут представлять транзакции и каким образом выполняются их методы status/rollback/retry/describe.


ecs лучше ООП если нужно добавить новые сущности или изменить сущ-ие, серия статей и про это

Это ваше личное и бездоказательное оценочное суждение, разбивающееся о суровую реальность. Даже в этом обсуждении уже было показано, что в ECS мы имеем чистой воды динамическую типизацию, в которой разработчик никак не защищен от попытки получить компонент, которого нет. И проявится это только в run-time. Одно это уже ставит жирный крест на "ecs лучше ООП".


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

Позволю себе усомниться. Выше был показан некий интерфейс transaction и описана процедура, которая обрабатывает список транзакций с этим интерфейсом. Через 5 лет после появления этого интерфейса можно будет добавить еще одну реализацию, которая, скажем, реализует межбанковскую распределенную транзакцию посредством блокчейна. Но процедура обработки списка транзакций все равно будет работать.


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

«компонент transaction» — например, может содержать данные, которые есть у любой транзакции. В вашем примере это status. Остальные компоненты могут предоставлять опциональные данные.


"разработчик никак не защищен от попытки получить компонент, которого нет" — можно создать view который гарантирует наличие компонента. Да, это будет с проверками в runtime. Да, можно опечататься (как и без ecs) и вернуть пустой view. Вам не нравится именно вероятность опечатки при создании сущности из отдельных компонентов. Я не вижу в этом большой проблемы (достаточно избегать опечаток и писать тесты).


Одно это уже ставит жирный крест на "ecs лучше ООП". — говорилось «лучше» именно про легкость изменения логики. Лучше во всем конечно не может быть. Также уточню, что сравнение идет с ООП подходом, в котором есть иерархия (runtime полиморфизм).


Представим, что фильтр — комбинация из тысяч настроек, которые задает пользователь в runtime. Под ООП походом я подразумеваю код с объектами вида FilterNameAgeRaceDepositGender (отдельный объект под настройки). Разве ecs хуже в таком случае? Динамическая типизация здесь сама напрашивается.


ECS лучше если нужно изменять код… «Это ваше личное и бездоказательное оценочное суждение, разбивающееся о суровую реальность»
Это не только мое личное мнение. Немало статей говорят про гибкость ECS к изменениям.
Advantage of ECS
Here are advantages of using ECS in programming:


You can create shorter and less complicated code.
Very flexible Emergent behaviour.
It provides an architecture for 3D and VR development.
Enables scripting by behavior by non-techies.
It helps you to divide your monolithic classes.
Easy refuse and compossibility.
An easy option for unit testing and mocking.
It allows you to build a VR application in terms of complexity.
Substitute components with mocked components at runtime.
It helps you to extend new features.
ECS is a friendly method for parallel-processing and multi-threading,
Helps you to separate data from functions that act on it
Provide better flexibility when defining objects by mixing with reusable parts.
It offers a clean design using the decoupling, encapsulation, modularization, reusability methods.
https://www.guru99.com/entity-component-system.html


Вы просили пример сравнения с хорошим ООП кодом:
https://adventures.michaelfbryan.com/posts/ecs-outside-of-games/


ECS также дает нам возможность написать процедуру, которая берет на вход список транзакций, а возвращает три списка: в первом будут завершенные транзакции если они имеют тег CompletedTransaction и т.д. (сущность может быть любой, главное чтобы имела нужный тег или компонент)


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


«Как я понимаю, аналогичная процедура для ECS должна будет модифицирироваться каждый раз при добавлении нового типа транзакции» — нет, не должна. Например, если новый тип транзакции (т.е. добавился опциональный компонент IsBlockchain) фильтруется по уже имеющейся логике, например, по компоненту SenderName (иначе мы получаем совершенно новый тип фильтра и разницы с ООП нет — везде нужна правка кода).
ecs код легко разбивается на отдельные системы, которые могут взаимодействовать, например, через добавление меток к данным после обработки (вида TransactionCompleted)
Даже метод прохода по списку транзакий (где вызов фильтрации) легко разбивается на отдельные случаи (или несколько общих случаев, как захотите)

«компонент transaction» — например, может содержать данные, которые есть у любой транзакции. В вашем примере это status.

Ну вот на этом сразу и стоп. Вы почему-то решили, что status — это зафиксированное значение. Тогда как оно может быть вычисляемым. И на этом, как я понимаю, на ECS ставится крест.


Да и извините, что не уточнил выше (я это подразумевал, но не описал явно). Процедура, про которую шла речь, не фильтрует транзакции. Она пытается их выполнить, если они еще не выполнены. Т.е. идет по исходному списку, если транзакция не выполнена, то предпринимается попытка ее выполнить (вызывается retry). Если выполнилась, то попадает в список завершенных, если оказалась в состоянии "выполняется", то идет во второй, если результат неудачный, то идет в третий.


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

«status — это зафиксированное значение. Тогда как оно может быть вычисляемым. И на этом, как я понимаю, на ECS ставится крест.»
Как раз нет, создается отдельная система, которая обновляет компонент status.


Да, порядок выполнения систем (вычисления логики) в ecs нужно строго контролировать. Для кого то это минус такого подхода.


ECS и традиционный подход могут решать одни и те же задачи, просто по разному. Нельзя сказать, что ECS не может что то что можно сделать там.


«Если выполнилась, то попадает в список завершенных, если оказалась в состоянии "выполняется", то идет во второй, если результат неудачный, то идет в третий. В ECS подходе чтобы сделать это вам придется знать про все существующие типы транзакций.» — логику обработки транзакций разделяем на отдельные системы, в строгом порядке следования. Каждая система по завершении добавляет определенные метки или компоненты к сущностям. Следующая система работает только с теми, сущностями, которые специально помечены. При этом каждая из систем не знает про все виды сущностей т.е. берет только интересный ей компонент, игнорируя другие компоненты сущности (даже не подключая hpp этих компонентов).


По завершении обработки всех систем делаем новую систему, которая создаст три списка на основании наличия компонентов и т.д.


В случае игры системы обычно выполняются в цикле, периодически. Но можно их вызывать и не в цикле. Можно и по условию «обработана ли транзакция» и т.д. В нашем случае функция должна вернуть три списка, значит в функции вызываются обрабатывающие транзакции системы (можно и не вызывать их, тогда результат пред. обработки вернет), затем результат обработки анализируется. Если код асинхронный для созранения транзакций, то можно вернуть результат в promise. В общем, проблемы не вижу, способов решения много.


Как я понял «знать про все существующие типы транзакций» подразумевает знать про все возможные компоненты которые можно добавить к сущности транзакции… Это в частности и позволяет избегать ecs, не так ли?

Как раз нет, создается отдельная система, которая обновляет компонент status.

Это еще что за "система"?


В ООП есть сущность "транзакция". У нее вызывается свойство "status" и все. Как именно реализуется сущность нам не важно. И все.


В ECS, как я понимаю, может быть некая структура (компонент) "транзакция" с полем "status" внутри. Но откуда возьмется там это значение? Нам же нужно выполнить какую-то операцию для вычисления status-а. А эта операция может быть сильно разной для разных типов транзакций. Значит, чтобы узнать статус, нам нужно знать про все возможные транзакции.


По крайней мере ECS я понимаю так.

Это еще что за «система»?


В случае entt — это entt::view или entt::group.

Система (возможно не одна) и выполнит какую-то операцию для вычисления status-а.

В ООП есть сущность «транзакция». В ECS нет сущности «транзакция», есть id сущности с набором связанных компонент (композиция), некоторые из которых (обычно в сочетании) мы условно считаем «транзакцией» и можем как-то обработать.

Но обработать при этом можно любой набор компонент изолированно.

Система (возможно не одна) должна выполнить какую-то операцию для вычисления status-а. Но ей не нужно «знать про все возможные транзакции», тем более понятия транзакции нет.

Одна система обновит компонент status только если есть TransactionTag (любая транзакция)

Другая система после обновит компонент status только если есть одновременно и TransactionTag и DepositTag и NameComponent (Deposit транзакция у которой есть Name)

Другая система после обновит компонент status только если есть одновременно SenderComponent и MoneyComponent (заметьте, про TransactionTag или DepositTag вообще не известно этой системе)

Я вот уже теряюсь в догадках — это я уже совсем тупой стал с годами или это вы никак не можете объяснить что к чему.


и можем как-то обработать

Вот это и непонятно. "Можем", "как-то" и "обработать". Все вместе это что означает?


Это означает, что нам нужно иметь некий deposit_processor, который умеет обрабатывать транзакции по внесению средств на счет. Затем нам нужен некий money_transfer_processor, который производит транзакции-переводы. Затем нам нужен некий currency_exchange_processor, который производит транзакции по обмену валюты.


Нам все эти processor-ы нужно как-то выстроить в очередь и затем каждому из processor-ов нужно скармливать коллекцию сущностей-транзакций. А каждый processor будет брать очередную транзакцию из этой коллекции, проверять, соответствует ли она нужному типу, если соответствует, то обрабатывает.


Так получается?

«проверять, соответствует ли она нужному типу»


— entt::view даст на вход processor-y т.е. системе уже проверенный на соответствие тип (точнее массив со всеми типами, которые должны попасть в entt::view). Выбор нужных компонентов произойдет быстро, entt использует sparse set, на сайте автора entt есть подробности про реализацию entt.


У нас нет отдельного типа транзакции т.е. обьекта "транзакцияОбмена" или "транзакцияПополнения", вместо них есть компоненты или теги вида «ЛюбаяТранзакция», «Обмен», «Пополнение», «IdТранзакции», «Имя», «Сумма» и т.д.


каждому из processor-ов т.е. каждой системе не нужно скармливать коллекцию сущностей-транзакций (по одной за раз). Каждый processor это отдельный цикл.


Я ранее приводил пример кода с entt. Там вся фильтрация смешана в один цикл. Пример был простейшим чтобы сравнить с do_casewise (демонстрировалось, что логика сводится к набору условий без создания «сложной» архитектуры вроде do_casewise). Если этот цикл разделить на несколько используя разные view, то код станет еще лучше и быстрее. В частности эти части логики станут полностью отдельными, станут даже в разных файлах и только с подключением нужных компонентов в отдельном файле.


Добавление новой логики сведется к созданию cpp файла, подключению в нем только нужных компонентов (отдельных составных частей воображаемой транзакции), обработки этих компонентов (возможно с добавлением новых компонентов или меток). От созданной системы «наружу» понадобится только функция вида processMyTitle(entt::registry&), которую нужно добавить в очередь обработчиков


Для примера есть серия видео уроков с использованием entt https://youtu.be/D4hz0wEB978
Intro to EnTT (ECS) | Game Engine series

— entt::view даст на вход processor-y т.е. системе уже проверенный на соответствие тип (точнее массив со всеми типами, которые должны попасть в entt::view).

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


В ООП понятно как это сделать.


В ECS я вижу два варианта:


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


  2. Либо нужно вести отдельные списки по каждому типу транзакции. И тогда каждый processor будет получать свой список на обработку.



Какой из этих подходов подразумевается в ECS? Или какой-то другой?


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

Выбор нужных компонентов произойдет быстро, entt использует sparse set, на сайте автора entt есть подробности про реализацию entt.
мм. Виртуальные вызовы говорите дорогие, да?
Для примера есть серия видео уроков с использованием entt youtu.be/D4hz0wEB978
отличный материал ага. Чел 10 минут показывает как установить header-only библиотеку с гита, еще 10 минут воюет с дублированием неявных конструкторов и ненужным оператором преобразования, и за последние 10 минут показывает беглый обзор из которого вообще не понятно зачем всё это

Также про то, что у транзакции теперь есть виртуальный метод «virtual retry() = 0;»
Проблема еще в личном стиле разработки ПО, кому как нравится.
Мне, например, нравится разделять данные и логику т.е. DOD подход (в частности, ECS) я предпочитаю ООП иерархиям.
Заменим транзакцию на пирожок для наглядности.
Вы добавли в сущность пирожок метод «купить», преполагая, что все пирожки на продажу.
Это не плохо и не хорошо, это зависит от решаемой задачи и стиля кода.
Я бы лично не стал добавлять в каждую сущность метод «virtual describe(logger &) = 0;» просто потому, что в моей программе все объекты должны логироваться.

Мне, например, нравится разделять данные и логику т.е. DOD подход (в частности, ECS) я предпочитаю ООП иерархиям.

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


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

Я не добавлял. И не вижу смысла добавлять такой метод.

При этом мы вообще понятия не имеем о том, что из себя будут представлять транзакции и каким образом выполняются их методы status/rollback/retry/describe.

Извините, а вы пост читали? Обсуждаемая тут программа не исполняет транзакции, а мониторит их. (Ну и, как я подозреваю, имеются в виду вообще не транзакции СУБД)

Так-то в статье и про ECS и слова не было. Здесь же я пытаюсь у blockspacer выяснить чем, по его мнению, ECS лучше ООП (а заодно и какой смысл в ECS вообще). Поэтому тема разговора уже отошла от фильтров из статьи.


И да, я так же не имел в виду транзакции СУБД. Скорее транзакции платежных систем (которые могут быть длительными, могут откатываться, могут докатываться, могут повторяться (в том числе и для проведения рекурентных платежей)).

Чем лучше?
Например, тем, что только предложенный ecs способен оптимально (оптимально алгоритмически, даже речь не про кеш процессора) решить задачу из статьи (если выбирать из предложенных ранее примеров кода с runtime полимормизмом). Достаточно сделать для каждого фильтра свой отдельный entt::view.

Все остальные примеры кода с runtime полимормизмом или избегали демонстрации «цикла фильтрации» или бежали в одном цикле по всем видам транзакций (со временем, возможно, тысячам типов транзакций), даже если фильтру нужна всего одна транзакция из миллионов (например, тип транзакции только с полями money и senderId).

Также вспомните обсуждение легкости модификации ecs в сравнении с традиционным подходом. Как сильно нужно изменить архитектуру чтобы начать оптимально решать задачу из статьи? (на практике, скорее всего, проблему производительности увидят когда код с традиционным подходом существует долго и оброс связанным функционалом). Скорее всего в кач-ве решения откажутся от runtime полиморфизма (перейдут на DOD, data-oriented design, подход — DOD и ECS уже не сравниваем) сделав для каждого фильтра свой цикл. Но тогда какой смысл в runtime полиморфизме останется?

habr.com/en/company/piter/blog/524882/#comment_22276078

Цитирую:
Также как в предложенном правильном ООП подходе оптимально фильтровать объекты (заметьте, статья о фильтрации)? Допустим, у нас миллионы объектов ITransaction. У ITransaction могут быть тысячи частей (name, money и т.д.). Проблема: мы хотим эффективно отфильтровать транзакции по name и money одновременно. При текущем подходе мы должны просмотреть миллионы транзакций и выбрать только те, у которых есть нужные части. Т.е. в идеале наш фильтр должен обработать только те транзакции у которых задан optional (тривиальный случай) или указатель (второй случай). Т.е. нам не нужны миллионы объектов ITransaction, а лишь несколько. Как эту проблему решить? Хранить массив указателей для каждой части отдельно (если optional задан), а также для комбинаций? А если нужно изменить значение в массиве и оптимизировать расход памяти, то использовать sparse set и т.д.? Или придется уйти от предложенного правильного ООП подхода?

Простите, но лично я уже устал от нескольких вещей:


  1. Вашей неспособности внятно рассказать о ECS и о способах решения задач посредством ECS.
  2. Вашей привычки давать ссылки на какие-то материалы сомнительного качества, после знакомства с которыми появляется еще больше вопросов. Наглядный пример — это ваша ссылка на статью про использование ECS в Rust-е. В которой автор сначала написал про проблемы ООП, а затем привел фрагменты кода, в которых вообще нет ни одного решения ни одной из описанных им же проблем.
  3. Вашего (скажем мягко) странного представления об ООП.

Так что не вижу смысла продолжать спор слепого с глухим. Вы считаете, что ECS лучше? Да нет проблем. Вам ECS помогает писать код и решать ваши задачи (о которых вы боитесь рассказать)? Да это же просто отлично.


Но лично мне вы не смогли донести ни преимуществ ECS, ни дать вменяемых ответов на поставленные вопросы.


PS. Статья не о фильтрации на самом-то дело.

«вы не смогли донести ни преимуществ ECS» — например, алгоритмическая оптимальность (сравнивая озвученные примеры) точно является преимуществом. Разве это не достаточная причина?

Пример кода с ECS, который алгоритмически оптимален:

#include <entt/entt.hpp>
#include <entt/entity/registry.hpp>
#include <entt/entity/helper.hpp>
#include <entt/entity/entity.hpp>
#include <entt/core/type_info.hpp>
#include <entt/core/type_traits.hpp>
#include <entt/entity/group.hpp>

#include <boost/algorithm/string.hpp>
#include <boost/current_function.hpp>

#include <cstddef>
#include <cstdint>
#include <sstream>
#include <string>
#include <type_traits>

// на практике проверят валидность данных иначе
const std::string kInvalidStr{"INVALID..."};

struct DebugName
{
  std::string debugName{kInvalidStr};
};

// ECS компонент - данные без логики
struct AccountNumber
{
  int accountId{-1};
};

// ECS компонент - данные без логики
struct AccountName
{
  std::string accountName{kInvalidStr};
};

// ECS компонент - данные без логики
struct MoneyAmount
{
  int moneyAmount{-1};
};

// Метка для любого перевода
using TransactionTag
  = entt::tag<"TransactionTag"_hs>;

// Метка для перевода, который успешно прошел через все фильтры
using FilteredTransactionTag
  = entt::tag<"FilteredTransactionTag"_hs>;

// Метка для перевода, который не прошел через некоторый фильтр
using WrongTransactionTag
  = entt::tag<"WrongTransactionTag"_hs>;

// Метка для входящего перевода
using DepositTag
  = entt::tag<"DepositTag"_hs>;

// Метка для исходящего перевода
using TransferTag
  = entt::tag<"TransferTag"_hs>;

// пометит `FilteredTransactionTag` переводы,
// которые успешно прошли через все фильтры
// пометит `WrongTransactionTag` переводы,
// которые не прошли через некоторые фильтр
using FilterAndMarkFn = std::function<void(entt::registry&)>;

// входные транзакции для примера,
// на практике могут поступать в любой момент
void createTestTransactions(entt::registry& registry)
{
  {
    entt::entity entity
      = registry.create();
    registry.emplace<DebugName>(entity, "Transaction1");
    registry.emplace<TransactionTag>(entity);
    registry.emplace<DepositTag>(entity);
    registry.emplace<MoneyAmount>(entity, MoneyAmount{130});
    registry.emplace<AccountNumber>(entity, AccountNumber{1});
    registry.emplace<AccountName>(entity, AccountName{"Peter"});
  }

  {
    entt::entity entity
      = registry.create();
    registry.emplace<DebugName>(entity, "Transaction2");
    registry.emplace<TransactionTag>(entity);
    registry.emplace<TransferTag>(entity);
    registry.emplace<MoneyAmount>(entity, MoneyAmount{101});
    registry.emplace<AccountNumber>(entity, AccountNumber{2});
    registry.emplace<AccountName>(entity, AccountName{"habrahabr"});
  }
}

void markGoodTransaction(
  entt::registry& registry
  , entt::entity entity
  , const char* debugMsg)
{
  DCHECK(!registry.has<WrongTransactionTag>(entity));

  // Метка для перевода, который успешно прошел через все фильтры
  registry.emplace_or_replace<FilteredTransactionTag>(entity);

  // debug log
  {
    DCHECK(registry.has<DebugName>(entity));
    DLOG(INFO)
      << "entity: "
      << registry.get<DebugName>(entity).debugName
      << " passed filter: "
      << debugMsg;
  }
}

void markBadTransaction(
  entt::registry& registry
  , entt::entity entity
  , const char* debugMsg)
{
  registry.remove_if_exists<FilteredTransactionTag>(entity);
  registry.emplace_or_replace<WrongTransactionTag>(entity);

  // debug log
  {
    DCHECK(registry.has<DebugName>(entity));
    DLOG(INFO)
      << "entity: "
      << registry.get<DebugName>(entity).debugName
      << " failed filter: "
      << debugMsg;
  }
}

void runMoneyAmountLessFilter(
  entt::registry& registry)
{
  auto view
    = registry.view<
        TransactionTag
        , MoneyAmount
        , DebugName
        , DepositTag // отфильтруются только Deposit транзакции
      >(entt::exclude<
          WrongTransactionTag
        >);

  for(const entt::entity& transactionEntity: view)
  {
    // debug проверки входных данных...
    {
      DCHECK(registry.valid(transactionEntity));
      DCHECK(view.get<DebugName>(transactionEntity).debugName != kInvalidStr);
    }

    MoneyAmount& moneyAmount
      = view.get<MoneyAmount>(transactionEntity);

    // тут могут быть доп. проверки входных данных...
    if(moneyAmount.moneyAmount < 0)
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    const int kTestAmount = 42;
    if(moneyAmount.moneyAmount >= kTestAmount)
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    markGoodTransaction(
      std::ref(registry)
      , transactionEntity
      , BOOST_CURRENT_FUNCTION);
  }
}

void runMoneyAmountGreaterFilter(
  entt::registry& registry)
{
  auto view
    = registry.view<
        TransactionTag
        , MoneyAmount
        , DebugName
      >(entt::exclude<
          WrongTransactionTag
        , TransferTag // отфильтруются только не Transfer транзакции
        >);

  for(const entt::entity& transactionEntity: view)
  {
    // debug проверки входных данных...
    {
      DCHECK(registry.valid(transactionEntity));
      DCHECK(view.get<DebugName>(transactionEntity).debugName != kInvalidStr);
    }

    MoneyAmount& moneyAmount
      = view.get<MoneyAmount>(transactionEntity);

    // тут могут быть доп. проверки входных данных...
    if(moneyAmount.moneyAmount < 0)
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    const int kTestAmount = 42;

    if(moneyAmount.moneyAmount >= kTestAmount)
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    markGoodTransaction(
      std::ref(registry)
      , transactionEntity
      , BOOST_CURRENT_FUNCTION);
  }
}

void runNameIgnoreCaseFilter(
  entt::registry& registry)
{
  auto view
    = registry.view<
        TransactionTag
        , AccountName
        , DebugName
        , TransferTag // отфильтруются только Transfer транзакции
      >(entt::exclude<
          WrongTransactionTag
        >);

  for(const entt::entity& transactionEntity: view)
  {
    // debug проверки входных данных...
    {
      DCHECK(registry.valid(transactionEntity));
      DCHECK(view.get<DebugName>(transactionEntity).debugName != kInvalidStr);
    }

    AccountName& accountName
      = view.get<AccountName>(transactionEntity);

    // тут могут быть доп. проверки входных данных...
    if(accountName.accountName == kInvalidStr)
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    const std::string kTestName = "habrahabr";

    if(!boost::iequals(accountName.accountName, kTestName))
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    markGoodTransaction(
      std::ref(registry)
      , transactionEntity
      , BOOST_CURRENT_FUNCTION);
  }
}

void runNameCaseSensitiveFilter(
  entt::registry& registry)
{
  auto view
    = registry.view<
        TransactionTag
        , AccountName
        , DebugName
      >(entt::exclude<
          WrongTransactionTag
        >);

  for(const entt::entity& transactionEntity: view)
  {
    // debug проверки входных данных...
    {
      DCHECK(registry.valid(transactionEntity));
      DCHECK(view.get<DebugName>(transactionEntity).debugName != kInvalidStr);
    }

    AccountName& accountName
      = view.get<AccountName>(transactionEntity);

    // тут могут быть доп. проверки входных данных...
    if(accountName.accountName == kInvalidStr)
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    const std::string kTestName = "habrahabr";

    if(!boost::equals(accountName.accountName, kTestName))
    {
      markBadTransaction(
        std::ref(registry)
        , transactionEntity
        , BOOST_CURRENT_FUNCTION);
      return;
    }

    markGoodTransaction(
      std::ref(registry)
      , transactionEntity
      , BOOST_CURRENT_FUNCTION);
  }
}

// обработка входящих транзакций
void runFiltersOnTransactions(
  entt::registry& registry
  , std::vector<FilterAndMarkFn> enabledFilters)
{
  for(const FilterAndMarkFn& func: enabledFilters)
  {
    func(std::ref(registry));
  }
}

// обработка входящих транзакций
void processFilteredTransactions(
  entt::registry& registry)
{
  // получаем все транзакции, которые прошли через фильтр
  auto viewFiltered
    = registry.view<FilteredTransactionTag>();

  for(const entt::entity& entity: viewFiltered)
  {
    LOG(INFO)
        << "\n"
      << "==================="
      << "\n"
      << "entity passed through filters: "
      << registry.get<DebugName>(entity).debugName
      << "\n"
      << "===================";

    // транзакции, которые прошли через фильтр
    // можно сохранить в БД и т.д.
  }

  // получаем все транзакции, которые не прошли через фильтры
  auto viewWrong
    = registry.view<WrongTransactionTag>();

  for(const entt::entity& entity: viewWrong)
  {
    LOG(INFO)
        << "\n"
      << "==================="
      << "\n"
      << "entity failed filtering: "
      << registry.get<DebugName>(entity).debugName
      << "\n"
      << "===================";

    // транзакции, которые не прошли через фильтр
    // можно отправить тех. поддержке и т.д.
  }
}

void destroyAllTransactions(
  entt::registry& registry)
{
  // получаем все транзакции,
  // которые и прошли через фильтр и не прошли
  auto view
    = registry.view<TransactionTag>();

  // удалим все entity после обработки
  registry.destroy(view.begin(), view.end());
}

int main(int argc, char* argv[])
{
  // предметная область — "банковские транзакции."
  // необходимо фильтровать подозрительные транзакции

  // хранилище данных (ECS)
  entt::registry registry;

  createTestTransactions(std::ref(registry));

  // Каждый фильтр - отдельная часть логики, "система".
  // Оптимальное алгоритмически решение:
  // каждый фильтр имеет "свой отдельный цикл"
  // т.е. получает входные данные
  // сразу с нужным набором компонентов,
  // вместо просмотра всех возможных типов транзакций
  std::vector<FilterAndMarkFn> enabledFilters;

  enabledFilters.push_back(
    &runMoneyAmountLessFilter);

  enabledFilters.push_back(
    &runMoneyAmountGreaterFilter);

  enabledFilters.push_back(
    &runNameIgnoreCaseFilter);

  enabledFilters.push_back(
    &runNameCaseSensitiveFilter);

  // на практике обработчики
  // можно вызывать периодически
  // по мере поступления новых данных
  {
    runFiltersOnTransactions(
      std::ref(registry)
      , std::ref(enabledFilters));

    processFilteredTransactions(
        std::ref(registry));

    destroyAllTransactions(
      std::ref(registry));
  }

  return
    EXIT_SUCCESS;
}


Для того, чтобы мне внятно удалось рассказать о ECS, прошу прочитать (в частности, часть про entt::view) github.com/skypjack/entt/wiki/Crash-Course:-entity-component-system

«странного представления об ООП» — я конкретизировал «примеры кода с runtime полимормизмом» (уже даже слово ООП не использую для них)

Приведу пример для
«добавлять новые реализации уже определенных ранее интерфейсов не меняя ничего в уже существующем коде»
Но как это можно сделать в ecs (там понятие интерфейса фильтрации заменяется на набор систем с логикой фильтрации).
У нас есть логика фильтрации т.е. это интерфейс фильтрации, не так ли?
Мы хотим добавить новый фильтр.
В примере кода (выше) с entt фильтр — все, что имеет тег filter.
Мы можем добавить новый фильтр не меняя существующий код других фильтров, не так ли?


Если код примера разделить на отдельные системы с отдельными view для каждого фильтра, то, станет очевиднее изолированность частей логики (систем) в ecs. Т.е. разные фильтры (интерфейсы фильтрации) не связаны, легко изменяются и т.д.

OOP — «derive Hammerdwarf and Axedwarf from Dwarf and Swordgoblin from Goblin.»
А представляете как круто бы выглядел код, если бы это были не «Hammerdwarf, Axedwarf и Swordgoblin», а «a Humanoid with a weapon», где «with a» реализуется композицией. Да более того, даже сам автор про это пишет:
NB: Inheritance can be used inside of composition. For example, the Axe, Shortsword, Spear, and Hammer components could all inherit from Weapon.
но почему-то предпочитает умолчать что так можно было и в ООП

Если человек/дварф/эльф/всадник человек/всадник дварф и т.д. может носить тысячи видов вооружения (в разных руках), то сколько понадобится обьектов без runtime полиморфизма чтобы реализовать логику композицией? Примените шаблоны (не забываем о времени компиляции) или придете к схожему с ecs подходу?


Про ООП — я изначально сравнивал ecs с ООП из статьи (полиморфизм), а не с «ООП путем композиции», не с data driven development. Так и ecs можно назвать ООП, ведь это тоже data driven development. Например, комментарий https://m.habr.com/en/company/piter/blog/524882/comments/#comment_22271322 содержит замечательный код с композицией без runtime полиморфизма, но всем ли привычное ли это ООП или ближе к ecs, data driven development? (посмотрите на код с visitor или на код с do_casewise, неужели он по Вашему лучше? — изначально обсуждалось это)


Сравнивать ecs и data driven development не вижу смысла.

Если человек/дварф/эльф/всадник человек/всадник дварф и т.д. может носить тысячи видов вооружения (в разных руках), то сколько понадобится обьектов без runtime полиморфизма чтобы реализовать логику композицией?
Во-первых, тысяча видов вооружения в играх это как правило всего несколько классов, а всё остальное — их параметры. Во-вторых, если тысяча персонажей носит одинаковые топоры, нет смысла создавать по объекту топора на каждого персонажа, достаточно просто ссылаться на уже созданное оружие.
Примените шаблоны (не забываем о времени компиляции) или придете к схожему с ecs подходу?
ни то, ни другое здесь не понадобится
Про ООП — я изначально сравнивал ecs с ООП из статьи (полиморфизм), а не с «ООП путем композиции», не с data driven development
у вас какое-то абсолютно извращенное понимание ООП. То, что у персонажа есть оружие, ни разу не значит, что персонаж должен наследоваться от оружия. Еще раз напомню что наследование это механизм выразить отношение «является» а не «состоит из». И рекомендация «предпочитайте композицию наследованию» значит не то, что надо отказываться от ООП, а то, что если вы можете заменить наследование композицией, значит вы пытаетесь выразить отношение «состоит из», для которого не нужно использовать наследование.

Грубо говоря, если я опишу свою архитектуру как «a Humanoid, who can be Human, Orc or Elf, has a Weapon, that can be Axe, Sword or Bow», тут должно быть очевидно какие отношения выражаются наследованием, а какие — композицией
посмотрите на код с visitor или на код с do_casewise, неужели он по Вашему лучше?
этот код вообще не ООП ни разу. В ООП подходе интерфейс транзакции должен быть заведомо достаточен для любого вида фильтрации.
Сравнивать ecs и data driven development не вижу смысла.
если у вас такие же проблемы с пониманием DDD как с OOP, действительно, спорить рановато.

Про оружие
Вы предложили использовать композицию без наследования (если добавляем наследование, то вернемся к проблеме HumanWithWeaponWithWings...).
Возможно пример с оружием не лучший. Вы говорите, что Humanoid содержит все нужные данные.
Humanoid может быть и с крыльями, и с костылями, и со шляпой, и на самокате и т.д. Как я понимаю, есть класс Humanoid у него есть поля weaponId, hairID, hatId и т.д. Сколько таких id должен иметь human? Так как Humanoid не базовый класс (отказ от наследования), то он должен учесть все возможные варианты. Аналогично для orcoid, goblinoid и т.д. (хотя нет, Вы сказали, что humanoid может быть orc, значит, добавим к нему еще и raceId. А лучше массив, если смесь рас допустима)
В такой ситуации захочется использовать что то похожее на ecs, про это статья сравнивающая разные подходы к созданию объекта игрового персонажа.


Про ООП
«этот код вообще не ООП ни разу»
Если финальный вариант кода рекомендованный в статье про фильтрацию транзакций не является ООП, то что это?


OOP про иерархии обьектов, не так ли?


DOD про данные без иерархии (в частности, композиция). Данные могут быть как последовательными в памяти, так и нет. DOD не только про cache miss.


Да, вместо Data driven progamming (data driven development) мне нужно было написать data-oriented design.


Рассмотрим код из комментария чтобы понять что я не считаю ООП иерархией https://m.habr.com/en/company/piter/blog/524882/comments/#comment_22271322


Данные отделены от логики, это раз.


И фильтры, и транзакции можно рассматривать как отдельные компоненты, это два. Компоненты итерируют, компоненты перебирают в visit. (Схоже с ecs)


Я вполне могу называть такой код не OOP, а DOD.


За неимением примеров кода, предполагаю, что вариант с Humanoid подразумевал схожую архитектуру т.е. DOD, а не ООП.


И мы опять начинаем сравнивать композицию с ecs (вместо сравнения кода из статьи с do_casewise и кода с ecs)

Про оружие
Вы предложили использовать композицию без наследования
я предложил использовать композицию и наследование там, где они нужны.
В такой ситуации захочется использовать что то похожее на ecs, про это статья сравнивающая разные подходы к созданию объекта игрового персонажа.
а если я поставлю задачу правильно и не захочется?
Если финальный вариант кода рекомендованный в статье про фильтрацию транзакций не является ООП, то что это?
этот код диспатчит транзакции по конкретным типам вместо того, чтобы обращаться к ним через единообразный интерфейс. Какое ж это ООП?
OOP про иерархии обьектов, не так ли?
нет, ООП это про инкапсуляцию данных и логики обработки этих данных вместе, в виде т.н. объектов, работать с которыми можно только в соответствии с их публичным интерфейсом.
Рассмотрим код из комментария чтобы понять что я не считаю ООП иерархией
автор тоже не считает это ООП
За неимением примеров кода, предполагаю, что вариант с Humanoid подразумевал схожую архитектуру т.е. DOD, а не ООП.
нет. Вы действительно не способны предположить какой может быть архитектура такого простейшего примера в ООП?
И мы опять начинаем сравнивать композицию с ecs (вместо сравнения кода из статьи с do_casewise и кода с ecs)
я уже объяснил почему это не ООП. Вы пытаетесь доказать что ECS лучше ООП сравнивая пример кода на ECS и на чем угодно кроме ООП. Да даже автор оригинальной статьи не утверждает что это ООП, он лишь показывает как можно избавиться от наследования реализации.

Ну ладно, давайте попробуем продемонстрировать на примере. Я попытаюсь сделать очень чистый и каноничный ООП, максимально в соответствии с его заветами и без попыток срезать углы. В первой итерации мы определяем лишь два интерфейса, для транзакции и фильтра, как-то так:
class ITransaction {
public:
    virtual ~ITransaction() = default;
    virtual AccountNumber account() const noexcept = 0;
    virtual std::string_view name_on_account() const noexcept = 0;
    virtual Money amount() const noexcept = 0;
};

class IFilter {
public:
    virtual ~IFilter() = default;
    bool matches(const ITransaction& txn) const noexcept = 0;
};

Здесь IFilter знает только про ITransaction, который в свою очередь вообще ни о чем не знает, а весь остальной код кроме фабричных методов можно прятать в .cpp. На какой-то итерации разработки нас настигает та же проблема что и в статье: у некоторых транзакций есть имя клиента, и оно должно совпадать с держателем аккаунта. Придется расширять интерфейс ITransaction, навскидку могу предложить пару способов:
1. Тривиальный
суть: опциональное поле представляется в коде как опциональное значение. Вроде всё просто:
class ITransaction {
public:
    ...
    virtual std::optional<std::string_view> name_of_customer() const noexcept = 0;
};

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

class NamedTransactionFilter final : public IFilter { // Запрещаем наследование реализации
public:
    bool matches(const ITransaction& txn) const noexcept override {
        auto name = txn.name_of_customer();
        return name && (name == txn.name_on_account());
    }
};

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

2. Введение расширений интерфейса
Суть: некоторые варианты наших сущностей могут иметь расширенный интерфейс, в то время как другие — нет. Попробуем именно это и сделать:
class ITransaction {
public:
    ...
    virtual class INamedTransaction* named_interface() const noexcept = 0;
};

class INamedTransaction {
public:
    virtual ~INamedTransaction() = default;
    virtual std::string_view name_of_customer() const noexcept = 0;
};

Очевидно что обычные транзакции будут возвращать nullptr, а NamedTransaction будет еще и реализовывать именованный интерфейс:
class NamedTransaction final : public ITransaction, public INamedTransaction {
public:
    ...
    INamedTransaction* named_extension() const noexcept override {
        return this;
    }

    std::string_view name_of_customer() const noexcept override {
        return _name;
    }
};

// Либо слегка по-другому, через композицию:
class NamedTransaction final : public ITransaction {
public:
    INamedTransaction* named_extension() const noexcept override {
        return &_namedData;
    }
private:
    struct NamedTransactionData final : public INamedTransaction {
        std::string _name;

        std::string_view name_of_customer() const noexcept override {
            return _name;
        }
    };

    NamedTransactionData _namedData;
};

// Фильтр конечно же тривиален:
class NamedFilter final : public IFilter {
public:
    bool matches(const ITransaction& txn) const noexcept override {
        auto named = txn.named_extension();
        return named && (named->name_of_customer() == txn.name_on_account());
    }
};

Относительно решения в статье такое как минимум не нарушает принцип инверсии зависимостей и действует в соответствии с принципом разделения интерфейсов. А еще виртуальный вызов хоть и не бесплатен, но всё еще дешевле dynamic_cast'а. А еще сущности, не знающие про INamedTransaction, всё равно не смогут им воспользоваться.

По ситуации можно еще пару способов придумать, главное — следовать очень простым рекомендациям и лишний раз не переусложнять код. Плюсы — абсолютная изолированность компонентов, друг от друга. Минусы — виртуальные вызовы и выделение объектов в куче. Хотя второе и можно обойти с помощью например такого трюка, с этим надо быть осторожным по простой причине (она, кстати, актуальна и для типов-сумм, в частности std::variant): если у вас одна транзакция сильно жирнее других, вы будете тратить на маленькие столько же памяти, сколько и на жирную.

Остается ровно один нерешенный вопрос переиспользования кода. Например, если все транзакции включают один и тот же набор данных и операций над ними, их можно вынести в CommonTransactionData и включать его в реализации транзакций через композицию, будет лишь немного бойлерплейта в обертках. Иногда общие данные/логику выносят в абстрактный класс, как-то так:
class AbstractTransaction : public ITransaction {
public:
    AccountNumber account() const noexcept override;
    std::string name_on_account() const noexcept override;
    Money amount() const noexcept override;
};

class SomeTransaction final : public AbstractTransaction { ... };
class AnotherTransaction final : public AbstractTransaction { ... };
...
Так делать не советую — вы не сэкономите столько на бойлерплейте, насколько усложните чтение кода. Всяких «mixin'ов через CRTP» и прочих похожих трюков рекомендую избегать. Вообще попытки срезать углы в ООП редко хорошо заканчиваются. Хотите переиспользовать данные — композиция, хотите переиспользовать логику — можете выносить её в свободные функции, главное — не нарушать принципы взаимодействия объектов.

Прежде всего, спасибо за пример кода.


«Humanoid содержит все нужные данные.» заменим на ITransaction. Тогда в статье (где было про AxedDwarf) говорят о проблемах:


  1. "мы расширяем интерфейс для всех вариантов, хотя пользоваться им будут не все виды транзакций"
    Менять интерфейс нужно и в тривиальном, и в остальных случаях.
    Да, в статье другой «ужасный ООП», но проблема остается (изменение слишком многих сущностей перетекает в изменение слишком многих частей в одной сущности)


  2. ITransaction может иметь очень много частей. Т.е. код вида «virtual class INamedTransaction* named_interface() const noexcept = 0;» в ITransaction будет занимать десятки тысяч строк кода. (Игровой персонаж из серии статей может быть очень сложным по структуре, хоть всю структуру и не перечислили).
    Кому то это может и не понравиться, а кто то только так и пишет код с файлами в десятки тысяч строк (индивидуальный стиль кода у всех разный).


  3. Заметьте, что в не тривиальном случае «named && (named->name_of_customer() == txn.name_on_account()» имеет проблему схожую с ecs, которую назвали проблемой динамической типизации в runtime. Часть compile time проверок перешла в runtime, как и с ecs. Т.е. мы фактически проверяем есть ли у сущности компонент.


  4. Хоть статья не про производительность, но DOD часто выбирают еще из за избежания проблемы «еще виртуальный вызов хоть и не бесплатен, но всё еще дешевле dynamic_cast'а».
    Также для обработки сущностей у которых есть часть name нужно проверить все сущности, что не оптимально (хотя можно решить эту проблему храня указатели на такие сущности отдельно, но ecs это из коробки решает).


  5. Хоть статья и не про легкость архитектуры, но ecs позволяет не задумываться о «Так делать не советую — вы не сэкономите столько на бойлерплейте». Т.е. следовать очень простым рекомендациям и лишний раз не переусложнять код — само сабой разумеющееся для ecs. Но многие другие архитектуры страдают от проблемы «нож слишком острый и им легко перезаться» т.е. проблем вида «mixin'ов через CRTP». Например, для команд из тысяч людей с разным уровнем опыта это может быть очень важно.


  6. Хоть статья и не про разделение данных и логики, но для меня DOD стиль кода больше подходит (и возможно для многих других). Подробнее тут https://m.habr.com/en/company/piter/blog/524882/comments/#comment_22274100



Т.е. в ITransaction десятки тысяч частей на много строк кода, а используем, например, только пару частей во многих объектах (зависит от постоянно меняющегося дизайна игры)


Т.е. в статье про AxedDwarf говорят, что традиционный подход имеет некоторые проблемы. И ecs (и не только) позволяет решить некоторые проблемы. Вы с этим не согласны?


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


Конечно новый подход не значит, что новые проблемы не появятся (отлично решает одни проблемы, но не идеален). https://stackoverflow.com/questions/58596897/what-are-the-disadvantages-of-the-ecs-entity-component-system-architectural-pa

Менять интерфейс нужно и в тривиальном, и в остальных случаях.
Да, в статье другой «ужасный ООП», но проблема остается (изменение слишком многих сущностей перетекает в изменение слишком многих частей в одной сущности)
у вас в любом случае появился объект, который не вписывается в старую логику обработки, и вам в любом случае придется её дорабатывать. При этом вы еще и хотите по максимуму переиспользовать уже существующую логику. Те или иные компромиссы будут везде. Например, в ECS будет упячка если у конкретного вида сущности меняется логика обработки одного из её компонентов — придется вводить новый компонент.
ITransaction может иметь очень много частей. Т.е. код вида «virtual class INamedTransaction* named_interface() const noexcept = 0;» в ITransaction будет занимать десятки тысяч строк кода
поэтому мы прибегаем к принципу разделения интерфейсов — делим большой интерфейс объекта на мелкие, так, чтобы различные участки кода использовали только те методы, которые им нужны.
Заметьте, что в не тривиальном случае «named && (named->name_of_customer() == txn.name_on_account()» имеет проблему схожую с ecs, которую назвали проблемой динамической типизации в runtime. Часть compile time проверок перешла в runtime, как и с ecs. Т.е. мы фактически проверяем есть ли у сущности компонент.
нет, мы проверяем поддерживает ли сущность интерфейс. Это несколько разные вещи — у нас может быть несколько разных видов транзакций, поддерживающих этот интерфейс, но по-разному его реализующих. И да, можете уточнить, дорого ли проверить наличие у сущности компонента?
Хоть статья не про производительность, но DOD часто выбирают еще из за избежания проблемы «еще виртуальный вызов хоть и не бесплатен, но всё еще дешевле dynamic_cast'а».
мне кажется вы преувеличиваете стоимость виртуальных вызовов и недооцениваете накладные расходы от ECS
Также для обработки сущностей у которых есть часть name нужно проверить все сущности, что не оптимально (хотя можно решить эту проблему храня указатели на такие сущности отдельно, но ecs это из коробки решает).
то есть чтобы обработать одну сущность мы должны лезть в отдельные регионы памяти для каждого из её компонентов, и это как-то помогает в кеш-локальности?
Хоть статья и не про легкость архитектуры, но ecs позволяет не задумываться о «Так делать не советую — вы не сэкономите столько на бойлерплейте». Т.е. следовать очень простым рекомендациям и лишний раз не переусложнять код — само сабой разумеющееся для ecs.
что лучше — иметь универсальный инструмент и придерживаться правил работы с ним, или инструмент, функциональность которого будет искуственно ограничена так, чтобы им не могли неправильно пользоваться?
Например, для команд из тысяч людей с разным уровнем опыта это может быть очень важно.
когда вы будете нанимать тысячи людей, вы заметите, что подавляющее большинство из них будет иметь намного больший опыт работы именно с ООП подходом
Хоть статья и не про разделение данных и логики, но для меня DOD стиль кода больше подходит
ну это ваше мнение
Т.е. в ITransaction десятки тысяч частей на много строк кода, а используем, например, только пару частей во многих объектах (зависит от постоянно меняющегося дизайна игры)

Т.е. в статье про AxedDwarf говорят, что традиционный подход имеет некоторые проблемы. И ecs (и не только) позволяет решить некоторые проблемы. Вы с этим не согласны?
не согласен, и я уже объяснил причины: про ITransaction выше в этом коментарии, а про AxedDwarf — здесь
Пример: Если для текущего проекта в наследовании (а именно runtime полиморфизме) нет проблем, это не означает, что в другом проекте от этого проблем не будет.
тогда я бы рекомендовал пройтись в другом проекте профилировщиком и оптимизировать до достаточности или пока не упретесь в виртуальные вызовы. И я вас уверяю: подавляющее большинство проектов никогда не упрется в виртуальные вызовы, а то меньшинство которое где-то упрется, можно будет ускорить локальными изменениями кода.
«у вас в любом случае появился объект, который не вписывается в старую логику обработки, и вам в любом случае придется её дорабатывать.»
— в случае ECS не нужно при каждой правке (добавлении нового компонента) изменять ITransaction (God Object). Даже логику обработки транзакций не обязательно менять (например, компонент NetworkId не повлияет никак на логику обработки транзакций, его даже в hpp подключать не придется).

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

Давайте сравним добавление нового компонента с

// Фильтр конечно же тривиален:
class NamedFilter final : public IFilter {
public:
    bool matches(const ITransaction& txn) const noexcept override {
        auto named = txn.named_extension();
        return named && (named->name_of_customer() == txn.name_on_account());
    }
};


Допустим был только ecs компонент Name и для него был view с логикой фильтрации.

Добавляем новый ecs компонент Money для `сущности транзакции`. Создаем для него свой view с отдельной логикой фильтрации (заголовочный файл компонента Name даже не подключаем в этот фильтр).

«При этом вы еще и хотите по максимуму переиспользовать уже существующую логику.» — логика обработки компонента Name не меняется при добавлении Money.

Если же нужно обработать совокупность Name и Money — создаем новый view с отдельной логикой фильтрации. Нам даже не нужно создавать уникальный тип фильтра `NameMoney(тут еще куча слов)...Filter`

Сравниваем с `NamedFilter`. Он должен обработать транзакцию даже если в ней нет `Name`, это раз (не делает выборку по `Name`). Он знает о том, что есть `txn.money()`, это два (в выборке не только `Name`). Также все типы данных, что есть в `ITransaction` подключатся `NamedFilter` даже если не используются, что плохо для времени компиляции.

«меняется логика обработки одного из её компонентов — придется вводить новый компонент.» — нет, один компонент может обрабатываться по разному разными системами, изолированными частями логики.

Даже если один и тот же компонент должен обработаться по разному в зависимости от условия, то и эта проблема легко решается (или условием прямо в логике, или добавлением entt::tag — метки, здесь означающей истинность условия).

«принципу разделения интерфейсов» — т.е. начать разделять ITransaction (God Object)? Как много разделений интерфейсов придется сделать? Не вернемся ли к проблеме AxedDwarf спустя много разделений? А если в полученной иерархии с ITransaction придется вдруг сделать серьезные изменения, то не будет ли проще сделать правку с ecs архитектурой?

«нет, мы проверяем поддерживает ли сущность интерфейс.» — в случае ecs это был бы компонент name, а не интерфейс.

«И да, можете уточнить, дорого ли проверить наличие у сущности компонента?» — не дорого, ниже код

«мне кажется вы преувеличиваете стоимость виртуальных вызовов и недооцениваете накладные расходы от ECS» — разница есть, ниже код

«то есть чтобы обработать одну сущность мы должны лезть в отдельные регионы памяти для каждого из её компонентов, и это как-то помогает в кеш-локальности?» — нет, не должны лезть в отдельные регионы памяти для каждого из её компонентов.

int transactionEntity1 = 0;
int transactionEntity2 = 1;

const int NULL_ENTITY = 0;

std::vector<NameComponent> names;
// Конечно нормальная реализация ecs без `*.reserve(MAX_TRANSACTIONS);`.
names.reserve(MAX_TRANSACTIONS);
names[transactionEntity1] = NameComponent{"John"}; 
names[transactionEntity2] = NULL_ENTITY; 

std::vector<GuidComponent> ids;
// Конечно нормальная реализация ecs без `*.reserve(MAX_TRANSACTIONS);`.
ids.reserve(MAX_TRANSACTIONS);
ids[transactionEntity1] = NULL_ENTITY; 
ids[transactionEntity2] = GuidComponent{"GUID-123-456"}; 

std::vector<int> transactions;
// Конечно нормальная реализация ecs без `*.reserve(MAX_TRANSACTIONS);`.
transactions.reserve(MAX_TRANSACTIONS);
transactions[0] = transactionEntity1;
transactions[1] = transactionEntity1;

for(const int& transactionEntity: transactions)
{
  if(names[transactionEntity] != NULL_ENTITY) std::cout << names[transactionEntity].name;

  if(ids[transactionEntity] != NULL_ENTITY) std::cout << ids[transactionEntity].id;
}


В кеш попадают компоненты из names и ids по которым идут последовательно. В идеале компоненты из names и ids содержат только нужные данные без логики, чтобы поместиться в кеш. См. `entt::group` для примера реализации.

В обычном подходе без ecs в кеш попадают последовательно transactions, а для них уже ищут names и ids которые идут не последовательно + добавляют виртуальные вызовы.

«иметь универсальный инструмент» — кому как нравится. ecs тоже можно назвать универсальным инструментом (он позволяет делать все то же, нет ограничений по возможностям). А иметь ограничения по code style (данные и логика отдельно) — вполне нормально. И наличие ecs обычно не означает, что в проекте нельзя применять никакие другие техники (зависит от решаемых задач).

«подавляющее большинство из них будет иметь намного больший опыт работы именно с ООП подходом» — смотря в какой индустрии. Например, в разработке игр на Unity ECS распространен. К тому же ECS вполне реально изучить (например, за дней 6 выходных), это не месяцы за книгами.

«тогда я бы рекомендовал пройтись в другом проекте профилировщиком и оптимизировать до достаточности или пока не упретесь в виртуальные вызовы.» — Вы говорите, что в любом проекте не будет проблем от runtime полиморфизма, что достаточно пройтись профилировщиком и как-то что-то «оптимизировать». Т.е. в игровых движках зря применяют DOD в hot loop?

«не согласен, и я уже объяснил причины» — т.е. у традиционного подхода нет вообще никаких проблем решаемых ecs, т.е. ecs вообще не нужен?
При этом логика обработки транзакций отлично разделяется на части используя разные entt::view, каждая из которых работает с своим (изолированным) набором компонент.
еще раз. Что если нам надо обрабатывать одни и те же компоненты по-разному в зависимости от того, каким сущностям они принадлежат?
Даже если один и тот же компонент должен обработаться по разному в зависимости от условия, то и эта проблема легко решается (или условием прямо в логике, или добавлением entt::tag — метки, здесь означающей истинность условия).
то есть мы всё-таки должны изменять уже существующий код, да? Старая логика ведь должна будет научиться учитывать этот tag
Если же нужно обработать совокупность Name и Money — создаем новый view с отдельной логикой фильтрации
и как-то убираем эти компоненты из старых view, да?
«принципу разделения интерфейсов» — т.е. начать разделять ITransaction (God Object)?
«god object» это антипаттерн объекта, нарушающего принцип единственной ответственности. Большой интерфейс объекта с единственной ответственностью — не god object, и не надо пожалуйста кидаться баззвордами которые вы не понимаете. Есть большой интерфейс — я предложил классическую методологию решения проблемы
Не вернемся ли к проблеме AxedDwarf спустя много разделений?
если в результате разделения одного интерфейса на несколько у вас два разных объекта склеились воедино, вы точно, прям абсолютно наверняка, делаете что-то не так
А если в полученной иерархии с ITransaction придется вдруг сделать серьезные изменения, то не будет ли проще сделать правку с ecs архитектурой?
«не будет ли проще заменить кусок одной архитектуры на другую» — нет, нет, прям абсолютно точно, наверняка, нет!
«нет, мы проверяем поддерживает ли сущность интерфейс.» — в случае ecs это был бы компонент name, а не интерфейс.
не надо натягивать ECS-терминологию на ООП-подход, какая разница как «было бы»
«И да, можете уточнить, дорого ли проверить наличие у сущности компонента?» — не дорого, ниже код

«мне кажется вы преувеличиваете стоимость виртуальных вызовов и недооцениваете накладные расходы от ECS» — разница есть, ниже код

«то есть чтобы обработать одну сущность мы должны лезть в отдельные регионы памяти для каждого из её компонентов, и это как-то помогает в кеш-локальности?» — нет, не должны лезть в отдельные регионы памяти для каждого из её компонентов.
но в вашем примере вы лезете в разные регионы памяти для разных компонентов! Ведь names и ids в общем случае выделены в разных местах. И чтобы обработать сущность, состоящую из многих компонентов, вы должны подтянуть каждый из этих компонентов черт пойми откуда.
В обычном подходе без ecs в кеш попадают последовательно transactions, а для них уже ищут names и ids которые идут не последовательно + добавляют виртуальные вызовы.
в обычном подходе без ecs name и id одного объекта лежат рядом, внутри этого объекта.
К тому же ECS вполне реально изучить (например, за дней 6 выходных), это не месяцы за книгами.
а что, изучить пять простых принципов SOLID и пару десятков паттернов проектирования это месяцы за книгами?
Вы говорите, что в любом проекте не будет проблем от runtime полиморфизма, что достаточно пройтись профилировщиком и как-то что-то «оптимизировать». Т.е. в игровых движках зря применяют DOD в hot loop?
в игровых движках решение использовать ECS принимается задолго до того, как финализируется производительность, а не после того, как ООП-подход оказывается медленным.
т.е. у традиционного подхода нет вообще никаких проблем решаемых ecs, т.е. ecs вообще не нужен?
конкретно меня вы пока что не убедили что ECS решает хоть какие-то проблемы нормального ООП, только какого-то соломенного чучела которое вы старательно пытаетесь выдать за ООП.
Antervis

«если нам надо обрабатывать одни и те же компоненты по-разному в зависимости от того, каким сущностям они принадлежат» — если надо обработать по разному, то нужно как то различать типы сущностей (точнее не типы объектов различать, а наборы компонентов). В случае ecs будем различать по наличию разных компонентов. Например, если у сущности есть компонент owner, то компонент деньги обработает система 1. А если у сущности есть компоненты sender и fee, то компоненты деньги и fee обработает система 2.

«изменять уже существующий код» — я приводил именно пример не с удалением, а добавлением логики. В этом примере я привел подробное сравнение, в частности «Также все типы данных, что есть в `ITransaction` подключатся `NamedFilter` даже если не используются, что плохо для времени компиляции.»

Так удаление, конечно, потребует изменений логики везде и разницы тогда не увидеть.

«и как-то убираем эти компоненты из старых view, да» — зачем их убирать?

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

Про разделение интерфейсов — проблема осталась. Как много интерфейсов нам нужно для описания игрового объекта? Насколько сложной получится архитектура?

«но в вашем примере вы лезете в разные регионы памяти для разных компонентов» — эти разные регионы памяти загружают и соседние элементы массивов т.к. данные расположены последовательно. В кеш не поступает один компонент, а сразу несколько соседних участков памяти даже если мы это в коде не написали явно, так работает кеш. В случае же прохода по объектам которые используют runtime полиморфизм, как минимум pointer hopping возможен:

gameprogrammingpatterns.com/data-locality.html «In C++, using interfaces implies accessing objects through pointers or references. But going through a pointer means hopping across memory, which leads to the cache misses this pattern works to avoid.»

«в игровых движках решение использовать ECS принимается» — обычно на GDC говорят, что на ecs перешли или ради скорости, или ради лучшей архитектуры ПО. В GDC разраб Overwatch, например, говорил про архитектуру ПО www.youtube.com/watch?v=W3aieHjyNvw
если надо обработать по разному, то нужно как то различать типы сущностей (точнее не типы объектов различать, а наборы компонентов). В случае ecs будем различать по наличию разных компонентов. Например, если у сущности есть компонент owner, то компонент деньги обработает система 1. А если у сущности есть компоненты sender и fee, то компоненты деньги и fee обработает система 2.
Так, еще раз. Вот у нас есть сущность, определяющаяся компонентом Foo, и логика её обработки. Надо добавить другую, с компонентами Foo и Bar, но при этом обрабатывать Foo у новой сущности надо будет по-другому. Теперь внимание вопрос: мы должны заставить старую логику, работающую с Foo, научиться игнорировать новые объекты, или мы должны сделать отдельный тип Foo2, просто чтобы он не попадал в пайплайн Foo?
В этом примере я привел подробное сравнение, в частности «Также все типы данных, что есть в ITransaction подключатся NamedFilter даже если не используются, что плохо для времени компиляции.»
во-первых, не NamedFilter, а INamedFilter — интерфейс. Во-вторых, вы не обязаны инклюдить интерфейсы, которые вы не собираетесь использовать — достаточно forward declare. В третьих, интерфейсы практически не влияют на время компиляции.
gameprogrammingpatterns.com/data-locality.html «In C++, using interfaces implies accessing objects through pointers or references. But going through a pointer means hopping across memory, which leads to the cache misses this pattern works to avoid.»
1. Будьте внимательнее, я уже отвечал на это здесь. 2. Простой пример: юнит X наносит урон юниту Y. Мы должны обратиться к характеристикам X, оружию X, здоровью Y и броне Y. Точнее, не к «оружию» X, а к «конкретному оружию X» — оно же может быть и Axe, и Bow, и Sword, верно? По моим подсчетам, такая простейшая операция потребует «заглянуть» в 6 обособленных локаций в памяти, т.к. разные компоненты в ECS создаются в разных местах. В ООП у нас будут два объекта X и Y, где данные обоих будут лежать либо рядом, либо будут ссылки на полиморфные объекты — например, оружие. В этом примере это будут 3-4 локации в памяти. Внимание вопрос: что лучше с точки зрения кеша? То есть да, нам может это помочь если мы пробегаемся по паре векторов с целью батч-обработки однотипных данных. Например, видюхой. Но для данных, которым лучше лежать вместе, мы можем написать простой аллокатор, и они сразу будут выделяться в нужных локациях. Или размещать их вручную в тех контейнерах, в которых они потом будут молотиться, только лучше, чем под капотом в ECS. В общем, это не проблема.
Про разделение интерфейсов — проблема осталась. Как много интерфейсов нам нужно для описания игрового объекта? Насколько сложной получится архитектура?
Как много компонентов нам нужно для описания игрового объекта? Насколько сложной получится архитектура?
«мм. Виртуальные вызовы говорите дорогие, да?»
— сарказм не понятен
чего не понятен то? Для проверки на наличие у сущности компонента надо делать lookup в sparse set'е. И это точно намного дольше чем просто виртуальный вызов.
Если мне не верите, то может разрабу Unity поверите
aras-p.info/texts/files/2018Academy%20-%20ECS-DoD.pdf
а вы сами смотрели эту презенташку? Там в первых же двух слайдах нарушение примерно всех принципов ООП. Черт, да там даже хуже чем кажется с первого взгляда. Автор изначально закладывает ECS архитектуру (у нас есть GameObject'ы, и каждый реализован как динамический набор абстрактных компонент), и пытается натянуть её на ООП. И дело даже не в том, что так нельзя, а в том, как именно он это делает
«сущность, определяющаяся компонентом Foo» — Как я понял проблема пересечения компонента Foo. Если Вы хотите «добавить другую, с компонентами Foo и Bar» и обрабатывать Foo + Bar разными системами без пересечения логики обработки, то можно добавить компонент notBar (одно из многих вариантов решения проблемы). Foo + Bar — одним способом. Foo без Bar — другим способом. Если Вам не по душе вариант с notBar, то привожу еще пример: можно поменять сущ-ую логику чтобы проверяла наличие Bar в простом условии.

«интерфейсы практически не влияют на время компиляции.» — например, интерфейс возвращает тип std::string, следовательно, нужно подключить . Forward declare для std::string тут не подойдет, не так ли? Если интерфейс на десятки тысяч строк (или подключает много других интерфейсов), то у него будет много зависимостей аналогично .

«юнит X наносит урон юниту Y.» — здесь проблема для кеша в том, что в одной «функции» Вы хотите обратиться к компонентам разных ecs entity (юнит X и юнит Y). При этом предположим, что Axe, и Bow, и Sword вполне могут быть отдельными компонентами т.к. полиморфные объекты не нужны в этом примере. «Что лучше с точки зрения кеша?» — сначала отдельно обработать компоненты юнита X, затем компоненты юнита Y. Вот как можно это сделать — в момент нанесения урона помечаем юнит X компонентом IncomingDamage{amount}, а юнит Y компонентом DealtDamage{amount}. У всех entity с IncomingDamage обновляем компонент health и компонент AnimationState. У всех entity с DealtDamage обновляем компонент score (таблица рейтинга или trustScore для проверки на читы и т.д.) и компонент AnimationState. Да, в момент нанесения урона единожды обратимся к юниту Y чтобы вычислить amount урона на основании наличия у него Axe, или Bow, или Sword. Но при очередной итерации игрового цикла обработаем entity с IncomingDamage последовательно, что даст прирост произв-ти. А нам нужно избежать cache miss обычно именно в hot loop т.е. при итерации игрового цикла.

Возможно еще сильнее можно оптимизировать, например, момент нанесения урона также сделать отложенным во времени и создать для него отдельный компонент (хоть это и не hot loop и не требует оптимизаций). Но считывание разных entity здесь уже нетривиально к оптимизации кеша, но решаемо через entt::group или registry.sort с multi_instance_storage github.com/skypjack/entt/commit/eb8e96f413a3e523bcd45a9e2dcf6fa04d07e7fc

«Или размещать их вручную в тех контейнерах, в которых они потом будут молотиться, только лучше, чем под капотом в ECS» — конечно создать DOD оптимально т.е. хранить сущности в отдельных контейнерах и без runtime полиморфизма. Только такой вариант с ECS нет смысла сравнивать. Я же предполагал, что предполагается наличие runtime полиморфизма для ITransaction (Иначе как их в контейнерах оптимально для кеша разместить? Никак, динамическая аллокация ведь). DOD вообще не сравним с ECS в плане произв-ти (фактически мы пришли к варианту ECS где нет разделения на компоненты).

«Как много компонентов нам нужно для описания игрового объекта?» — сколько угодно, добавление новых компонентов тривиально.

«Насколько сложной получится архитектура?» (ecs) — о ней и не придется задумываться при добавлении новых компонентов к entity. Даже о «разделении интерфейсов» не стоит вопрос.

«на наличие у сущности компонента надо делать lookup в sparse set» — делаем view гарантируя нужный набор компонент, избегаем проблемы lookup внутри цикла.

Также не все виды ECS используют sparse set как entt. Есть еще «Archetype-based ECS» (Unity DOTS, Flecs, Legion) и другие ajmmertens.medium.com/why-storing-state-machines-in-ecs-is-a-bad-idea-742de7a18e59

«Там в первых же двух слайдах нарушение примерно всех принципов ООП.» — речь была не про ООП, а про производительность и про cache (про «мм. Виртуальные вызовы говорите дорогие, да?»).
в прод. пред. комментария
«потребует «заглянуть» в 6 обособленных локаций в памяти» против «В этом примере это будут 3-4 локации в памяти.»
— странно (вообще не сравнимо) сравнивать подходы по cache miss, когда они оба не cache friendly (не в hot loop). Вся идея избавления от cache miss была в представлении данных последовательно и обязательно в проходе по ним в цикле.
Как эту проблему решить — привел пример с ecs, где вводится компонент IncomingDamage.
Считать же обращения к разным участкам памяти в функции события «нанесен урон» (вне циклов) вообще есть ли смысл?

«мы можем написать простой аллокатор, и они сразу будут выделяться в нужных локациях» — как я понимаю, вы предлагаете ITransaction оставить с runtime полиморфизмом, но custom аллокатор расположит объекты типа «игровой юнит» в, например, std::vector последовательно. Если говорить о cache miss, то в представлении данных последовательно и обязательно в проходе по ним в цикле.
Но у нас без декомпозиции сущности останется проблема — мы не можем отфильтровать «игровой юнит» по, например, наличию оружия — нам нужно пройти всегда каждый «игровой юнит» и явно сделать проверку в цикле. Даже если некоторая логика работает с «игровым юнитом» у которого обязательно наличие оружия — мы все равно делаем выборку по всем «игровым юнитам» и фильтруем их все (возможно делаем цикл по миллионам сущностей вместо нескольких!).
Т.е. custom аллокатор не решил проблем с cache miss.

Схожий момент описан в habr.com/en/post/490500 «вы никогда не знаете, какие игровые объекты обладают нужными компонентами, и, следовательно, вы должны проходиться по всем ним в каждой системе и проверять наличие нужных компонентов у объекта»

«полиморфные объекты — например, оружие» — Также оружие в ecs представимо набором компонент. Скорее всего будет набор тегов «AxeWeaponType», «BowWeaponType»,… (набор тегов вообще без данных, просто для удобства — их можно и не использовать) и набор компонентов «WeaponDamage», «WeaponDurability»,… Т.е. действительно не уместно использование полиморфизма для оружия в ecs, как я и сказал ранее.
о ней и не придется задумываться при добавлении новых компонентов к entity. Даже о «разделении интерфейсов» не стоит вопрос.
интерфейс не придется делить, если изначально не делать монстров.
например, интерфейс возвращает тип std::string, следовательно, нужно подключить. Forward declare для std::string тут не подойдет, не так ли?
у вас и в ECS-аналоге std::string будет.
Вот как можно это сделать — в момент нанесения урона помечаем юнит X компонентом IncomingDamage{amount}, а юнит Y компонентом DealtDamage{amount}
сначала нанесли урон, а потом его посчитали? Но даже ладно. В вашей архитектуре вы считаете урон, описываемый простой математической формулой, в несколько проходов по одним и тем же компонентам. Добавление компонента может быть и тривиально, а вот убедиться что они все корректно учтены и в правильной последовательности посчитаны…
Если Вам не по душе вариант с notBar, то привожу еще пример: можно поменять сущ-ую логику чтобы проверяла наличие Bar в простом условии.
В первом случае мы меняем уже существующую логику, чего в ООП делать не придется. Во втором мы возвращаемся к тому, что на каждой итерации по Foo надо делать lookup в сете на предмет наличия у сущности Bar.
конечно создать DOD оптимально т.е. хранить сущности в отдельных контейнерах и без runtime полиморфизма. Только такой вариант с ECS нет смысла сравнивать
а кто говорит про отсутствие runtime полиморфизма? У нас вполне может быть контейнер объектов, на которые ссылаются в соответствии с их интерфейсом. Разные реализации одного и того же интерфейса могут лежать в разных контейнерах.
«Там в первых же двух слайдах нарушение примерно всех принципов ООП.» — речь была не про ООП, а про производительность и про cache (про «мм. Виртуальные вызовы говорите дорогие, да?»).
нет, в слайдах которые вы скинули крупным текстом было написано «ООП плохой ECS хороший». А по факту там сначала натягивание ECS-совы на ООП-глобус, да еще и невероятно кривое, а потом героическая победа этих трудностей реализацией архитектуры на том, подо что она была изначально спроектирована. Вы их сами-то читали или нет? Да и про дороговизну я уже комментировал, не вижу смысла повторяться.

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


«у вас и в ECS-аналоге std::string будет.» — разница в том, что интерфейс подключается не только логикой обработки. В случае ecs логика обработки — отдельный cpp файл (даже несколько файлов с изволированными частями логики — системами)


«сначала нанесли урон, а потом его посчитали?» — да, иначе производительность на 99.99% одинакова и сравнивать нет смысла. Мы же производительность обсуждали (кеш)?


«убедиться что они все корректно учтены и в правильной последовательности посчитаны…» — это одна из проблем ecs, я о ней упоминал. Одни проблемы решает, но другие создает.


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


«а кто говорит про отсутствие runtime полиморфизма» — тогда от использования custom allocator нет пользы. Что описано ранее в https://m.habr.com/en/company/piter/blog/524882/comments/#comment_22275970


«нет, в слайдах которые вы скинули » — я уже говорил, что на слайдах интересны только слова про производительность. В частности, скорость доступа к кешу.


  • Read from CPU L1 cache: 0.5ns
    ● Branch mispredict: 5ns
    ● Read from CPU L2 cache: 7ns
    ● Read from RAM: 100ns
    ● Read from SSD: 150’000ns
    ● Read 1MB from RAM: 250’000ns
    ● Send network packet CA->NL->CA: 150’000’000ns
    В целом этот вопрос мы уже обсудили, т.е. что lookup не проблема в sparse set ecs т.к. есть view на гарантию наличия компонентов. И что полиморфизм не бесплатен.

Также как в предложенном правильном ООП подходе оптимально фильтровать объекты (заметьте, статья о фильтрации)? Допустим, у нас миллионы объектов ITransaction. У ITransaction могут быть тысячи частей (name, money и т.д.). Проблема: мы хотим эффективно отфильтровать транзакции по name и money одновременно. При текущем подходе мы должны просмотреть миллионы транзакций и выбрать только те, у которых есть нужные части. Т.е. в идеале наш фильтр должен обработать только те транзакции у которых задан optional (тривиальный случай) или указатель (второй случай). Т.е. нам не нужны миллионы объектов ITransaction, а лишь несколько. Как эту проблему решить? Хранить массив указателей для каждой части отдельно (если optional задан), а также для комбинаций? А если нужно изменить значение в массиве и оптимизировать расход памяти, то использовать sparse set и т.д.? Или придется уйти от предложенного правильного ООП подхода?

но как, у нас по условию класс игрового персонажа монстр на десятки тысяч строк кода с кучей возможных частей, если его не делить
Не было такого условия, его вы взяли из головы. В нормальном ООП класс изначально никогда не будет огромным.
да, иначе производительность на 99.99% одинакова и сравнивать нет смысла. Мы же производительность обсуждали (кеш)?
так она в ECS и так на порядок ниже чем в ООП, и эдак. В одном случае вы делаете лукапы всех нужных компонентов, в других — проходите по одним и тем же компонентам несколько раз. Когда урон в большинстве случаев описывается простейшей формулой. И вы кстати забываете что на самом деле ваш view итерируется по данным, которые могут не лежать подряд. Например, если у вас сущности состоят из Foo и, возможно, Bar, то view по Foo, Bar пройдется по всем Foo и всем Bar. Ну так, для справки.
это одна из проблем ecs, я о ней упоминал. Одни проблемы решает, но другие создает.
т.е. таки шило на мыло?
если быть точным, мы изменим выборку данных, с которой работает та же самая логика.
заметьте: это вы предложили вариант менять данные, чтобы логика могла их выбирать, а не я.
я уже говорил, что на слайдах интересны только слова про производительность. В частности, скорость доступа к кешу.
я уже объяснял почему на слайдах соломенное чучело вместо ООП и почему их вообще не стоит воспринимать всерьез. Повторюсь: в нормальной архитектуре dynamic_cast нужен примерно никогда, там же в примере «ООП» кода dynamic_cast делается на буквально каждый чих.
В целом этот вопрос мы уже обсудили, т.е. что lookup не проблема в sparse set ecs т.к. есть view на гарантию наличия компонентов.
нет. У вас view идет по компонентам одной сущности. У нас же по условию (и в этот раз это действительно условие — юнит наносит урон другому) взаимодействуют две разные сущности.
И что полиморфизм не бесплатен.
Да, не бесплатен. Но будьте добры, загляните в таблички Agner'а Fog'а и посмотрите насколько, как соотносятся виртуальные вызовы и обычные по быстродействию. А потом замерьте стоимость поиска компонента у сущности.
Допустим, у нас миллионы объектов ITransaction. У ITransaction могут быть тысячи частей (name, money и т.д.).
у ITransaction не будет тысячи частей. Мне уже начинает надоедать это повторять
Проблема: мы хотим эффективно отфильтровать транзакции по name и money одновременно. ...
так name и money принадлежат базовому виду транзакции, или какому-то из подвидов?
«В нормальном ООП класс изначально никогда не будет огромным.» — игровой персонаж — очень сложная сущность, состоящая из многих частей. Его нужно или «разделять» на много частей, или делать огромным (если, цитирую, «интерфейс не придется делить»).

«В одном случае вы делаете лукапы всех нужных компонентов» — нам важен только случай с циклом, иначе сравнение не корректно делать. В случае с циклом — предложено использовать view. Там будет только один lookup, перед циклом. Все, больше lookup будут не нужны.
Если же Вы про опциональные компоненты, которые можно передать в параметрах entt::view, то нужно или менять выборку данных, или там уже нечего оптимизировать.

«view итерируется по данным, которые могут не лежать подряд.» — разные компоненты не лежат подряд. Но это не мешает им кешироваться. Так работает кеш. Два компонента кладутся в кеш И их соседи в массивах. Ведь одинаковые компоненты лежат подряд. Мы не говорим явно соседним элементам массивов попасть в кеш, но они будут там. Да, размер кеша ограничен, но для двух небольших компонентов с их соседями места хватит.

К слову, если нужно чтобы где-то еще данные шли подряд в цикле — меняем код, например, делаем обработку отложенной.

«т.е. таки шило на мыло?» — напоминаю про случай, когда нам нужно произвести фильтрацию по отдельным частям транзакции и мы хотим это сделать оптимально. Это уже достаточная причина перехода на ecs (или DOD) в данном примере (фильтрации транзакций).

«я уже объяснял почему на слайдах» — Какая разница какое там соломенное чучело вместо ООП, ведь на слайдах таблица с скоростью доступа к кешу интересна и т.д. Цитирую «В целом этот вопрос мы уже обсудили, т.е. что lookup не проблема в sparse set ecs т.к. есть view на гарантию наличия компонентов. И что полиморфизм не бесплатен.»

«У вас view идет по компонентам одной сущности… взаимодействуют две разные сущности.»
— я уже привел пример как избавиться от проблемы «две разные сущности» и как изменить код на цикл (с обработкой каждого набора сущностей отдельно) вместо события (обработать целый набор сущностей отложенно в след. итерации игрового цикла). Или Вы в событии «нанес урон» вне циклов пытаетесь сравнивать скорость доступа к кешу, но зачем? Вы ведь понимаете, что столь схожая производительность для одиночного события не сравнима вообще?

«замерьте стоимость поиска компонента у сущности.» — зачем? У нас не будет проблемы lookup. Делаем view по нужному набору компонент и нет проблемы lookup в местах в которых важна производительность (hot loop).

«так name и money принадлежат базовому виду транзакции, или какому-то из подвидов?» — Если базовому, то проблема очевидна (когда все в одном классе). Если подвидам — то нам понадобится два подвида — один с name, другой с money (иначе как сделать оптимальную выборку по ним отдельно?) + их комбинация name + money одновременно (иначе как сделать оптимальную выборку по ним вместе?). В итоге приходим к DOD и уходим от полиморфизма.

«у ITransaction не будет тысячи частей» — значит будут сотни интерфейсов, не так ли? Напоминаю, что нам нужно делать фильтрацию по отдельным частям и проблема разбиения на отдельные части (+ их комбинации для выборки очередного фильтра) осталась (чтобы не смотреть все типы транзакций в каждом фильтре).
игровой персонаж — очень сложная сущность, состоящая из многих частей. Его нужно или «разделять» на много частей, или делать огромным (если, цитирую, «интерфейс не придется делить»)
вы читаете что пишете, комментарии на которые отвечаете, что-нибудь хотя бы оседает у вас в голове? Вы одну и ту же простую мысль не можете освоить уже порядка 6-7 комментариев. Перечитывайте до посинения, мне надоело.
Какая разница какое там соломенное чучело вместо ООП, ведь на слайдах таблица с скоростью доступа к кешу интересна и т.д.
вот сейчас я начинаю беситься. Какой-то «разработчик unity» намеренно делает паршивую реализацию которую он называет «ООП», сравнивает её со своим ECS, под который он эту паршивую реализацию изначально натягивал, приводит в таблицу, я уже три раза подробно объясняю почему это плохое сравнение, тыкаю носом в говно, если можно так выразиться, а вам всё малина.
К слову, если нужно чтобы где-то еще данные шли подряд в цикле — меняем код, например, делаем обработку отложенной.
вы привели пример как в 3-4 прохода «юнит X наносит урон юниту Y» А если там какая-нибудь механика а-ля «обратный урон», то будет 6-8 проходов, да?
напоминаю про случай, когда нам нужно произвести фильтрацию по отдельным частям транзакции и мы хотим это сделать оптимально
контрпример: мы хотим проитерироваться сразу по всем аспектам всех транзакций. Ну-ка, как будет выглядеть этот код в ECS? Какой view придется написать? Насколько производительным он будет?
зачем? У нас не будет проблемы lookup. Делаем view по нужному набору компонент и нет проблемы lookup в местах в которых важна производительность (hot loop).
я так понимаю, вы будете просто делать эти hot loop до посинения, пока все проходы логики не выполнятся. Звучит как N^2 ассимптотика.
«у ITransaction не будет тысячи частей» — значит будут сотни интерфейсов, не так ли?
приведите хоть один пример из вашей личной практики, где в объекте либо сотни интерфейсов, либо тысячи частей, либо интерфейс на тысячу строк кода.
«Вы одну и ту же простую мысль не можете освоить» «игровой персонаж — очень сложная сущность»
— Я считаю проблему разделения интерфейсов проблемой, Вы нет. Вот и все.

«говно» «приводит в таблицу»
— В ней скорость доступа к кешу и RAM вне зависимости от выбранного подхода. Я не про сравнение якобы «ООП» с его ECS. Если не ошибаюсь, Вы уже сами однажды говорили, что runtime полиморфизм, доступ к RAM не бесплатен. Началось с того, что Вы сказали «достаточно что то оптимизировать, пройтись профилировщиком», но runtime полиморфизм можно оставить. Так runtime полиморфизм не бесплатен, его можно также убрать…
Также Вы говорили, что lookup не избежать. Тогда ecs бы в Unity, Dava Engine, и т.д. и не применяли. И таких презентаций не было бы никогда…

«А если там какая-нибудь механика а-ля «обратный урон», то будет 6-8 проходов, да?»
— Во первых, где, в событии нанесения урона или в игровом цикле? Если в событии нанесения урона, то скорость будет примерно одинакова и сравнивать нечего.
По поводу «механика а-ля» — всегда можно придумать крайний случай (мы начали с конкретного примера), например, есть случаи когда ecs плохо работает ajmmertens.medium.com/why-storing-state-machines-in-ecs-is-a-bad-idea-742de7a18e59

«проитерироваться сразу по всем аспектам всех транзакций» — это конечно очень странный крайний случай (т.е. Вы хотите создать фильтр сразу по тысячам возможных полей всех возможных типов транзакций?). В кеш поместятся только некоторые компоненты. Прирост скорости будет не значителен.

«N^2 ассимптотика.» — где и почему? Мы отложили обработку данных до след. прохода игрового цикла, а не обработали их во вложенном цикле лишний раз. Т.е. данные в любом случае нужно обработать и какая разница сейчас обработать или на N ms позже?

«интерфейс на тысячу строк кода» — конечно такой код найти сложно. Но давайте на более конкретном практическом примере минусы приведем
github.com/TrinityCore/TrinityCore/blob/d934824421c83598853487c5cc9e4cbb3c5d0006/src/server/game/Server/WorldSession.h#L1763
Код содержит «std::unique_ptr _battlePetMgr;», «std::unique_ptr _collectionMgr;» — сколько таких «Mgr» может быть? В теории немало, и все в одном месте. Это вполне может стать проблемой.
Аналогично с «bool m_playerSave;», «bool m_playerLogout;», и т.д. — приходится тщательно следить за полями класса чтобы его не раздуть. Это вполне может стать проблемой.
С ecs не задумываешься о содержимом интерфейса и разделении интерфейсов т.к. интерфейса вообще нет, разве не так? Вот, что я пытался донести.
«Вы одну и ту же простую мысль не можете освоить» «игровой персонаж — очень сложная сущность»
проблемы не будет если её не создавать
В ней скорость доступа к кешу и RAM вне зависимости от выбранного подхода. Я не про сравнение якобы «ООП» с его ECS. Если не ошибаюсь, Вы уже сами однажды говорили, что runtime полиморфизм, доступ к RAM не бесплатен. Началось с того, что Вы сказали «достаточно что то оптимизировать, пройтись профилировщиком», но runtime полиморфизм можно оставить. Так runtime полиморфизм не бесплатен, его можно также убрать…
скажите, вы хоть одну высоконагруженную систему разрабатывали? Хоть раз в жизни. Хоть раз пытались реально оптимизировать код? Хоть раз оптимизировали с профилировщиком? Хоть раз у вас получалось, что виртуальные вызовы — основной потребитель CPU? Или вы говорите исходя из собственных предположений, основанных на материале паршивого качества? Вы сейчас пытаетесь рассказать как надо экономить на спичках и как в ECS нет никаких накладных расходов. Что разумеется не так.
Во первых, где, в событии нанесения урона или в игровом цикле? Если в событии нанесения урона, то скорость будет примерно одинакова и сравнивать нечего.
а в ООП это просто x->doDamage(y). А там внутри одна формула, по которой урон будет расчитан относительно характеристик x/y. И я хоть убейте не поверю что «скорость будет примерно одинакова».
«N^2 ассимптотика.» — где и почему?
вы на каждый частный случай добавляете «систему» которая делает один проход по набору компонент. N видов компонент и ~N «систем».
Тогда ecs бы в Unity, Dava Engine, и т.д. и не применяли. И таких презентаций не было бы никогда…
С ecs не задумываешься о содержимом интерфейса и разделении интерфейсов т.к. интерфейса вообще нет, разве не так? Вот, что я пытался донести.
Не задумываешься — правильная формулировка. У меня есть гипотеза, и связана она с тем, что иногда программистам нарочно дают урезанные инструменты, чтобы они не могли сделать чего лишнего. Простейший пример — язык Go. Собственно, предположение в том, что цель ECS — не увеличить производительность, а не дать плохим кодерам писать плохой код.
«интерфейс на тысячу строк кода» — конечно такой код найти сложно. Но давайте на более конкретном практическом примере минусы приведем
это не интерфейс, и там даже наследования нет. Типичный god object. Но скажите: вы конкретно с этим классом работали? Или просто нагуглили очередное соломенное чучело?

Давайте простой пример. Вот у вас умер игровой персонаж, который описывается «тысячами» (по вашим же словам) компонент. И надо всего лишь удалить все его компоненты. Это ведь будет проход по всем компонентам, да? А если один забыли?
eao197
«Вы не поняли. Мне не нужно создавать кучу view с разными типами. У меня есть понятие «транзакция», за которым могут скрываться разные типы. У меня есть некий список транзакций, в котором будут транзакции разных типов.»

Т.е. что будет если пытаться использовать ECS, но без разделения сущностей на компоненты? Ничего хорошего не будет.

Antervis
«мм. Виртуальные вызовы говорите дорогие, да?»
— сарказм не понятен
Если мне не верите, то может разрабу Unity поверите
aras-p.info/texts/files/2018Academy%20-%20ECS-DoD.pdf
mobile.twitter.com/aras_p/status/1044656885100675072?lang=en

«показывает беглый обзор из которого вообще не понятно зачем всё это»
— это только один урок из серии уроков на том же канале
А на вопрос зачем отвечает
youtu.be/p65Yt20pw0g
Раз уж тут все еще идет обсуждение, решил набросать свой вариант

// Компоненты транзакций
struct Name {
  std::string name;
};

struct Address {
  std::string address;
};

struct Gender {
  bool male;
};


// Сами транзакции состоят из разных компонентов
struct Transaction1: public Name, public Address {
  Transaction1()
    : Name{"Name 1"}
    , Address{"Address 1"}
  {}
};

struct Transaction2: public Name, public Address {
  Transaction2()
    : Name{"Name 2"}
    , Address{"Address 2"}
  {}
};

struct Transaction3: public Name, public Address, public Gender {
  Transaction3(bool male)
    : Name{"Name 1"}
    , Address{"Address 3"}
    , Gender{male}
  {}
};

// Общий тип для транзакции
using Transaction = std::variant<Transaction1, Transaction2, Transaction3>;

// Фильтры
struct FilterName {
  std::string expectedName = "Name 1";
};

struct FilterNameAndGender {
  std::string expectedName = "Name 1";
  bool expectedMale = true;
};

// Общий тип для фильтра
using Filter = std::variant<FilterName, FilterNameAndGender>;

// Функции фильтрации
// Предположим, мы хотим сравнить конкретный фильтр с конкретной транзакцией
bool filter(const FilterName& filter, const Transaction1& tx) {
  return filter.expectedName == tx.name;
}

// Но не всегда это нужно. В предыдущем примере мы могли обойтись только полем name у транзакции. Так что давайте будем брать ссылку только на нужный нам подэлемент 
bool filter(const FilterName& filter, const Name& txName) {
  return filter.expectedName == txName.name;
}

// Более сложная ситуация. Что, если нам нужно сравнивать не с одним компонентом транзакции, а сразу с двумя? И тут мы справимся. txName и txGender - это одна и таже транзакция, просто разные ее представления
bool filter(const FilterNameAndGender& filter, const Name& txName, const Gender& txGender) {
  return filter.expectedMale == txGender.male && filter.expectedName == txName.name;
}

// Плюс такого подхода - то что компилятор не даст скомпилировать код, пока мы не пропишем все необходимые соответствия
bool filter(const FilterNameAndGender&, const Transaction2&) {
  return true;
}

bool filter(const FilterNameAndGender&, const Transaction1&) {
  return true;
}

// Разные страшные шаблонные хэлперы
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
template<typename = void, typename... Args>
struct test : std::false_type {};
template<typename... Args>
struct test<std::void_t<decltype(filter(std::declval<Args>()...))>, Args...>
    : std::true_type {};
template<typename... Args>
inline constexpr bool test_v = test<void, Args...>::value;

// Библиотечная функция фильтрации транзакций
std::vector<Transaction> filterAll(const std::vector<Filter>& filters, const std::vector<Transaction>& txs) {
  std::vector<Transaction> result;
  for (const Transaction& tx: txs) {
    bool res = true;
    for (const Filter& f: filters) {
      res &= std::visit([](const auto& f, const auto& tx) -> bool {
        if constexpr (test_v<decltype(f), decltype(tx), decltype(tx), decltype(tx), decltype(tx)>) {
          return filter(f, tx, tx, tx, tx);
        } else if constexpr (test_v<decltype(f), decltype(tx), decltype(tx), decltype(tx)>) {
          return filter(f, tx, tx, tx);
        } else if constexpr (test_v<decltype(f), decltype(tx), decltype(tx)>) {
          return filter(f, tx, tx);
        } else {
          return filter(f, tx);
        }
      }, f, tx);
    }
    if (res) {
      result.emplace_back(tx);
    }
  }
  return result;
}

// Проверка
void example() {
  std::vector<Filter> filters({FilterName(), FilterNameAndGender()});
  std::vector<Transaction> txs({Transaction1(), Transaction2(), Transaction3(true), Transaction3(false)});
  std::vector<Transaction> result = filterAll(filters, txs);
  for (const Transaction& tx: result) {
    std::visit(overloaded{[](const Transaction1& t) {
      std::cout << "Transaction 1 " << t.name;
    }, [](const Transaction2& t) {
      std::cout << "Transaction 2 " << t.name;
    }, [](const Transaction3& t) {
      std::cout << "Transaction 3 " << t.name << " " << t.male;
    }},
      tx
    );
    std::cout << ",";
  }
}
это как раз тот случай когда композиция явно лучше наследования. Если вы всё равно делаете диспатч через std::visit, то есть знаете все возможные типы входных транзакций, можете реализовать filter как перегружаемую функцию. Получится абсолютный минимум кода:
struct SimpleTransaction {
    size_t Quantity;
};

struct AnotherSimpleTransaction {
    size_t Quantity;
};

struct NamedTransaction {
    size_t Quantity;
    std::string Name;
};

struct NumberedTransaction {
    size_t Quantity;
    size_t Num;
};

using Transaction = std::variant<
    SimpleTransaction,
    AnotherSimpleTransaction,
    NamedTransaction,
    NumberedTransaction
>;

template <typename T>
bool filter(const T& txn) { // generic implementation
    return txn.Quantity > 0;
}

bool filter(const NamedTransaction& txn) {
    // extending generic implementation
    return txn.Name == "somename" && filter<>(txn);
}

bool filter(const NumberedTransaction& txn) {
    // stricter condition on Quantity
    return txn.Num != 0 && txn.Quantity > 1;
}

int main() {
    std::vector<Transaction> transactions {
        SimpleTransaction{0}, // not ok
        SimpleTransaction{1}, // ok
        AnotherSimpleTransaction{1}, // ok
        NamedTransaction{1, "somename"}, // ok
        NamedTransaction{1, "notsomename"}, // not ok
        NumberedTransaction{2, 1}, // ok
        NumberedTransaction{0, 1}, // not ok
        NumberedTransaction{1, 1} // not ok
    };

    for (const auto& txn : transactions) {
        auto f = [](auto& txn) { return filter(txn); }; // use filter by overload
        bool ok = std::visit(f, txn);
        ...
    } 
}

godbolt
Ну у меня всетаки была немного другая идея

И тут наследование выступает больше в роли поглощения функциональности, а не собственно наследования.

По-моему это решение превосходит решение из статьи.


Код и прост, и понятен, и решает свою задачу оптимально (не хуже быстродействие).

Статья похоже породила неслабое обсуждение, что говорит об актуальности темы. Авторское решение в этом плане вполне достойно обсуждения. Учитывая, что в каментах народ набросился с кучей абстракций для решения похожей задачи. Но о чём многие забывают, так это о том, что абстракция вещь платная. И чем абстрактнее, тем выше стоимость. Видно, что не все комментаторы измеряли стоимость std::function, std::visit, virtual, о ужас dynamic_cast, своего механизма динамической типизации, boost (включая тонны его библиотек в репозитории, зачастую ради решения мелких и незначительных задач), ну и так далее. Ну вот я бы порекомендовал заняться на досуге. Написать разные реализации, оппозиционные авторскому решению, и сравнить стоимость вызова его решения, своего, и не только своего. Очень увлекательное, занимательное, показательное и поучительное занятие.
Одновременно неплохо бы учитывать не только производительность и необходимость данных решений в коде, но также коммерческую стоимость времени разработки и поддержки каждого из этих решений в перспективе командой не всегда опытных разработчиков.

Sign up to leave a comment.