Несколько лет назад Джонатан Боккара написал в своем замечательном блоге 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). Что мне не нравится, так это то, что становится сложно, пытаясь усовершенствовать прямые аргументы определенного типа…

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