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