Восемь неочевидных вещей в шаблонах С++
Привет, я backend-разработчик IT-компании SimbirSoft Леонид. В этой статье расскажу про 8 нюансов, которые я обнаружил при изучении шаблонов С++. Честно признаюсь, что наткнувшись на некоторые из них, я был удивлен: «Хм, SFINAE есть, а слова нет?» или «А что, есть разница между шаблоном в шаблоне и шаблоном с двумя параметрами?».
Материал будет полезен начинающим разработчикам, которые знакомятся с шаблонами, а также специалистам уровня middle, которые используют шаблоны время от времени.
Некоторые из примеров были описаны в cpp-referernce чуть ли не в самом первом абзаце, некоторые потребовали пошерстить stackoverflow, и в конце концов все есть в стандарте. Но кто учит язык по документации? У кого из нас не было такой ситуации: «Сейчас я код потыкаю, а там разберемся, что к чему». Так вот, сейчас пришло время узнать, как это работает и почему именно так.
Предлагаю начать с терминологии, на всякий случай.
В контексте рассматриваемой темы про шаблоны я предлагаю остановиться на следующем определении. Инстанцирование (англ. instance — создание экземпляра чего-то) — процесс порождения специализации. Компилятор или разработчик где-то порождает код через подстановку параметров в шаблон и соответственно, инстанцирование может быть неявным и явным.
Специализация — непосредственное указание частного случая для конкретного типа.
А теперь перейдем к примерам.
1. SFINAE есть, а слова нет
Несмотря на то, что комбинация букв SFINAE звучит отовсюду, где начинают говорить про шаблоны, самой комбинации букв SFINAE в стандарте нет. Конечно, всё, что мы имеем в виду, когда говорим про SFINAE, там описано, но если вы планируете найти в стандарте вот прям SFINAE, то вас ждет разочарование или удивление. Впрочем, как и меня.
2. Про »= delete», и как запретить специализации
Есть расхожее мнение, что »= delete
» означает, что компилятор не будет генерировать эти конструкторы для вас, но…
Можно запрещать специализации через »=delete
».
template
void f1(T) {cout << 1;} //общий шаблон
template<>
void f1(int) {cout << 2;} //специализация для int
template<>
void f1(short)=delete; //специализация для short (запрещаем)
…
short val = 10;
f1(val);
Если ожидали вывода »2» или даже »1», то вы ошибаетесь. Будет ошибка компиляции (CE). В этом случае вы сами не хотите писать специализацию и компилятору запрещаете.
Даже можно запретить общий шаблон и разрешить только специализации (-ю).
template
void f1(T) =delete; //общий шаблон (запрещаем)
template<>
void f1(int) {cout << 2;} //специализация для int
template<>
void f1(short) {cout << 3;} //специализация для short
f1(1.0);
return 1;
В данном случае тоже CE, потому что запретили общий, и разрешили только для int
и short
. И попытка создать для double
провалена.
Кстати, перегрузки функций тоже можно запрещать.
3. Шаблон в шаблоне!=(не равно) шаблон с двумя параметрами
У нас есть 2 класса X1 и X2.
template
class X1{
T a;
public:
template
void setA(U);
void setB();
};
и
template
class X2{
T a;
public:
void setA(U);
void setB();
};
Казалось бы, что в X1, что в Х2 надо натолкать два типа, но есть нюанс.
При первом знакомстве с шаблонами я задался вопросом: «А как написать реализацию метода вне класса для обоих случаев?». И судя по stackoverflow, я не один такой. И какая вообще разница между этими двумя случаями?
В первом случае setA
является шаблоном функции — члена шаблона класса, где шаблон класса требует один тип и метод требует один тип:
template
template
void X1::setA (U) {}
Здесь при использовании будет инстанцирована требуемая специализация метода класса.
Во втором случае это не шаблонная функция — член шаблона класса, в которой шаблон класса хочет сразу два типа:
template
void X2::setA (U) {}
В этом случае метод зависит от инстанцированного класса и его параметров. И метод для нужного нам типа при использовании будет всегда инстанцирован вместе с классом (в отличие от первого случая, где будет инстанцирована только нужная специализация метода, а не весь класс).
И еще один нюанс, про который можно забыть (а я забыл). Может возникнуть ощущение, что setB()
никак не зависит от параметров шаблона. А если есть ощущения, что все-таки что-то тут не так, то так и есть. Надо помнить, что каждый метод имеет доступ к this
(во время вызова метода this
фактически передается в метод). А в зависимости от параметра шаблона, указанного при создании экземпляра объекта, this
будет разным. Получается, что все методы должны соответствовать всем формам this
. Хотя сам метод явно и не выглядит каким-то особенным.
4. Частичный вывод типов
Как известно, при инстанцировании шаблона функции можно явно не указывать те аргументы шаблона, которые могут быть выведены из типов фактических аргументов функции.
template
void f(T value) {}
При вызове f(1.0f);
компилятор умен настолько, чтобы понять, что T — это float;
, но при этом он не может вывести тип возвращаемого значения. Поэтому вот так не получится:
template
T f(U value){ return value; }
…
int d = f(1.0f);
Чтобы получить такой результат, можно просто указать эти типы:
int flt = f(1.0);
А можно указать только один — для возвращаемого типа, а второй из параметра выведет компилятор.
int flt = f(1.0);
Таким образом, при проектировании становится важным порядок расположения шаблонных аргументов. Те, которые компилятор может вывести, стоит располагать в конце списка.
Если задать параметры шаблонной функции по умолчанию, то они не будут участвовать в выводе типов.
Продолжая тему вывода типов, рассмотрим такой пример:
template
void func(T x = 1.0f){};
Если есть ожидания, что вызов func()
будет работать, то ваши ожидания — это ваши проблемы.
Компилятор так не сможет.
Но он сможет, если сделать так:
template
void func(T x = 1.0f){};
Параметры по умолчанию у шаблонной функции не участвуют в выводе типов, поэтому компилятору нужно указать тип по умолчанию.
5. Зависимые имена шаблонов
Примеры из этого пункта для разных компиляторов ведут себя по-разному. Например, MSVC сам до всего догадался, а gcc — нет.
Итак, у нас есть вот такая конструкция:
template
struct X{
template
void setA();
};
template
void f ()
{
X x;
x.setA();
}
Так вот будет CE из-за того, что компилятор не знает, что x.setA
это шаблонная функция, и сделает вывод, что это поле и поэтому x.setA
< T (где «<» — это меньше, а дальше («>()») синтаксическая ошибка).
Поэтому компилятору надо подсказать и надо сделать так:
x.template setA();
Для этого, оказывается, есть своё слово — «disambiguatioin» (устранение неодназначности). Та же самая техника и то же самое слово, когда мы используем typename.
6. Инстанцирование — это ленивый процесс
Данный код будет собираться и выполняться без ошибок:
template
struct Dat
{
using arr = char[N];
};
template
struct Some
{
void f()
{
Dat dat;
}
};
…
Some some;
…
Мы можем использовать данный код, и все будет хорошо. Но как только будет вызван (инстанцирован) метод f()
, все сломается. Компилятор не будет инстанцировать f()
, пока его об этом не попросят. Таким образом, про ошибку узнаем только тогда, когда начнем использовать метод f()
.
7. Явное управление инстанцированием
Неявное инстанцирование шаблона функции происходит в тот момент, когда компилятор первый раз видит, что вызывается требуемая специализация в данной единице трансляции. Таким образом, если есть два или более модулей, в каждом из которых будет вызвана одна и та же специализация для шаблонной функции, то компилятор сделает инстанцирование в каждом модуле.
У нас есть возможность заставить компилятор делать так, как надо нам.
Имеем шаблонную функцию:
template
T funct(T x) { return x;};
• Явно вызываем инстанцирование в этой единице трансляции.
template int funct (int);
Таким образом, в этом месте компилятор инстанцирует то, что мы его попросили.
• Явно запрещаем инстанцирование в этой единице трансляции
extern template int funct (int);
А вот теперь компилятор не будет инстанцировать в этой единице трансляции, потому что, скорее всего, мы будем инстанцировать в другом месте.
Если компилятор неявно создал специализацию в данной единице трансляции, то второй раз не получится ее написать.
template // основной шаблон
void print(T v) {}
void func(int v)
{
print(v); //специализацию для int создает компилятор
}
template<>
void print(int v) {}; //здесь будет ошибка, потому что для int специализация уже создана
8. Более специальный шаблон выигрывает у менее специального
Хотя это правило известное и простое, приведу пример, который лично мне показался интересным и не сразу понятным.
template
void func(T) {}; //1
template
void func(T*) {}; //2
template
void func(T**) {}; //3
template
void func(T***) {}; //4
template
void func(T****) {}; //5
int ***a;
func(a); // -> 4
func(a); // -> 2
В случае func(a)
будет выбран четвертый, и вопросов не возникает. Чтобы понять, как интересно комбинируются звездочки для func
— пришлось взглянуть пристальнее. [int**]
— это Т, а тип аргумента функции — это int ***
, следовательно, не хватает одной звездочки: [int**] *
= T* (выбирается второй вариант).
В качестве заключения хотелось бы сказать, что приступая к изучению шаблонов, надо быть готовым к тому, что вся «глубина глубин» С++ и здесь никуда не пропадёт. Чем глубже ты погружаешься, тем больше тебе предстоит увидеть и возможно даже изучить. Но С++ хорош тем, что ты можешь продолжать писать хороший код и в течение долгого времени не сталкиваться ни с чем, о чём я написал.
Спасибо за внимание!
Авторские материалы для разработчиков мы также публикуем в наших соцсетях — ВКонтакте и Telegram.