вторник, 21 февраля 2023 г.

[prog.c++] Эксперимент с метапрограммрованием для создания более продвинутого варианта шаблона tagged_value_t

В языке C++ мне очень не хватает т.н. strong typedef. Временами хочется сказать, что вот этот вот int -- это ширина (width), а вот этот вот int -- это высота (height). Но в C++ штатных возможностей вывести разные несовместимые друг с другом типы из одного базового нет. Поэтому приходится велосипедить. Иногда просто вот так:

struct width_t { int value; }
struct height_t { int height; }

void fill(width_t w, height_t h) {...}

fill(width_t{640}, height_t{480});

Иногда и посложнее.

Для упрощения себе жизни когда-то сделал несложный вспомогательный тип tagged_value_t по типу вот такого:

templatetypename V, typename Tag >
class tagged_value_t
{
   V m_value;

public:
   explicit tagged_value_t( const V & value ) : m_value( value ) {}
   explicit tagged_value_t( V && value ) : m_value( std::forward<V>(value) ) {}

   [[nodiscard]]
   const V &
   value() const noexcept { return m_value; }

   [[nodiscard]]
   V &
   value() noexcept { return m_value; }
};

Этот тип кочует у меня из одного проекта в другой и позволяет делать что-то вроде:

struct url_tag {};
using url_t = tagged_value_t< std::string, url_tag >;

struct username_tag {};
using username_t = tagged_value_t< std::string, username_tag >;

struct raw_password_tag {};
using raw_password_t = tagged_value_t< std::string, raw_password_tag >;

struct base64_password_tag {};
using base64_password_tag = tagged_value_t< std::string, base64_password_tag >;

Подобные штуки применяю для указания типов параметров для функций/методов/конструкторов. В принципе удобно: на входе в функцию/метод ошибиться сложно, а внутри можно извлекать значения исходных типов (тех же int или std::string) и работать с ними привычными способами.

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

Понятное дело, что для каждого tagged_value выписывать ручками operator< или operator== не хочется. Поэтому решил попробовать посредством метапрограммирования модифицировать tagged_value_t так, чтобы в нем появлялись operator< и/или operator== если таковые операторы определены для исходного типа.

Под катом то, что у меня получилось в первом приближении. Для C++17, т.к. это самый "свежий" стандарт, который я могу себе позволить. Возможно, концепты из C++20 позволят записать все это компактнее.

Получилось довольно объемно и не сказать, чтобы тривиально.

Хотя здесь я должен сделать важный дисклаймер: мои знания C++ за последние года 2-3 заметно ухудшились, т.к. особо упражняться было негде, текущая загрузка не предусматривает необходимости углубляться в тонкости языка или строить какие-то сложные шаблонные конструкции. Так что прошу принять и простить, пианист играет как умеет... :(

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

Основная же идея такова (на примере operator==, т.к. он тривиальнее): нужно иметь два базовых класса, один из которых реализует operator==, а второй нет. Что-то вроде:

templatetypename Derived >
struct equal_to_operator_impl_t
{
private:
   [[nodiscard]]
   const Derived &
   as_derived() const noexcept { return static_cast<const Derived &>(*this); }

public:
   [[nodiscard]]
   bool
   operator==( const Derived & o ) const
      noexceptnoexcept(as_derived().value() == o.value()) )
   {
      return as_derived().value() == o.value();
   }
};

struct no_equal_to_operator_impl_t
{
};

Эти типы предназначены для того, чтобы их использовали в качестве базовых типов в паттерне CRTP. Соответственно, нужна метафункция-селектор, которая выберет один из этих типов:

templatetypename Tagged_Value, bool Has_Equal_To_Comparison >
struct equal_to_operator_provider_t_fn
{
   using type = no_equal_to_operator_impl_t;
};

templatetypename Tagged_Value >
struct equal_to_operator_provider_t_fn< Tagged_Value, true >
{
   using type = equal_to_operator_impl_t< Tagged_Value >;
};

templatetypename Value, typename Tagged_Value >
using equal_to_operator_selector_t =
      typename equal_to_operator_provider_t_fn<
            Tagged_Value,
            has_equal_to_comparison_v_fn< Value >::value
      >::type;

В роли метафункции-селектора выступает equal_to_operator_selector_t, а он, в свою очередь, опирается на equal_to_operator_provider_t_fn. Причем шаблон equal_to_operator_provider_t_fn через частичную специализацию имеет две версии: одну для случая, когда operator== есть (тогда Has_Equal_To_Comparison равен true), а вторую -- когда operator== нет.

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

templatetypenametypename = std::void_t<> >
struct has_equal_to_comparison_v_fn : public std::false_type {};

templatetypename T >
struct has_equal_to_comparison_v_fn<
      T,
      std::void_t< decltype( std::declval<T>() == std::declval<T>() ) >
   >
   : public std::true_type {};

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

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

И не обошлось без забавных сочетаний символов, например: std::less<>{}(). Почувствуй себя растоманом, что называется :)

В очередной раз остается пожалеть, что в C++ нет static if из D, реализация tagged_value_t с таким static if была бы гораздо проще и компактнее.

Отдельно доставило дублирование кода для того, чтобы вывести noexcept для операторов сравнения/равенства. Насколько я понимаю, здесь C++ ничего не может предложить.

Если кто-то будет разбираться с кодом, то для operator< я специально заморочился на использование std::less, т.к. вроде бы std::less позволяет безопасно сравнивать указатели в C++ (что, вообще-то говоря, опасно делать через встроенный оператор сравнения для указателей, т.к. это чревато UB).

В общем, вот код, а поиграться с ним можно на wandbox-е.

Если кого-то еще интересует эта тема, то что интересное я когда-то видел в библиотеке type_safe от foonathan (документация по strong_typedef).

#include <iostream>
#include <set>
#include <string>

#include <functional>
#include <utility>
#include <type_traits>

namespace tagged_value_impl
{

templatetypenametypename = std::void_t<> >
struct has_less_then_comparison_v_fn : public std::false_type {};

templatetypename T >
struct has_less_then_comparison_v_fn<
      T,
      std::void_t< decltype( std::declval<T>() < std::declval<T>() ) >
   >
   : public std::true_type {};

templatetypename Derived >
struct less_then_operator_impl_t
{
private:
   [[nodiscard]]
   const Derived &
   as_derived() const noexcept { return static_cast<const Derived &>(*this); }

public:
   [[nodiscard]]
   bool
   operator<( const Derived & o ) const
      noexceptnoexcept( std::less<>{}( as_derived().value(), o.value() )) )
   {
      return std::less<>{}( as_derived().value(), o.value() );
   }
};

struct no_less_then_operator_impl_t
{
};

templatetypename Tagged_Value, bool Has_Less_Then_Comparison >
struct less_then_operator_provider_t_fn
{
   using type = no_less_then_operator_impl_t;
};

templatetypename Tagged_Value >
struct less_then_operator_provider_t_fn< Tagged_Value, true >
{
   using type = less_then_operator_impl_t< Tagged_Value >;
};

templatetypename Value, typename Tagged_Value >
using less_then_operator_selector_t =
      typename less_then_operator_provider_t_fn<
            Tagged_Value,
            has_less_then_comparison_v_fn< Value >::value
      >::type;

templatetypenametypename = std::void_t<> >
struct has_equal_to_comparison_v_fn : public std::false_type {};

templatetypename T >
struct has_equal_to_comparison_v_fn<
      T,
      std::void_t< decltype( std::declval<T>() == std::declval<T>() ) >
   >
   : public std::true_type {};

templatetypename Derived >
struct equal_to_operator_impl_t
{
private:
   [[nodiscard]]
   const Derived &
   as_derived() const noexcept { return static_cast<const Derived &>(*this); }

public:
   [[nodiscard]]
   bool
   operator==( const Derived & o ) const
      noexceptnoexcept(as_derived().value() == o.value()) )
   {
      return as_derived().value() == o.value();
   }
};

struct no_equal_to_operator_impl_t
{
};

templatetypename Tagged_Value, bool Has_Equal_To_Comparison >
struct equal_to_operator_provider_t_fn
{
   using type = no_equal_to_operator_impl_t;
};

templatetypename Tagged_Value >
struct equal_to_operator_provider_t_fn< Tagged_Value, true >
{
   using type = equal_to_operator_impl_t< Tagged_Value >;
};

templatetypename Value, typename Tagged_Value >
using equal_to_operator_selector_t =
      typename equal_to_operator_provider_t_fn<
            Tagged_Value,
            has_equal_to_comparison_v_fn< Value >::value
      >::type;

/* namespace tagged_value_impl */

//
// tagged_value_t
//
templatetypename V, typename Tag >
class tagged_value_t
   :  public tagged_value_impl::less_then_operator_selector_t< V, tagged_value_t< V, Tag > >
   ,  public tagged_value_impl::equal_to_operator_selector_t< V, tagged_value_t< V, Tag > >
{
   V m_value;

public:
   using value_type = V;

   explicit tagged_value_t( const V & value ) : m_value( value ) {}
   explicit tagged_value_t( V && value ) : m_value( std::forward<V>(value) ) {}

   templatetypename... Args >
   tagged_value_t(
      std::piecewise_construct_t /*not_used*/,
      Args && ...args )
      : m_value{ std::forward<Args>(args)... }
   {}

   [[nodiscard]]
   const V &
   value() const noexcept { return m_value; }

   [[nodiscard]]
   V &
   value() noexcept { return m_value; }
};

using namespace std::string_literals;

struct ip_tag {};
using ip_t = tagged_value_t< std::string, ip_tag >;

struct simple_values_tag {};
using simple_ptr_t = tagged_value_t< int*, simple_values_tag >;

struct my_data
{
   std::string m_data;
};
struct binary_tag {};
using my_binaries_t = tagged_value_t< my_data, binary_tag >;

int main()
{
   ip_t a{ "192.168.1.1" };
   ip_t b{ "128.0.0.1" };

   std::cout << "a < b: " << (a < b) << std::endl;
   std::cout << "a > b: " << (b < a) << std::endl;
   std::cout << "a == b: " << (a == b) << std::endl;
   std::cout << "b == a: " << (b == a) << std::endl;
   std::cout << "a == a: " << (a == a) << std::endl;
   std::cout << "b == b: " << (b == b) << std::endl;

   std::set< ip_t > ips{
      ip_t{ "192.168.1.1" },
      ip_t{ "128.0.0.1" },
      ip_t{ "10.1.1.1" },
      { std::piecewise_construct, "192.168.100.1" },
      { std::piecewise_construct, "192.168.1.100" }
   };

   forconst auto & ip : ips )
      std::cout << "ip: " << ip.value() << std::endl;

   int i1 = 0;
   char padding_1 = 0; (void)padding_1;
   int i2 = 3;
   char padding_2 = 0; (void)padding_2;
   int i3 = 4;

   std::set< simple_ptr_t > ints{
      simple_ptr_t{ &i2 },
      simple_ptr_t{ &i3 },
      simple_ptr_t{ &i1 }
   };

   std::cout << &i1 << " " << &i2 << " " << &i3 << std::endl;
   forconst auto & p : ints )
      std::cout << p.value() << " ";
   std::cout << std::endl;

#if 0
   my_binaries_t bin1{ my_data{ "123456"s } };
   my_binaries_t bin2{ my_data{ "567890"s } };

   const auto cr = (bin1 < bin2);
   const auto er = (bin1 == bin2);
#endif
}

7 комментариев:

Alex комментирует...

на взгляд прикладника вполне годное и читаемое решение. void_t стала стандартной идиомой.

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

using url_t = tagged_value_t< std::string, struct url_tag >;

sergegers комментирует...

Если задавать поддерживаемые операторы явно - получится boost.operators.

eao197 комментирует...

@sergegers

Кстати да, спасибо, что напомнили.

Но у меня была идея записывать как-то так:

using url_t = tagged_value<std::string, url_tag, std::less, std::equal_to>

Т.е. шаблон был бы какой-то такой:

template<typename V, typename Tag, typename... Operators>
class tagged_value_t : ...

И список необходимых базовых классов для tagged_value_t формировался бы на основе разбора шаблонного параметра Operators.

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

eao197 комментирует...

@Alex

На тему объявления struct url_tag прямо по месту недавно была хорошая статья https://habr.com/ru/company/pvs-studio/blog/715436/

sergegers комментирует...

@eao197 У меня на boost.operators сделан самодельный strong_typedef. Сделаны несколько шаблонов, поддерживающие разные наборы операций. Их можно использовать как вы предполагаете - с помошью using декларации с базовым типом, тегом типа и дефолтным значением или с наследованием, где можно добавить дополнительные операции, методы и интероперабельность с другими типами.

Константин комментирует...

@eao197, вариант со свободной функцией без наследования не рассматривали?

template
auto operator < (const tagged_value_t& v1, const tagged_value_t& v2) -> decltype(v1.value() < v2.value())
{
return v1.value() < v2.value();
}

eao197 комментирует...

@Константин

Нет, не рассматривал. Нужно будет подумать об этом.

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