[Перевод] Именованные параметры в современном C++

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

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()]); Задействованные сущности:

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

where = {a, b, c } where[logger = x] → { a, b, c }[ argument<0>(x) ] → {x, b, c} Набросок реализации:

// argument template struct argument { T arg; argument (const T& that) : arg (that) { } }; // void argument — just to use operator= template struct argument { argument (int = 0) { } template argument operator=(const T& that) const { return that; } argument operator=(std: ostream& that) const { return that; } }; // «пакет аргументов» (хранит значения) template 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 argument_pack operator[](const argument<0, T>& x) const { return argument_pack(x.arg, second, third); } template argument_pack operator[](const argument<1, T>& x) const { return argument_pack(first, x.arg, third); } template argument_pack operator[](const argument<2, T>& x) const { return argument_pack(first, second, x.arg); } }; enum { LESS, LOGGER }; const argument comparator = 0; const argument logger = 0; typedef argument_pack, std: ostream> pack_t; static const pack_t where (basic_comparator (), less(), std: cout); За полным кодом загляните в оригинальную книгу.

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

Теги Andrzej Krzemieński опубликовал интересный пост «Интуитивный интерфейс», где предложил следующее: именованные параметры представляют собой пары компаньонов — реального значения и пустой структуры (пустые структуры разных типов нужны для выбора нужной перегруженной функции). Вот пример этого подхода из STL: std: function f{std: allocator_arg, a}; // a — аллокатор std: unique_lock l{m, std: defer_lock}; // отложенный lock Andrzej предложил обобщить подход:

// не настоящий STL std: vector v1(std: with_size, 10, std: with_value, 6); Как вы понимаете, потребуется создать некоторое количество перегруженных функций, а также вы не можете выбирать порядок параметров. К плюсам можно отнести отсутствие необходимости наличия конструкторов копирования\переноса. Передача значений по-умолчанию также работает без проблем. Из статьи: «Теги не являются идеальным решением, поскольку засоряют пространства имён перегруженными функциями, которые полезны лишь в нескольких местах их вызова»

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

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

// код класса #include #include #include 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 File (by_name_t, Args&&… args) {} Объект типа File может быть создан любым из двух способов. Если вы используете второй конструктор — он просмотрит все параметры в наборе и вызовет первый конструктор с соответствующим набором параметров. Просмотр параметров и генерация кода выполняется на этапе компиляции, занимает линейное время и не влияет на затраты времени на вызов в рантайме.

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

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

File (string path, bool readonly, bool createIfNotExists /*…*/) : _path (move (path)), _createIfNotExist (createIfNotExist), _readonly (readonly) // , etc… { } template 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 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 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) Стандартная библиотека языка уже включает функции передачи / конкатенации объектов и кортежей, что гарантирует производительность и корректность Мы будем использовать макрос для определения глобальных констант, представляющих тег Синтаксис создания набора параметров будет выглядеть как (tag1=value1)+(tag2=value2)+… Клиент будет принимать набор параметров как ссылку на шаблонный тип, т.е.template void MyFunction ([whatever], T& parameter_pack) // или const T&, T&&, и т.д.

Внутри вызова функции клиент извлечёт нужные значения из набора параметров и как-то их использует (ну например запишет в локальные переменные): namespace tag { CREATE_TAG (age, int); CREATE_TAG (name, std: string); } template 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 #include template struct parameter {}; #define CREATE_TAG (name, TYPE) \ \ struct name##_t \ { \ std: tuple, TYPE> operator=(TYPE&& x) const \ { return std: forward_as_tuple (parameter(), x); } \ \ name##_t (int) {} \ }; \ \ const name##_t name = 0 Раскрытие макроса CREATE_TAG (age, int) создаёт класс и глобальный объект.

struct age_t { std: tuple, int> operator=(int&& x) const { return std: forward_as_tuple (parameter(), x); } age_t (int) {} }; const age_t age = 0; Концептуально присваивание

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

make_tuple (parameter(), 18); Обратите внимание, что мы написали:

std: tuple, int> operator=(int&& x) const Мы требуем r-value справа. Это сделано ради безопасности: ради повышения читабельности кода с наборами параметров вы можете захотеть присваивать константы, а не переменные.

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

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

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

std: tuple, int> operator=(int&& x) const { return std: forward_as_tuple (parameter(), x); } очень тонкая. В последнем случае возвращается std: tuple<…, int&&>, но поскольку функция возвращает std: tuple<…, int> — вызывается конструктор перемещения std: tuple.

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

std: tuple, int> operator=(int&& x) const { return std: make_tuple (parameter(), std: move (x)); } А теперь мы напишем подходящий оператор конкатенации для наших кортежей.

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

template std: tuple, P1…, parameter, P2…> operator+ (std: tuple, P1…>&& pack1, std: tuple, P2…>&& pack2) { return std: tuple_cat (pack1, pack2); } Очень простая функция: проверяет, что оба кортежа имеют вид

tuple, type, [maybe something else]> и соединяет их.

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

template bool extract_from_pack (TAG tag, T& var, std: tuple, P…>& pack); Работает она следующим образом: если набора содержит parameter, тогда переменная получает значение, следующее непосредственно за ним и функция возвращает true. Иначе случается что-то плохое (мы можем выбрать — ошибка компиляции, вернуть false, сгенерировать исключение).

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

template bool extract_from_pack (TAG tag, T& var, std: tuple, P…>& pack) и вызывать мы её будем вот так:

extract_from_pack< erorr_policy > (age, myage, mypack); В виду правил работы с variadic templates, extract_from_pack знает, что набор параметров имеет форму tuple, так что нужно проверить рекурсивно действительно ли 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 struct extractor { template static bool extract (USERTAG tag, T& var, std: tuple, P…>&& pack) { return extract (tag, var, std: get(pack), std: move (pack)); } template static bool extract (USERTAG tag, T& var, parameter p0, std: tuple&& pack) { return extractor<(N+2 >= sizeof…(P)) ? size_t (-1) : N+2, ERR>:: extract (tag, var, std: move (pack)); } template static bool extract (USERTAG tag, T& var, parameter, std: tuple&& pack) { var = std: move (std: get(pack)); return true; } }; template struct extractor { template static bool extract (TAG tag, T& var, std: tuple, P…>&& pack) { return ERR: err (tag); } }; template bool extract_from_pack (TAG tag, T& var, std: tuple, P…>& pack) { return extractor<0, ERR>:: extract (tag, var, std: move (pack)); } В виду гибкой природы наборов параметров, лучшей политикой обработки ошибком может считаться «return false» (любое более строгое поведение будет на самом деле означать обязательность каждого параметра).

struct soft_error { template static bool err (T) { return false; } }; Тем ни менее, если зачем-то нужно, мы можем выбрать также из вот этих двух:

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

(age=18)+(age=19) Финальные заметки Мы не обсудили рантайм-техники, вроде: void MyFunction (option_parser& pack) { auto name = pack.require («name»).as(); auto age = pack.optional («age», []{ return 10; }).as(); … } Код работает на рантайме, пытаясь достать нужные ему параметры по ходу работы, соответственно мы имеем затраты времени, ну и об ошибке вы узнаете лишь когда она возникнет. Код далёк от идеала, я привожу его лишь как «proof of concept» и не думаю, что в таком виде его можно применять в реальных проектах.

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

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

© Habrahabr.ru