Статья написана с целью максимально просто, на живых примерах рассказать о шаблонах C++.

Как создатели языка пришли к концепции шаблонов? Почему шаблонов не стоит бояться? Как они помогают сделать код чище? Почему стоит изучать шаблоны уже сегодня, несмотря на существующий к ним скепсис?

Статья пытается ответить на все эти и многие другие вопросы.


Вступление

Для того чтобы статья читалась с большей пользой, по желанию можно ознакомиться с несколькими ремарками:

  1. Статья написана с прицелом на начинающих разработчиков. Тем не менее, от читателя ожидается минимальная подготовка. Для эффективного изучения шаблонов стоит понимать синтаксис C++, знать, как работают его управляющие конструкции, понимать, что такое функции и их перегрузки, а также иметь общее представление о классах.

  2. Для лучшего восприятия стоит читать статью последовательно, от начала до конца. Разделы располагаются в порядке нарастания сложности. Последующие разделы развивают примеры предыдущих. При этом код используется наравне с текстом - объяснения даются прямо в комментариях.
    Примечание: После публикации оказалось, что развёрнутые комментарии в коде неудобно читать с мобильных устройств. Постараюсь учесть это замечание в следующих статьях.

  3. Стоит компилировать примеры. В идеале, экспериментировать: пробовать менять и улучшать код. Как любая другая тема в программировании, шаблоны лучше всего познаются практикой. Если лень разбираться с настройкой среды разработки, можно использовать какой-нибудь онлайн-компилятор. Например, для анализа ассемблерного кода, получаемого после компиляции, в статье использовался онлайн-компилятор godbolt.org (с отключением оптимизаций опцией "-O0").

  4. Вопреки традиции учебных материалов, примеры кода не содержат распечатки переменных в поток вывода (без "printf" / "std::cout"). Это было сделано намеренно, чтобы избежать лишнего шума в коде. Если будете компилировать код примеров в IDE, можете просматривать значения переменных в дебаггере. Если же удобнее использовать поток вывода - как вариант, можно использовать следующий макрос:

    Макрос PrintExpression
    // В начале файле где объявляется макрос не забудьте добавить
    // инклуд: "#include <iostream>"
    
    // Собственно, сам макрос. Распечатывает в "std::cout" выражение
    // в виде строки (для упрощение чтения выражение обрамляется
    // фигурными скобками) и значение вычисленного выражения.
    #define PrintExpression(Expression)\
         std::cout << "{" #Expression "}: " << (Expression) <<\
         		std::endl;
    
    // Примеры использования макроса:
    
    // 1. Распечатка переменной
    int value = 1;
    PrintExpression(value)
    //Распечатает следующее: {value}: 1
    
    // 2. Распечатка выражения
    int arrayValue[]{ 1, 2, 3, 4 };
    PrintExpression(arrayValue[1] + arrayValue[2])
    // Распечатает следующее: {arrayValue[1] + arrayValue[2]}: 5
  5. Цель статьи - рассказать про шаблоны максимально понятно, чтобы пользу от чтения извлёк даже начинающий программист. С позиций бывалого разработчика исходный код примеров далёк от идеала: почти нет проверок на корректность значений переменных, для индексов используется тип "int" вместо "size_t", местами дублируется код, почти не используется передача значений по ссылкам и т.д. Это делалось чтобы минимально уходить в смежные темы, концентируясь, в первую очередь, на иллюстрации использования шаблонов.

  6. Для иллюстрации приёмов на рабочем коде уходить в не связанные с шаблонами темы иногда всё-таки приходилось. Комментарии, не относящиеся напрямую к теме шаблонов, помечены звёздочкой - вот так: (*). В случае, если при прочтении больше интересует тема шаблонов, - такие комментарии можно не читать.

  7. Хабр - преимущественно русскоязычный ресурс. Поэтому я старался писать статью на русском. Как часто бывает в программировании, при этом были трудности с переводом терминов. Например, для понятия "template instantiation" используется несколько "творческий" перевод "порождение шаблона". Неуклюже - однако лучшего перевода придумать не вышло. Чтобы компенсировать возможные непонятки, к определениям терминов привязаны оригинальные названия, которые можно посмотреть наведя мышку. Если вы знаете варианты, которые будут удачнее приведённых в статье, - пишите, обсудим. Я с радостью поменяю терминологию на более распространённую.

  8. Буду благодарен за указание ошибок, опечаток и неточностей в статье. По традиции, в конце заведены титры с перечислением "народных" редакторов. Чтобы комментарии не загромождались лишним спамом, по незначительным замечаниям лучше писать в личку.

Оглавление

  1. Шаблоны функций

  2. Выведение типов шаблонных аргументов

  3. Шаблоны классов

  4. Специализации

  5. Валидация шаблонных аргументов

  6. Больше шаблонных аргументов

  7. Шаблонные аргументы-константы

  8. Передача шаблонных аргументов

  9. Частичные специализации шаблонов

Заключение

Часто задаваемые вопросы

1. Шаблоны функций

Концепция шаблонов возникла из принципа программирования Don't repeat yourself. Можно проследить логику, по которой авторы C++ ввели шаблоны в язык.

В процедурном программировании повторяющиеся фрагменты кода выносятся в функции. Код, вычисляющий большее из трёх значений…

int main()
{
	const int a = 3, b = 2, c = 1;
	
	const int abMax = (a >= b) ? a : b;
	const int max = (abMax >= c) ? abMax : c;

	return 0;
}

…переписывают, убирая логику в функцию:

int max(int a, int b)
{
	return (a >= b ? a : b);
}

//...

int main()
{
	const int a = 3, b = 2, c = 1;

	const int abMax = max(a, b);
	const int max = max(abMax, c);

	return 0;
}

Использование функций даёт несколько преимуществ:

  1. Если надо поменять повторяющуюся логику - достаточно сделать это в функции, не надо менять все копии одинакового кода в программе. Если бы в примере выше вариант без функции содержал системную ошибку в тернарных вызовах, с путаницей порядка операндов: "(a >= b) ? b : a" и "(max_ab >= c) ? c : max_ab" - ошибку пришлось бы искать и править во всех местах использования. Вариант с функцией же требует одной правки - в реализации функции.

  2. При грамотном именовании в коде с функциями логика кода становится прозрачнее. В примере без функции внимательного прочтения требует каждая конструкция вида "(... >= ...) ? ... : ..." , надо узнавать повторяющуюся логику выбора большего значения из двух каждый раз заново. Функция же во втором варианте именует повторяющуюся логику, за счёт чего общий смысл программы понятнее.

Процедурное программирование делает код чище. Однако, что если логику получения максимального элемента надо поддерживать для всех числовых типов: для всех размеров (1, 2, 4, 8 байт), как знаковых, так и беззнаковых (signed / unsigned), для чисел с плавающей точкой ("float", "double")?

Можно воспользоваться перегрузкой функций:

char max(char a, char b)
{
	return (a >= b ? a : b);
}

unsigned char max(unsigned char a, unsigned char b)
{
	return (a >= b ? a : b);
}

short int max(short int a, short int b)
{
	return (a >= b ? a : b);
}

unsigned short int max(unsigned short int a, unsigned short int b)
{
	return (a >= b ? a : b);
}

int max(int a, int b)
{
	return (a >= b ? a : b);
}

unsigned int max(unsigned int a, unsigned int b)
{
	return (a >= b ? a : b);
}

// ... и т.д. для всех числовых типов, включая "float" и "double"...

int main()
{
	const int a = 3, b = 2, c = 1;
	const int abMax = max(a, b);
	const int max = max(abMax, c);
  
  // ...зато теперь можно получить максимальный "char"
  const char aChar = 'c', bChar = 'b', cChar = 'a';
	const char abMaxChar = max(aChar, bChar);
	const char maxChar = max(abMaxChar, cChar);

	return 0;
}

Выглядит громоздко. Что печальнее, теряются преимущества процедурного программирования. Логика, совпадающая с точностью до типа, копируется в каждой из перегрузок заново.

Придя к тем же неутешительным выводам, в 1985 году разработчики языка придумали шаблоны:

// Ниже описывается шаблон функции max, имеющей один шаблонный аргумент
// с именем "Type". Имя может быть любым другим, правила формирования те же что
// для именования переменных и типов.
// Вместо ключевого слова "typename" для обозначения шаблонного аргумента-типа
// может использоваться ключевое слово "class". Не считая некоторых нюансов
// (выходящих за рамки данной статьи) эти ключевые слова абсолютно синонимичны.
template<typename Type>
Type max(Type a, Type b)
{
	return (a >= b ? a : b);
}

int main()
{
  // Использование шаблона "max<Type>(Type, Type)" с подстановкой "int"
	const int a = 3, b = 2, c = 1;
	const int abMax = max<int>(a, b);
	const int max = max<int>(abMax, c);

  // Использование того же шаблона max<Type>(Type, Type) с подстановкой "char"  
	const char aChar = 3, bChar = 2, cChar = 1;
	const char abMaxChar = max<char>(aChar, bChar);
	const char maxChar = max<char>(abMaxChar, cChar);  
  
	return 0;
}

Перегрузки функций с повторяющийся логикой заменились на одну "функцию" с новой конструкцией - template<typename Type>. Слово "функция" взято тут в кавычки намеренно. Это не совсем функция. Данная запись означает для компилятора следующее: "После конструкции template<typename Type> описан шаблон функции, по которому подстановкой типа вместо шаблонного аргумента Type порождаются конкретные функции".

Не стоит путать при этом аргументы функции (в примере - это "Type a" и "Type b") и аргументы шаблона (в примере - это "typename Type"). Первые задают значения, которые принимает функция при вызове. Вторые же задают параметры, подстановкой в которые значений по месту использования порождаются конкретные функции из шаблонов.

Использование шаблона выглядит так: "max<int>(a, b)". В треугольных скобках передаются значения шаблонных аргументов. В данном случае, в качестве значения шаблонного аргумента "Type" передаётся значение - тип "int". После подстановки компилятор создаст "под капотом" конкретную функцию из обобщённого кода. То, что вызывается по записи "max<int>()", для компилятора выглядит так:

int max<int>(int a, int b)
{
	return (a >= b ? a : b);
}

Встречая дальше обращения к шаблонной функции с подстановкой в качестве "Type" типа "int", компилятор будет использовать эту же сгенерированную из шаблона функцию.

Встретив же следующую запись - "max<char>(aChar, bChar)" - компилятор породит для себя новую функцию - но по тому же шаблону:

// Функция max<char>() для компилятора выглядит так
char max<char>(char a, char b)
{
	return (a >= b ? a : b);
}

Несмотря на родство по шаблону, функции "max<int>()" и "max<char>()" - совершенно самостоятельны, каждая из них будет превращаться при компиляции в свой ассемблерный код.

Зафиксируем терминологию.

В терминах C++ обобщённое описание функции называется шаблоном функции. Шаблон без подстановки конкретного типа не превращается в реальный код. Для компилятора это рецепт, правило "генерации" кода функции. В случае подстановки шаблонных аргументов в шаблон функции порождается реальный код функции для подставленного типа. Сгенерированную конкретную функцию называют шаблонной функцией. Термины звучат похоже и есть риск запутаться, поэтому резюмируем: для разных типов, передаваемых аргументами в шаблон функции на этапе компиляции будут порождаться разные шаблонные функции.

Зафиксируем также терминологию более высокого уровня.

Парадигму программирования, в которой единожды описанный алгоритм может применяться для разных типов, называют обобщённым программированием. Помимо языка C++, который качественно реализует эту парадигму с помощью шаблонов, обобщённое программирование в той или иной мере поддерживают многие популярные языки: C#, Java, TypeScript (каждый по-своему реализует парадигму посредством обобщений), Python (на уровне аннотаций типов).

Под термином метапрограммирование объединяют техники написания кода, который генерирует новый код в результате исполнения. C++ позволяет использовать метапрограммирование, причем шаблоны играют при этом ключевую роль. О том, как именно выглядит метапрограммирование в C++, мы подробно поговорим в следующих статьях.

2. Выведение типов шаблонных аргументов

В примере с шаблоном функции "template<Type> max(Type, Type)" использовалась явная передача типов в шаблон. Однако во многих случаях компилятор может автоматически вывести тип шаблонного аргумента.

Вызов шаблонной функции из примера...

// const int a = 3, b = 2;
const int abMax = max<int>(a, b);

...можно записать, опустив <int>:

// const int a = 3, b = 2;
const int abMax = max(a, b);

Такая запись корректна с точки зрения языка. Компилятор проанализирует типы переменных "a" и "b" и выполнит выведение типа для передачи в качестве значения шаблонного аргумента "Type".

Тип переменной "a" - "int", тип переменной "b" – тоже "int". Они передаются в шаблон функции "template<Type> Type max(Type, Type)", в котором ожидается, что оба аргумента будут иметь одинаковый тип "Type". Так как типы "a" и "b" совпадают, и нет других правил ограничивающих данный шаблонный аргумент "Type", компилятор делает вывод, что записью "max(a, b)" ожидают применения шаблонной функции "max<int>(a, b)".

Стоит отметить, что, например, следующий код...

const int a = 1;
const char bChar = 'b';
const int abMax = max(a, bChar);

...не скомпилируется с ошибкой вроде: "deduced conflicting types for parameter ‘Type’".

Проблема в том, что для этого кода типы переменных "a" и "b" не совпадают. Компилятор не может однозначно определить какой тип надо передать в качестве значения аргумента "Type". У него есть вариант подставить тип "int" или тип "char". Непонятно какая из подстановок ожидается программистом.

Чтобы избавиться от этой проблемы, можно применить явную передачу типа в шаблон:

const int a = 1;
const char bChar = 'b';
const int abMax = max<int>(a, bChar);

Теперь всё хорошо. Шаблонная функция определена однозначно: "int max<int>(int, int)". Значение переменной "bChar" в этом вызове приведётся к типу "int" - так же, как это произошло бы при вызове нешаблонной функции "int max(int, int)" из самого начала статьи.

3. Шаблоны классов

Шаблоны можно использовать не только для функций, но также для классов и структур.

Вот, например, описание шаблонного класса Interval. С его помощью помощью можно описывать промежутки значений произвольного типа:

// Чтобы код собрался нужен будет шаблон "template<Type> max(Type, Type)" из
// прошлого раздела. Нужно вставить его до шаблона класса. Также по аналогии с
// "max<>()" нужно описать шаблон "template<Type> min(Type, Type)", возвращающий
// меньшее из двух значений. Это будет несложной задачей на дом.

template<typename Type>
class Interval
{
public:
	Interval(Type inStart, Type inEnd)
		: start(inStart), end(inEnd)
  {
  }

	Type getStart() const
  {
    return start;
  }

	Type getEnd() const
  {
    return end;
  }

	Type getSize() const
  {
    return (end - start);
  }

  // Метод для получения интервала пересечения данного интервала с другим
	Interval<Type> intersection(const Interval<Type>& inOther) const
  {
    return Interval<Type>{
        max(start, inOther.start),
        min(end, inOther.end)
    };
  }

private:
  Type start;
  Type end;
};

Шаблоны классов работают аналогично шаблонам функций. Они не описывают готовые типы, это инструкции для порождения классов подстановкой значений шаблонных аргументов.

Пример использование шаблона класса "template<Type> class Interval":

int main()
{
  // Тестируем для подстановки типа "int"
	const Interval<int> intervalA{ 1, 3 };
	const Interval<int> intervalB{ 2, 4 };

	const Interval<int> intersection{ intervalA.intersection(intervalB) };
	const int intersectionStart = intersection.getStart();
	const int intersectionEnd = intersection.getEnd();
  const int intersectionSize = intersection.getSize();
  
  // Тестируем для подстановки типа "char"
	const Interval<char> intervalAChar{ 'a', 'c' };
	const Interval<char> intervalBChar{ 'b', 'd' };

	const Interval<char> intersectionChar{ intervalAChar.intersection(intervalBChar) };
	const char intersectionStartChar = intersectionChar.getStart();
	const char intersectionEndChar = intersectionChar.getEnd();
  const char intersectionSizeChar = intersectionChar.getSize();  
  
	return 0;
}

// (*)
// Небольшая техническая ремарка №1
// Здесь и дальше для классов используется "унифицированная инициализация"
// (англ.: "uniform initialization"). Можете поискать о ней информацию. Если
// коротко - это часто используемая в индустрии форма записи для
// конструкторов/инициализиатора переменных. В фигурных скобках пишут аргументы,
// передаваемые в конструктор/инициализиатор. Эту форму можно использовать как
// для примитивных типов:
// 
// int unifiedInitializedInt{ 0 };
// 
// так и для классов (пример для структуры описывающей точку в 2D пространстве):
//
// Point2D unifiedInitializedPoint2D{ 1.f, 2.f };

// (*)
// Небольшая техническая ремарка №2
// На всякий случай отметим: в примере при создании переменных "intersection"
// и "intersectionChar" используется конструктор копирования соответствующих
// шаблонных классов. Он не объявлен в шаблоне класса, однако, в C++ конструктор
// копирования создаётся по умолчанию. Реализация по умолчанию подходит для
// такого простого класса. 

Встретив запись "Interval<int>" в первый раз, по шаблону класса будет порождён новый шаблонный класс. Порождённый класс будет выглядеть для компилятора следующим образом:

// В качестве значения шаблонного аргумента "Type" выполняется подстановка
// типа "int".
//
// Комментариями над методами обозначено как они выглядели в шаблоне до
// подстановки.
//
class Interval<int>
{
public:
	//Interval(Type inStart, Type inEnd)
	Interval(int inStart, int inEnd)
		: start(inStart), end(inEnd)
  {
  }

	//Type getStart() const
	int getStart() const
  {
    return start;
  }

	//Type getEnd() const
	int getEnd() const
  {
    return end;
  }

	//Type getSize() const
	int getSize() const
  {
    return (end - start);
  }

	//Interval<Type> intersection(const Interval<Type>& inOther) const
	Interval<int> intersection(const Interval<int>& inOther) const
  {
  	//return Interval<Type>{
    //    max(start, inOther.start),
    //    min(end, inOther.end)
    //};
    return Interval<int>{
        max(start, inOther.start),
        min(end, inOther.end)
    };
  }

private:
	//Type start;
  int start;
  
  //Type end;
  int end;
};

Так же, как это было с функциями, порождение шаблонного класса выполнится подстановкой "int" вместо "Type". Порождённый тип будет использоваться везде, где шаблон "template<Type> class Interval<Type>" с подстановкой "int".

Терминология для шаблонов классов аналогична рассмотренной для функций. Обобщённое описание называется шаблоном класса. После передачи типа в качестве шаблонного аргумента из шаблона класса порождается новый шаблонный класс. Во всех местах, где выполняется подстановка того же типа, будет подразумеваться один и тот же тип шаблонного класса.

4. Специализации

Это, пожалуй, один из самых важных и сложных разделов статьи, поэтому он будет длиннее других.

Лучший пример на котором можно разобраться со специализациями шаблонов - шаблон класса "массив". Вспомним, массив – структура данных, хранящая набор однотипных значений последовательно одно за другим в памяти. В стандартной библиотеке шаблонов эту структуру данных реализует шаблон класса "std::vector<>".

Вот элементарная реализация шаблона массива:

template<typename Type>
class SimpleArray
{
public:
  
	// (*) Для простоты, количество элементов будем задавать один раз при создании
  // массива. Количество элементов определяется аргументом конструктора, оно
  // неизвестно на этапе компиляции - поэтому элементы создаём на куче, вызовом
  // оператора "new[]"
	SimpleArray(int inElementsNum)
		: elements(new Type[inElementsNum]), num(inElementsNum)
	{
	}

	int getNum() const
	{
		return num;
	}

	Type getElement(int inIndex) const
	{
		return elements[inIndex];
	}

	void setElement(int inIndex, Type inValue)
	{
		elements[inIndex] = inValue;
	}

	~SimpleArray()
	{
		delete[] elements;
  }

private:
	Type* elements = nullptr;
	int num = 0;
};

По реализации, надеюсь, всё понятно. Рассмотрим пример использования:

int main()
{
	SimpleArray<int> simpleArray{ 4 };

	simpleArray.setElement(0, 1);
	simpleArray.setElement(1, 2);
	simpleArray.setElement(2, 3);
	simpleArray.setElement(3, 4);

	int sum = 0;
	for (int index = 0; index < simpleArray.getNum(); ++index)
		sum += simpleArray.getElement(index);

	return 0;
}

"SimpleArray<int>" - шаблонный класс, для получения которого в шаблон "template<Type> class SimpleArray" в качестве аргумента "Type" передаётся тип "int". Массив заполняется с помощью обращения к методу "setElement()", после чего в цикле рассчитывается сумма всех элементов.

Это рабочий шаблон. Однако есть ситуация, в которой он не достаточно эффективен. Вот пример использования шаблонного класса с подстановкой типа bool:

int main()
{
	SimpleArray<bool> simpleBoolArray{ 4 };

	simpleArray.setElement(0, true);
	simpleArray.setElement(1, false);
	simpleArray.setElement(2, false);
	simpleArray.setElement(3, true);

	return 0;
}

Элементы массива имеют булевый тип, который выражается одним из всего двух возможных значений: "false" или "true" (численно описывающихся, соответственно, значениями "0" или "1"). Вот как "SimpleArray<bool>" использует память для хранения элементов (тут исходим из того, что тип "bool" занимает один байт):

В каждом элементе всего один бит полезной информации. Если бы значения хранились побитно, их можно было бы расположить в восемь раз компактнее.

C++ задумывался как язык для создания высокоэффективных программ. Поэтому его авторы создали механизм, позволяющий в таких ситуациях добиться большей производительности.

Так появились специализации шаблонов. Они позволяют описывать вариации шаблонов, которые надо выбирать при передаче в шаблон определённых заданных типов. Например, можно описать вариацию, которая будет выбираться только если в качестве значения шаблонного аргумента передан тип "bool".

Вот по какому принципу описывается специализация:

// У шаблона всегда должно быть привычное нам, обобщённое описание. Оно будет
// выбираться при подстановке в случае если ни одна специализация не подойдёт.
template<typename Type>
class SimpleArray
{
	//...
};

// Ниже описывается _специализация шаблона_. В случае, если в SimpleArray в
// качестве "Type" передаётся "bool" ("SimpleArray<bool>"), будет выбрано именно
// это описание шаблона.
template<> // [1]
class SimpleArray<bool> // [2]
{
	//...
};

// [1] – тут можно задать дополнительные шаблонные аргументы, от которых
//  зависит специализация. Этот механизм необходим для более сложных шаблонных
//  конструкций: для так называемых _частичных специализаций_ (partial
//  specialization). Мы немного коснёмся этой темы в последнем разделе.
//
// [2] – тут определяется, собственно, правило выбора данной специализации. В
//  данном случае оно очень простое: специализация выбирается если в качестве
//  значения шаблонного аргумента "Type" в "template<Type> class SimpleArray"
//  передаётся тип "bool".
//
// Специализаций по разным типам может быть сколько угодно. Например, если бы
// это имело смысл, можно было бы описать ещё одну специализацию:
//
// template<>
// class SimpleArray<int>
// {
// 	//...
// };
//
// Она выбиралась бы, если бы в качестве "Type" передавался тип "int".

Ниже - полный код специализации шаблона класса "template<Type> class SimpleArray".

// (*) Вспомогательная структура "BitArrayAccessData" хранит информацию для
// доступа к битам в специализации "SimpleArray<bool>". Суть этой информации
// описана ниже, в комментарии к методу "SimpleArray<bool>::getAccessData()".
struct BitArrayAccessData
{  
  int byteIndex = 0;
  int bitIndexInByte = 0;
};

// Специализация ниже будет выбрана, если в качестве значения шаблонного аргумента
// "Type" передаётся тип "bool".
template<>
class SimpleArray<bool>
{
public:

	// (*) Для хранения битов будет использовать массив "unsigned char", так как
  // этот тип занимает один байт во всех популярных компиляторах.
	SimpleArray(int inElementsNum)
		: elementsMemory(nullptr), num(inElementsNum)
	{
  	// (*) Специализация подчиняется тем же правилам, что и обобщённая версия
    // шаблона. Она будет содержать количество элементов передаваемое в
    // конструктор. В конструкторе считается количество байт нужных для
    // размещения битов элементов.
    
    // (*) Для начала расчитывается в каком байте и по какому биту в этом байте
    // будет размещаться значение последнего элемента массива. Подробнее эта
    // логика описана в реализации "SimpleArray<bool>::getAccessData()".
  	const int lastIndex = (inElementsNum - 1);
  	const BitArrayAccessData lastElementAccessData = getAccessData(lastIndex);

		// (*) После этого выделяется количество байт достаточное, чтобы запрос
    // байт по последнему индексу был корректным. Так как индексы начинаются с
    // нулевого, надо прибавить единицу к индексу чтобы доступ к байту по этому
    // индексу был корректным.
		const int neededBytesNum = lastElementAccessData.byteIndex + 1;
		elementsMemory = new unsigned char[neededBytesNum];
    
    // (*) Стоит отметить, что при размерах не кратных восьми, в последнем
    // байте битового массива часть битов будет оставаться неиспользованной.
    // Однако этот вариант намного лучше чем старый. В нём неэффективно
    // используются лишь биты последнего байта (причём, не больше семи бит).
	}

	int getNum() const
	{
		return num;
	}

	bool getElement(int inIndex) const
	{
    // (*) Получение элемента по битовой маске. В начале берётся индекс байта,
    // в котором находится значение элемента. Потом по номеру бита, берётся бит
    // в этом байте (как именно - можно почитать под катом ниже данного кода).
		const BitArrayAccessData accessData = getAccessData(inIndex);
    const unsigned char elementMask = (1 << accessData.bitIndexInByte);
		return elementsMemory[accessData.byteIndex] & elementMask;
	}
	
	void setElement(int inIndex, bool inValue) const
	{
		const BitArrayAccessData accessData = getAccessData(inIndex);

		const unsigned char elementMask = (1 << accessData.bitIndexInByte);
		elementsMemory[accessData.byteIndex] =
		     (elementsMemory[accessData.byteIndex] & ~elementMask) |
		     (inValue ? elementMask : 0);
	}
  
  ~SimpleArray()
  {
		delete[] elementsMemory;
  }
  
private:
  // (*)
  // Функция формирования данных для доступа к битам массива.
  // В начале вычисляется индекс байта, в котором ищется значение элемента:
  //
  //   inIndex / sizeof(unsigned char)
	//
  // Потом, вычитанием из индекса элемента количества полных бит в байтах до
  // байта с интересующем нас значением, получается индекс бита в этом байте:
  //
  //   inIndex - byteIndex* sizeof(unsigned char)
  //
  // Звучит запутанно. Лучше логику получения индексов можно понять из следующей
  // иллюстрации. В поля BitArrayElementAccessData будут записываться значения
  // "индекс байта" и "индекс бита в байте":
  //
  // Индексы...
  // ...сквозных битов |0 1 2 3 4 5 6 7|8 9 10 11 12 13 14 15|
  // ...байтов:        |        0      |          1          | --> byteIndex
  // ...битов в байтах |0 1 2 3 4 5 6 7|0 1 2  3  4  5  6  7 | --> bitIndexInByte
  //
  static BitArrayAccessData getAccessData(int inElementIndex)
  {  
  	BitArrayAccessData result;
    result.byteIndex = inElementIndex / 8;
		result.bitIndexInByte = inElementIndex - result.byteIndex * 8;  	
    return result;
  }

  unsigned char* elementsMemory = nullptr;
  int num = 0;
};
(*) Ликбез по побитным операциям

Для доступа к битам используются следующие побитовые операции:

  1. Операция побитового сдвига влево (<<)

  2. Операция побитового "И" (&)

  3. Операция побитового "ИЛИ" (|)

  4. Операция побитового отрицания (~)

Разберём принцип доступа к битам на примере. Пусть есть значение длиной в байт, содержащее следующие биты:

Индексы битов:  0 1 2 3 4 5 6 7
Биты значения:  1 0 0 1 1 0 0 1

Как получить значение бита с заданым индексом? Значение бита может быть либо "0", либо "1", поэтому для его выражения используют тип "bool". "bool" имеет смысл "ложь" если все его биты равны "0" и смысл "истина" если хотя бы один его бит не равен "0". Таким образом, чтобы понять имеет ли интересующий нас бит значение "0" или "1", надо добиться того чтобы все биты кроме интересующего нас приняли значение "0". Для этого используются так называемые битовые маски - значения которыми "фильтруются" интересующие нас биты.

Например, надо получить значение бита с индексом "4". Для того чтобы "обнулить" значения всех битов кроме интересующего, формируется битвая маска в которой бит по индексу "4" имеет значение "1", а все остальные биты - значение "0". После этого, выполнив побитовое "И" каждого бита значения с битами маски можно добиться того чтобы все биты кроме интересующего гарантированно стали равны "0":

Получение бита 4
                       v
Индексы битов: 0 1 2 3 4 5 6 7
Биты значения: 1 0 0 1 1 0 0 1
               & & & & & & & &
Биты маски:    0 0 0 0 1 0 0 0
               ---------------
Результат:     0 0 0 0 1 0 0 0 = true
                       ^

Ещё примеры:

Получение бита 0
               v
Индексы битов: 0 1 2 3 4 5 6 7
Биты значения: 1 0 0 1 1 0 0 1
               & & & & & & & &
Биты маски:    1 0 0 0 0 0 0 0
               ---------------
Результат:     1 0 0 0 0 0 0 0 = true
               ^
Получение бита 5
                         v
Индексы битов: 0 1 2 3 4 5 6 7
Биты значения: 1 0 0 1 1 0 0 1
               & & & & & & & &
Биты маски:    0 0 0 0 0 1 0 0
               ---------------
Результат:     0 0 0 0 0 0 0 0 = false
                         ^
Получение бита 1
                 v
Индексы битов: 0 1 2 3 4 5 6 7
Биты значения: 1 0 0 1 1 0 0 1
               & & & & & & & &
Биты маски:    0 1 0 0 0 0 0 0
               ---------------
Результат:     0 0 0 0 0 0 0 0 = false
                 ^

Разберём обобщённый алгоритм. Как понятно из примеров, чтобы получить значение бита по индексу "bitIndex", надо выполнить операцию побитового "И" между значением и маской, в которой бит по индексу "bitIndex" имеет значение "1", а остальные биты - значение "0". В коде эта логика записывается следующим образом:

// В "value" хранится значение из которого мы извлекаем биты.
// Используется битовая запись значения, для компиляции требуется
// поддержка C++14
const unsigned char value = 0b1001'1001;

// Индекс бита который нужно получить
const int bitIndex = 4;

// В строчке ниже - формирование маски. Для этого используется
// операция побитового сдвига влево на значение индекса. Побитовый
// сдвиг возвращает значение, равное значению первого операнда с
// каждым битом перемещённым в сторону старших битов на количество
// битов равное значению второго операнда. Младшие биты при этом
// заполняются нулями.
//
// Примеры:
// "00000001 << 0" равно "00000001"
// "00000001 << 1" равно "00000010"
// "00000001 << 3" равно "00001000"
// "00000001 << 7" равно "10000000"
//
// Операция называется сдвигом потому что мы как бы берём все биты
// числа и "перетаскиваем" биты по разрядам значения влево, замещяя
// младшие биты нулями.
const unsigned char mask = (1 << bitIndex);

// "result" будет иметь значение "true" если в бите было значение "1"
// и "false" если бит был равен "0"
const bool result = value & mask;

Как читать биты терерь известно.

Однако, как заполнить бит в байте по индексу нужным значением? Эту операцию лучше всего выполнять в два этапа:

  1. Значение нужного бита в байте "сбрасывается" в "0". Этого добиваются выполняя логическое "И" между изменяемым байтом и маской в которой бит по целевому индексу имеет значение "0", а все остальные биты - значение "1".

  2. Сброшенное в "0" значение нужного бита "записываются" нужным значением. Это достигается выполнением логического "ИЛИ" между результатом первого этапа и маской в которой по целевому индексу находится значение "1", а все остальные биты имеют значение "0".

Звучит сложно. Чтобы понять как это работает проще всего будет рассмотреть несколько примеров (в скобках записывается с какого на какое значение бита происходит изменение):

Заполнение бита 2 значением 1 (0 -> 1)
                          v
Индексы битов:        0 1 2 3 4 5 6 7
Биты значения:        1 0 0 1 1 0 0 1
                      & & & & & & & &
"Сбрасывающая" маска: 1 1 0 1 1 1 1 1
                      - - - - - - - -
Биты после сброса:    1 0 0 1 1 0 0 1
                      | | | | | | | |
"Записывающая" маска: 0 0 1 0 0 0 0 0  <-- пишем значение "1" "1"
                      ---------------
Результат:            1 0 1 1 1 0 0 1
                          ^
Заполнение бита 7 значением 0 (1 -> 0)
                                    v
Индексы битов:        0 1 2 3 4 5 6 7
Биты значения:        1 0 0 1 1 0 0 1
                      & & & & & & & &
"Сбрасывающая" маска: 1 1 1 1 1 1 1 0
                      - - - - - - - -
Биты после сброса:    1 0 0 1 1 0 0 0
                      | | | | | | | |
"Записывающая" маска: 0 0 0 0 0 0 0 0   <-- пишем значение "0"
                      ---------------
Результат:            1 0 0 1 1 0 0 0
                                    ^
Заполнение бита 6 значением 0 (0 -> 0)
                                  v
Индексы битов:        0 1 2 3 4 5 6 7
Биты значения:        1 0 0 1 1 0 0 1
                      & & & & & & & &
"Сбрасывающая" маска: 1 1 1 1 1 1 0 1
                      - - - - - - - -
Биты после сброса:    1 0 0 1 1 0 0 1
                      | | | | | | | |
"Записывающая" маска: 0 0 0 0 0 0 0 0  <-- пишем значение "0"
                      ---------------
Результат:            1 0 0 1 1 0 0 1
                                  ^
Заполнение бита 3 значением 1 (1 -> 1)


                            v
Индексы битов:        0 1 2 3 4 5 6 7
Биты значения:        1 0 0 1 1 0 0 1
                      & & & & & & & &
"Сбрасывающая" маска: 1 1 1 0 1 1 1 1
                      - - - - - - - -
Биты после сброса:    1 0 0 0 1 0 0 1
                      | | | | | | | |
"Записывающая" маска: 0 0 0 1 0 0 0 0  <-- пишем значение "1"
                      ---------------
Результат:            1 0 0 1 1 0 0 1
                            ^

В коде эта логика записывается следующим образом (конкретные значения взяты из первого примера с объяснением выставления полей):

unsigned char value = 0b1001'1001;

// Индекс бита который нужно получить и значение которое нужно записать
const int bitIndex = 2;

//В битах "bitValueToSet" будет битовое значение "00000001".
// Если бы тут присваивалось значение "false" там было бы битовое
// значение "00000000".
const bool bitValueToSet = true;

// Формируем маски

// Дополнительно к побитовому сдвигу который уже использовался раньше
// для "сбрасывающей" маски используется унарная операция побитового
// отрицания (~).
// Она используется чтобы получить сбрасывающую маску. Суть работы
// простая - эта операция возвращает значение операнда в котором все
// биты инвертированы на противоположное значение (0->1, 1->0).
// Например, вот какими будут значения выражений в данном случае:
//
// "1 << bitIndex" будет иметь значение:    00000100
// "~(1 << bitIndex)" будет иметь значение: 11111011
//
// При записи значений одно над другим побитно хорошо видно инверсию
// значения каждого бита
//
const unsigned char resetMask = ~(1 << bitIndex);

// Для формирования "записывающей" маски используется сдвиг значения
// "bitValueToSet" переменной (равного "00000001"). "bitIndex" имеет
// значение "2", соответственно в "setMask" будет "00000001 << 2",
// что равно "00000100".
const unsigned char setMask = (bitValueToSet << bitIndex);

// Результат (можно посмотреть в первом примере установки значений):
// "(10011001 & 11111011) | 00000100", что равно "10011101"
value = (value & resetMask) | setMask;

Рассмотрим новый пример использования "template<Type> class SimpleArray" с поддержкой специализации по типу "bool":

int main()
{
  SimpleArray<char> simpleArray{ 4 };

	simpleArray.setElement(0, 'A');
	simpleArray.setElement(1, 'B');
	simpleArray.setElement(2, 'C');
	simpleArray.setElement(3, 'D');
  //
  // Над комментарием - пример использования специализации
  // "template<Type> class SimpleArray" по типу "char".
  //
  // Выбирая шаблонную конструкцию в которую надо подставить тип, компилятор
  // отбросит специализацию "template<> class SimpleArray<bool>" - так как
  // передаваемый тип не является типом "bool". Других специализаций нет,
  // компилятор остановит свой выбор на обобщённой версии шаблона:
  // "template<Type> class SimpleArray". Именно она будет использована для
  // порождения шаблонного класса "SimpleArray<char>"

	SimpleArray<bool> simpleBoolArray{ 8 };

	simpleArray.setElement(0, true);//  1
	simpleArray.setElement(1, false);// 0
	simpleArray.setElement(2, false);// 0
	simpleArray.setElement(3, true);//  1
	simpleArray.setElement(4, true);//  1
	simpleArray.setElement(5, false);// 0
	simpleArray.setElement(6, false);// 0
	simpleArray.setElement(7, true);//  1
  //
  // Над комментарием - пример использования специализации
  // "template<Type> class SimpleArray" по типу "bool".
  //
  // Тут компилятор выберет специализацию, ведь подставляемый в шаблон тип
  // это "bool". Он подходит по описанным правилам для специализации
  // "template<> class SimpleArray<bool>"

  
  // Отметим несколько моментов:
  //
  // 1. Переменные типа "char" и "bool" обе занимают один байт памяти.
  //  Однако несмотря на это, за счёт использования специализации по типу bool,
  //  "SimpleArray<bool>" требует для хранения восьми элементов всего одного
  //  байта (каждый бит которого будет хранить значение одного элемента массива,
  //  то есть, в данном случае, в битах этого байта будет значение "10011001").
  //  Для хранения же четырёх элементов в "SimpleArray<char>", требуется целых
  //  четыре байта - по одному на каждый элемент типа "char".
  //  За счёт специализации нам действительно удалось сделать массив булевых
  //  переменных в восемь раз компактнее.
  //
  // 2. В который раз отметим сущность шаблонных классов. Шаблонные классы
  //  "SimpleArray<char>" и "SimpleArray<bool>" - это разные типы.
  //  Они оба породились из шаблона "template<Type> class SimpleArray" и, как
  //  будет видно дальше, компилятор может использовать информацию об этом их
  //  "родстве". Однако на шаблонные классы порождённые из одного шаблона стоит
  //  смотреть как на разные типы (потому что это действительно разные типы).
  
	return 0;
}

Заканчивая раздел, чуть заглянем в будущее. Изначально специализации создавались как механизм для выбора оптимальной реализации шаблона для конкретного типа. Однако позже они начали исполнять одну из ключевых ролей в метапрограммировании на C++. Возможность выбирать вариации шаблона по передаваемому типу позволяет анализировать типы в программе, что открывает доступ к рефлексии времени компиляции.

5. Валидация шаблонных аргументов

Данная тема неразрывно связана с шаблонами, и по логике ей место сразу после рассказа о шаблонах функций. Однако в начале статьи раздел выглядел слишком пессимистично. Я решил дать шаблонам возможность показать себя с лучшей стороны.

Что ж... Добавим немного дёгтя.

Уже при описании шаблонной функции "template<Type> max(Type, Type)" неминуемо возникал вопрос: как проверяется корректность типа, который подставляется в шаблон? Ведь в шаблоне тип как-то используется. Например, что будет если передать в качестве аргумента "template<Type> max(Type, Type)" тип, не поддерживающий оператор ">=" ?

template<typename Type>
Type max(Type a, Type b)
{
	return (a >= b ? a : b);
}

// Структура, определяющая позицию точки в двухмерном пространстве. Для точки
// нельзя сказать "больше" ли она другой точки. Можно сравнивать конкретные
// координаты ("x" или "y") точек, но нельзя сравнить сами точки. Для структуры
// Point2D _не определена_ операция сравнения ">=".
struct Point2D
{
	float x = 0.f;
	float y = 0.f;
};

// ...

int main()
{
  Point2D a;
  Point2D b;

  Point2D abMax = max<Point2D>(a, b);
  //
  // В результате подстановки типа "Point2D" в аргумент "Type" шаблона
  // "template<Type> max(Type, Type)" породится шаблонная функция, которая для
  // компилятора выглядит так:
  //
  // Point2D max<Point2D>(Point2D a, Point2D b)
  // {
  //    return (a >= b ? a : b);
  // }
  // 
  // В теле функции выполняется сравнение двух значений ("a" и "b") имеющих тип
  // "Point2D". Однако, как было отмечено выше, для их типа "Point2D" операция
  // сравнения _не определена_ . Компилятору остаётся лишь сгенерировать ошибку
  // компиляции вроде следующей (так отображает ошибку компилятор GCC):
  //
  // "no match for 'operator<=' (operand types are 'Point2D' and 'Point2D')"
  
	return 0;
}

Увы, C++ выявляет ошибки компиляции шаблонных конструкций очень поздно: уже после выполнения подстановки, когда некомпилирующаяся конструкция уже полностью порождена.

Сейчас в промышленно используемом C++ нет механизма валидации шаблонных аргументов.

Долгое время это была одна из главных проблем шаблонов и, в целом, одной из главных проблем языка C++. Особенно ужасно она проявляла себя в сложных шаблонных конструкциях из сторонних библиотек. Там ошибки компиляции могли появляться в глубинах логики чужих шаблонов. Приходилось долго разбираться в реализации стороннего кода. Имевшие дело со стандартной библиотекой шаблонов, с её самыми популярными шаблонами классов "std::vector<>" и "std::map<>", наверняка не раз страдали от многоэтажных ошибок компиляции в недрах их реализаций.

Проблему с валидацией решали по-разному. Использовали свойства подстановок, вводили в язык конструкцию "static_assert()", придумывали стили комментариев, в которых текстом описывались бы требования к аргументам шаблонов.

Лишь спустя годы поисков, к версии C++20 комитет по стандартизации языка прекратил хождение по мукам и наконец-то качественно решил вопрос, введя в язык КОНЦЕПТЫ.

Концепты позволяют описывать требования к типу, который передаётся как шаблонный аргумент. Например, для шаблона функции "template<Type> Type max(Type, Type)" с помощью концептов можно потребовать передавать в качестве значения "Type" тип, поддерживающий операцию сравнения. С помощью концептов компилятор может обнаружить ошибку до выполнения некорректной подстановки типа в шаблон.

Напишу без лишнего пафоса - концепты открывают новую страницу в развитии языка. Они резко снижают порог входа в метапрограммирование, которое до этого было отравлено поздним анализом ошибок компиляции. Метапрограммирование же, в свою очередь, даёт невероятную гибкость в написании кода, а также позволяет писать более эффективный код.

Могу ответственно сделать прогноз: в течение десяти лет шаблоны и концепты выйдут из резервации библиотек и станут ежедневным инструментом прикладного разработчика. Если вы связываете свою профессиональную карьеру с языком C++, изучайте шаблоны и концепты уже сегодня. Не обращайте внимания на скептиков, они тоже когда-то засядут за изучение, будьте же первыми!

На этом закончу пропаганду. Цель статьи - дать вводную начинающим разработчикам, которым предстоит работать с реальным кодом, использующимся в индустрии прямо сейчас. Код этот, увы, написан, в основном, с использованием старых стандартов. Освоим для начала их.

6. Больше шаблонных аргументов

До этого речь шла о шаблонах зависящих от одного аргумента. Но C++ позволяет задавать и большее их количество. Чтобы прочувствовать как это используется в реальном коде, рассмотрим шаблон, зависящий от двух шаблонных аргументов.

В качестве примера опишем очень простую реализацию шаблона класса словарь (известного также как ассоциативный массив или отображение). Это класс-контейнер, хранящий набор значений, доступ к которым, в отличие от массива, происходит не по индексу (числу выражающему номер элемента), а по ключу (произвольному, уникальному относительно других ключей значению). В стандартной библиотеке шаблонов эту структуру данных реализует шаблон класса "std::map<>".

Ниже представлена элементарная реализация словаря. Использование шаблона позволяет как ключ, так и значение задавать произвольным типом:

// Служебный шаблон структуры для хранения пар "ключ и значение" произвольных
// типов. Он понадобится в реализации словаря. Это первое место использования
// двух шаблонных аргументов. Первым задаётся тип ключей, вторым - тип значений,
// доступ к которым происходит по ключам. Внутри треугольных скобок добавляется
// объявление второго шаблонного аргумента-типа который можно использовать в
// теле шаблонного класса.
template<typename KeyType, typename ValueType>
struct KeyAndValue
{
    KeyType key;
    ValueType value;
};

// Собственно, сам шаблон класса "словарь".
template<typename KeyType, typename ValueType>
class Dictionary
{
public:
  
    // (*) Как и для массива, зафиксируем максимальное возможное количество
  	// элементов при создании. В отличие от массива, мы не можем считать
  	// ассоциативный массив заполненным по умолчанию, так как созданные
  	// по умолчанию элементы-пары массива будут иметь одинаковые ключи, что
  	// нарушает основное свойство словаря (ключи должны быть уникальными).
  	// Поэтому для хранения размеров мы заведём два поля: одно будет хранить
  	// максимальное возможное количество элементов словаря (capacity), второе -
  	// фактическое количество заполненных, значимых элементов (num).
    Dictionary(int inCapacity)
        : keysAndValues(new KeyAndValue<KeyType, ValueType>[inCapacity]),
  			capacity(inCapacity), num(0)
    {
    }

    const ValueType* getValue(KeyType inKey) const
    {
        KeyAndValue<KeyType, ValueType>* foundKeyAndValue = findPair(inKey);
        return foundKeyAndValue ? &foundKeyAndValue->value : nullptr;
    }
    
    void setElement(KeyType inKey, ValueType inValueType)
    {
        KeyAndValue<KeyType, ValueType>* keyAndValueToSet = findPair(inKey);
        
        // (*) Если по ключу в массиве нет пары ключ-значение - добавляем новую
        if (!keyAndValueToSet)
        {
            // (*) Минимальная проверка: не достигли ли мы максимального
         		// количества элементов в словаре. В промышленном коде тут бы
          	// использовались исключения (exceptions).
            if (num == capacity)
                return;
            
            keyAndValueToSet = &keysAndValues[num];
            keyAndValueToSet->key = inKey;
            ++num;
        }
        
        keyAndValueToSet->value = inValueType;
    }
    
    ~Dictionary()
		{
			delete[] keysAndValues;
  	}
  
private:
  	KeyAndValue<KeyType, ValueType>* findPair(KeyType inKey)
    {
        for (int index = 0; index < num; ++index)
            if (keysAndValues[index].key == inKey)
                return &keysAndValues[index];
      	
      	return nullptr;
    }
  
    KeyAndValue<KeyType, ValueType>* keysAndValues = nullptr;
    int capacity = 0;
    int num = 0;
};

Пример, иллюстрирующий использование шаблона:

int main()
{
  	// Пример словаря, позволяющего получать доступ к булевым флагам по
  	// целочисленным значениям - шаблонный класс "Dictionary<int, bool>"
    Dictionary<int, bool> dictionary{ 2 };
    dictionary.setElement(1, false);
    dictionary.setElement(3, true);
  
  	//Переменные ниже будут иметь, соответственно, следующие значения:
  	// value1 - указатель на булеву переменную со значением false
  	// value2 - нулевой указатель, по ключу 2 в словаре не задавалось значение
  	// value3 - указатель на булеву переменную со значением true
		const bool* value1 = dictionary.getElement(1);
		const bool* value2 = dictionary.getElement(2);
		const bool* value3 = dictionary.getElement(3);
  
  	// Пример использования шаблонного класса "Dictionary<char, int>",
  	// позволяющего получить символ, которым обозначается число в тексте [1].
    Dictionary<int, char> dictionaryChar{ 3 };  
    dictionaryChar.setElement(1, '1');
    dictionaryChar.setElement(2, '2');
    dictionaryChar.setElement(3, '3');
  
    //Переменные ниже будут иметь, соответственно, следующие значения:
  	// value1Char - указатель на "char" со значением '1'
  	// value2Char - указатель на "char" со значением '2'
  	// value3Char - указатель на "char" со значением '3'
  	const char* value1Char = dictionaryChar.getElement(1);
		const char* value2Char = dictionaryChar.getElement(2);
		const char* value3Char = dictionaryChar.getElement(3);
  
    return 0;
}

// [1] - (*) данный код стоит рассматривать исключительно как пример, не стоит
//   применять словари таким образом в промышленном программировании. Отображать
//   числа в символьное представление лучше используя ASCII значение 
//   (будет работать если значение "numberValue" в промежутке [0, 9]):
//
//		int numberValue = 5;
//		char numberChar = '0' + numberValue;

В случае необходимости возможно описывать шаблоны и от большего количества аргументов. Начиная с версии C++11 вообще возможно описывать шаблоны от произвольного количества аргументов, использующие пакеты параметров. Это важный механизм, вместе с move-семантикой и range-based for, сделавший стандарт C++11 базовым в современной разработке.

К сожалению, тема шаблонов от произвольного количества аргументов слишком обширная. В данной статье мы её касаться не будем. Если когда-нибудь напишу материал по теме - обязательно оставлю здесь ссылку на него.

7. Шаблонные аргументы-константы

До этого рассматривались шаблоны, принимающие лишь типы в качестве шаблонных аргументов. Однако в качестве аргументов шаблонов могут выступать также константы времени компиляции. Такие аргументы по-английски называются non-type template arguments, дословно "шаблонные аргументы не являющиеся типами". Дословный перевод по-русски звучит неуклюже, поэтому дальше будем использовать термин "шаблонные аргументы-константы".

Рассмотрим синтаксис использования таких аргументов на небольшом примере, который, несмотря на слегка безумную реализацию, демонстирует сразу несколько аспектов использования шаблонных аргументов-констант. Реализуем шаблон функции для расчёта факториала на шаблонных аргументах:

// Синтаксис объявления шаблонного аргумента-константы выглядит очень похожим
// на синтаксис объявления аргументов-типов. Вместо ключевого слова "typename"
// записывается тип, который имеет константа. В данном случае, зададим число от
// которого считается факториал типом "int", а аргумент назовём "Value".
// После объявления аргумента можно использовать его в теле шаблона функции как
// обычную константу.
template<int Value>
int getFactorial()
{
  	// Мы считаем факториал рекурсивным вызовом _другой шаблонной функции_,
  	// получаемой _из этого же шаблона функции_ передачей в качестве
  	// значения шаблонного аргумента значения "Value - 1". То есть из вызова
  	// "getFactorial<4>()" будет вызываться "getFactorial<3>()", из него -
  	// "getFactorial<2>()" и т.д.
  	// Ниже, в "main()" подробно разбирается как будет работать данный шаблон
  	// функции.
    return Value * getFactorial<Value - 1>();
}

// Специализации возможно использовать с шаблонными аргументами-константами
// так же, как с аргументами-типами. В данном случае мы описываем
// специализацию шаблона "template<int Value> int getFactorial()" по значению
// шаблонного аргумента "Value", условие выбора специализации - равенство
// значения шаблонного аргумента числу "1". Значение, по которому будет
// выбираться специализация записывается так же, как это делалось для
// специализаций по типам, с той разницей, что для аргументов-констант мы пишем,
// собственно, значение константы.
template<>
int getFactorial<1>()
{
    return 1;
}

int main()
{
  	// Чтобы понять как работает данная реализация факториала рассмотрим как
  	// компилятор выполняет данный вызов.
  	//
  	// 1. Встретив запись getFactorial<4>() компилятор обратится к описанию
  	//  шаблона функции "template<int Value> int getFactorial()". У шаблона есть
  	//  одна специализация - по равенству значения аргумента Value единице:
  	//  "template<> int getFactorial<1>()". В вызов передано значение 4, значит
  	//  специализация не подходит и компилятор выберет обобщённую версию шаблона.
  	//  В порождённой шаблонной функции "getFactorial<4>()" вызывается
  	// "getFactorial<Value - 1>()", то есть "getFactorial<3>()"
    //
  	// 2. С "getFactorial<3>()" всё будет аналогично пункту 1. Специализация по
  	//  равенству Value единице не подойдёт, порождённая функция
  	//  "getFactorial<3>()" будет содержать вызов "getFactorial<2>()".
  	//
  	// 3. Для "getFactorial<2>()" специализация по равенству "Value" единице
  	//  также не подходит. Порождённая функция "getFactorial<2>()" будет содержать в
  	//  в реализации вызов "getFactorial<1>()"... И вот тут, наконец-то, будет
  	//  выбрана специализация "template<> int getFactorial<1>()", которая вернёт
  	//  константу "1". С этого места начнётся возврат из "рекурсивного" вызова.
  	//
  	// Слово "рекурсивный" записано в кавычках, потому что тут мы имеем дело с
  	// непривычной рекурсией. Функция "getFactorial<4>()" вызывает функцию
  	// "getFactorial<3>()", та вызывает "getFactorial<2>()" и та, наконец,
    // вызывает "getFactorial<3>()"... и все четыре эти функции порождённые из
  	// "template<int Value> int getFactorial()" - это разные функции. Как в
  	// прошлых примерах со специализациями по типам, из шаблонов функций с
  	// аргументами-константами будут получаться разные шаблонные функции
  	// подстановкой разных констант.
  	const int factorial4Result = getFactorial<4>();
  
    return 0;
}

За счёт того, что значение шаблонного аргумента-константы по определению не зависит от вычислений этапа исполнения программы, компилятор с большой вероятностью сможет оптимизировать код при компиляции, подставив в ассемблерном коде константу 4*3*2*1 (то есть, сразу значение 24), вместо полноценного вызова функции "getFactorial<4>()" и всей содержащейся в ней логики.

Рассмотрим какие ещё варианты передачи значения шаблонного аргумента-константы допустимы:

int main()
{ 
    const int constVariable = 4;
    const int factorial1 = getFactorial<constVariable>();
    //
    // Код выше скомпилируется успешно. Тип переменной constVariable помечен
    // как const и не зависит от переменных времени исполнения - поэтому его
    // можно передать в качестве значения шаблонного аргумента-константы

    int mutableVariable = 4;
    //const int factorial2 = getFactorial<mutableVariable>();
    //
    // Код выше не скомпилируется с ошибкой: "the value of ‘mutableVariable’
    // is not usable in a constant expression". Передавать переменные в
    // getFactorial<>() нельзя, так как mutableVariable не помечена как const и
    // является для компилятора значением времени исполнения.
    
    int a = 1;
    int b = 3;
    const int constVariableFromMutableVariables = a + b;
    //const int factorial3 = getFactorial<constVariableFromMutableVariables>();
    //
    // Код выше не скомпилируется с той же ошибкой. Несмотря на то, что
    // "constVariableFromMutableVariables" помечена как "const", её значение
    // зависит от переменных "a" и "b", которые могут меняться во время
    // исполнения программы. Это превращает её из константы времени компиляции в
    // переменную времени исполнения. Да, она помечена как неизменная. Но в
    // данном случае, для компилятора это лишь "обещание", что переменная не
    // будет меняться после инициализации значением "a+b" и компилятор может
    // попытаться выполнить какие-то оптимизации опираясь на эту информацию.

    const int constA = 1;
    const int constB = 3;
    const int constVariableFromConstVariables = constA + constB;
    const int factorial4 = getFactorial<constVariableFromConstVariables>();
    //
    // А вот этот код скомпилируется успешно. constVariableFromConstVariables
    // зависит только от константных значений времени компиляции.   
  
    return 0;
}

Cтоит отметить: в реальном коде редко когда стоит таким образом реализовывать вычисление факториала. Да, при правильной доработке эта реализация идеально оптимизирована. Но программы почти всегда оперируют значениями времени исполнения, которые нельзя передать в качестве значений шаблонных аргументов-констант. Этот пример стоит воспринимать скорее как иллюстрацию логики работы шаблонных аргументов-констант.

В разделе "Частичные специализации шаблонов" будет ещё один пример, использующий шаблонные аргументы-константы. Он ближе к реальной жизни.

8. Передача шаблонных аргументов в шаблонном контексте

Вероятно, в разделе про шаблонные классы у читающего мог возникнуть резонный вопрос: можно ли передать шаблонный класс в функцию, сохранив код обобщённым? Например, возможно ли описать функцию для получениея максимального элемента в шаблонном массиве "template<Type> SimpleArray".

Можно начать плодить перегрузки с конкретными шаблонными классами:

// Используем шаблон функции "template<Type> Type max(Type, Type)" из первого
// раздела и шаблон класса "template<Type> class SimpleArray" из четвёртого.

// Перегрузка функции для шаблонного класса "SimpleArray<int>"
int* getMaxElement(const SimpleArray<int>& inArray)
{
  // (*) Вернуть нулевой указатель - самый простой способ обозначить ошибку,
  // если в функцию передан пустой массив (нельзя получить максимальный элемент
  // если элементов в массиве вообще нет).
  if (inArray.num() == 0)
    return nullptr;

  // Отметим - у переменной "maxElement" тип "int", ведь шаблонный массив
  // "SimpleArray<int>" хранит внутри типы "int"
  int maxElement = inArray.getElement(0);
  for (int index = 1; index < inArray.num(); ++index)
    maxElement = max(maxElement, inArray.getElement(index));
  
  return maxElement;
}

// Копия той же логики, но для шаблонного класса "SimpleArray<char>". На всякий
// случай, отмечу в который раз - здесь _не будет_ ошибки перегрузки, так как
// типы "SimpleArray<int>" и "SimpleArray<char>" это два разных типа, пусть и
// порождены они из одного шаблона класса.
char* getMaxElement(const SimpleArray<char>& inArray)
{
  if (inArray.num() == 0)
    return nullptr;

  // Тип "char", ведь массив "SimpleArray<char>" содержит элементы этого типа.
  char maxElement = inArray.getElement(0);
  for (int index = 1; index < inArray.num(); ++index)
    maxElement = max(maxElement, inArray.getElement(index));
  
  return maxElement;
}

// ... и так далее, копирование одного и того же кода с точностью до типа
// подстановки в SimpleArray.

Такая запись свела на нет все преимущества обобщённого программирования - снова копируется одна и та же логка. Думаю, внимательный читатель без труда вспомнит: статья начиналась с рассмотрения похожей проблемы. Только там копировалась с точностью до типа логика нешаблонных функций "max()", когда понадобилась поддержка всех числовых типов.

Что ж, C++ позволяет использовать шаблон и в такой ситуации. На самом деле, случаи нужного нам типа подстановок встречались в статье раньше, просто внимание на них не акцентировалось. Вот, к примеру, метод шаблона "template<Type> class Interval":

template<typename Type>
class Interval
{
  //...
  
  // Шаблонный аргумент передаётся в "Interval<Type>". Шаблонный аргумент "Type"
  // в теле шаблона "template<Type> class Interval" можно использовать любым
  // образом, в том числе для подобной подстановки - как значение шаблонного
  // аргумента метода.
	Interval<Type> intersection(const Interval<Type>& inOther) const
  {
    return Interval<Type>{
        max(start, inOther.start),
        min(end, inOther.end)
    };
  }
  
  //...
};

Вместо повторяющихся перегрузок "getMaxElement()", можно описать шаблон функции, аргумент которой передаётся в шаблон класса "template<Type> class SimpleArray":

// Один шаблон функции "getMaxElement()" вместо повторяющейся одной и той же
// логики. Использует подстановку "Type" в шаблон "template<Type> SimpleArray"
template<typename Type>
Type* getMaxElement(const SimpleArray<Type>& inArray)
{
  if (inArray.num() == 0)
    return nullptr;

  Type maxElement = inArray.getElement(0);
  for (int index = 1; index < inArray.num(); ++index)
    maxElement = max(maxElement, inArray.getElement(index));
  
  return maxElement;
}

// Сразу рассмотрим пример использования функции:

int main()
{
  // --- Пример с шаблонным классом SimpleArray<int> ---
  SimpleArray<int> intArray{ 2 };
  intArray.setElement(0, 2);
  intArray.setElement(1, 1);

	// Тут мы выполняем явную передачу шаблонного аргумента в шаблон функции.
  int* intMax = getMaxElement<int>(intArray);

  // --- Пример с шаблонным классом SimpleArray<char> ---
  SimpleArray<char> charArray{ 3 };
  charArray.setElement(0, 'c');
  charArray.setElement(1, 'b');
  charArray.setElement(2, 'a');
  
  char* charMax = getMaxElement(charArray);
  //
  // Функция вызывается без явной передачи значения шаблонного аргумента. Это
  // будет работать. Рассмотренный во втором разделе механизм вывода типов
  // настолько умён, что даже в такой ситуации способен сам вывести тип "Type"
  // шаблона функции "getMaxElement<Type>()" из типа передаваемого в функцию
  // аргумента. Для вычисления значения шаблонного аргумента компилятор выполнит
  // следующий анализ:
  //
  // 1. Передаваемая в функцию переменная "charArray" имеет тип 
  // "SimpleArray<char>".
  //
  // 2. В качестве аргумента (нешаблонного) шаблона функции "getMaxElement<>()"
  //  ожидается "const SimpleArray<Type>&".
  //
  // 3. Если "наложить" передаваемый в функцию тип "SimpleArray<char>" на
  //  шаблонную конструкцию "const SimpleArray<Type>&", можно сделать вывод, что
  //  при передаче типа "char" в качестве "Type" вызов шаблонной функции
  // "getMaxElement<char>(charArray)" будет корректен.
  //
  // 4. Компилятор самостоятельно подставляет тип "char" в качестве значения
  //  шаблонного аргумента "Type".
}

Использованный в примере выше вариант автоматического вывода типов на деле могущественнее, чем кажется. Это один из столпов метапрограммирования. На пару со специализациями, с его помощью можно анализировать типы (как шаблонные, так и нет) которыми оперирует программа. Фактически, вместе эти два механизма позволяют реализовать рефлексию на этапе компиляции.

9. Частичные специализации шаблонов

Пришло время коснуться темы частичных специализаций шаблонов. Тема находится на границе между базовым метапрограммированием и более сложными вопросами, поэтому логично на этом закончить вводную статью.

Сделаем более оптимизированную версию массива из четвёртого раздела. Новый шаблон чуть потеряет в гибкости использования, однако будет значительно быстрее работать с памятью.

В шаблоне класса "template<Type> SimpleArray" использовалась динамическая память:

template<typename Type>
class SimpleArray
{
  //...
  
  // (*) Динамическая память для элементов выделяется вызовом "new[]"
	SimpleArray(int inElementsNum)
		: elements(new Type[inElementsNum]), num(inElementsNum)
	{
	}

	//...

  // (*) Динамическая память освобождается вызовом оператора "delete[]"
	~SimpleArray()
	{
		delete[] elements;
  }

  //...
};

Использование динамической памяти позволяло создавать массивы разной длины, определяемой на этапе исполнения программы:

int main()
{
    int firstElementsNum = 1, secondElementsNum = 2;
  
    // Изменяем значения переменных во время исполнения.
    ++firstElementsNum;
    ++secondElementsNum;

    // Два экземпляра одного шаблонного класса "SimpleArray<int>":
  	// "first" длиной в два элемента, "second" - длиной в пять (2+3). Длина
  	// может вычисляться во время исполнения программы.
    SimpleArray<int> first{ firstElementsNum };
    SimpleArray<int> second{ firstElementsNum + secondElementsNum };

    return 0;
}

Память для элементов выделяется единожды, при создании экземпляров. После этого расширить или сократить объём памяти нельзя. Так ли важна эта возможность?

Оценим издержки на использование динамической памяти. Вот пример создания буферов одинаковой длины, расположенных в разных видах памяти:

int main()
{
	int arrayStack[3]{ 1, 2, 3 };
  //
  // Выше объявлен буфер из элементов распологающихся на стеке. Его размер
  // известен во время компиляции (размер "int" умноженный на размер массива, 3).
  // Выделение и освобождение памяти для "arrayStack" практически бесплатное.
  // Для выделения размер массива прибавляется к счётчику, который хранит смещение
  // вершины стека, для освобождения - этот размер отнимается от счётчика.
  
  int* arrayHeap = new int[3]{ 1, 2, 3 };
	delete[] arrayHeap;
  //
  // Выше выполняется создание буфера в динамической памяти. Размер и наполнение
  // будет идентично "arrayStack". Однако количество действий для выделения и
  // освобождения памяти будет намного большее:
  // 1. При вызове "new int[3]" аллокатор по умолчанию (default allocator)
  //  выполнит поиск в динамической памяти блока нужного для буфера размера
  //  (размер "int" умноженный на размер массива, 3). Поиск будет требовать
  //  определённых ресурсов времени исполнения.
  // 2. Найденный блок будет помечен как занятый и адрес блока памяти запишется
  //  в переменную-указатель "arrayHeap". Так как запрашиваемый блок имеет
  //  небольшой размер, это будет вызывать фрагментацию памяти [*].
  // 3. Освобождение динамической памяти тоже не "бесплатное". При вызове
  //  оператора "delete[]", аллокатор должен пометить блок памяти занимаемый
  //  буфером как свободный.
  
	return 0;
}

// __________________
// [*] - фрагментация памяти - ситуация когда выделяется много маленьких блоков
//      памяти из-за чего повышается сложность поиска одного большого блока.
Иллюстрация принципа работы стека и динамической памяти

Картинка, иллюстрирующая принцип работы кучи и стека. Цветные элементы со знаками "+" и "-" иллюстрируют принцип по которым работает, соответственно, выделение и освобождение памяти этих типов.

Блоки памяти в стеке выделяются простым сдвигом вершины стека.

Блоки динамической памяти выделяются и удаляются в произвольных местах динамической памяти, что требует дополнительного управляющего механизма ответственного за поиск свободных блоков - аллокатора.

При этом указатели, которые используются для доступа к блокам динамической памяти, хранятся на стеке.

Можно сравнить ассемблерный код который получится при компиляции примера:

Уже по количеству команд для записи значений элементов видно что использование динамической памяти требует большего количества действий. Однако "call" вызовы для создания и освобождения динамической памяти - это ещё более тяжёлые операции обращения к функциям.

Было бы здорово получить структуру данных, хранящую элементы в стековой памяти. В стандартной библиотеке шаблонов такую структуру реализует шаблон "std::array<>".

Чтобы подобную структуру данных получить из "template<Type> SimpleArray", надо сменить тип поля для хранения элементов массива:

// Новый шаблон класса не позволяет задавать количество элементов во время
// исполнения программы. Так как поведение нового шаблона отличается от старого,
// лучше назвать шаблон по-другому: "template<Type> SimpleStaticArray".
template<typename Type>
class SimpleStaticArray
{
  //...  
	
private:
  // ! В коде ниже значение "Size" должно быть известно на этапе компиляции !
  
  Type elements[Size]; // <<- Стековый буфер вместо буфера в динамической памяти.
	int num = Size;
};

Чтобы это работало, количество элементов массива (значение "Size") надо передавать константой времени компиляции. Такой мехнизм уже известен: константы времени компиляции передаются в шаблоны с помощью шаблонных аргументов-констант. Добавим шаблонный аргумент-константу:

// Добавляем шаблонный аргумент-константу "Size" в котором передаётся количество
// элементов массива.
template<typename Type, int Size>
class SimpleStaticArray
{
  //...  
	
private:
  Type elements[Size];
  
  // От поля "num" теперь можно в принципе отказаться. Длина массива - это
  // значение шаблонного аргумента-константы "Size", он доступен в классе.
};

Вот полная реализация обобщённого шаблона класса. Она очень простая:

template<typename Type, int Size>
class SimpleStaticArray
{
public:
  	SimpleStaticArray()
		: elements()
	{
	}

	int getNum() const
	{
    // Как писалось выше, количество элементов теперь доступно в шаблонном
    // аргументе-константе.
		return Size;
	}

	Type getElement(int inIndex) const
	{
		return elements[inIndex];
	}

	void setElement(int inIndex, Type inValue)
	{
		elements[inIndex] = inValue;
	}

private:
	Type elements[Size];
};

Теперь внимательному читателю, вероятно, интересно: что же будет со специализацией по типу "bool"? Она, с одной стороны, требует "фиксации" значения первого шаблонного аргумента "Type", с другой - должна поддерживать произвольное значение второго аргумента "Size" (массив флагов может быть любой длины).

Для решения этого вопроса существуют частичные специализации шаблонов:

template<typename Type, int Size>
class SimpleStaticArray
{
  //...
};

// В реализации используется "BitArrayAccessData" из четвёртого раздела.

// Специализация должна выбираться при любом значении второго шаблонного
// аргумента-константы "Size" и при передаче строго конкретного значения "bool" в
// качестве первого аргумента. В строке [1] задаётся _аргумент специализации_,
// который _исключительно для данной специализации_ описывает обобщённое
// произвольное значение которое может иметь второй аргумент шаблона при
// подстановке. Аргумент используется в строке [2]. При этом в той же строке
// "фиксируется" значением "bool" первый аргумент.
//
template<int Size> //[1]
class SimpleStaticArray<bool, Size> //[2]
{
public:
	SimpleStaticArray()
		: elementsMemory()
	{
	}

	int getNum() const
	{
    // Количество элементов возвращаем по тому же принципу что и для обобщённой
    // версии шаблона - возвращаем значение шаблонного аргумента.
		return Size;
	}

  // Все методы ниже остаются такими же, какими они были в четвёртом разделе,
  // поменялось лишь размещение памяти для элементов, логики это не коснулось.
  
	bool getElement(int inIndex) const
	{
		const BitArrayAccessData accessData = getAccessData(inIndex);
		const unsigned char elementMask = (1 << accessData.bitIndexInByte);
		return elementsMemory[accessData.byteIndex] & elementMask;
	}
	
	void setElement(int inIndex, bool inValue) const
	{
		const BitArrayAccessData accessData = getAccessData(inIndex);

		const unsigned char elementMask = (1 << accessData.bitIndexInByte);
		elementsMemory[accessData.byteIndex] =
		     (elementsMemory[accessData.byteIndex] & ~elementMask) |
		     (inValue ? elementMask : 0);
	}
  
private:
  static BitArrayAccessData getAccessData(int inElementIndex)
  {
  	BitArrayAccessData result;
    result.byteIndex = inElementIndex / 8;
		result.bitIndexInByte = inElementIndex - result.byteIndex * 8;  	
    return result;
  }

	// (*) При объявлении типа поля "elementsMemory" нужно посчитать количество
  // байт нужных для хранения элементов. Значение будет вычисляться на этапе
  // компиляции при порождении подстановки для нового значения шаблонного
  // аргумента "Size". Принцип по которому выполняется расчёт можно найти в
  // комментарии к логике конструктора шаблона класса
  // "template<Type> class SimpleStaticArray" из начала четвёртого раздела.
	unsigned char elementsMemory[Size / sizeof(unsigned char) + 1];
};

Рассмотрим пример использования, аналогичный примеру из четвёртого раздела, разобрав логику по которой компилятор будет выбирать специализацию:

int main()
{
  SimpleStaticArray<char, 4> simpleArray{ };

	simpleArray.setElement(0, 'A');
	simpleArray.setElement(1, 'B');
	simpleArray.setElement(2, 'C');
	simpleArray.setElement(3, 'D');
  //
  // Над комментарием - пример использования специализации
  // "template<Type, int Size> class SimpleStaticArray" по типу "char"
  // и размером "Size" в четыре элемента.
  //
  // Выбирая шаблонную конструкцию в которую надо подставить тип, компилятор
  // отбросит специализацию "template<Size> class SimpleStaticArray<bool, Size>".
  // Передаваемый тип не является типом "bool". За неимением других специализаций,
  // компилятор остановит свой выбор на обобщённой версии шаблона:
  // "template<Type, int Size> class SimpleStaticArray". Именно она будет
  // использована для порождения шаблонного класса
  // "SimpleStaticArray<char, 4>".

	SimpleStaticArray<bool, 8> simpleBoolArray{ };

	simpleBoolArray.setElement(0, true);//  1
	simpleBoolArray.setElement(1, false);// 0
	simpleBoolArray.setElement(2, false);// 0
	simpleBoolArray.setElement(3, true);//  1
	simpleBoolArray.setElement(4, true);//  1
	simpleBoolArray.setElement(5, false);// 0
	simpleBoolArray.setElement(6, false);// 0
	simpleBoolArray.setElement(7, true);//  1
  //
  // Над комментарием - пример использования специализации
  // "template<Type> class SimpleStaticArray" по типу "bool". Специализация
  // будет выбрана, так как первый аргумент имеет значение "bool" (что
  // удовлетворяет условию выбора специализации), а второй аргумент в
  // специализации не фиксирован никакими правилами в специализации.
 
  // Для всех подстановок ниже будут порождаться шаблонные классы, использующие
  // при порождении всё ту же специализацию по типу "bool", все они подходят
  // по условию, несмотря на разные значения второго шаблонного аргумента:
  SimpleStaticArray<bool, 6> simpleBoolArraySixElements{ };
  SimpleStaticArray<bool, 4> simpleBoolArrayFourElements{ };
  SimpleStaticArray<bool, 20> simpleBoolArrayTwentyElements{ }; 
  
  // Также важно отметить что в этом примере порождается много разных шаблонных
  // классов:
  //
  // SimpleStaticArray<char, 4>
  // SimpleStaticArray<bool, 8>
  // SimpleStaticArray<bool, 6>
  // SimpleStaticArray<bool, 4>
  // SimpleStaticArray<bool, 20>
  //
  // Это всё _разные типы_. При неосторожном использовании специализации могут
  // увеличивать объём бинарного кода после компиляции. Эта тема подробнее
  // разобрана в секции часто задаваемых вопросов в конце статьи.
  
	return 0;
}

Частичные специализации - очень мощный механизм. В следующих статьях будет рассмотрено как он позволяет выполнять глубокий анализ передаваемых в шаблоны аргументов.

Увы, частичные специализации не поддерживаются шаблонами функций:


template<typename ResultType, int Value>
ResultType getFactorial()
{
    ResultType result = 1;
    for (int currentValue = 2; currentValue < Value; ++currentValue)
    {
        result *= currentValue;
    }
    
    return result;
}

// Специализации ниже не скомпилируются из-за того, что C++ не поддерживает
// частичные специализации функций:
//
//template<typename ResultType> //<<< Для функций эта секция должна быть пуста
//ResultType getSum<ResultType, 0>()
//{
//    return 1;
//}

//template<typename ResultType> //<<< Для функций эта секция должна быть пуста
//ResultType getSum<ResultType, 1>()
//{
//    return 1;
//}

// ---------------------

int main()
{
    short int result0 = getFactorial<short int, 0>();
    short int result1 = getFactorial<short int, 1>();
    int result8 = getFactorial<int, 8>();

    return 0;
}

Это ограничение можно обойти, однако, лучше рассмотреть этот вопрос в следующих статьях.

Заключение

Спасибо всем кто осилил этот огромный текст. Вы крутые! Надеюсь, он пригодится вам в работе и учёбе. Пишите отзывы в комментариях или в личку, они помогут сделать будущие публикации качественнее.

Если материал окажется не безнадёжно провальным, я планирую написать ещё две статьи по шаблонам. Одна коснётся более сложных тем связанных с шаблонами. Вторая рассмотрит техники и трюки, выступающие примитивами в "большом" метапрограммировании.

Часто задаваемые вопросы

1. Вопрос: Чем шаблоны отличаются от макросов?

Ответ: Макросы выполняют текстовую подстановку аргументов, в то время как шаблоны лексически и синтаксически проверяются компилятором. Если для решения задачи стоит выбор между шаблонами и макросы - стоит предпочитать шаблоны.

Развёрнутый ответ

Эти вопросы логично возникают у программистов, изучающих шаблоны. Действительно, на первый взгляд, шаблоны похожи на макроподстановки компиляторов. Оба механизма позволяют "генерировать" код по определённому трафарету с подстановкой передаваемых по месту применения значений-аргументов.

Главная разница заключается в том, что макросы - это действия с текстом, который не воспринимается компилятором как исходный код состоящий из определения функций, переменных, выражений, и т.д. С текстом программы работает препроцессор, для которого программа - набор символов (букв, цифр, знаков для операторов, пробелов, и т.д.), которые можно копировать и вставлять полностью аналогично тому как программист это делает в IDE с помощью Ctrl+C, Ctrl+V:

  • #include - указание "вставить вместо макроса весь текст содержащийся в файле"

  • #define - указание "встречая идентификатор определяющий макрос, вставить текст следующий за макросом с заменой аргументов передаваемым по месту использования текстом".

  • и т.д.

В случае с шаблонами, компилятор полностью разбирает описание шаблона на конструкции языка, выполняет проверку корректности на уровне типов, переменных, подстановок шаблонов, для функций выполняет проверку правил перегрузки, и т.д.

Лучше всего разницу можно понять на следующем примере, который показывает опасность макросов и преимущество шаблонов для прикладных задач.

Допустим, имеется следующие фукнции для работы с файлами, содержащими числа:

// Функция, переоткрывающая файл для считывания значений начиная с 
// первого.
void reopen(const char* fileName);

// Функция, с помощью которой мы читаем из файла расположенные одно за 
// другим численные значения. Возвращает считанное из файла число и, 
// что важно, _передвигает каретку_ считывания значения на следующее 
// место. То есть, если в файле хранятся значения: "1, 2, 3" - то при 
// первом вызове "loadNextValueFromFile()" вернёт 1, при втором 2, при
// третьем 3.
int loadNextValueFromFile(const char* fileName);

// Функция возвращающая "true", если файл прочитан до конца и "false",
// если нет.
bool isEndOfFile(const char* fileName);

Задача следующая - надо найти максимальное число в файле.

Для начала рассмотрим как в этой задаче сработает обобщённая логика для поиска максимального значения, описанная с помощью макроса:

#define MAX(A, B) (A >= B) ? A : B

//...

int main()
{
    reopen("file");

  	if (!endOfFile("file"))
    {
      int currentMax = loadNextValueFromFile("file");
    	while (!endOfFile("file"))
        currentMax = MAX(currentMax, loadNextValueFromFile("file"));    
    }
    
    return 0;
}

На первый взгляд, логика должна работать корректно.

Однако давайте посмотрим в какой код буквально раскроется строчка с макросом:

// Вот код до выполнения макроподстановки:
//
// currentMax = MAX(currentMax, loadNextValueFromFile("file"));
//
// Во время подстановки значения макроса, препроцессор буквально
// вставит следующий текст: "(A >= B) ? A : B", подставив буквально
// текст "currentMax" вместо аргумента макроса "A" и, буквально,
// текст "loadNextValueFromFile("file")" вместо аргумента "B"
//
// Получится следующее:
currentMax = (currentMax >= loadNextValueFromFile("file")) ?
  currentMax : loadNextValueFromFile("file");

Именно с таким кодом функция "main()" отправится на компиляцию. Если обратить внимание на то как работает функция "loadNextValueFromFile()" и внимательно вчитаться в то что сгенерировал препроцессор, в программе можно увидеть неприятный баг.

Вот как выполнится логика алгоритма если в файле содержатся числа "1, 3, 0":

  1. Записываем в currentMax первое число из файла (число "1").

  2. Вычисляем результат сравнения "currentMax >= loadNextValueFromFile("file")" - причём из-за вызова функции чтения из файла каретка для чтения перемещается на следующее число.
    Результат проверки - текущее значение currentMax (число "1") меньше чем взятое из файла (число "3"), тернарный оператор должен вернуть значение по условию "false".

  3. Для расчёта значения по условию "false" снова вызывается "loadNextValueFromFile("file")". Этот вызов вернёт число "0", так как каретка передвинулась при вычислении сравнения. В currentMax записывается число "0", которое, очевидно, не является самым большим в файле.

Безусловно, код можно (и, пожалуй, даже стоило бы) переписать, сохраняя вычитываемое из файла значение в локальную переменную. Однако, промышленный код часто пишется в цейтноте. Уставший, торопливый, либо просто неопытный программист может написать код вроде представленного в примере.

Из-за примитивности механизма работы препроцессора, использование макросов в прикладном коде будет стабильно приводить к подобным трудно уловимым ошибкам.

Поэтому лучше предпочитать макросам шаблоны:

template<typename Type>
Type max(Type a, Type b)
{
	return (a >= b ? a : b);
}

//...

int main()
{
		reopen("file");

    int currentMax = 0;
    while (!endOfFile("file"))
    {
        // Используем шаблон "template<Type> max(Type, Type)" с
        // подстановкой типа "int". Здесь возможен автоматический
        // вывод типа: оба передаваемых в функцию значения имеют
        // одинаковый тип "int" - однако подчеркнём явной передачей,
        // что здесь используется шаблон.
        currentMax = max<int>(currentMax, loadNextValueFromFile("file"));
    }
        
  
    return 0;
}

"max<int>()" - это простой вызов функции. При вызове функций выражения, передаваемые в качестве аргументов, вычисляются единожды перед передачей в функцию. Ошибки компиляции внутри шаблонной функции будут проверяться компилятором на уровне логических конструкций программы, без "магического" собирания текста программы из кусков.

2. Вопрос: Увеличивают ли шаблоны объём скомпилированного кода?

Ответ: Относительно эквивалентной логики без шаблонов - нет.

Развёрнутый ответ

Уже отмечалось, что для разных шаблонных функций и классов порождаются полностью самостоятельные фрагменты кода. Если есть шаблон функции от одного аргумента который используется с подстановкой трёх разных типов - объём полученного в результате компиляции кода будет в три раза больше, чем требующийся для одной.

Наличие подстановок разных типов в шаблонную логику означет необходимость в коде логики для разных типов. Даже без шаблонов её пришлось бы описывать - с помощью перегрузок, требующих при компиляции ровно столько же ассемблерного кода сколько требуется для всех аналогичных шаблонных функций.

Сравнение ассемблерного кода для функции "max()"

Код без шаблонов:

int max(int a, int b)
{
    return (a >= b ? a : b);
}

char max(char a, char b)
{
    return (a >= b ? a : b);
}

// ------------------------------

int main()
{
    // --- Пример для типа "int" ---

    int a = 1;
    int b = 2;
    int abMax = max(a, b);

    // --- Пример для типа "char" ---

    char aChar = 1;
    char bChar = 2;
    char abMaxChar = max(aChar, bChar);
    return 0;

}

Код с шаблонами:

template<typename Type>
Type maxTemplate(Type a, Type b)
{
    return (a >= b ? a : b);
}

// ------------------------------

int main()
{
    // --- Пример для типа "int" ---

    int a = 1;
    int b = 2;
    int abMax = maxTemplate(a, b);

    // --- Пример для типа "char" ---

    char aChar = 1;
    char bChar = 2;
    char abMaxChar = maxTemplate(aChar, bChar);

    return 0;
}

Сравнение ассемблерного кода:

Код идентичен.

Это же касается шаблонов классов. Компилятор не формирует ассемблерный код для неиспользуемых методов, а логика для используемых понадобилась бы так или иначе.

Сравнение ассемблерного кода для метода "Interval::intersection()"
class IntervalInt
{
public:
  IntervalInt(int inStart, int inEnd)
	: start(inStart), end(inEnd)
  {
  }

  int getStart() const
  {
    return start;
  }

  int getEnd() const
  {
    return end;
  }

  int getSize() const
  {
    return (end - start);
  }

  IntervalInt intersection(const IntervalInt& inOther) const
  {
    return IntervalInt{
        start >= inOther.start ? start : inOther.start,
        end <= inOther.end ? end : inOther.end
    };
  }

private:
  int start;
  int end;
};

// ----------------------------------------------

class IntervalChar
{
public:
  IntervalChar(char inStart, char inEnd)
	: start(inStart), end(inEnd)
  {
  }

  char getStart() const
  {
    return start;
  }

  char getEnd() const
  {
    return end;
  }

  char getSize() const
  {
    return (end - start);
  }

  IntervalChar intersection(const IntervalChar& inOther) const
  {
    return IntervalChar{
        start >= inOther.start ? start : inOther.start,
        end <= inOther.end ? end : inOther.end
    };
  }

private:
  char start;
  char end;
};

Код с шаблонами

template<typename Type>
class IntervalTemplate
{
public:
  IntervalTemplate(Type inStart, Type inEnd)
	: start(inStart), end(inEnd)
  {
  }

  Type getStart() const
  {
    return start;
  }

  Type getEnd() const
  {
    return end;
  }

  Type getSize() const
  {
    return (end - start);
  }

  IntervalTemplate<Type> intersection(const IntervalTemplate<Type>& inOther) const
  {
    return IntervalTemplate<Type>{
        start >= inOther.start ? start : inOther.start,
        end <= inOther.end ? end : inOther.end
    };
  }

private:
  Type start;
  Type end;
};

Сравнение ассемблерного кода:

3. Вопрос: Увеличивают ли шаблоны расход ресурсов на компиляцию кода?

Ответ: Да, но после перехода на C++20 ситуация может стать лучше.

Развёрнутый ответ

Шаблоны не бесплатные с точки зрения времени компиляции и расходования требуемой для компиляции оперативной памяти. Выполнение вывода типов требует от компилятора дополнительной работы, а память нужна для хранения информации про порождённые шаблонные классы.

Концепты из нового стандарта C++20 могут поменять ситуацию - по крайней мере, со временем выполнения компиляции. Они позволяют останавливать подстановку аргументов в шаблон до полноценного формирования шаблонного типа, и экономить таким образом время на завершение генерации априори некорректного типа.

4. Вопрос: Затрудняют ли шаблоны отладку кода?

Ответ: Поиск ошибок компиляции - затрудняют (однако с приходом концептов из C++20 станет лучше). Отладку ошибок в логике исполнения программы - нет.

Развёрнутый ответ

Проблемы разбора ошибок компиляции были подробно описаны в пятом разделе.

Что касается отладки под дебаггером - удобство зависит от среды разработки. Например, при установке точки останова отладчика в шаблонном контексте Visual Studio позволяет выбирать среди всех имеющихся на этапе исполнения подстановок нужную.

Титры

Редакторы

Кузьменко Лилия

Семенякин Николай

Кузьменко Игорь

Бета-читатели

Базанов Александр

Олег Князев

"Народные" редакторы с Habrahabr

Комментарии (21)


  1. fk01
    10.01.2022 05:07
    +34

    Я бы сказал так, что шаблоны -- это на самом деле совсем не шаблоны... На самом деле C++ -- это несколько отдельных, по меньшей мере три, языка программирования:

    1. язык C-препроцессора осуществляющий подстановку на уровне текста -- работает даже не во время, а до компиляции;

    2. усовершенствованная версия языка C с классами;

    3. декларативный язык программирования, программы для которого исполняются в момент компиляции, который оперирует несколькими ортогональными понятиями, по меньшей мере существует пространство типов (классов), пространство численных значений (констант) и функций.

    Результатом работы упомянутого декларативного языка является генерация некой условной, в явном виде не отображаемой программисту, программы для C-с-классами (подробности можно подглядеть на https://cppinsights.io/).

    Основными функциями этого воображаемого декларативного языка, как части C++, являются:

    • преобразование типов (неявное, с использованием пользовательских функций преобразования типов, подстановка конструкторов);

    • статическая диспетчеризация, function lookup, зависимая от типов аргументов, поддержка концепции SFINAE;

    • и самое главное, собственно вишенка на торте: шаблоны.

    Все эти функции выполняются совершенно явно в декларативном порядке: компилятор сам ищет решение и либо находит его, либо не находит, либо находит множественные решения которые допустимы (например, более специализированная шаблонная функция), или не допустимы (например, перегрузка позволяет вызвать более одной функции, что вызывает неоднозначность).

    Шаблоны, на мой взгляд, в данном случае следует скорей рассматривать как набор деклараций, правил, по которым могут быть выведены новые функции и, самое главное новые типы. Можно сказать, что шаблоны C++ -- это декларативный язык выполняющий вычисления в пространстве типов.

    Важно понимать, что подразумевается под понятием типа. Тип -- это не описание того, как данные хранятся в памяти (структура), это пока не важно (это важно будет для компилятора C с классами). Важно что тип может обладать некоторым набором свойств. В частности вложенными типами, вложенными правилами вывода типов и т.д. и т.п. И на самом деле в пространстве типов попросту возможно программирование, вся программа может целиком исполняться в процессе компиляции, а не после компиляции. Конечно программирование в таком виде не слишком удобно и не предназначено для решения прикладных задач, а предназначено для задач метапрограммирования.

    На самом деле конечно описанный декларативный язык не совсем декларативный. Некоторые вещи, например Argument Dependent Lookup начинает зависеть от порядка и видимости деклараций, раскрытие шаблонов тоже. В частности существует такая штука как type loophole которая попросту паразитирует на том факте, что порядок деклараций в C++ всё же имеет значение...

    Возвращаясь к шаблонам. Шаблоны названы так исторически. Технически шаблон это совершенно не подстановка текста. Подстановка текста -- это C-препроцессор, из чего изначально шаблоны родились. Шаблоны -- это именно набор деклараций, используя которые компилятор самостоятельно ищет решение. И в вашей статье описывается, мол есть шаблоны классов, шаблоны функций, шаблоны переменных наконец (начиная с C++14). Но это всё на самом деле не слишком принципиально. Это скорей историческое наследие: типичный пример использования шаблона функций сейчас в метапрограммировании -- это отнюдь не генерация функции, а использование автоматического вывода типов. Даже без тела собственно самой функции. Чтоб в шаблоне функции попросту получить типы в виде параметров шаблона (их компилятор автоматически выведет) и использовать их где-то дальше, например для параметризации уже другого шаблона класса. Да, шаблоны можно использовать и для генерации классов, и для генерации функций и переменных, но это давно не единственное их предназначение. Основным я бы назвал реализацию приёмов метапрограммирования.

    Процитирую некоторые моменты:

    В терминах C++ обобщённое описание функции называется шаблоном функции.

    Да и нет. В силу возможности специализации для конкретных параметров шаблона, важным отличием шаблонов в C++ от дженериков в C# и других языках является то, что собственно сами специализации могут быть кардинально разными в зависимости от параметров. Т.е. это не просто подстановка типа, Функция просто может делать совсем что-то другое. Или, в случае специализации класса, специализация для конкретного типа, например, может породить что-то совершенно отличное от обощённой специализации. Данная практика широко используется для задач метапрограмирования.

    Так же стоит сказать о порождении класса из шаблона. Принципиальным является то, что класс из шаблона появляется в момент его первого использования (фактически, это в каком-то смысле ленивое вычисление в пространстве типов). И потом уже существует до конца работы компилятора. При определённых обстоятельствах факт генерирования нового типа из шаблона ("инстанциации" шаблона) может иметь побочные эффекты и их можно обнаружить. И самое важное, типы от которых шаблон зависит тоже будут востребованы в момент генерации типа из шаблона, а не в момент парсинга шаблона компилятором. В момент парсинга компилятор вынужден работать с определениями которые ещё не определены (отсюда и нужда руками выписывать ключевые слова typename и template внутри шаблона, отсюда же и необходимость создания алиасов для типов, которые до генерации типа из шаблона не видны в пространстве имён, например в случае наследования от класса заданного в параметрах шаблона).

    Нужно заметить, что концепты сами по себе не является каким-то новым свойством, по сути это такой синтакс-сахар для удобной замены std::enable_if. Ключевым моментом здесь является не концепт, а сама концепция SFINAE реализуемая C++-компилятором и не реализуемая во многих других языках. Как было сказано выше, компилятор ищет решение, возможную подстановку, и может при этом откинуть некорректные варианты без ошибки.

    Не рассмотрен такой важный вопрос, что в C++ параметром шаблона может являться другой шаблон. Это тоже принципиальное отличие C++ от других языков с обобщёнными функциями (generics). Шаблон принявший другой шаблон сам может параметризовать его как ему нужно и начать использовать полученные из этого шаблона функции и типы. Без такого функционала, метапрограммирование в C++ было бы очень ограничено.

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

    Процитирую ещё:

    Макросы выполняют текстовую подстановку аргументов, в то время как шаблоны лексически и синтаксически проверяются компилятором

    НЕТ. Шаблоны не осуществляют просто подстановку. Только в простейших случаях. Но в более общем случае, шаблоны -- это декларативный язык который описывает, как может быть сгенерирован уже настоящий код (классы, функции и т.п.) Этот момент крайне важно понимать, иначе метапрограммирование не будет даваться. На одних подстановках далеко не уедешь.

    Затрудняют ли шаблоны отладку кода?

    Пошаговая отладка, например в gdb, или в VisualStudio, сколько-нибудь сложного кода с шаблонами скорей не реальна. Отладчик конечно будет отлично шагать по сгенерированным из шаблона функциям, проблема не в этом. Проблема в том, чтоб шаблонный код управляет работой компилятора, и сгенерировано в итоге может быть что-то совсем не то, на что расчитывал программист. Ошибка может быть такая, что её при пошаговой отладке просто будет невозможно понять. Потому, что шаблоны -- это процесс компиляции, а не исполнения.

    Если нужно отладить шаблоны, то как правило есть две проблемы:

    1. ошибка компиляции -- для gcc или clang сейчас вывод компилятора достаточно хорош, показывается весь путь раскрытия шаблонов, параметры шаблонов. Ситуация реально лучше, чем было лет 10 тому назад. Компиляторы не полностью разворачивают всю историю, и если не хватает вывода, то можно использовать опцию -ftemplate-backtrace-limit.

    2. непонятно что вообще получается, ошибки допустим нет. В такой ситуации можно использовать metashell для того, чтобы понять во что превращаются шаблоны. Впрочем часто достаточно спровоцировать ошибку и посмотреть вывод компилятора...

    PS: в статье на мой взгляд слишком длинные и нудные примеры, что затрудняет понимание новичками. Примеры по-моему лучше сделать короткими, и заодно помимо исторических использований шаблонов показать основные ключевые приёмы метапрограммирования. Например такие как std::void_t, std::integer_sequence, std::enable_if и ключевое слово decltype (чего так же нет во многих других других языках, а в C существует в виде нестандартного typeof), std::declval (который в голом C спрятан в макросе offsetof), можно показать, как шаблоны могут использоваться для вывода возвращаемого значения и типов аргументов другой функции и тому подобные вещи...


    1. semenyakinVS Автор
      10.01.2022 10:03
      +2

      Спасибо за такой подробный и развёрнутый комментарий! Действительно видна погружённость в тему. Про cppinsights знал, но как-то не доводилось много пользоваться при метапрограммировании. Про metashell вообще не знал. Чуть посмотрел. Сходу не понял как пользоваться - но, вероятно, штука крутая.

      Что касается продвинутых техник настоящего метапрограммирования - тоже всё верно пишете, это важные темы, я планирую написать о них во второй статье. В данной же хотелось рассказть про шаблоны людям не имевшим с ними дела (и почти не имевшим дела с C++). Уровень целевой аудитории - люди, прошедшие курс C++ (не факт что академический курс, это могли быть онлайн-курсы) и программисты использующие другие языки, которым интересно было бы почитать про плюсы. Отсюда простые примеры, повторения в духе канала Discovery и отсутствие формализма. Я боялся отпугнуть читателей сложными терминами. При таком подходе есть риск ввести в некоторое заблуждение - но, с другой стороны, углубление знаний это часто путь от понимания с упрощениями и неточностями к более глубокому пониманию. Когда людям читают курс по школьной алгебре им говорят что нельзя брать квадратный корень из минус единицы, а потом в университете дают комплексные числа.

      Ещё раз спасибо за такой подробный ответ. Я, скорее всего, не так глубоко понимаю тему как вы, поэтому, если это возможно, можно ли будет скинуть вам для бета-вычитки вторую статью с рассмотрением более сложных техник метапрограммирования (когда допишу её)? Думаю, она может стать глубже и полезнее для людей при такой экспертной оценке.


    1. RabraBabr
      10.01.2022 10:43
      +1

      Камент интереснее статьи и тянет на отдельную статью.


      1. semenyakinVS Автор
        10.01.2022 12:54
        +4

        Цель статьи была сделать так чтобы людям стало не так страшно пользоваться шаблонами, а не чтобы блеснуть знаниями. Метпрограммирования, сказать откровенно, боятся и избегают даже в индустрии. Многие коллеги по работе (а я работаю в не самой маленькой фирме) не используют шаблоны потому что они кажутся сложными. Как по мне, такое отношение к метапрограммированию вредит экосистеме C++ и как раз из надежды что-то поменять в этой ситуации была написана статья.

        Возможно, я не до конца понял аудиторию хабра... Туторы по базовым темам тут в принципе не принято публиковать? Буду благодарен рекомендации русскоязычной площадки на которой публикация по основам будет более уместной.

        P.S.: Коммент @fk01, развёрнутый в полноценную статью, я б тоже с удовольствием почитал.


        1. indestructable
          10.01.2022 17:55
          +3

          У вас хорошая статья, спасибо за нее, хотя и базовая. Мне, как человеку, работавшему с С++ много лет назад (и я не использовал шаблоны), очень интересно и понятно.

          Я так понимаю, вы планируете цикл статей


          1. semenyakinVS Автор
            10.01.2022 23:45

            Спасибо большое, рад что статья могла быть полезной!) Да, я планирую ещё две, стараясь так же подробно разбирать материал. Одну по более продвинутым заложенным в язык механизмам связанным с шаблонами (их очень предметно описал@fk01, я думал попробовать более простым языком и с примерами рассказать о том же) и вторую по более высокоуровневым трюкам которые строятся поверх шаблонных языковых конструкций и лежат в основе метапрограммирования.


  1. horror_x
    10.01.2022 06:14
    +3

    Отладку ошибок в логике исполнения программы — нет.
    Ну как же нет, как минимум в типах и составных именах сложнее ориентироваться, плюс отладчику часто приходится мапать все инстанциированные варианты шаблона на файлы/строки, из-за чего он может подвисать.

    Ну и про проблемы отдельных отладчиков сами же написали.


  1. SourenTopchian
    10.01.2022 12:12

    "template instantiation" - создание экземпляра?


    1. habrisdead
      10.01.2022 16:41

      конкретная реализация шаблона


  1. adeshere
    10.01.2022 21:46
    +2

    Спасибо автору за статью и @fk01за отличные дополнения к ней! Я не пишу на языках семейства С (уже лет 20 только фортран! ;-), но краем уха слыхал о шаблонах. Поэтому открыл статью, чтобы понять базовые идеи и принципы. Все зашло без натуги и с первого раза, хотя в моем возрасте изучать что-то новое уже почти невозможно. Особенно ценной мне показалась информация о проблемах отладки. Я ведь использовать язык не планирую, и темой интересуюсь сугубо для расширения кругозора. Поэтому времени на системное изучение вопроса нет и не будет. А при беглом поиске по верхам такую сводку знаний вряд ли найдешь где-то еще!

    А вопрос у меня такой: я правильно понял, что для обращения к элементам шаблонного массива надо использовать метод setElement и аналогичные? Но это же кошмарные накладные расходы, если надо перебирать элементы большого массива?! Или компилятор автоматически инлайнит все подобные обращения, в результате чего шаблонный массив будет так же эффективен, как встроенный?


    1. semenyakinVS Автор
      11.01.2022 00:55
      +1

      Спасибо. Здорово что базовые идеи стали понятнее!

      По поводу вызовов "setElement()" и "getElement()"... Инлайнинг никогда не гарантируется наверняка Чтобы максимально принудить инлайнить вызовы есть всякие специфичные для компиляторов ключевые слова (например, __forceinline).

      Тем не менее, в таких тривиальных случаях при оптимизации выше "-O0" с вероятностью 99% компилятор выполнит инлайнинг.

      Можно взять какой-нибудь простой код и посмотреть на godbolt.org как будет выполняться оптимизация:

      Пример на простом коде

      Упрощённый массив:

      template<typename Type>
      class SimpleArray
      {
      public:
        	SimpleArray(int inElementsNum)
      		: elements(new Type[inElementsNum]), num(inElementsNum)
      	{
      	}
      
      	Type getElement(int inIndex) const
      	{
      		return elements[inIndex];
      	}
      
      	void setElement(int inIndex, Type inValue)
      	{
      		elements[inIndex] = inValue;
      	}
      
      	~SimpleArray()
      	{
      		delete[] elements;
        }
      
      private:
      	Type* elements = nullptr;
      	int num = 0;
      };
      
      // -----------------------------------------------------
      
      // Чтобы избежать лишних для нашего теста оптимизаций
      // времени компиляции будем задавать количество элементов
      // массива из количества элементов командной строки 
      int main(int argc, char *argv[])
      {
          //-- Int array --
      
          SimpleArray<int> intArray{ argc };
      
          for (int i = 0; i < argc; ++i)
              intArray.setElement(i, i);
      
          int sum1 = 0;
          for (int i = 0; i < argc; ++i)
              sum1 += intArray.getElement(i);
      
          //-- Raw storage --
      
          int* rawArray = new int[argc];
      
          for (int i = 0; i < argc; ++i)
              rawArray[i] = i;
      
          int sum2 = 0;
          for (int i = 0; i < argc; ++i)
              sum2 += rawArray[i];
      
      	return sum1 + sum2;
      }

      Уже при минимальной оптимизации "-O1" видно что в ассемблерном коде нет call-вызовов методов:

      Ассемблерный код с оптимизацией -O1
      main:
              push    r13
              push    r12
              push    rbp
              push    rbx
              sub     rsp, 8
              movsx   r13, edi
              movabs  rax, 2305843009213693950
              cmp     r13, rax
              ja      .L2
              mov     r12d, edi
              sal     r13, 2
              mov     rdi, r13
              call    operator new[](unsigned long)
              mov     rbp, rax
              lea     ecx, [r12-1]
              mov     eax, 0
              test    r12d, r12d
              jle     .L21
      .L5:
              mov     DWORD PTR [rbp+0+rax*4], eax
              mov     rdx, rax
              add     rax, 1
              cmp     rdx, rcx
              jne     .L5
              mov     rax, rbp
              lea     rdx, [rbp+4+rcx*4]
              mov     ebx, 0
      .L6:
              add     ebx, DWORD PTR [rax]
              add     rax, 4
              cmp     rax, rdx
              jne     .L6
      .L3:
              mov     rdi, r13
              call    operator new[](unsigned long)
              jmp     .L22
      .L2:
              call    __cxa_throw_bad_array_new_length
      .L21:
              mov     ebx, 0
              jmp     .L3
      .L22:
              test    r12d, r12d
              jle     .L12
              lea     esi, [r12-1]
              mov     edx, 0
      .L8:
              mov     DWORD PTR [rax+rdx*4], edx
              mov     rcx, rdx
              add     rdx, 1
              cmp     rsi, rcx
              jne     .L8
              mov     rdx, rax
              lea     rcx, [rax+4+rsi*4]
              mov     eax, 0
      .L9:
              add     eax, DWORD PTR [rdx]
              add     rdx, 4
              cmp     rdx, rcx
              jne     .L9
      .L7:
              add     ebx, eax
              mov     rdi, rbp
              call    operator delete[](void*)
              mov     eax, ebx
              add     rsp, 8
              pop     rbx
              pop     rbp
              pop     r12
              pop     r13
              ret
      .L12:
              mov     eax, 0
              jmp     .L7
              mov     rbx, rax
              mov     rdi, rbp
              call    operator delete[](void*)
              mov     rdi, rbx
              call    _Unwind_Resume

      Для сравнения, вот вариант без оптимизации, в нём есть не заинлайненные функции и их вызовы (например, "call SimpleArray<int>::setElement(int, int)"):

      Ассемблерный код с оптимизацией -O0
      main:
              push    rbp
              mov     rbp, rsp
              push    rbx
              sub     rsp, 72
              mov     DWORD PTR [rbp-68], edi
              mov     QWORD PTR [rbp-80], rsi
              mov     edx, DWORD PTR [rbp-68]
              lea     rax, [rbp-64]
              mov     esi, edx
              mov     rdi, rax
              call    SimpleArray<int>::SimpleArray(int) [complete object constructor]
              mov     DWORD PTR [rbp-20], 0
      .L3:
              mov     eax, DWORD PTR [rbp-20]
              cmp     eax, DWORD PTR [rbp-68]
              jge     .L2
              mov     edx, DWORD PTR [rbp-20]
              mov     ecx, DWORD PTR [rbp-20]
              lea     rax, [rbp-64]
              mov     esi, ecx
              mov     rdi, rax
              call    SimpleArray<int>::setElement(int, int)
              add     DWORD PTR [rbp-20], 1
              jmp     .L3
      .L2:
              mov     DWORD PTR [rbp-24], 0
              mov     DWORD PTR [rbp-28], 0
      .L5:
              mov     eax, DWORD PTR [rbp-28]
              cmp     eax, DWORD PTR [rbp-68]
              jge     .L4
              mov     edx, DWORD PTR [rbp-28]
              lea     rax, [rbp-64]
              mov     esi, edx
              mov     rdi, rax
              call    SimpleArray<int>::getElement(int) const
              add     DWORD PTR [rbp-24], eax
              add     DWORD PTR [rbp-28], 1
              jmp     .L5
      .L4:
              mov     eax, DWORD PTR [rbp-68]
              cdqe
              movabs  rdx, 2305843009213693950
              cmp     rax, rdx
              ja      .L6
              sal     rax, 2
              mov     rdi, rax
              call    operator new[](unsigned long)
              jmp     .L15
      .L6:
              call    __cxa_throw_bad_array_new_length
      .L15:
              mov     QWORD PTR [rbp-48], rax
              mov     DWORD PTR [rbp-32], 0
      .L9:
              mov     eax, DWORD PTR [rbp-32]
              cmp     eax, DWORD PTR [rbp-68]
              jge     .L8
              mov     eax, DWORD PTR [rbp-32]
              cdqe
              lea     rdx, [0+rax*4]
              mov     rax, QWORD PTR [rbp-48]
              add     rdx, rax
              mov     eax, DWORD PTR [rbp-32]
              mov     DWORD PTR [rdx], eax
              add     DWORD PTR [rbp-32], 1
              jmp     .L9
      .L8:
              mov     DWORD PTR [rbp-36], 0
              mov     DWORD PTR [rbp-40], 0
      .L11:
              mov     eax, DWORD PTR [rbp-40]
              cmp     eax, DWORD PTR [rbp-68]
              jge     .L10
              mov     eax, DWORD PTR [rbp-40]
              cdqe
              lea     rdx, [0+rax*4]
              mov     rax, QWORD PTR [rbp-48]
              add     rax, rdx
              mov     eax, DWORD PTR [rax]
              add     DWORD PTR [rbp-36], eax
              add     DWORD PTR [rbp-40], 1
              jmp     .L11
      .L10:
              mov     edx, DWORD PTR [rbp-24]
              mov     eax, DWORD PTR [rbp-36]
              lea     ebx, [rdx+rax]
              lea     rax, [rbp-64]
              mov     rdi, rax
              call    SimpleArray<int>::~SimpleArray() [complete object destructor]
              mov     eax, ebx
              jmp     .L16
              mov     rbx, rax
              lea     rax, [rbp-64]
              mov     rdi, rax
              call    SimpleArray<int>::~SimpleArray() [complete object destructor]
              mov     rax, rbx
              mov     rdi, rax
              call    _Unwind_Resume
      .L16:
              add     rsp, 72
              pop     rbx
              pop     rbp
              ret
      SimpleArray<int>::SimpleArray(int) [base object constructor]:
              push    rbp
              mov     rbp, rsp
              sub     rsp, 16
              mov     QWORD PTR [rbp-8], rdi
              mov     DWORD PTR [rbp-12], esi
              mov     eax, DWORD PTR [rbp-12]
              cdqe
              movabs  rdx, 2305843009213693950
              cmp     rax, rdx
              ja      .L18
              sal     rax, 2
              jmp     .L20
      .L18:
              call    __cxa_throw_bad_array_new_length
      .L20:
              mov     rdi, rax
              call    operator new[](unsigned long)
              mov     rdx, rax
              mov     rax, QWORD PTR [rbp-8]
              mov     QWORD PTR [rax], rdx
              mov     rax, QWORD PTR [rbp-8]
              mov     edx, DWORD PTR [rbp-12]
              mov     DWORD PTR [rax+8], edx
              nop
              leave
              ret
      SimpleArray<int>::~SimpleArray() [base object destructor]:
              push    rbp
              mov     rbp, rsp
              sub     rsp, 16
              mov     QWORD PTR [rbp-8], rdi
              mov     rax, QWORD PTR [rbp-8]
              mov     rax, QWORD PTR [rax]
              test    rax, rax
              je      .L23
              mov     rax, QWORD PTR [rbp-8]
              mov     rax, QWORD PTR [rax]
              mov     rdi, rax
              call    operator delete[](void*)
      .L23:
              nop
              leave
              ret
      SimpleArray<int>::setElement(int, int):
              push    rbp
              mov     rbp, rsp
              mov     QWORD PTR [rbp-8], rdi
              mov     DWORD PTR [rbp-12], esi
              mov     DWORD PTR [rbp-16], edx
              mov     rax, QWORD PTR [rbp-8]
              mov     rax, QWORD PTR [rax]
              mov     edx, DWORD PTR [rbp-12]
              movsx   rdx, edx
              sal     rdx, 2
              add     rdx, rax
              mov     eax, DWORD PTR [rbp-16]
              mov     DWORD PTR [rdx], eax
              nop
              pop     rbp
              ret
      SimpleArray<int>::getElement(int) const:
              push    rbp
              mov     rbp, rsp
              mov     QWORD PTR [rbp-8], rdi
              mov     DWORD PTR [rbp-12], esi
              mov     rax, QWORD PTR [rbp-8]
              mov     rax, QWORD PTR [rax]
              mov     edx, DWORD PTR [rbp-12]
              movsx   rdx, edx
              sal     rdx, 2
              add     rax, rdx
              mov     eax, DWORD PTR [rax]
              pop     rbp
              ret


      1. tzlom
        11.01.2022 07:14
        +1

        Стоит добавить что всё что выше валидно для единого модуля трансляции, различные варианты сборки могут влиять на то будет заинлайнена функция или нет. Так же инлайнинг может быть частичным - в одних местах он произойдёт, в других нет.


  1. Tuxman
    11.01.2022 00:40

    А если я пишу вот так:

    auto max(auto a, auto b) {
        return (a >= b ? a : b);
    }

    Это шаблоны или что? И, если это не шаблоны, то чем отличается от:

    template<typename T>
    T max(T a, T b) {
    	return (a >= b ? a : b);
    }
    


    1. horror_x
      11.01.2022 01:10
      +2

      Это шаблоны или что?
      Начиная с C++20 это сокращение для шаблона (ничем не отличается от варианта ниже, разве что T может быть свой для каждого аргумента и возвращаемого значения). В предыдущих версиях это невалидный код.


      1. Tuxman
        11.01.2022 07:45

        Это так можно писать почти как на питоне, везде auto и утиная типизация, вместо интерфейсного наследования.


        1. Kelbon
          12.01.2022 08:22
          +1

          Всё типы всё равно будут определены на компиляции, а значит это не будет также как на питоне. Ну и конечно возвращать нужно не auto а decltype(auto), ведь может оказаться и ссылка в общем случае.

          Ну и разница большая, версия с auto эквивалентна
          template<typename T, typename U>
          auto max(T a, U b);

          И важно понимать, что auto в возвращаемом типе никак не влияет на то что это шаблон, а вот в принимаемых аргументах - влияет. Ну это достаточно очевидно, но тем не менее иногда новички думают что auto это вообще динамическая типизация


    1. yeputons
      11.01.2022 12:28

      Шаблоны, но отличается: в первом примере у вас типы аргументов могут не совпадать, а тип возвращаемого значения выводится автоматически по правилам для тернарного оператора. Во втором примере сначала выводится тип аргументов (должен быть один для двух аргументов), а потом он же становится возвращаемым типом.


  1. Tuxman
    11.01.2022 00:41

    Тема SFINAE не раскрыта!


    1. semenyakinVS Автор
      11.01.2022 01:01
      +1

      To be continued... Я работаю над второй частью)


  1. GS_Aero
    11.01.2022 13:10
    +1

    Статья огонь.
    1) Можно было бы упомянуть о шаблонных шаблонных параметрах — где параметром шаблона является шаблон. В 1-м комментарии об этом упоминается. Возможно это будет в других статьях.
    2) Для меня, как не сильного спеца по плюсам впечатлила книга Андрея Александреску «Современное проектирование на C++» — первая часть книги для меня была магической.


    1. semenyakinVS Автор
      11.01.2022 19:04

      Да, Александреску топ! С приходом концептов, вероятно, многие решения из неё ощутимо человекочитаемее станут.