Просто о шаблонах C++

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

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

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

Вступительные ремарки

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

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

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

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

    Макрос PrintExpression
    // В начале файле где объявляется макрос не забудьте добавить
    // инклуд: "#include "
    
    // Собственно, сам макрос. Распечатывает в "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
Type max(Type a, Type b)
{
	return (a >= b ? a : b);
}

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

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

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

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

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

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

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

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

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

Несмотря на родство по шаблону, функции «max()» и «max()» — совершенно самостоятельны, каждая из них будет превращаться при компиляции в свой ассемблерный код.

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

Зафиксируем также более высокоуровневую терминологию. Программирование с использованием шаблонов называют метапрограммированием или обобщённым программированием.

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

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

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

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

…можно записать, опустив :

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

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

Тип переменной «a» — «int», тип переменной «b» — тоже «int». Они передаются в шаблон функции «template Type max (Type, Type)», в котором ожидается, что оба аргумента будут иметь одинаковый тип «Type». Так как типы «a» и «b» совпадают, и нет других правил ограничивающих данный шаблонный аргумент «Type», компилятор делает вывод, что записью «max (a, b)» ожидают применения шаблонной функции «max(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(a, bChar);

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

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

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

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

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

template
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 intersection(const Interval& inOther) const
  {
    return Interval{
        max(start, inOther.start),
        min(end, inOther.end)
    };
  }

private:
  Type start;
  Type end;
};

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

Пример использование шаблона класса «template class Interval»:

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

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

	const Interval 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» в первый раз, по шаблону класса будет порождён новый шаблонный класс. Порождённый класс будет выглядеть для компилятора следующим образом:

// В качестве значения шаблонного аргумента "Type" выполняется подстановка
// типа "int".
//
// Комментариями над методами обозначено как они выглядели в шаблоне до
// подстановки.
//
class Interval
{
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 intersection(const Interval& inOther) const
	Interval intersection(const Interval& inOther) const
  {
  	//return Interval{
    //    max(start, inOther.start),
    //    min(end, inOther.end)
    //};
    return Interval{
        max(start, inOther.start),
        min(end, inOther.end)
    };
  }

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

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

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

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

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

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

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

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

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

int main()
{
	SimpleArray 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» занимает один байт):

image-loader.svg

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

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

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

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

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

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

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

Ниже — полный код специализации шаблона класса «template class SimpleArray».

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

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

	// (*) Для хранения битов будет использовать массив "unsigned char", так как
  // этот тип занимает один байт во всех популярных компиляторах.
	SimpleArray(int inElementsNum)
		: elementsMemory(nullptr), num(inElementsNum)
	{
  	// (*) Специализация подчиняется тем же правилам, что и обобщённая версия
    // шаблона. Она будет содержать количество элементов передаваемое в
    // конструктор. В конструкторе считается количество байт нужных для
    // размещения битов элементов.
    
    // (*) Для начала расчитывается в каком байте и по какому биту в этом байте
    // будет размещаться значение последнего элемента массива. Подробнее эта
    // логика описана в реализации "SimpleArray::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 class SimpleArray» с поддержкой специализации по типу «bool»:

int main()
{
  SimpleArray simpleArray{ 4 };

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

	SimpleArray 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 class SimpleArray" по типу "bool".
  //
  // Тут компилятор выберет специализацию, ведь подставляемый в шаблон тип
  // это "bool". Он подходит по описанным правилам для специализации
  // "template<> class SimpleArray"

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

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

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

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

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

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

template
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(a, b);
  //
  // В результате подстановки типа "Point2D" в аргумент "Type" шаблона
  // "template max(Type, Type)" породится шаблонная функция, которая для
  // компилятора выглядит так:
  //
  // Point2D max(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++ н

© Habrahabr.ru