Несколько лет назад Джонатан Боккара написал в своем замечательном блоге Fluent{C++} серию статей о том, как передать в функцию переменное количество аргументов одного типа; в то время я участвовал в поиске одного из способов достижения этой цели (и, кстати, статьи вы можете найти здесь: https://www.fluentcpp.com/2019/01/25/variadic-number- тип-параметров-функции/).
Многие из решений, найденных в то время, включали SFINAE и/или другие методы, и хотя я уже знал об концепциях, я еще не знал достаточно, поэтому сначала я думал, что их использование для решения проблемы было своего рода злоупотреблением… Кроме того, я знал, что было предложение добавить такую возможность прямо в язык (см., https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019 /p1219r2.html).
И, кстати, если вы спросите, почему бы не использовать список инициализаторов… это потому, что это было бы неэффективно, поскольку его элементы почти всегда будут копироваться из-за правил языка.
Прошли годы, и сегодня я считаю, что ошибался, и что концепции, вероятно, являются лучшим инструментом, который у нас есть, чтобы достичь того, чего мы хотим.
Готовы к реальным примерам?
Пример №1: передача переменного количества аргументов одного и того же типа
Допустим, у нас есть функция, которая принимает вектор строк и вариативное количество строк, которые будут добавлены к вектору. Если мы хотим проверить во время компиляции, что все аргументы имеют тип std::string, мы можем сделать следующее:
#include <concepts> #include <vector> #include <string> void add(std::vector<std::string>& v, std::same_as<std::string> auto const&... args) { (v.push_back(args), ...); } int main() { std::vector<std::string> v; std::string s1{"hello"}; std::string s2{"concepts"}; add(v, s1, s2); // ... }
Мы используем новый упрощенный синтаксис C++20 для шаблонов и используем стандартную концепцию std::same_as, чтобы гарантировать, что мы можем принимать только аргументы std::string. Если мы передадим что-то, что не является строкой, компилятор выдаст ошибку.
Обратите внимание, что мы принимаем переменные аргументы через const&, но мы также можем передавать в функцию строки rvalue, потому что rvalues может связываться с const& квалифицированный аргумент типа std::string:
std::vector<std::string> v; std::string s1{"hello"}; add(v, s1, std::string{"concepts"});
В любом случае, как вы, наверное, заметили, таким образом мы в конечном итоге копируем вторую строку в вектор вместо того, чтобы эффективно перемещать ее в него. Чтобы избежать копирования, мы должны включить идеальную пересылку аргументов для нашей функции. Давайте посмотрим, как это сделать на следующем примере.
Пример № 2: совершенная пересылка вариативного количества аргументов одного типа
Чтобы иметь идеальную пересылку, мы должны изменить определение нашей функции «добавить». К сожалению, все становится немного сложнее…
На первый взгляд можно подумать, что это сработает:
void add(std::vector<std::string>& v, std::same_as<std::string> auto&&... args) { (v.push_back(std::forward<decltype(args)>(args)), ...); }
Если мы используем нашу недавно определенную функцию, передавая только строки rvalue, кажется, что она работает нормально:
add(v, std::string{"hello"}, std::string{"concepts"});
К сожалению, если мы вызываем «добавить», используя аргументы lvalues или смешанные lvalues/rvalues , компилятор не устраивает и выдает ошибку. Это верно, даже если мы определим нашу функцию другими способами:
// attempt to support constrained perfect forwarding: DOES NOT WORK template <std::same_as<std::string>... Args> void add(std::vector<std::string>& v, Args&&... args) { (v.push_back(std::forward<Args>(args)), ...); } // alternate syntax: DOES NOT WORK EITHER template <typename... Args> requires (std::same_as<std::string, Args> && ...) void add(std::vector<std::string>& v, Args&&... args) { (v.push_back(std::forward<Args>(args)), ...); }
Оба подхода не работают, так как компилятор по-прежнему настаивает на том, что мы можем передавать в функцию только строки rvalue (я использую MSVC 17.1.3 и GCC 12.1.0).
Чтобы иметь возможность передавать lvalues И rvalues и правильно пересылать их, мы должны прибегнуть к std::remove_cvref_t в наших requires , выполнив следующие действия:
// Perfect forwarding arguments with concepts: a working solution template <typename... Args> requires (std::same_as<std::string, std::remove_cvref_t<Args>> && ...) void add(std::vector<std::string>& v, Args&&... args) { (v.push_back(std::forward<Args>(args)), ...); }
Таким образом, компилятор доволен, и мы можем передавать std::string с любым квалификатором в любой комбинации:
std::vector<std::string> v; std::string s1{"hello"}; std::string s2{"concepts"}; add(v, s1, s2); // all l-values add(v, std::string{"hello"}, std::string{"concepts"}); // all r-values add(v, s1, std::move(s2), std::string{"!!!"}); // mixed l-values/r-values
Пример №3: передача вариативного количества аргументов, конвертируемых в определенный тип
Иногда мы хотели бы не ограничивать наши аргументы одним типом, а несколькими типами, если они конвертируемы в тот, который нас интересует. Например, в нашем случае имеет смысл разрешить передачу в функцию строк в стиле C без необходимости явного создания экземпляра std::string, например:
add(v, "hello", "concepts", "!!!");
В то же время мы хотели бы сохранить возможность идеальной пересылки реальных std::string и использовать нашу функцию «добавить» следующим образом:
add(v, s1, std::move(s2), "!!!"); // mixed l-values/r-values and C-style strings
К счастью, вызвать нашу функцию в соответствии с этими требованиями снова легко:
void add(std::vector<std::string>& v, std::convertible_to<std::string> auto&&... args) { (v.push_back(std::forward<decltype(args)>(args)), ...); }
Все, что нам нужно сделать, это использовать стандартную концепцию std::convertible_to и для идеальной переадресации нам нужно получить типы аргументов. В данном случае мы делаем это с помощью decltype(args). Кроме того, вы также можете явно указать имя для типов вариативных пакетов:
template <std::convertible_to<std::string>... Args> void add(std::vector<std::string>& v, Args&&... args) { (v.push_back(std::forward<Args>(args)), ...); } // OR (it's really the same) template <typename... Args> requires (std::convertible_to<Args, std::string> && ...) void add(std::vector<std::string>& v, Args&&... args) { (v.push_back(std::forward<Args>(args)), ...); }
Выводы
Хотя было бы неплохо иметь однородные вариативные параметры, напрямую поддерживаемые языком, с помощью Concepts у нас есть рабочее решение, относительно чистое и удобочитаемое.
Что мне нравится в использовании понятий, так это то, что из интерфейса функции ясно, разрешен ли в качестве параметра только определенный тип (с помощью std:same_as) или разрешены ли преобразования (с помощью std::convertible_to). Что мне не нравится, так это то, что становится сложно, пытаясь усовершенствовать прямые аргументы определенного типа…
Надеюсь, эта статья будет для вас полезной и конечно же, отзывы и предложения приветствуются 😉.