Именованные параметры в современном C++

в 10:08, , рубрики: c++, c++11, C++14, named parameters, Блог компании Инфопульс Украина, Программирование

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

Давайте посмотрим на пример:

createArray(10, 20); // Что это значит? Что за "10" ? Что за "20" ?
createArray(length=10, capacity=20); // О, вот теперь понятнее!
createArray(capacity=20, length=10); // И наоборот тоже работает.

И еще один пример на выдуманном псевдо-языке:

window = new Window {
   xPosition = 10,
   yPosition = 20,
   width = 100,
   height = 50
};

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

Комментарии

Давайте начнём с ненастоящего, но наиболее простого способа — эмуляция именованных параметров через комментарии :)

Window window {
 10, // xPosition
 20, // yPosition
 100, // width
 50 // height
};

Этот подход весьма популярен среди Windows-разработчиков, поскольку примеры в MSDN часто снабжены такими комментариями.

Идиома «именованного параметра»

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

// 1
File f { OpenFile{"path"} // это обязательно
   .readonly()
   .createIfNotExist()
   . ... };
// 2 классическая версия (не подходит для случая "хотим оставить всё по-умолчанию")
File f = OpenFile { ... }
   .readonly()
   .createIfNotExist()
   ... ;
// 3 для случая "хотим оставить всё по-умолчанию" - просто добавим ещё один слой (вызов CreateFile)
auto f = CreateFile ( OpenFile("path")
 .readonly()
 .createIfNotExists()
 . ... ));

Класс OpenFile — это набор параметров, а конструктор File принимает объект этого класса. Некоторые авторы (например, здесь) утверждают, что OpenFile должен иметь только private-члены и объявить класс File дружественным. Это может иметь смысл, если вы хотите использовать какую-то более сложную логику установки параметров. Но для присвоения простых значений вполне пойдет и вышеуказанный стиль с публичными методами.

В этом подходе:

  • Обязательные параметры всё так-же позиционны (вызов конструктора OpenFile должен быть первым и это нельзя изменить)
  • Опциональные параметры должны иметь конструкторы копирования (перемещения)
  • Вам нужно написать дополнительный прокси-класс

Идиома «пакета параметров»

Идея похожа на предыдущую и взята из книги Davide Di Gennaro’s Advanced C++ Metaprogramming – техника использования прокси-объектов для установки параметров через оператор присваивания (=), в итоге мы получим следующий синтаксических сахар:

MyFunction(begin(v), end(v), where[logger=clog][comparator=greater<int>()]);

Задействованные сущности:

  • logger и comparator — глобальные константы. Оператор присваивания просто возвращает обёрнутую копию присваиваемого значения
  • where — глобальная константа типа «пакет параметров». Её оператор [] просто возвращает новый прокси-объект, который заменяет один из своих членов новым аргументом.

В символах:

where = {a, b, c }
where[logger = x] → { a,b,c }[ argument<0>(x) ]  →   {x,b,c}

Набросок реализации:

// argument
template <size_t CODE, typename T = void>
struct argument
{
   T arg;
   argument(const T& that)
      : arg(that)
   {
   }
};
 
// void argument - just to use operator=
template <size_t CODE>
struct argument<CODE, void>
{
   argument(int = 0)
   {
   }
   template <typename T>
   argument<CODE, T> operator=(const T& that) const
   {
     return that;
   }
   argument<CODE, std::ostream&> operator=(std::ostream& that) const
   {
      return that;
   }
};
 
// "пакет аргументов" (хранит значения)
template <typename T1, typename T2, typename T3>
struct argument_pack
{
   T1 first;
   T2 second;
   T3 third;
   argument_pack(int = 0)
   {
   }
   argument_pack(T1 a1, T2 a2, T3 a3)
     : first(a1), second(a2), third(a3)
   {
   }
   template <typename T>
   argument_pack<T, T2, T3> operator[](const argument<0, T>& x) const
   {
      return argument_pack<T, T2, T3>(x.arg, second, third);
   }
   template <typename T>
   argument_pack<T1, T, T3> operator[](const argument<1, T>& x) const
   {
      return argument_pack<T1, T, T3>(first, x.arg, third);
   }
   template <typename T>
   argument_pack<T1, T2, T> operator[](const argument<2, T>& x) const
   {
      return argument_pack<T1, T2, T>(first, second, x.arg);
   }
};
 
enum { LESS, LOGGER };
const argument<LESS> comparator = 0;
const argument<LOGGER> logger = 0;
typedef argument_pack<basic_comparator, less<int>, std::ostream> pack_t;
static const pack_t where(basic_comparator(), less<int>(), std::cout);

За полным кодом загляните в оригинальную книгу.

Хотя техника и кажется интересной, на практике тяжело сделать её достаточно удобной и общной. В книге она вообще была представлена не решением рассматриваемой нами задачи, а примером «цепочного» вызова оператора [].

Теги

Andrzej Krzemieński опубликовал интересный пост «Интуитивный интерфейс», где предложил следующее: именованные параметры представляют собой пары компаньонов — реального значения и пустой структуры (пустые структуры разных типов нужны для выбора нужной перегруженной функции). Вот пример этого подхода из STL:

std::function<void()> f{std::allocator_arg, a}; // a - аллокатор
std::unique_lock<std::mutex> l{m, std::defer_lock}; // отложенный lock

Andrzej предложил обобщить подход:

// не настоящий STL
std::vector<int> v1(std::with_size, 10, std::with_value, 6);

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

Кроме того, один из читателей предложил хорошую идею другой реализации тегов.:
std::vector v1(std::with_size(10), std::with_value(6));

Boost

В Boost есть библиотека параметров.

Как и можно было ожидать, это довольно полная и практичная реализация. Пример:

// код класса
#include <boost/parameter/name.hpp>
#include <boost/parameter/preprocessor.hpp>
#include <string>
 
BOOST_PARAMETER_NAME(foo)
BOOST_PARAMETER_NAME(bar)
BOOST_PARAMETER_NAME(baz)
BOOST_PARAMETER_NAME(bonk)
 
BOOST_PARAMETER_FUNCTION(
   (int), // возвращаемый тип функции
   function_with_named_parameters, // имя функции
   tag, // часть "магии". Если вы используете BOOST_PARAMETER_NAME, в этом месте нужно вставить "tag"
   (required // имена и типы всех обязательных параметров
      (foo, (int))
      (bar, (float))
   )
   (optional // имена, типы и значения по-умолчанию всех опциональных параметров
      (baz, (bool) , false)
      (bonk, (std::string), "default value")
   )
)
{
   if (baz && (bar > 1.0)) return foo;
      return bonk.size();
}
 
// код клиента
function_with_named_parameters(1, 10.0);
function_with_named_parameters(7, _bar = 3.14);
function_with_named_parameters( _bar = 0.0, _foo = 42);
function_with_named_parameters( _bar = 2.5, _bonk= "Hello", _foo = 9);
function_with_named_parameters(9, 2.5, true, "Hello");

Именованные параметры в современном С++

Последние стандарты языка С++ открывают новые двери. Давайте посмотрим, получится ли применить что-нибудь из них для решения нашей задачи.

Лямбды

Метод «цепочных вызовов» слишком многословен. Я не хочу добавлять кучу функций, возвращающих сам объект. Как на счёт определить структуру и устанавливать её члены через лямбда-функции?

struct FileRecipe
{
   string Path; // обязательный параметр
   bool ReadOnly = true; // опциональный параметр
   bool CreateIfNotExist = false; // опциональный параметр
   // ...
};
 
class File
 {
   File(string _path, bool _readOnly, bool _createIfNotexist)
      : path(move(_path)), readOnly(_readOnly), createIfNotExist(_createIfNotExist)
 {}
 
private:
   string path;
   bool readOnly;
   bool createIfNotExist;
 };
 
auto file =  CreateFile( "path", [](auto& r) { // такая-себе мини-фабрика
   r.CreateIfNotExist = true;
});

Нам всё ещё нужен класс для хранения параметров, но сам подход масштабируется лучше, чем классическая идиома именованного параметра, в которой нужно явно прописать все «цепочные» функции. Ещё один вариант — сделать конструктор класса File, принимающий объект типа FileRecipe.

Как улучшить читаемость обязательных параметров? Давайте попробуем соединить данный подход с тегами:

auto file =  CreateFile( _path, "path", [](auto& r) {
   r.CreateIfNotExist = true;
 });

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

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

TEST_F(SomeDiceGameConfig, JustTwoTurnsGame)
{
   GameConfiguration gameConfig { 5u, 6, 2u };
}

С использованием данного подхода они могут выглядеть так:

TEST_F(SomeDiceGameConfig, JustTwoTurnsGame)
{
   auto gameConfig = CreateGameConfig( [](auto& r) {
       r.NumberOfDice = 5u;
       r.MaxDiceValue = 6;
       r.NumberOfTurns = 2u;
   });
}

Также мы можем использовать макрос, чтобы не повторяться в каждом тесте с вызовом одинаковых лямбд:

TEST_F(SomeDiceGameConfig, JustTwoTurnsGame)
{
   auto gameConfig = CREATE_CONFIG(
       r.NumberOfDice = 5u;
       r.MaxDiceValue = 6;
       r.NumberOfTurns = 2u;
   );
}

Использование Variadic Templates

Появившиеся в С++11 Variadic Templates могут улучшить способ, описанный выше. Давайте снова вспомним теги. Теги могут быть лучшим подходом, чем лямбда + объект параметров, поскольку нам не нужно создавать ещё один объект, нет проблем с конструкторами копирования, все параметры обрабатываются единообразно (с лямбдами нам приходилось иначе обрабатывать обязательные параметры). Но теги могут быть достаточно хорошим подходом, только если бы у нас вышло:

  • Обойтись объявлением лишь одного перегруженного конструктора или функции
  • Получить возможность свободного определения порядка параметров (пар «тег-значение»)
  • Иметь как обязательные, так и опциональные параметры

Что-то типа:

File f { _readonly, true, _path, "some path" };

или:

File f { by_name, Args&&... args) {} 

Моя идея в следующем: я хочу использовать Variadic Templates чтобы дать пользователю возможность определять порядок параметров и опускать опциональные параметры.

Представьте два конструктора:

File(string path, bool readonly, bool createIfNotExist) {} // все параметры обязательны
 
template<typename... Args>
File(by_name_t, Args&&... args) {}

Объект типа File может быть создан любым из двух способов. Если вы используете второй конструктор — он просмотрит все параметры в наборе и вызовет первый конструктор с соответствующим набором параметров. Просмотр параметров и генерация кода выполняется на этапе компиляции, занимает линейное время и не влияет на затраты времени на вызов в рантайме.

Данная реализация лишь набросок, наверняка её можно улучшить.

Вот как может быть спроектирован класс:

File(string path, bool readonly, bool createIfNotExists /*...*/)
   : _path (move(path)), _createIfNotExist(createIfNotExist), _readonly(readonly) // ,etc...
{
}
 
template<typename Args...>
File(named_tag, Args&&... args)
   : File{ REQUIRED(path), OPTIONAL(read, false) // , etc... } // делегирование
{
}

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

auto f = File { by_name, readonly=true, path="path" };

Основное отличие здесь в передаче аргументов: с прокси мы получаем синтаксический сахар (оператор=), но теперь нам нужно хранить и передавать значения (не очень хорошо для не-перемещаемых/копируемых типов).

Здесь вы можете поэкспериментировать с кодом. Я начал с версии с тегами и потом перешел к прокси, поэтому там обе версии. Вы найдёте две секции под названием “PACK UTILS” (для тегов и прокси).

Вот как будет выглядеть класс:

class window
{
public:
    // обычный конструктор
    window( string pTitle, int pH, int pW,
    int pPosx, int pPosy, int& pHandle)
       : title(move(pTitle)), h(pH), w(pW), posx(pPosx), posy(pPosy), handle(pHandle)
    {
    }
 
    // конструктор, использующий прокси (_title = "title")
    template<typename... pack>
    window(use_named_t, pack&&... _pack)
       : window { REQUIRED_NAME(title), // required
                  OPTIONAL_NAME(h, 100), // optional
                  OPTIONAL_NAME(w, 400), // optional
                  OPTIONAL_NAME(posx, 0), // optional
                  OPTIONAL_NAME(posy, 0), // optional
                  REQUIRED_NAME(handle) } // required
    {
    }
 
    // конструктор, использующий теги (__title, "title")
    template<typename... pack>
    window(use_tags_t, pack&&... _pack)
       : window { REQUIRED_TAG(title), // required
                  OPTIONAL_TAG(h, 100), // optional
                  OPTIONAL_TAG(w, 400), // optional
                  OPTIONAL_TAG(posx, 0), // optional
                  OPTIONAL_TAG(posy, 0), // optional
                  REQUIRED_TAG(handle) } // required
    {
    }
 
private:
  string title;
  int h, w;
  int posx, posy;
  int& handle;
};

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

Следующий кусок кода показывает, как пользователь может создать объект:

int i=5;
// версия с тегами
window w1 {use_tags, __title, "Title", __h, 10, __w, 100, __handle, i};
cout << w1 << endl;
 
// версия с прокси
window w2 {use_named, _h = 10, _title = "Title", _handle = i, _w = 100};
cout << w2 << endl;
 
// классическая версия
window w3 {"Title", 10, 400, 0, 0, i};
cout << w3 << endl;

Плюсы:

  • Обязательные и опциональные параметры используются однообразно
  • Порядок не определён жестко
  • Способ с тегами не имеет недостатков, связанных с передачей параметров
  • Способ с прокси весьма нагляден (за счет оператора =)

Минусы:

  • Ошибки на этапе компиляции могут быть сложны для понимания (static_assert может помочь в некоторых случаях)
  • Доступные параметры должны быть документированы
  • «Загрязнение» пространства имён лишними функциямиконструкторами
  • Значения по-умолчанию всегда вычисляются
  • Способ с тегами не идеален с точки зрения наглядности (тег и значение следуют через запятую)
  • Способ с прокси не идеален с точки зрения передачи параметров

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

main.cpp:28:2: error: static_assert failed "Required parameter"
        static_assert(pos >= 0, "Required parameter");
        ^             ~~~~~~~~
main.cpp:217:14: note: in instantiation of template class 'get_at<-1, 0>' requested here
                :       window { REQUIRED_NAME(title),

                                 ^

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

Минималистичный подход с использованием std::tuple

[этот параграф написал Davide Di Gennaro]

Мы можем использовать функционал кортежей (std::tuple) для написания весьма компактной и портируемой реализации нашей задачи. Мы будем опираться на несколько простых принципов:

  • Набор параметров будет специальным кортежем, где после каждого «типа тега» будет идти его значение (то есть тип будет чем-то вроде (std::tuple<age_tag, int, name_tag, string, … >)
  • Стандартная библиотека языка уже включает функции передачи / конкатенации объектов и кортежей, что гарантирует производительность и корректность
  • Мы будем использовать макрос для определения глобальных констант, представляющих тег
  • Синтаксис создания набора параметров будет выглядеть как (tag1=value1)+(tag2=value2)+…
  • Клиент будет принимать набор параметров как ссылку на шаблонный тип, т.е.

    template <typename pack_t>
    void MyFunction([whatever], T& parameter_pack) // или const T&, T&&, и т.д.

  • Внутри вызова функции клиент извлечёт нужные значения из набора параметров и как-то их использует (ну например запишет в локальные переменные):
namespace tag
{
   CREATE_TAG(age, int);
   CREATE_TAG(name, std::string);
}
 
template <typename pack_t>
void MyFunction(T& parameter_pack)
{
   int myage;
   std::string myname;
   bool b1 = extract_from_pack(tag::name, myname, parameter_pack);
   bool b2 = extract_from_pack(tag::age, myage, parameter_pack);
   assert(b1 && myname == "John");
   assert(b2 && myage == 18);
}
 
int main()
{
   auto pack =  (tag::age=18)+(tag::name="John");
   MyFunction(pack);
}

Вот как может выглядеть реализация этой идеи.

Сначала макрос:

#include <tuple>
#include <utility>
 
template <typename T>
struct parameter {};
 
#define CREATE_TAG(name, TYPE) 

   struct name##_t 
   { 
      std::tuple<parameter<name##_t>, TYPE> operator=(TYPE&& x) const 
      {  return std::forward_as_tuple(parameter<name##_t>(), x); } 
      
      name##_t(int) {} 
}; 

const name##_t name = 0

Раскрытие макроса CREATE_TAG(age, int) создаёт класс и глобальный объект.

struct age_t
{
   std::tuple<parameter<age_t>, int> operator=(int&& x) const
   {
      return std::forward_as_tuple(parameter<age_t>(), x);
   }
   age_t(int) {}
};
 
const age_t age = 0;

Концептуально присваивание

age = 18

Преобразовывается во что-то типа:

make_tuple(parameter<age_t>(), 18);

Обратите внимание, что мы написали:

std::tuple<parameter<age_t>, int> operator=(int&& x) const

Мы требуем r-value справа. Это сделано ради безопасности: ради повышения читабельности кода с наборами параметров вы можете захотеть присваивать константы, а не переменные.

int myage = 18;
f(myage); // ok
 
g((...) + (age=18)); // ok
g((...) + (age=myage)); // ошибка компиляции, а также избыточно с точки зрения читабельности 

Кроме того, мы можем использовать семантику перемещения:

Разница между

std::tuple<parameter<age_t>, int> operator=(int&& x) const
{
   return std::make_tuple(parameter<age_t>(), x);
}

и

std::tuple<parameter<age_t>, int> operator=(int&& x) const
{
   return std::forward_as_tuple(parameter<age_t>(), x);
}

очень тонкая. В последнем случае возвращается std::tuple<…, int&&>, но поскольку функция возвращает std::tuple<…, int> — вызывается конструктор перемещения std::tuple.

В виде альтернативы мы могли бы написать:

std::tuple<parameter<age_t>, int> operator=(int&& x) const
{
   return std::make_tuple(parameter<age_t>(), std::move(x));
}

А теперь мы напишем подходящий оператор конкатенации для наших кортежей.

Мы неявно соглашаемся с тем, что все кортежи, начинающиеся с parameter были созданы нашим кодом, так что без всякой явной валидации мы просто выбросим parameter.

template <typename TAG1, typename... P1, typename TAG2, typename... P2>
std::tuple<parameter<TAG1>, P1..., parameter<TAG2>, P2...>
operator+ (std::tuple<parameter<TAG1>, P1...>&& pack1, std::tuple<parameter<TAG2>, P2...>&& pack2)
{
    return std::tuple_cat(pack1, pack2);
}

Очень простая функция: проверяет, что оба кортежа имеют вид

tuple<parameter<tag>, type, [maybe something else]>

и соединяет их.

Ну и наконец, мы напишем функцию извлечения аргумента из набора. Обратите внимание, что данная функция имеет семантику переноса (т.е. после её вызова параметр будет извлечён из набора).

template <typename TAG, typename T, typename... P, typename TAG1>
bool extract_from_pack(TAG tag, T& var, std::tuple<parameter<TAG1>, P...>& pack);

Работает она следующим образом: если набора содержит parameter, тогда переменная получает значение, следующее непосредственно за ним и функция возвращает true. Иначе случается что-то плохое (мы можем выбрать — ошибка компиляции, вернуть false, сгенерировать исключение).

Чтобы сделать этот выбор возможным, функция будет выглядеть как:

template <typename ERR, typename TAG, typename T, typename... P, typename TAG1>
bool extract_from_pack(TAG tag, T& var, std::tuple<parameter<TAG1>, P...>& pack)

и вызывать мы её будем вот так:

extract_from_pack< erorr_policy > (age, myage, mypack);

В виду правил работы с variadic templates, extract_from_pack знает, что набор параметров имеет форму tuple<parameter, … >, так что нужно проверить рекурсивно действительно ли TAG равен TAG1. Мы реализуем это направлением вызова классу:

extract_from_pack< erorr_policy > (age, myage, mypack);

вызывает

extractor<0, erorr_policy >::extract (age, myage, mypack);

который далее вызывает

extractor<0, erorr_policy >::extract (age, myage, std::get<0>(pack), mypack);

который имеет два перегруженных варианта:

extract(TAG, … , TAG, …)

которые, если выполняется, выполняет присваивание и возвращает true или

extract(TAG, … , DIFFERENT_TAG, …)

который продолжает итерацию, вызывая снова

extractor<2, erorr_policy >::extract (age, myage, mypack);

когда продолжение итерации невозможно — вызывается error_policy::err(…)

template <size_t N, typename ERR>
struct extractor
{
   template <typename USERTAG, typename T, typename TAG, typename... P>
   static bool extract(USERTAG tag, T& var, std::tuple<parameter<TAG>, P...>&& pack)
   {
      return extract(tag, var, std::get<N>(pack), std::move(pack));
   }
 
   template <typename USERTAG, typename T, typename TAG, typename... P>
   static bool extract(USERTAG tag, T& var, parameter<TAG> p0, std::tuple<P...>&& pack)
   {
      return extractor<(N+2 >= sizeof...(P)) ? size_t(-1) : N+2, ERR>::extract(tag, var, std::move(pack));
   }
 
   template <typename USERTAG, typename T, typename... P>
   static bool extract(USERTAG tag, T& var, parameter<USERTAG>, std::tuple<P...>&& pack)
   {
      var = std::move(std::get<N+1>(pack));
      return true;
   }
};
 
template <typename ERR>
struct extractor<size_t(-1), ERR>
{
   template <typename TAG, typename T, typename DIFFERENT_TAG, typename... P>
   static bool extract(TAG tag, T& var, std::tuple<parameter<DIFFERENT_TAG>, P...>&& pack)
   { return ERR::err(tag); }
};
 
template <typename ERR, typename TAG, typename T, typename... P, typename TAG1>
bool extract_from_pack(TAG tag, T& var, std::tuple<parameter<TAG1>, P...>& pack)
{
   return extractor<0, ERR>::extract(tag, var, std::move(pack));
}

В виду гибкой природы наборов параметров, лучшей политикой обработки ошибком может считаться “return false” (любое более строгое поведение будет на самом деле означать обязательность каждого параметра).

struct soft_error
{
   template <typename T>
   static bool err(T)
   {
      return false;
   }
};

Тем ни менее, если зачем-то нужно, мы можем выбрать также из вот этих двух:

struct hard_error
{
   template <typename T>
   static bool err(T); // обратите внимание, что static_assert(false) здесь не работает. Можете ли вы догадаться почему?
};
 
struct throw_exception
{
   template <typename T>
   static bool err(T)
   {
      throw T();
      return false;
   }
};

Дополнительным усовершенствованием может быть проверка избыточности для таких случаев как:

(age=18)+(age=19)

Финальные заметки

Мы не обсудили рантайм-техники, вроде:

void MyFunction (option_parser& pack)
{
   auto name = pack.require("name").as<string>();
   auto age = pack.optional("age", []{ return 10; }).as<int>();
   ...
}

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

А ещё я нашел предложение добавить именованные параметры в стандарт языка С++ вот здесь. Неплохо было бы.

Автор: tangro

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js