Всё началось с того, что мне понадобилось написать функцию, принимающую на себя владение произвольным объектом. Казалось бы, что может быть проще:
template <typename T>
void f (T t)
{
// Завладели экземпляром `t` типа `T`.
...
// Хочешь — переноси.
g(std::move(t));
// Не хочешь — не переноси.
...
}Но есть один нюанс: требуется, чтобы принимаемый объект был строго rvalue. Следовательно, нужно:
- Сообщать об ошибке компиляции при попытке передать
lvalue. - Избежать лишнего вызова конструктора при создании объекта на стеке.
А вот это уже сложнее сделать.
Поясню.
Требования к входным аргументам
Допустим, мы хотим обратного, то есть чтобы функция принимала только lvalue и не компилировалась, если ей на вход подаётся rvalue. Для этого в языке присутствует специальный синтаксис:
template <typename T>
void f (T & t);Такая запись означает, что функция f принимает lvalue-ссылку на объект типа T. При этом заранее не оговариваются cv-квалификаторы. Это может быть и ссылка на константу, и ссылка на неконстанту, и любые другие варианты.
Но ссылкой на rvalue она быть не может: если передать в функцию f ссылку на rvalue, то программа не скомпилируется:
template <typename T>
void f (T &) {}
int main ()
{
auto x = 1;
f(x); // Всё хорошо, T = int.
const auto y = 2;
f(y); // Всё хорошо, T = const int.
f(6.1); // Ошибка компиляции.
}Может, есть синтаксис и для обратного случая, когда нужно принимать только rvalue и сообщать об ошибке при передаче lvalue?
К сожалению, нет.
Единственная возможность принять rvalue-ссылку на произвольный объект — это сквозная ссылка (forwarding reference):
template <typename T>
void f (T && t);Но сквозная ссылка может быть ссылкой как на rvalue, так и на lvalue. Следовательно, нужного эффекта мы пока не добились.
Добиться нужного эффекта можно при помощи механизма SFINAE, но он достаточно громоздкий и неудобный как для написания, так и для чтения:
#include <type_traits>
template <typename T,
typename = std::enable_if_t<std::is_rvalue_reference<T &&>::value>>
void f (T &&) {}
int main ()
{
auto x = 1;
f(x); // Ошибка компиляции.
f(std::move(x)); // Всё хорошо.
f(6.1); // Всё хорошо.
}А чего бы на самом деле хотелось?
Хотелось бы вот такой записи:
template <typename T>
void f (rvalue<T> t);Думаю, смысл данной записи выражен достаточно чётко: принять произвольное rvalue.
Первая мысль, которая приходит в голову, — это создать псевдоним типа:
template <typename T>
using rvalue = T &&;Но такая штука, к несчастью, не сработает, потому что подстановка псевдонима происходит до вывода типа шаблона, поэтому в данной ситуации запись rvalue<T> в аргументах функции полностью эквивалентна записи T &&.
Забавно, что из-за ошибки в системе вывода типов компилятора Clang (версию точно не помню, кажется, 3.6) этот вариант "сработал". В компиляторе GCC этой ошибки не было, поэтому поначалу мой затуманенный безумной идеей разум решил, что ошибка не в Кланге, а в Гэцэцэ. Но, проведя, небольшое расследование, я понял, что это не так. А через некоторое время и в Кланге эту ошибку исправили.
Ещё одна идея — по сути, аналогичная, — которая может прийти в голову знатоку шаблонного метапрограммирования — это написать следующий код:
template <typename T>
struct rvalue_t
{
using type = T &&;
};
template <typename T>
using rvalue = typename rvalue_t<T>::type;К структуре rvalue_t можно было бы припилить SFINAE, которое отваливалось бы, если бы T было ссылкой на lvalue.
Но, к сожалению, эта идея также обречена на провал, потому что такая структура "ломает" механизм вывода типов. В результате функцию f вообще будет невозможно вызвать без явного указания аргумента шаблона.
Я очень расстроился и на время забросил эту идею.
Возвращение
В начале этого года, когда появилась новость о том, что комитет не включил концепты в стандарт C++17, я решил вернуться к заброшенной идее.
Немного поразмыслив, я сформулировал "требования":
- Должен работать механизм вывода типа.
- Должна быть возможность натравливать
SFINAE-проверки на выводимый тип.
Из первого требования немедленно следует, что нужно всё-таки использовать псевдонимы типов.
Тогда возникает закономерный вопрос: можно ли натравливать SFINAE на псевдонимы типов?
Оказывается, можно. И выглядеть это будет, например, следующим образом:
template <typename T,
typename = std::enable_if_t<std::is_rvalue_reference<T &&>::value>>
using rvalue = T &&;Наконец-то получаем и требуемый интерфейс, и требуемое поведение:
template <typename T>
void f (rvalue<T>) {}
int main ()
{
auto x = 1;
f(x); // Ошибка компиляции.
f(std::move(x)); // Всё хорошо.
f(6.1); // Всё хорошо.
}Победа.
Концепты
Внимательный читатель негодует: "Так где же тут концепты-то?".
Но если он не только внимательный, но ещё и сообразительный, то быстро поймёт, что эту идею можно использовать и для "концептов". Например, следующим образом:
template <typename I,
typename = std::enable_if_t<std::is_integral<I>::value>>
using Integer = I;
template <typename I>
void g (Integer<I> t);Мы создали функцию, которая принимает только целочисленные аргументы. При этом получившийся синтаксис достаточно приятен и пишущему, и читающему.
int main ()
{
g(1); // Всё хорошо.
g(1.2); // Ошибка компиляции.
}Что ещё можно сделать?
Можно попытаться ещё больше приблизиться к истинному синтаксису концептов, который должен выглядеть следующим образом:
template <Integer I>
void g (I n);Для этого воспользуемся, кхм, макроснёй:
#define Integer(I) typename I, typename = Integer<I>Получим возможность писать следующий код:
template <Integer(I)>
void g (I n);На этом возможности данной техники, пожалуй, заканчиваются.
Недостатки
Если вспомнить название статьи, то можно подумать, что у этой техники есть какие-то недостатки.
Таки да. Есть.
Во-первых, она не позволяет организовать перегрузку по концептам.
Компилятор не увидит разницы между сигнатурами функций
template <typename I>
void g (Integer<I>) {}
template <typename I>
void g (Floating<I>) {}и будет выдавать ошибку о переопределении функции g.
Во-вторых, невозможно одновременно проверить несколько свойств одного типа. Вернее, возможно, но придётся городить достаточно сложные конструкции, которые сведут на нет всю удобочитаемость.
Выводы
Приведённая техника — назовём её техникой фильтрующего псевдонима типов — имеет достаточно ограниченную область применения.
Но в тех случаях, когда она применима, она открывает программисту достаточно неплохие возможности для чёткого выражения намерения в коде.
Считаю, что она имеет право на жизнь. Лично я пользуюсь. И не жалею.