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

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

Дисклеймер N2: Я буду рад конструктивной обратной связи, выраженной словами. Если вы понимаете хуже меня, и меня ругаете, значит, я где-то недостаточно доходчиво объяснил, и есть смысл переработать текст. Если вы понимаете лучше меня — значит, я получу ценный опыт. Заранее спасибо.

Дисклеймер N3: Я писал большие приложения с нуля, но не писал серверных и клиентских корпоративных приложений. Там всё другое и, вероятно, мой опыт будет казаться странным специалисам в этой области. Да и статья не о том, те же вопросы масштабируемости здесь вообще не рассматриваются.
Дисклеймер N4 (Upd. по результатам комментов): Некоторые комментаторы предположили, что я переизобретаю Фаулера и предлагаю давно известные шаблоны проектирования. Это точно не так. Я предлагаю очень небольшой инструмент параметризации, который позволяет реализовать эти шаблоны с минимумом писанины. В том числе и Dependency Injection и Service Locator Фаулера, но не только — так же с помощью класса TypedSet можно так же экономно реализовать набор стратегий. При этом доступ у Фаулера осуществлялся через строки, что дорого — мой инструмент zero-cost, нулевая стоимость (если совсем строго, то log(N) вместо 2M*log(N), где M — длина строки-параметра для Service Locator. А после появления constexpr typeid в с++20 цена должна стать полностью нулевой ). Поэтому прошу не расширять смысл статьи на паттерны проектирования. Здесь вы найдёте лишь метод дешёвой реализации этих паттернов.

Примеры будут на C++, но всё сказанное вполне реализуемо на Java. Возможно, со временем я и для Java'ы приведу рабочий код, если запрос на это будет в комментариях от Вас.

Часть 1. Сферическая архитектура в вакууме


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

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

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

Второй принцип — это модульность. Точнее, высокоизолированная модульность без использования библиотек/хардкода, не имеющих отношения к самому модулю. Сейчас при проектировании серверных архитектур модно делить монолит на микросервисы. Я же открою вам страшную тайну — каждый модуль в монолите должен быть как микросервис. В том плане, что должен легко выделятся из общего кода с минимумом подключаемых заголовков в тестовом окружении. Пока ещё непонятно, но я поясню на примере: Вы пытались когда-нибудь выделить shared_ptr из буста? Если вы при этом умудритесь потащить за собой не весь буст, а только половину его сырцов, то это значит, что вы убили дня три — пять на то, чтобы обрубить лишние зависимости!!! При этом вы потащите за собой то, что к shared_ptr точно не имеет никакого отношения!!!

И это хуже, чем ошибка — это архитектурное преступление.

При хорошей архитектуре вы должны иметь возможность выдрать shared_ptr, безболезненно и быстро заменив всё, что не имеет отношения к shared_ptr на тестовые версии. Например, тестовую версию аллокатора. Или забудем о бусте. Допустим, вы пишете парсер xml/html. Вам для парсера нужна работа со строками, и работа с файлами. И если уж мы говорим об идеальной архитектуре, не привязанной к потребностям конкретного производства/софтварной фирмы, то для парсера с идеальной архитектурой мы не имеем права использовать std::istream, std::file_system, std::string и хардкодить поисковые операции со строками в парсере. Мы должны предоставить интерфейс потока, интерфейс файловых операций (возможно, поделить на подинтерфейсы, но доступ к подинтерфейсам всё равно придётся производить через интерфейс модуля файловых операций), интерфейс работы со строками, интерфейс аллокатора и в идеале ещё и интерфейс самой строки. В результате мы можем безболезненно заменить всё, что не имеет отношения к парсингу тестовыми болванками, или вставить тестовую версию аллокатора/работы с файлами/строковый поиск с дополнительными проверками. Да и универсальность решения повысится — завтра под интерфейсом потока окажется не файл, а сайт где-то в интернете, и никто этого не заметит. Можно заменить стандартную библиотеку на Qt, а потом переехать на visual c++, а потом начать использовать только линуксовые вещи — и переделки будут минимальны. В качестве спойлера скажу, что при таком подходе в полный рост встаёт вопрос цены — закрывать интерфейсами всё, включая элементы стандартной библиотеки, дорого, но это уже вопрос не цели, а средств решения.

Вообще, провозглашаемый в этой статье радикальный принцип «модуль-как-микросервис» — это больное место C++ и вообще типового плюсового кода. Если создав файлы деклараций, выделив интерфейсы отдельно от имплементаций ещё можно создать независимость/изолированность cpp- файлов друг от друга, и то, относительную, не 100%, то заголовочники обычно сплетены в жёский монолит, из которого без мяса ничего и не выдрать. И хотя это ужасно влияет на время компиляции, но это так. При этом даже если достигнута независимость заголовочников, это автоматически означает невозможность агрегировать классы. Собственно, единственный способ достигнуть независимости и .cpp файлов, и заголовочников в c++ — это продекларировать заранее используемые классы (без их определения), а далее использовать только указатели на них. как только вы используете в заголовочном файле вместо указателя класса сам класс (то есть агрегируете его), вы создадите связку всех .cpp-шников, которые включат этот заголовочник, и того .cpp-шника, который содержит определение класса. Есть ещё fastpimpl, но он как раз гарантировано создаёт зависимости на уровне cpp.

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

Сформулируем основные признаки хорошей архитектуры, включая обозначенные выше моменты, попунктно.

Определимся с термином «Модуль». Модуль — это сумма логически связанных функциональностей. Например, работа с потоками или файловая работа, или html-парсер.

Модуль «Файловая работа» может сочетать много функциональностей — открыть файл, закрыть, позиционировать, считать свойства, считать размер файла. При этом сканер папок можно оформить как часть интерфейса «Файловая работа», а можно как отдельный модуль, а работу с потоками — точно выносить в отдельный модуль. Что, впрочем, не мешеает организовать доступ всем остальным модулям к потокам и сканеру папок опосредовано, через «Файловую работу». Это не обязательно, но вполне логично.

  1. Модульность. Императив «Модуль-как-микросервис».
  2. Выделение 20% кода, исполняемого 80% времени в отдельную библиотеку — ядро программы
  3. Тестируемость каждой функциональности каждого модуля
  4. Интерфейсность, она же отсутствие хардкода. Вы можете вызывать только тот хардкод, который непосредственно связан с функционалом модуля, а остальные прямые вызовы библиотек вы должны вынести в отдельный модуль и получать доступ к ним через интерфейс.
  5. Полная изоляция модуля интерфейсами от внешней среды. Запрет на «прибивание гвоздями» имплементаций, не имеющих отношения к функционалу класса. И более радикально, изоляция библиотек (включая стандартные) интерфейсами/адаптерами/декораторами
  6. Агрегация класса или создание переменной класса или fastpimpl используется только в случаях, когда это критично для производительности.

Конечно, мы с Вами разберём, как всего этого быстро добится за минимальную цену ниже, но я бы хотел обратить внимание ещё на одну проблему, решение которой будет нам в качестве бонуса — передача платформозависимых параметров. Например, если вам нужно сделать код, который одинаково будет работать и на Android'е, и на Windows, то будет логично платформозависимые алгоритмы выделить в отдельные модули. В этом случае, вероятно, имплементации для андроида может понадобится ссылка на ява (jni) окружение, JNIEnv *, и возможно пара ява-объектов. А имплементации на винде может понадобится рабочая папка программы (которую на андроиде можно запросить у системы, имея JNIEnv *). Фишка в том, что того же JNIEnv * в контексте винды не существует, поэтому даже типизированный union или его c++ альтернатива std::variant невозможен. Можно, конечно, в качестве параметра передавать вектор void *, или вектор std::any, но признаем честно: это атипичный костыль. Атипичный — потому что отказывается от главного преимущества c++, строгой типизации. И это опаснее, чем атипичная пневмония.

Дальше мы разберём, как решить этот вопрос в строго типизированной манере.

Часть 2. Волшебные пули и их ценник


Итак, допустим, у нас есть большой объём кода, который нужно написать с нуля, и в результате будет весьма крупный проект.

Как же можно собирать его в соответствии с определёнными нами принципами?

Классический способ, одобренный всеми мануалами — это поделить всё на интерфейсы и стратегии. С помощью интерфейсов и стратегий, если их много, любую подзадачу нашего проекта можно заизолировать до такой степени, что на ней начнёт работать принцип «модуль-как-микросервис». Но мой личный опыт состоит в том, что если вы будете делить проект на 20-30 частей, которые будут изолированы до уровня «модуль-как-микросервис», то у вас получится. Но главная фишка хорошей архитектуры — в возможности тестировать любой класс вне контекста проекта. А если уже каждый класс изолировать — то уже более 500 модулей, и по моему опыту, это увеличивает время разработки в 3-5 раз, а значит в «боевых условиях» вы так делать не будете и пойдёте на компромисс между ценой и качеством.

Кто-то может усомнится, и будет в своём праве. Давайте сделаем грубую прикидку. Пусть средний класс будет иметь 3-5 членов и 20 функций и 3 конструктора. Плюс 6-10 геттеров и сеттеров(мутаторов) для досупа к нашим членам. Итого порядка 40 единиц в классе. В типовом проекте каждому «центровому» классу нужен доступ в среднем к пяти функциональностям, не центровому к 3. Например, очень многие классы нуждаются в аллокаторе, файловой системе, работе со строками, работа с потоками, доступе к базам данных.

Каждая стратегия/интерфейс потребует одного члена типа std::shared_ptr<CreateStreamStrategy> m_create_stream;. Двух мутаторов, плюс инициализация в каждом из трёх конструкторов. плюс где-то при инициализации нашего класса нужно будет вызывать пару раз что-то вроде myclass->SetCreateStreamStrategy( my_create_stream_strategy ), итого 8 единиц на интерфейс/стратегию, а так как их у нас примерно пять, то будет 40 единиц. То есть мы сделали исходный класс вдвое более громоздким. А потеря простоты неминуемо скажется на читабельности, и ещё где-то в процессе отладки, причём раза в полтора, несмотря на то, что ничего вроде по сути не поменялось.

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

Мы пишем класс в стиле

template < struct Traits > class MyClass {
 public:
  void DoMainTaskFunction() {
    ...
    MyStream stream = Traits::streamwork::Open( stream_name );
    ...
  }
};

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

Я сам люблю пошаблонить, но с сожалением для себя признаю: шаблоны в ординарном коде любят только шаблонные маги. Значительная масса программистов при слове «шаблон» слегка поморщится. Более того, в отрасли огромная часть плюсовиков на самом деле никакие не плюсовики, а слегка переученные на c++ сишники, которые не обладают глубокими познаниями в плюсах, а при слове «шаблон» падают, и притворяются мёртвыми.

Если перевести это на производственный язык, то сопровождение кода на статической параметризации дороже и сложнее.

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

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

Часть третья. Предлагаемое решение и нашкоденный на это решение код


Итак, ТАДАМ!!! Встречайте — шаблонный класс TypedSet. Одному единственному типу он сопостовляет один умный указатель данного типа. При этом для указанного типа у него может быть объект, а может и не быть. Название мне не нравится — поэтому буду благодарен, если в комментариях подскажете более удачный вариант.

Один тип — один объект. Но число типов не ограничено! Поэтому можно передавать такой класс в качестве параметризатора.

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

Сделаем три базовые функции: Create, Get и Has. Соответственно создание, получение, и проверка наличия элемента.


/// @brief Класс для хранения стратегий. Хранит для одного типа один указатель, но только один
///        При этом указатель в коллекции появляется только после его создания
///
class TypedSet {
 public: 
  template <class TypedElement> void Create( const std::shared_ptr<TypedElement> & value );
  template <class TypedElement> std::shared_ptr<TypedElement> Get() const;
  template <class TypedElement> bool Has() const;
  
  size_t GetSize() const { return storage_.size(); }
 
 protected:
  typedef std::map< size_t, std::shared_ptr<void> > Storage;
  
  Storage const &     storage() const { return storage_; }
  Storage       & get_storage()       { return storage_; }  
  
 private:
  Storage storage_;
};

template <class TypedElement> void TypedSet::Create(
                    const std::shared_ptr<TypedElement> & value ) {
  size_t hash = typeid(TypedElement).hash_code();
  if (  storage().count( hash ) > 0   ) {
      LogError( "Access Violation" );
      return;
  }
  std::shared_ptr<void> to_add ( value );
  get_storage().insert(   std::pair( typeid(TypedElement).hash_code(), to_add )   );
}

template <class TypedElement> bool TypedSet::Has() const {
  size_t hash = typeid(TypedElement).hash_code();
  return storage().count( hash ) > 0;
}


template <class TypedElement> std::shared_ptr<TypedElement> TypedSet::Get() const {
    size_t hash = typeid(TypedElement).hash_code();
    if (  storage().count( hash ) > 0   ) {
        std::shared_ptr<void> ret( storage().at(hash) );
        return std::static_pointer_cast<TypedElement>( ret );
    } else {
        LogError( "Access Violation" );
        return std::shared_ptr<TypedElement> ();
    }
}

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

GlobalConfigurator()["FileSystem"].Get().As<FileSystem>()

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

На основе TypedSet мы можем закрафтить класс StrategiesSet, уже более продвинутый. В нём мы будем хранить не только по одному объекту на интерфейс доступа к каждому функционалу, но и на каждый интерфейс (далее по тексту — стратегию) дополнительный TypedSet – с параметрами для данной стратегии. Уточняю: параметры, в отличии от переменных функции — это то, что задаётся один раз при инициализации программы или один раз за крупный прогон программы. Параметры позволяют делать код истинно кросплатформенным. Именно в них мы загоняем всю платформо-зависимую кухню.

Тут у нас будет больше базовых функций: Create, Get, CreateParamsSet и GetParamsSet. Has не закладываю, т. к. он архитектурно избыточен: если ваш код обращается к функционалу работы с файловой системы, а вызывающий код его не предоставил, вы можете только исключение бросить или assert, или сделать программе сепукку вызвать функцию abort().


class StrategiesSet {
 public:  
  template <class Strategy> void Create( const std::shared_ptr<Strategy> & value );
  template <class Strategy> std::shared_ptr<Strategy> Get();
    
  template <class Strategy> void CreateParamsSet();
  template <class Strategy> std::shared_ptr<TypedSet> GetParamsSet();
  template <class Strategy, class ParamType> void CreateParam( const std::shared_ptr<ParamType> & value );
  template <class Strategy, class ParamType> std::shared_ptr<ParamType> GetParam();
  
 protected:
  TypedSet const &     strategies() const { return strategies_; }
  TypedSet       & get_strategies()       { return strategies_; }
  TypedSet const &     params() const { return params_; }
  TypedSet       & get_params()       { return params_; }
  
  template <class Type> struct ParamHolder {
    ParamHolder( ) : param_ptr( std::make_shared<TypedSet>() ) {}
    
    std::shared_ptr<TypedSet> param_ptr;
  };
 
 private:
   TypedSet strategies_;
   TypedSet params_;
};


template <class Strategy> void
StrategiesSet::Create( const std::shared_ptr<Strategy> & value ) { 
      get_strategies().Create<Strategy>( value ); 
}

template <class Strategy> std::shared_ptr<Strategy>
StrategiesSet::Get() { 
  return get_strategies().Get<Strategy>();
}
    
template <class Strategy> void
StrategiesSet::CreateParamsSet(  ) {
  typedef ParamHolder<Strategy> Holder;    
  std::shared_ptr< Holder > ptr = std::make_shared< Holder >( );
  ptr->param_ptr = std::make_shared< TypedSet >();
  get_params().Create< Holder >( ptr );
}

template <class Strategy> std::shared_ptr<TypedSet> 
StrategiesSet::GetParamsSet() {
  typedef ParamHolder<Strategy> Holder;
  if ( get_params().Has< Holder >() ) {
    return get_params().Get< Holder >()->param_ptr;
  } else {
    LogError("StrategiesSet::GetParamsSet : get unexisting!!!");      
    return std::shared_ptr<TypedSet>();
  }
}

template <class Strategy, class ParamType> void 
StrategiesSet::CreateParam( const std::shared_ptr<ParamType> & value ) {
  typedef ParamHolder<Strategy> Holder;
  if ( !params().Has<Holder>() ) CreateParamsSet<Strategy>();
  if ( params().Has<Holder>() ) {
    std::shared_ptr<TypedSet> params_set = GetParamsSet<Strategy>();
    params_set->Create<ParamType>( value );
  } else {
      LogError( "Param creating error: Access Violation" );
  }
}

template <class Strategy, class ParamType> std::shared_ptr<ParamType>
StrategiesSet::GetParam() {    
  typedef ParamHolder<Strategy> Holder;
  if ( params().Has<Holder>() ) {
    return GetParamsSet<Strategy>()->template Get<ParamType>(); // ключевое слово template позволяет вызывать шаблонный метод по указателю без создания промежуточной переменной. Без него не скомпилируется.
  } else {
    LogError( "Access Violation" );
    return std::shared_ptr<ParamType> ();
  }
}

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

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

class Interface1 {
 public:
  virtual void Fun() { printf("\niface1\n");}
  virtual ~Interface1() {}
};

class Interface2 {
 public:
  virtual void Fun() { printf("\niface2\n");}
  virtual ~Interface2() {}
};

class Interface3 {
 public:
  virtual void Fun() { printf("\niface3\n");}
  virtual ~Interface3() {}
};

class Implementation1 : public Interface1 {
 public:
  virtual void Fun() override { printf("\nimpl1\n");}
};

class Implementation2 : public Interface2 {
 public:
  virtual void Fun() override { printf("\nimpl2\n");}
};

class PrintParams {
 public:
  virtual ~PrintParams() {}
  virtual std::string GetOs() = 0;
};

class PrintParamsUbuntu : public PrintParams  {
 public:
  virtual std::string GetOs() override { return "Ubuntu"; }
};

class PrintParamsWindows : public PrintParams  {
 public:
  virtual std::string GetOs() override { return "Windows"; }
};

class PrintStrategy {
 public:
  virtual ~PrintStrategy() {}
  virtual void operator() ( const TypedSet& params, const std::string & str ) = 0;
};

class PrintWithOsStrategy : public PrintStrategy {
 public:
  virtual void operator()( const TypedSet& params, const std::string & str ) override {
    auto os = params.Get< PrintParams >()->GetOs();
    printf(" Printing: %s (OS=%s)", str.c_str(), os.c_str() );
  }
};

void TestTypedSet() {
  using namespace std;
  TypedSet a;
  a.Create<Interface1>( make_shared<Implementation1>() ); 
  a.Create<Interface2>( make_shared<Implementation2>() ); 
  a.Get<Interface1>()->Fun();
  a.Get<Interface2>()->Fun();
  Log("Double creation:");    
  a.Create<Interface1>( make_shared<Implementation1>() ); 
    
  Log("Get unexisting:"); 
  a.Get<Interface3>();
}

void TestStrategiesSet() {    
  using namespace std;
  StrategiesSet printing;
  printing.Create< PrintStrategy >( make_shared<PrintWithOsStrategy>() );
  printing.CreateParam< PrintStrategy, PrintParams >( make_shared<PrintParamsWindows>() );
    
  auto print_strategy_ptr = printing.Get< PrintStrategy >();
  auto & print_strategy = *print_strategy_ptr;
  auto & print_params = *printing.GetParamsSet< PrintStrategy >();
  print_strategy( print_params, "Done!" );
}

int main()
{
  TestTypedSet();    
  TestStrategiesSet();    
  return 0;
}

Резюме


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

Код примера и инструментальных классов полностью можно найти здесь

Upd. от 13.11.2019
На самом деле приведённый здесь код — всего лишь упрощённый для читабельности пример. Дело в том, что typeid().hash_code реализован в современных компиляторах медленно и неэффективно. Его использование убивает значительную часть смысла. Более того, как подсказал уважаемый 0xd34df00d, стандарт не гарантирует возможности различать типы по хэшкоду(практически, такой подход однако работает). Но зато пример хорошо читаем. Я переписал TypedSet без typeid().hash_code(), более того, заменил map на array (но с возможностью быстро переключать с map на array и обратно изменением одной цифры в #if). Получилось сложнее, но для практического использования интереснее.
на coliru

 namespace metatype {

struct Counter {
  size_t GetAndIncrease() { return counter_++; }
 private:
   size_t static inline counter_ = 1;
};

template <typename Type> struct HashGetterBody {
  HashGetterBody() : hash_( counter_.GetAndIncrease() ) {
  }
  
  size_t GetHash() { return hash_; }
 private:
  Counter counter_;
  size_t hash_;
};

template <typename Type> struct HashGetter {
  size_t GetHash() {return hasher_.GetHash(); }
 private:
  static inline HashGetterBody<Type> hasher_;
};
     
} // namespace metatype

template <typename Type> size_t GetTypeHash() {
  return metatype::HashGetter<Type>().GetHash();
}


namespace details {
#if 1 // если критична скорость, и есть готовность платить за неё памятью (массив)
class TypedSetStorage {
 public:
  static inline const constexpr size_t kMaxTypes = 100;
  typedef std::array< std::shared_ptr<void>, kMaxTypes > Storage;
  void Set( size_t hash_index, const std::shared_ptr<void> & value ) {
    ++size_;
    assert( hash_index < kMaxTypes ); // too many types
    data_[hash_index] = value;
  }
  std::shared_ptr<void> & Get( size_t hash_index ) {
    assert( hash_index < kMaxTypes );
    return data_[hash_index]; 
  }
  const std::shared_ptr<void> & Get( size_t hash_index ) const { 
    if ( hash_index >= kMaxTypes ) return empty_ptr_;
    return data_[hash_index]; 
  }
  bool Has( size_t hash_index ) const {
    if (  hash_index >= kMaxTypes ) return 0;
    return (bool)data_[hash_index];
  }
  size_t GetSize() const { return size_; }
  
 private:
  Storage data_;
  size_t size_ = 0;
  static const inline std::shared_ptr<void> empty_ptr_;
};
#else // если память нужно сэкономить, а небольшие потери скорости доступа не критичны (std::map)
class TypedSetStorage {
 public:
  typedef std::map< size_t, std::shared_ptr<void> > Storage;
  void Set( size_t hash_index, const std::shared_ptr<void> & value ) { data_[hash_index] = value; }
  std::shared_ptr<void> & Get( size_t hash_index ) { return data_[hash_index]; }
  const std::shared_ptr<void> & Get( size_t hash_index ) const { return data_.at(hash_index);   }
  bool Has( size_t hash_index ) const { return data_.count(hash_index) > 0; }  
  size_t GetSize() const { return data_.size(); }
  
 private:
  Storage data_;
};
#endif
    
} // namespace details

/// @brief Класс для хранения стратегий. Хранит для одного типа один указатель, но только один
///        При этом указатель в коллекции появляется только после его создания
///
class TypedSet {
 public: 
  template <class TypedElement> void Create( const std::shared_ptr<TypedElement> & value );
  template <class TypedElement> std::shared_ptr<TypedElement> Get() const;
  template <class TypedElement> bool Has() const;
  
  size_t GetSize() const { return storage_.GetSize(); }
 
 protected:
  typedef details::TypedSetStorage Storage;
  
  Storage const &     storage() const { return storage_; }
  Storage       & get_storage()       { return storage_; }
  
  
 private:
  Storage storage_;
};

template <class TypedElement> void TypedSet::Create(
                    const std::shared_ptr<TypedElement> & value ) {                        
  size_t hash = GetTypeHash<TypedElement>();
  if (  storage().Has( hash )  ) {
      LogError( "Access Violation" );
      return;
  }
  std::shared_ptr<void> to_add ( value );
  get_storage().Set( hash,  to_add );
}

template <class TypedElement> bool TypedSet::Has() const {
  size_t hash = GetTypeHash<TypedElement>();
  return storage().Has( hash );
}


template <class TypedElement> std::shared_ptr<TypedElement> TypedSet::Get() const {
    size_t hash = GetTypeHash<TypedElement>();    
    if ( storage().Has( hash )   ) {
      std::shared_ptr<void> ret( storage().Get( hash ) );
      return std::static_pointer_cast<TypedElement>( ret );
    } else {
      LogError( "Access Violation" );
      return std::shared_ptr<TypedElement> ();
    }
}

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

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


  1. humbug
    11.11.2019 03:59

    del


  1. MooNDeaR
    11.11.2019 09:36
    +2

    Я сейчас правильно понял, что только что был изобретен очередной DI-фреймфорк?


    1. astapov Автор
      11.11.2019 14:11

      Точно нет. Я не предлагаю никаких фреймворков. Это как сравнить электросамокат с Бентли. Но по результатам комментов я сделал апдейт в тексте. Но в каком-то смысле вы правы — я предлагаю два класса, с помощью которых можно эффективно и дёшево (имхо) параметризовать другой класс, или реализовать Dependency Injection и Service Locator, или без лишней писанины добавить в класс «оптом» десяток стратегий.


  1. svr_91
    11.11.2019 09:48
    +2

    > Да и универсальность решения повысится — завтра под интерфейсом потока окажется не файл, а сайт где-то в интернете, и никто этого не заметит

    Почему-то все писатели таких «советов» игнорируют, что есть минимум 2 типа интерфейсов — синхронный и асинхронный. И работа с ними очень сильно отличается


    1. lair
      11.11.2019 12:15

      … а еще chatty/chunky. Нет, нельзя, нельзя трактовать локальное и удаленное одинаково.


    1. astapov Автор
      11.11.2019 14:14

      Причём здесь инструмент? сделайте два интерфейса, если работа с ним так различна. какие-нибудь SynchronousWork и AsynchronousWork. Инструмент позволяет сделать и так, и так, а как Вам надо — Вам виднее.


      1. svr_91
        11.11.2019 17:32

        И потом поддерживать 4 разных версии кода? (синхронный ввод-вывод, асинхронный ввод-вывод, синхронная сеть, асинхронная сеть).
        Я просто почему прицепился, уже не в первый раз вижу, когда говорится «просто возьмем и заменим». Вот бы и показали тогда, как это просто. А то попахивает банальным маркетингом идеи, как в разных книжках типа «а давайте накрутим архитектуру ради архитектуры». Не надо так


  1. andreyverbin
    11.11.2019 10:16
    +1

    Вы только что реализовали паттерн Service Provider, ещё один шаг и сделаете что-то вроде Dependency Injection.


    1. astapov Автор
      11.11.2019 14:19

      Сожалею, что недостаточно чётко составил текст, из-за чего у Вас сложилось ложное представление о моих предложениях. Я не предлагаю паттерны — и не реализую их. Я предлагаю лишь маааленький инструментик — на 20 строк — который позволяет реализовывать некоторые паттерны ( в том числе и фаулеровский Service Locator — я правильно понял, вы его имели в виду под Service Provider? ), но не только. Стратегии тоже можно фигачить им. Важно, что в отличие от фаулеровского решения (которого придерживались мои коллеги на Qt) в предложенном инструменте минимум писанины и скорость доступа не 2*длина_строки*log(число_сервисов), а просто log(число_сервисов). Было бы конечно, круто придумать новый паттерн проектирования, но я изначально замахивался на нечто более скромное.


      1. unC0Rr
        11.11.2019 19:11

        скорость доступа не 2*длина_строки*log(число_сервисов)

        Бьюсь об заклад, коллеги на Qt использовали QHash.


        1. astapov Автор
          12.11.2019 00:57

          в этом коде тоже движением руки можно заменить маппер на std::unordered_map или QHash. Просто для примера с 2мя интерфейсами это неопрадванно. Но основная часть состоит не в log(n), а в последовательном strlen + хэшировании строки. Что касается хэша — при большом количестве данных он всё равно становится log(n). просто с хорошим понижающим коэффициентом. Хотя, если честно, я немного упростил. Для «боевой» системы нужно выбрасывать typeid, присваивать по запросу каждому классу номер (считая его один раз), и делать даже не хэшмап, а дек c кусками большого размера. грубо говоря, вы выделяете массив индексов заведомо больший, чем число типов у вас в использовании, но навсякий случай, чтобы избежать его переполнения, вместо array используете deque. Впрочем, и array сойдёт. Дело в том, что из-за неэффективной реализации typeid на современных компиляторах, получатся те же грабли, что и QHash[«ИмяМоегоИнтерфейса»], один в один. Эта дрянь не кэширует хэш (хотя его можно посчитать compile-time), поэтому он найдёт в typeid-структуре имя класса, сделает ему strlen, а потом захэширует. поэтому код в тексте статьи я попозже дополню более сложным «боевым», более оптимизированным, но менее понятным и читабельным.

          Если вы читаете асм и с++ — вот вам пример на тему gcc.godbolt.org/z/esZgmf


  1. AlexanderG
    11.11.2019 13:37

    Миллениалы снова придумывают паттерны 30-летней давности. Пока выходят подобные статьи, за конкуренцию за рынке труда можно не беспокоиться…


    1. astapov Автор
      11.11.2019 14:06

      Раз столько комментариев на тему переоткрытия мною велосипедов, значит, я недостаточно чётко сформулировал текст. Я не предлагаю паттернов проектирования вообще. Я предлагаю метод их реализации за нулевую стоимость и с минимумом писанины. Причём сам метод укладывается в 20 строк кода. Если кратко, то я предлагаю метод экономной параметризации класса (в смысле числа строк кода и скорости использования) чем угодно. И если вам интересно, я не миллениал и старше Вас.


      1. AlexanderG
        11.11.2019 15:48

        Кстати, а где обещанная в заголовке Java?


        1. astapov Автор
          12.11.2019 00:39

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


          1. AlexanderG
            13.11.2019 12:25

            То есть вы ещё и лжёте


          1. lair
            13.11.2019 13:25

            Но я не спец по яве

            А на основании чего вы тогда делаете утверждение об "универсальном методе построения архитектуры приложения на [...] Java за минимальн. цену"


            PS. Вообще, конечно, вот это "минимальн." — это, как мне кажется, хорошая демонстрация оптимизации.


            1. astapov Автор
              13.11.2019 21:49
              -2

              На основании своих внутренних ощущений и представлений.

              моя точка зрения — не пуп земли

              минимальн. — это результат ограничения на длину заголовка. двух символов не хватило — либо минимальн. либо це вместо цену


              1. lair
                13.11.2019 22:30
                +1

                Занятно, как обоих вопросов можно было бы избежать, убрав "и Java" из заголовка...


                1. astapov Автор
                  14.11.2019 07:13

                  во-первых, пример на java будет, думаю. Во-вторых, это моя первая статья на хабре(не считая песочницы) и я не ожидал холодного приёма. Полагал, что не вызову столь большого интереса)


                  1. igormich88
                    14.11.2019 13:03
                    +1

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


  1. lair
    12.11.2019 00:55

    Я предлагаю очень небольшой инструмент параметризации, который позволяет реализовать эти шаблоны с минимумом писанины. В том числе и Dependency Injection и Service Locator Фаулера

    Нет, я все-таки спрошу. А как с помощью вашего инструмента реализовать dependency injection?


    1. astapov Автор
      12.11.2019 02:05

      а вы про Constructor Injection, Setter Injection, или про Interface Injection?


      1. lair
        12.11.2019 11:21

        В первую очередь — про Constructor, конечно же, это самый естественный вариант. Interface мне кажется необоснованно многословным, а Setter слишком часто требует лишних зависимостей (и оба они мешают инвариантам).


  1. 0xd34df00d
    12.11.2019 19:43
    +3

    size_t hash = typeid(TypedElement).hash_code();

    Оу. Сейчас больно вот без ноги остаться было.


    «No other guarantees are given: type_info objects referring to different types may have the same hash_code (although the standard recommends that implementations avoid this as much as possible), and hash_code for the same type can change between invocations of the same program.» Более того, на практике hash_code() может меняться, например, после загрузки shared object'а, и один и тот же тип в коде в разных .so может иметь разный hash_code().


    1. astapov Автор
      13.11.2019 00:08

      Спасибо, поставил плюсик. Не знал этого момента, больше полагался на то, как компилер в ассемблерный код это компилит. Впрочем, я всё равно собирался выложить «боевой код», т.е. код с меньшим числом упрощений в угоду читабельности. И там избавиться от typeid. Дело в том, что typeid на clang очень медленно работает, вызывая strlen и хэшируя название класса при каждом вызове. А это убивает всю идею. Смысл ведь в низкой цене и по скорости, и по числу строк. И заодно заменить std::map на std::array, что даст радикальное ускорение
      А вообще, отсутствие гарантий и UB бывают чисто теоретическими. Пример — атомарность операций с выровненным int. Стандарт не гарантирует — а Intel гарантирует. И arm, кажется, тоже.
      Или выход указателей за пределы выделенного куска памяти. По стандарту — это UB, но очень много людей на это полагается и строит, например, проверку выхода за пределы массива.


      1. astapov Автор
        14.11.2019 00:37

        «боевой код» выложил. Будет время — и до явы дойду.


    1. astapov Автор
      13.11.2019 00:25

      «Более того, на практике hash_code() может меняться, например, после загрузки shared object'а, и один и тот же тип в коде в разных .so может иметь разный hash_code().»

      Не наблюдал такого. clang и с O0, и с O3 и gcc на O3 просто херачит прямо runtime хэш имени класса, который задан в typeid info типа. Если тип известен на этапе компиляции — то автоматом загружает нужную typeid таблицу. Если динамическое определение — то из виртуальной таблицы ссылку на typeid таблицу берёт. А вот насчёт MS VC Не скажу — там всё упирается в функцию __std_type_info_hash, которую godbolt не показывает. но судя по расширению .so вы не о мелкософовском компиляторе…

      Но компилятор можно и надурить. Если в указателе на невиртуальный предок записан адрес потомка. При указателе на невиртуальный класс компилер не будет разбираться и будет загружать typeid того типа, на который указывает формально указатель gcc.godbolt.org/z/TeNJsE. Тоже, полагаю, будет из shared_ptr — механизм тот же.