Привет, я 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)


  1. NeoCode
    00.00.0000 00:00
    +9

    Да уж, глубина глубин... А ведь вы еще даже метапрограммирование на шаблонах не затронули! И самое главное - как удалось породить такое из простой, естественной и очевидной идеи параметризировать классы и функции на этапе компиляции по типу макросов. Универсальный, не зависящий от типа данных код для классов массивов, списков, алгоритмов поиска и сортировки и т.п.... Просто и естественно. И вот во что это вылилось.


    1. AetherNetIO
      00.00.0000 00:00

      Ну так и используйте только для этого. Никто вас не заставляет ни вариадики, ни parameter pack и тд использовать.


    1. knstqq
      00.00.0000 00:00

      посмотрите на машину тьюринга. Шаг влево, шаг вправо, условие перехода, останова — и ВНЕЗАПНО простая система полна по тьюрингу, можно выразить почти что угодно, хотя описать её — проще чем описать молоток!


      Шаблоны это просто, естественно и функционально. Замечательно же! :) То что на sed можно написать тетрис — никто не заставляет писать вас тетрис, используйте sed для того чтобы заменить слово в строке.


    1. MarinKa61
      00.00.0000 00:00

      Вот только не надо приплетать сюда политику. Статья хорошая и интересная. Метапрограммирование это тема отдельной статьи и не всем это надо. Примеры хорошие прикладные, и не заезженная тема статьи. Мне понравилось!


  1. 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); // нельзя, частичная специализация, хотя казалось бы