Программирование PC Программирование шаблонов C++ для идиотов, часть 2 Thu, November 21 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.


Программирование шаблонов C++ для идиотов, часть 2 Печать
Добавил(а) microsin   

В первой части серии [1] я рассматривал следующие аспекты шаблонов в C++ (здесь приведен перевод статьи Ajay Vijayvargiya [2]):

• Синтаксис шаблонов C++.
• Шаблоны функций и шаблоны классов.
• Шаблоны, получающие один и более аргументов.
• Шаблоны, получающие бестиповые интегральные аргументы, и получение аргументов по умолчанию.
• Процесс компилирования за 2 фазы.
• Обычные классы Pair и Array, которые могут содержать любые типы данных.

В этой второй части я попытаюсь передать более интригующие концепции шаблонов, их важность и связь с другими фичами языка C++, и также затрону STL. Нет, Вам не нужно знать все про STL, и я не собираюсь глубоко влезать в рассмотрение STL. Все, что я прошу - только обновить свои знания о шаблонах, которые Вы получили при чтении первой части [1] перед тем, как начать читать эту часть.

• Требования от нижележащих типов
    Требования: шаблоны функции
    Требования: шаблоны класса
• Отделение декларации (Declaration) от реализации (Implementation)
    Раздельная реализация класса
• Шаблоны и другие аспекты языка C++
    Шаблоны класса, Друзья (Class Templates, Friends)
    Шаблоны класса, перезагрузка оператора (Operator Overloading)
    Шаблоны класса, наследование (Inheritance)
    Указатели на функции и функции обратного вызова (Callbacks)
    Шаблоны и виртуальные функции
    Шаблоны и макросы
    Перезагрузка функции
• Введение в STL
• Шаблоны и разработка библиотеки
    Явное инстанциирование (Explicit Instantiation)

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

[Требования, поступающие от нижележащего типа]

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

[Требования: шаблоны функции]

Позвольте мне начать разбирать этот пункт темы с маленького и изящного примера. Следующая функция отобразит в консоли значение предоставленного типа (используя std::cout):

template< typename T >
void DisplayValue(T tValue)
{
   std::cout << tValue;
}

Следующий набор вызовов выполнится успешно:

DisplayValue(20);              // < int >
DisplayValue("Это текст");     // < const char* >
DisplayValue(20.4 * 3.14);     // < double >

Поскольку ostream (тип cout) имеет перезагруженный оператор << для всех базовых типов, то этот код будет нормально работать для типов int, char* и double. Здесь будет происходить неявный вызов одной из перезагрузок оператора << .

Теперь позвольте мне определить новую структуру, в которой будет 2 поля:

struct Currency
{
   int Dollar;
   int Cents;
};

Попытаемся использовать этот тип вместе с шаблоном функции DisplayValue:

Currency c;
c.Dollar = 10;
c.Cents = 54;
 
DisplayValue(c);

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

std::cout << tValue; // tValue теперь имеет тип Currency

Visual C++ начнет сообщать об ошибках так:

error C2679: binary ' << ' : no operator found which takes a right-hand operand of 
type 'Currency' (or there is no acceptable conversion)

Компилятор GCC начнет жаловаться так:

In function 'void DisplayValue(T) [with T = Currency]':
16: instantiated from here
2: error: no match for 'operator << ' in 'std::cout << tValue'

Вид ошибок отличается, но они означают одно и то же: нет доступной перезагрузки оператора ostream::operator << для вызова с типом Currency. Оба компилятора сообщают об этой простой ошибке как минимум в 100 строках! Ошибка произошла не полностью ни из-за ostream, ни из-за Currency и ни из-за какого-то шаблона - но из-за сложения всего этого вместе. В настоящий момент у Вас есть несколько доступных опций для решения проблемы:

• Не вызывать DisplayValue для типа Currency, и вместо этого написать другую функцию DisplayCurrencyValue. Именно это и делают большинство программистов после того, как они не смогли выполнить задачу с оригинальным вариантом DisplayValue, когда она вызывается с типом Currency. Делать так - значит признать поражение, и не использовать силу шаблонов C++. Не делайте так!
• Модифицировать класс ostream, и добавить к нему новый член (например оператор << ), который может принимать тип Currency. Но у Вас нет свободы поступать так, потому что ostream определен в одном из стандартных заголовков C++. Однако с глобальной функцией, которая берет типы ostream и Currency, Вы можете сделать это.
• Модифицируйте Ваш собственный класс Currency, так чтобы cout << tValue мог завершиться успешно.

Короче говоря, Вам нужно упростить одно из следующего:

• ostream::operator << (Currency value); (упрощенный синтаксис)
• ostream::operator << (std::string value);
• ostream::operator << (double value);

Первая версия весьма возможна, но синтаксис несколько сложноват. Определение пользовательской функции должно принимать как тип ostream, так и тип Currency, так чтобы cout << currency_object; мог сработать.

Вторая версия требует понимания std::string и будет медленнее и сложной в реализации.

Третья версия удовлетворяет требованиям настоящего момента, и проще других двух версий. Это означает, что тип Currency будет преобразован в double, и каждый раз, когда это будет потребовано, преобразованные данные будут переданы в вызов cout.

И вот это решение:

struct Currency
{
  int Dollar;
  int Cents; 
 
  operator double()
  {
    return Dollar + (double)Cents/100;
  }
};

Обратите внимание, что весь результат будет поставляться на выход в значении типа double. Таким образом, для объекта Currency{12, 72} этот перезагруженный оператор (функциональный оператор double()) вернет 12.72. Компилятор теперь будет счастлив, поскольку теперь есть преобразование из Currency в один из типов, который может принять ostream::operator << .

И таким образом следующий вызов для типа Currency:

std::cout << tValue;

будет развернут как:

std::cout << tValue.operator double(); // std::cout << (double)tValue;

И поэтому следующий вызов будет работать:

Currency c;
DisplayValue(c);

Вы видите, что простой вызов функции вовлекает несколько операций с помощью компилятора:

• Инстанциация DisplayValue для типа Currency.
• Вызов copy-конструктора для класса Currency, поскольку тип T был передан по значению, а не по ссылке.
• В попытке найти лучший вариант для вызова оператора cout << будет вовлечен оператор преобразования Currency::operator double.

Просто добавьте свой собственный код copy-конструктора для класса Currency class, выполните шаг входа в отладке, и Вы увидите, что простой вызов повлечет несколько операций!

Хорошо! Теперь, давайте пересмотрим шаблон функции PrintTwice из предыдущей части статьи (см. [1]):

template< typename TYPE >
void PrintTwice(TYPE data)
{
    cout << "Удвоенное значение: " << data * 2 << endl;
}

Важная часть функции здесь помечена жирным шрифтом. Когда Вы сделаете её вызов так:

PrintTwice(c); // c является типом Currency

то выражение data * 2 в PrintTwice сработает, поскольку тип Currency предусматривает такую возможность. Оператор cout может быть составлен компилятором так:

cout << "Удвоенное значение: " << data.operator double() * 2 << endl;

Когда Вы удалите перезагруженный оператор double() из класса Currency, компилятор будет жаловаться, что у него нет оператора operator*, или любой возможности вычислить выражение. Если Вы поменяете class (struct) Currency так:

struct Currency
{
  int Dollar;
  int Cents;
 
  /*УДАЛЕНО : operator double();*/
  
  double operator*(int nMultiplier)
  {
    return (Dollar+(double)Cents/100) * nMultiplier;
  }
};

то компилятор снова будет счастлив. Но выполнение этого приведет к тому, что DisplayValue не сможет инстанцироваться для Currency. Причина проста: cout << tValue не будет допустима.

Что если Вы предоставите как преобразование для double, так и оператор умножения? Потерпит ли неудачу вызов PrintTwice для типа Currency? Возможный ответ был бы да, компиляция не сработает, поскольку для компилятора будет двусмысленность при вызове:

cout << "Удвоенное значение: " << data * 2 << endl;// Неоднозначность: что же вызвать 
                                                   // - оператор конверсии или оператор * ?

Однако нет, компиляция не потерпит неудачу, и будет сделан вызов оператора *. Могут быть вызваны все другие возможные подпрограммы преобразования, если компилятор не найдет наилучшего кандидата. БОльшая часть понятий, упомянутых здесь, приходят из основного свода правил языка C++, и не находится под покровом принципов шаблонов. Я объяснил это для лучшего понимания, что происходит.

Обычно советуют, чтобы класс не выставлял наружу слишком много операторов конверсии, вместо этого должны быть написаны соответствующие функции класса (методы). Например, такой функцией является string::c_str(), возвращающая указатель на строку C-стиля; но другие реализации (наподобие CString) обеспечили бы необходимые преобразования неявно, предоставляя операторы преобразования. Но для большинства случаев рекомендуется, чтобы классы не публиковали ненужные операторы, чтобы класс мог работать с любым кодом. Класс должен предоставить только разумное количество неявных преобразований.

То же самое происходит с шаблонами и нижележащими типами, нижележащий тип может предоставить оператор преобразования (наподобие преобразования int или преобразования строки), но не должен предоставлять конверсии в избытке. Таким образом для класса Currency будет достаточной только конверсия double (и/или в строку) - здесь нет необходимости давать перезагрузку (двоичного) оператора *.

Пойдем дальше. Следующий шаблон функции:

template< typename T >
T Add(T n1, T n2)
{
   return n1 + n2;
}

потребовал бы, чтобы у T был в наличии оператор +, который берет аргументы одного типа, и возвращает тот же самый тип. В классе Currency class Вы должны реализовать это вот так:

Currency operator+(Currency); // Для упрощения без const

Интересно, что если Вы не реализуете оператор + в классе Currency, сохраните в этом классе оператор преобразования double, и вызовете функцию Add так:

Currency c1, c2, c3;
c3 = Add(c1,c2);

то получите некую странную ошибку:

error C2440: 'return' : cannot convert from 'double' to 'Currency'

Причины две:

• n1 + n2 приведут к тому, что будет вызван оператор double() для двух переданных объектов (implicit conversion, неявное преобразование). Таким образом, n1+n2 становится просто выражением double+double, и результат этого выражения также будет double.
• Поскольку конечное значение имеет тип double, и то же самое значение должно быть возвращено из функции Add, то компилятор попытается преобразовать double в Currency. Поскольку в классе Currency нет конструктора, который принимает double, то получается ошибка.

Для этой ситуации класс Currency может предоставить конструктор (конструктор преобразования), который будет принимать тип double. Это целесообразно, поскольку Currency теперь может предоставить оба преобразования: в double и из double. Предоставление оператора + только ради шаблона функции Add не имеет большого смысла.

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

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

template< typename T >
double GetAverage(T tArray[], int nElements)
{
   T tSum = T(); // tSum = 0
   for (int nIndex = 0; nIndex < nElements; ++nIndex)
   {
     tSum += tArray[nIndex];
   }
   
   // Какой бы не был тип T, преобразовать его в double
   return double(tSum) / nElements;
}

Эй, не будьте ленивыми... Давайте, сделайте это! Выполните требования GetAverage для типа Currency. Шаблоны не просто теоретизирование, это вполне себе практическая вещь! Не читайте дальше, пока не поймете каждый бит текста до этого момента.

[Требования: шаблоны класса]

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

Шаблоны класса запросили бы больше требований от нижележащего типа. Хотя это главным образом зависело бы от того, что Вы требуете от самого шаблона класса. Например, если Вы не вызываете эквивалентные методы min / max из инстанциации шаблона класса, то нижележащий тип не должен предоставлять соответствующие операторы отношения.

Большинство шаблонов класса потребовали бы наличие следующих нижележащих типов:

• Конструктор по умолчанию
• Copy-конструктор
• Оператор присваивания

Опционально, в зависимости от самого шаблона класса (например, от назначения шаблона класса), могут также потребоваться:

• Деструктор
• Move-конструктор и Move оператор присваивания (поверх ссылок R- Value)

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

Обратите внимание, что не все требуемые методы должны быть предоставлены для общего пользования нижележащим типом (т. е. не все эти методы должны быть public) из шаблона класса. Например, защищенный (protected) оператор присваивания из класса Currency не поможет для некоторой коллекции класса, который его потребует. Присвоение одной коллекции другой (обе типа Currency) не будет скомпилировано из-за protected-природы Currency.

Давайте пересмотрим набор классов, который я использовал в первой части статьи [1]. Вот следующий шаблон класса Item для удержания любого типа:

template< typename T >
class Item
{
   T Data;public:
   Item() : Data( T() ) {}
 
   void SetData(T nValue)
   {
      Data = nValue;
   }
 
   T GetData() const
   {
    return Data;
   }
 
   void PrintData()
   {
      cout << Data;
   }
};

Главное требование - иметь в наличии конструктор по умолчанию для типа T, как только Вы инстанциируете Item для какого-то частного типа. Так что когда Вы делаете так:

Item< int > IntItem;

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

Item() : Data( T() ) {}

Мы все знаем, что тип int имеет свой конструктор по умолчанию, который инициализирует переменную нулевым значением. Когда Вы инстанциируете Item для другого типа, и у этого типа нет конструктора по умолчанию, то компилятор расстроится. Чтобы понять это, создадим новый класс:

class Point
{
   int X, Y;public:
   // Нет конструктора по умолчанию
   Point(int x, int y);
};

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

Point pt; // ERROR: No default constructor available
          // (ошибка: нет конструктора по умолчанию)// Point pt(12,40);

Подобным образом следующая инстанциация также потерпит неудачу:

Item< Point > PointItem;

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

Но когда Вы используете Point для шаблона класса Item, компилятор сообщит об ошибке около реализации Item. Для примера PointItem, показанного ранее:

Item () : Data( T() )
{}

компилятор Visual C++ выдаст следующее сообщение об ошибке:

error C2512: 'Point::Point' : no appropriate default constructor available 
: while compiling class template member function 'Item< T >::Item(void)' 
with 
[ 
T=Point 
] 
: see reference to class template instantiation 'Item< T >' being compiled 
with 
[ 
T=Point 
]

Это очень маленькое сообщение об ошибке, хотя затронута система сообщений об ошибках шаблона C++. Чаще всего первая ошибка выдавала бы всю историю, и последняя ссылка на ошибку ("see reference to class...") показало бы фактическую причину ошибки. В зависимости от Вашего компилятора, ошибка может быть как понятной для понимания, или же может быть совершенно непонятна, так что трудно докопаться до истинной причины ошибки. Быстрота и принципиальная возможность найти и исправить ошибку зависит от Вашего опыта работы с программированием шаблонов.

Таким образом, чтобы использовать класс Point в качестве параметра типа для шаблона Item, Вы должны предоставить конструктор по умолчанию для Point. Да, одиночный конструктор, берущий 0, один или 2 параметра также работали бы:

Point(int x = 0, int y = 0 );

Но конструктор должен быть общедоступным (другими словами, декларирован как public).

Хорошо. Посмотрим, будут ли работать методы SetData и GetData для типа Point. Да, оба этих метода будут работать, поскольку у Point есть (предоставленный компилятором) copy-конструктор и оператор присваивания.

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

void SetData(T nValue) // Point nValue
{
   Data = nValue; // вызов оператора присваивания.
}

Если Вы разместите реализацию оператора присваивания в области private / protected класса Point:

class Point
{
   ...private:
   void operator=(const Point&);
};

и сделаете вызов Item::SetData:

Point pt1(12,40), pt2(120,400);
Item< Point > PointItem;
 
PointItem.SetData(pt1);

то это приведет сообщению об ошибке компилятора, которое будет начинаться с чего-то такого:

error C2248: 'Point::operator =' : cannot access private member declared in class 'Point'
99:7: error: 'void Point::operator=(const Point&)' is private

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

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

Этот пример также демонстрирует, как различные классы и функции вовлекаются в один вызов функции. Для нашего случая вовлечены только класс Point, шаблон класса Item и функция main.

Подобным образом, когда Вы делаете вызов метода Item< Point >::GetItem, будет вызван copy-конструктор (класса Point). Причина проста - GetItem возвращает копию сохраненных данных. Вызов GetItem не всегда может вызывать copy-конструктор, поскольку могут быть приняты во внимание семантики RVO/NRVO, move и т. п.. Но Вы всегда должны делать доступным copy-конструктор для нижележащего типа (он должен быть в области public).

Метод Item< >::PrintData не выполнится для типа Point, но он выполнился бы для Currency в качестве нижележащего типа для шаблона Item. У класса Point нет преобразования, или любого возможного вызова для срабатывания cout << . Вот Вам еще упражнение - сделайте класс Point выводимым в консоль через cout!

[Отделение декларации (Declaration) от реализации (Implementation)]

До сего момента я показал всю реализацию кода шаблона в одном файле исходного кода. Рассматривайте исходный файл как один заголовочный файл, или может быть реализован шаблон в том же файле, который содержит функцию main. Как должен знать любой программист C/C++, мы обычно размещаем декларации (или как говорят, интерфейсы) в файле заголовка (header, файл с расширением *.h, а иногда hpp), и соответствующую их реализацию в одном или нескольких файлах исходного кода (модуль реализации, файл обычно с расширением *.c или *.cpp). Заголовочный файл был бы подключен как соответствующим модулем реализации, так и одним или большим количеством клиентов этого интерфейса (клиенты - это другие классы или модули исходного кода, которые используют экземпляры и методы этого класса).

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

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

//Заголовок Sample.H
template< typename T >
void DisplayValue(T tValue); 
 
//Модуль Sample.CPP
template< typename T >
void DisplayValue(T tValue)
{
    std::cout << tValue;
}
 
//Модуль Main.cpp
#include "Sample.H"
 int main()
{
  DisplayValue(20);
  DisplayValue(3.14);
}

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

unresolved external symbol "void __cdecl DisplayValue< double >(double)" (??$DisplayValue@N@@YAXN@Z) 
unresolved external symbol "void __cdecl DisplayValue< int >(int)" (??$DisplayValue@H@@YAXH@Z)
 
(.text+0xee): undefined reference to `void DisplayValue< int >(int)'
(.text+0xfc): undefined reference to `void DisplayValue< double >(double)'

Если Вы внимательнее посмотрите на ошибки, то увидите, что линкер не нашел реализации следующих процедур:

void DisplayValue< int > (int);
void DisplayValue< double > (double);

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

Здесь есть секрет. Вы знаете, что шаблон функции получает инстанцирование только тогда, когда Вы делаете её вызов с определенным типом (или определенными типами, если у шаблона их несколько). Компилятор компилирует Sample.CPP отдельно от определения DisplayValue. Компиляция Sample.CPP в отдельном потоке трансляции. Компиляция Main.cpp выполняется в другом потоке трансляции. Эти два потока трансляции выдают два объектных файла (скажем, Sample.obj и Main.obj).

Когда компилятор работает с Sample.CPP, но не находит никаких обращений (вызовов) к DisplayValue, и не делает инстанциацию DisplayValue для любого типа. Причина проста, и была объяснена ранее - On-Demand-Compilation (компиляция по запросу). Откуда компилятору знать, к какому типу надо привязать DisplayValue при обработке Sample.CPP? Поскольку поток трансляции для Sample.CPP не требует какого-либо инстанцирования (для любого типа данных), то вторая фаза компиляции Sample.CPP для шаблона не выполняется. Для DisplayValue< > не будет сгенерирован объектный код.

В другом потоке трансляции Main.CPP будет скомпилирован и сгенерирован другой объектный код. При компилировании этого модуля компилятор увидит допустимую декларацию интерфейса для DisplayValue< >, и выполнит свою работу без всяких проблем. Поскольку мы сделали вызов DisplayValue с двумя разными типами, компилятор интеллигентно сам сделает нужные декларации (на основе шаблона):

void DisplayValue< int >(int tValue);
void DisplayValue< double >(double tValue);

Это нормальное поведение, компилятор предполагает, что эти символы находятся где-то в другом потоке трансляции (например, в другом файле объектного кода), и передает ответственность за дальнейшую обработку линкеру. При таком способе обработки файлы Sample.obj и Main.obj будут сгенерированы, но ни в одном из них не будет содержаться реализация для DisplayValue - и поэтому линкер выдаст серию ошибок.

Как можно решить проблему?

Самое простое решение, которое работает со всеми современными компиляторами - использовать модель подключения (Inclusion Model). Другая модель, которая в большинстве случаев не поддерживается разными разработчиками компиляторов - Separation Model.

До этого места каждый раз, когда я объяснял материал, касающийся шаблонов, с кодом реализации, определенном в одном файле, я использовал модель подключения. Проще говоря, Вы помещаете весь шаблон и любой связанный с ним код в один файл (обычно это файл заголовка). Клиент будет просто подключать этот предоставленный файл заголовка, и весь код будет компилироваться в одном потоке трансляции. Все это будет следовать процедуре компиляции по требованию (on-demand-compilation).

Для вышеприведенного примера заголовок Sample.H будет содержать определение (реализацию) для DisplayValue:

//Заголовочный файл Sample.H
//Здесь и интерфейс, и реализация
template< typename T >
void DisplayValue(T tValue) 
{
   std::cout << tValue;
}

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

template< typename T >
void DisplayValue(T tValue);
 
template< typename T >
void DisplayValue(T tValue)
{
   std::cout << tValue;
}

Это дает следующие преимущества:

• Логическое группирование всех деклараций и всех реализаций.
• Не будет ошибок компилятора, если шаблон функции A нуждается в использовании B, и B также нуждается в использовании A. Вы бы уже объявили прототипы для другой функции.
• Нет встраивания (Non-inlining) методов класса. До настоящего времени я разрабатывал весь шаблон класса в пределах тела декларации класса. Отделение реализации метода обсуждается позже.

Так как Вы можете логически отделить интерфейс от реализации, то можете также фигурально разделить их по разным файлам, с расширениями *.H и *.CPP:

template< typename T >
void DisplayValue(T tValue);
 
#include "Sample.CPP"

Sample.H дает прототип для DisplayValue, и по окончании файла он подключает файл Sample.CPP. Не беспокойтесь, это отлично совместимо с языком C++, будет обработано Вашим компилятором. Обратите внимание, Ваш проект / сборка теперь не должны добавлять в процесс компиляции файл Sample.CPP.

Клиент (модуль кода Main.CPP) подключит заголовок, который добавит код Sample.CPP к нему. В этом случае только один поток трансляции (для Main.cpp) будет делать такое подключение.

[Раздельная реализация класса]

Эта секция требует от читателей больше внимания. Реализация метода вне класса требует полной спецификации типа. Например, давайте реализуем Item::SetData вне определения класса.

template< typename T >
class Item
{
   ...
  void SetData(T data);
};
 
template< typename T >
void Item< T >::SetData(T data)
{
   Data = data;
}

Обратите внимание на выражение Item< T >, где упоминается класс, для которого определяется метод. Реализация метода SetData вместе с именем Item::SetData не будет работать, поскольку Item не простой класс, а шаблон класса. Символ Item пока еще не тип, но инстанциация Item< > является типом, как это имеет место в выражении Item< T >.

Например, когда Вы инстанциируете Item с типом short, и используете для него метод SetData:

Item< short > si;
si.SetData(20);

то компилятор сгенерирует код наподобие этого:

void Item< short >::SetData(short data)
{
   Data = data;
}

Здесь сформированное имя класса Item< short >, и метод SetData определяется для этого типа класса.

Давайте реализуем методы вне тела класса:

template< typename T >
T Item< T >::GetData() const
{
   return Data;
}
 
template< typename T >
void Item< T >::PrintData()
{
   cout << Data;
}

Четко заметьте для себя, что template< typename T > требуется во всех случаях, и также требуется Item< T >. Когда реализуется GetData, возвращаемый тип это T (как и должно быть). В реализации PrintData, хотя T не используется, спецификация Item< T > все равно должна присутствовать.

И наконец, вот конструктор, реализованный вне класса:

template< typename T >
Item< T >::Item()  /*: Data( T() ) */
{
}

Здесь символ Item< T > обозначает класс, и Item() обозначает метод этого класса (в нашем случае имя метода совпадает с именем класса, так что это конструктор). Нам не надо (или мы не можем, в зависимости от компилятора) использовать Item< T >::Item< T >() для определения конструктора. Конструктор (и деструктор) являются специальными методами класса, и не типами класса, так что в этом контексте они не должны использоваться как тип.

Я только для упрощения закомментировал инициализацию по умолчанию для Data с конструктором по умолчанию - вызов для типа T. Вы должны раскомментировать закомментированную часть, и понимать её значение.

Если шаблон класса имеет один или большее количество типов по умолчанию / или бестиповых параметров шаблона класса (non-type), нам просто нужно указать это при декларации класса:

template< typename T = int >
// По умолчанию тип intclass Item
{
   ... 
   void SetData(T data);
};
 
void Item< T >::SetData() { }

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

void Item< T = int >::SetData() {} // ERROR

Это просто было бы ошибкой. Правило и обоснование очень похоже на функцию C++, получающую аргумент (или аргументы) по умолчанию. Мы указываем только параметр (параметры) по умолчанию, когда декларируем интерфейс функции, и не когда (отдельно) реализуем функцию. Например:

void Allocate(int nBytes = 1024);
void Allocate(int nByte /* = 1024* / )  // Если раскомментировать, то будет ошибка.
{ }

[Реализация метода шаблонов вне класса]

Для этого сначала рассмотрите простой пример кода:

Item< int > IntItem;
Item< short > ShortItem; 
IntItem.SetData(4096);ShortItem = IntItem;

Важная строка в этом коде помечена жирным шрифтом. Она пытается присвоить объект Item< int > экземпляру объекта Item< short >, что невозможно, поскольку эти два объекта имеют разные типы. Конечно, мы вместо этого использовали бы SetData на одном объекте и GetData на другом. Но что если нам нужно реализовать работу именно присваивания?

Для этого Вы можете реализовать пользовательский оператор присваивания, который сам по себе был бы реализован поверх шаблонов. Это было бы классифицировано как шаблон метода, и было уже рассмотрено в части [1]. Здесь у нас идет обсуждения только для реализации вне класса. Так или иначе вот как будет выглядеть реализация в пределах класса:

template< typename U >
void operator = (U other)
{
   Data = other.GetData();
}

где U это тип другого класса (другая инстанциация Item). Я не использовал константу и спецификацию ссылки для другого аргумента только для упрощения. Когда имеет место ShortItem = IntItem, то генерируется следующий код:

void operator = (Item< int > other) 
{
   Data = other.GetData();
}

Обратите внимание, что other.GetData() возвращает int, и не short, поскольку исходный объект имеет тип Item< int >. Если Вы вызовете этот оператор присваивания с несовместимыми (не преобразуемыми друг в друга типами, как например int* в int), то это приведет к ошибке компилятора из-за невозможности неявного преобразования типов. Не нужно использовать любой способ преобразования типа в коде шаблона для такого вида преобразований. Позвольте компилятору сообщить об ошибке клиенту Вашего шаблона.

Есть еще одна интересная вещь, о которой стоит здесь упомянуть. Если вы кодируете упомянутый ранее оператор присваивания примерно так:

template< typename U >
void operator = (U other)
{
   Data = other.Data;
}

то это просто не скомпилируется - компилятор пожалуется, что Data имеет область доступа private! Вы не задали себе вопрос, почему так?

Причина довольно проста: этот класс (Item< short >), и другой класс (Item< int >) в действительности два совершенно разных класса, и между ними нет по сути никакой связи. По стандартному правилу языка C++, только тот же класс может получить доступ к данным private текущего класса. Поскольку Item< int > другой класс, то он не даст доступ к классу Item< short >, так что произойдет ошибка! Так что вместо этого я должен использовать метод GetData!

Так или иначе, вот наша реализация шаблона метода вне декларации класса.

template< typename T >
template< typename U >
void Item< T >::operator=( U other )
{
   Data = other.GetData();
}

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

template< typename T, class U >
void Item< T >::operator=( U other )

Причина проста - шаблон класса Item не принимает 2 аргумента шаблона; он получает только один. Или, если мы берем это наоборот - шаблон метода не получает 2 аргумента шаблона. Класс и метод это две раздельные строки с ключевым словом template, и их нужно классифицировать индивидуально.

Также Вы должны обратить внимание на то, что сначала идет < class T >, за ним идет < class U >. Изучая это слева направо по логике парсинга языка C++, мы видим, что сначала идет класс Item, затем метод. Вы можете это рассматривать как следующее определение:

template< typename T >
template< typename U >
void Item< T >::operator=( U other )
{ }

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

template< typename U >
template< typename T >
void Item< U >::operator=( T other )
{ }

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

Ну как, Вы это все прочитали и даже поняли? Ну тогда пора проверить себя в написании некоторого шаблона кода! Мне для работы нужно следующее:

const int Size = 10;
Item< long > Values[Size];
 
for(int nItem = 0; nItem < Size; ++nItem)
    Values[nItem].SetData(nItem * 40);
 
Item< float > FloatItem;
FloatItem.SetAverageFrom(Values, Size);

Шаблон метода SetAverageFrom вычислил бы среднее значение от переданного массива Item< >. Да, аргумент (Values) может быть любым, нижележащим типом массива Item. Реализуйте это вне тела класса! Независимо от того кто Вы есть - супергений в шаблонах C++, или нет. Или если Вы думаете, что это задача на уровне аэрокосмических исследований? Вы все равно должны это сделать.

Дополнительно, что бы Вы сделали, если бы Values в массиве были нижележащего типа Currency?

Большинство реализаций шаблонов использовали бы только лишь модель подключения (inclusion model), где весь код шаблона размещался бы только в одном встроенном файле кода! STL, к примеру, использует только заголовки, техника реализации inline. Несколько библиотек используют технику подключения других файлов - но они требуют от клиента только подключения заголовка, и таким образом работают только поверх модели подключения.

Для большей части кода шаблонов встраивание (inlining) не вредит коду по некоторым причинам.

Причина 1. Код шаблона (класс, функция, вся библиотека) обычно коротка по объему, наподобие реализации шаблона класса меньше, чем вызов оператора < на нижележащем типе. Или коллекция класса которая помещает данные в коллекцию и читает их оттуда, не делая слишком большой трудоемкой работы. Большинство классов выполняли бы маленькие и узкоспециализированные задачи наподобие вызова указателя функции / функтора, работая со материалом типа строк; и не делали интенсивных вычислений, чтения базы данных или файла, отправления пакета через сеть, подготовку буфера к загрузке и другой интенсивной работы.

Причина 2. Встраивание (inlining) это просто запрос от программиста к компилятору, и компилятор будет встраивать или не встраивать код по своему собственному усмотрению. Все это зависит от сложности кода, как часто это (метод) вызывается, действующих настроек оптимизации и т. д. Линкер и оптимизация с использованием профайлера (profile guided optimization, PGO) также играют важную роль в оптимизации кода, встраивании и т. д. Таким образом, размещение всего кода в определении класса не будет означать ничего плохого.

Причина 3. Не весь написанный код будет скомпилирован - только тот, кто получает инстацирование, и эта причина более важна из-за предыдущих упомянутых причин. Так что не беспокойтесь по поводу встраивания (inlining)!

Когда набор шаблонов класса находится вместе с несколькими вспомогательными функциями, код для компилятора то же самое, что и арифметическое выражение. Например, Вы использовали бы алгоритм std::count_if на vector< int >, передавая функтор, который вызвал бы некоторый оператор сравнения. Все это, когда соединено в одном операторе, может выглядеть сложным, и кажется сильно загружающим процессор. Но это не так! Все выражение, даже включая разные шаблоны класса и шаблоны функций, походит для компилятора на простое выражение - особенно в варианте сборки Release.

Другая модель - Separation Model, работает поверх ключевого слова export, которое большинство компиляторов до сих пор не поддерживает. Ни компилятор GCC, ни компилятор Visual C++ не поддерживают это ключевое слово - оба компилятора, однако, сообщили бы, что это зарезервированное слово для использования в будущем, вместо того, чтобы выбросить сообщение о традиционной ошибке синтаксиса.

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

[Шаблоны и другие аспекты C++]

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

К сожалению, или наоборот к счастью, эта секция не о том, как злоупотреблять языком C++ и как заставить компилятор работать за Вас. Эта секция говорит о других концепциях наподобие наследования (inheritance), полиморфизма (polymorphism), перезагрузки оператора (operator overloading), RTTI (Run-Time Type Identification) и и. п. касательно использования шаблонов.

[Шаблоны класса, Друзья (Friends)]

Любой ветеран программирования знает реальную важность ключевого слова friend. Любой новичок или забитый книгами "знаток" обычно терпеть не могут ключевое слово friend, говоря что оно разбивает инкапсуляцию, и кто-то может еще сказать "зависит". Независимо от того, как Вы на это смотрите, я надеюсь, что ключевое слово friend полезно, если рассудительно используется там, где это необходимо. Пользовательский выделитель (custom allocator) для различных классов; класс для поддержания взаимосвязи между двумя разными классами; или внутренний класс в классе - это все хорошие кандидаты случаев использования ключевого слова friend.

Позвольте мне сначала дать пример, где ключевое слово friend вместе с шаблоном почти необходимо. Если Вы помните основанный на шаблоне оператор присваивания класса Item< >, Вы должны также вспомнить, что я должен был использовать GetData другого объекта отличающегося типа (другой вариант шаблона Item< >). Вот определение (в классе):

template< typename U >
void operator = (U other)
{
   Data = other.GetData();
}

Причина проста: Item< T > и Item< U > имели бы разные типы, где T и U могут быть int и short, например. Один класс не может получить доступ к private члену другого класса. Если Вы реализовали оператор присваивания для обычного класса, то непосредственно получили бы доступ к данным другого объекта. Но что бы Вы сделали, чтобы получить доступ к данным другого класса (который, по иронии судьбы, по сути является тем же классом!)?

Так как две специализации шаблона класса принадлежат одному и тому же классу, можно ли сделать их друзьями? Я имею в виду, что можно ли сделать Item< T > и Item< U > друзьями друг другу, где T и U два разных типа данных (которые можно преобразовать один в другого)?

Логически это будет выглядеть так:

class Item_T
{
   ...
   friend class Item_U;
};

Так что теперь Item_U может получить доступ к Data (данные в области private) класса Item_T! Помните, что в действительности Item_T и Item_U не просто два классовых типа, это набор из двух инстанциаций поверх шаблона класса Item.

Взаимная дружба выглядит логически так же, но как этого достигнуть? Следующий код не сработает:

template< typename t >
class Item
{
   ...
   friend class Item;
};

Так как Item это шаблон класса, а не обычный класс, то символ Item недопустим в этом контексте. GCC сообщит следующее:

warning: class 'Item' is implicitly friends with itself [enabled by default] 
error: 'int Item::Data' is private

Забавно, что сначала он говорит, что это - неявный друг с самим собой, а потом жалуется на нарушение доступа к области private. Компилятор Visual C++ более снисходителен, тихо этот код скомпилирует, и сделает производные шаблона друзьями. Так или иначе, этот код не будет совместимым. Мы должны предоставить другой код, который будет переносимым, и он должен указывать на Item как на шаблон класса. Так как целевой тип не известен, мы не можем заменить T каким-то частным типом данных.

Нужно использовать следующее:

template < class U >
friend class Item; // Около 'Item' нет ключевого слова template

Это делает предварительную декларацию (forward-declare) класса, и подразумевает, что Item является шаблоном класса. Компилятор теперь удовлетворен, и не будет выдавать какие-либо предупреждения. И теперь следующий код будет работать без лишних затрат на вызов функции:

template< typename U >
void operator = (U other)
{
   Data = other.Data; // другой класс (например Item< U >) сделал 'меня' другом.
}

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

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

[Шаблоны класса, перезагрузка операторов]

Шаблоны класса использовали бы идею перезагрузки операторов чаще, чем это делают традиционные классы. Например, класс сравнения использовал бы один или больше операторов отношения. Класс коллекции использовал бы оператор индекса для упрощения операций получения и установки в целях организации доступа к элементу по индексу или ключу. Если Вы помните шаблон класса Array из предыдущей части [1], я использовал оператор индекса:

template< typename T, int SIZE >
class Array
{
   T Elements[SIZE];
   ...
public:
   T operator[](int nIndex) 
   {
      return Elements[nIndex];
   }
};

Для другого примера вспомните шаблон класса Pair, который также обсуждался в предыдущей части [1]. Так, например, я использовал этот шаблон класса так:

int main()
{
   Pair< int, int > IntPair1, IntPair2;
   
   IntPair1.first = 10;
   IntPair1.second = 20;
   IntPair2.first = 10;
   IntPair2.second = 40;
 
   if(IntPair1 > IntPair2)
      cout << "Pair1 больше.";
}

Это просто не будет работать, и требует, чтобы оператор > был бы реализован шаблоном класса Pair:

// Это реализация в классе
bool operator > (const Pair< Type1, Type2 >& Other) const
{
    return first > Other.first &&
         second > Other.second;
}

Хотя та же самая вещь уже обсуждалась в [1] (для оператора ==), я добавил это слово только для уместности с рассматриваемой концепцией.

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

template< typename Type >
class smart_ptr
{
   Type* ptr;public:
   smart_ptr(Type* arg_ptr = NULL) : ptr(arg_ptr)
   {} 
 
   ~smart_ptr()
   {
     // if(ptr) // Безопасное удаление null-указателя
     delete ptr;
   }
};

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

int main()
{
   int* pHeapMem = new int;
   smart_ptr< int > intptr(pHeapMem);
   //  *intptr = 10;
}

Я делегировал ответственность за освобождение выделенного блока памяти на объект smart_ptr (intptr). Когда будет вызван деструктор intptr, то он освободит выделенную память. Первая строка в функции main приведена для полной ясности. Конструктор smart_ptr может быть вызван и так:

smart_ptr< int > intptr(new int);

Примечание: этот класс (smart_ptr) предназначен только для иллюстрации, и его функциональность не эквивалентна любой реализации умных указателей (auto_ptr, shared_ptr, weak_ptr и т. п.).

Умный указатель позволит использовать любой тип для безопасного и правильного освобождения памяти. Вы могли бы использовать также и любой UDT (User Data Type, тип данных от пользователя):

smart_ptr< Currency > cur_ptr(new Currency);

После окончания текущего блока (например блока кода, выделенного скобками {}), был бы вызван деструктор smart_ptr< >, и использован оператор delete для освобождения памяти, выделенной под Currency. Поскольку тип известен во время компиляции (инстанциация произошла во время компиляции!), то будет вовлечен деструктор корректного типа. Если Вы введете деструктор Currency, то он был бы вызван, как только cur_ptr прекратил свое существование.

Вернемся к нашему обсуждению; как можно было бы упростить следующее:

smart_ptr< int > intptr(new int);
*intptr = 10;

Наверняка Вы реализовали бы оператор доступа по указателю (pointer indirection, унарный оператор):

Type& operator*() 
{
   return *ptr;
}

Отчетливо уясните, что это определение - реализация не константы, и по этой причине возвращает ссылку на объект (*ptr, не ptr), сохраненную экземпляром класса. Только поэтому допустимо присваивание значения 10.

Если бы это было реализовано как метод константы, то присваивание бы не сработало. Это обычно возвратило бы не ссылочный объект, или const-ссылку на удерживаемый объект:

// const Type& operator*() const
Type operator*() const
{
   return *ptr;
}

Следующий кусок кода показывает использование:

int main()
{
   smart_ptr< int > intptr(new int);
   *intptr = 10; // не const
 
   show_ptr(intptr);
}
 
// Предположим, что это реализовано в ранее приведенной функции main
void show_ptr(const smart_ptr< int >& intptr)
{
   cout << "Value is now:" << *intptr; // Const
}

Вы можете захотеть возвращать const Type& для сохранения нескольких байт программного стека, из функции const. Но обычно шаблоны класса делают вместо этого возврат типов по значению. Это сохраняет простой дизайн, позволяет избежать незаметных багов, если в нижележащем типе есть грубая ошибка реализации const / non-const. Это также предотвратит нежелательное создание ссылки даже для малых типов (наподобие int или Currency), который был бы тяжелее для понимания, чем возвраты типа по значению.

Интересно, что Вы можете шаблонизировать функцию саму по себе, так что она сможет показать значение любого нижележащего типа объекта smart_ptr. Можно много говорить о шаблонах функции / шаблонах класса, которые сами берут другой шаблон, но нужно сосредоточиться на обсуждаемой теме. При сохранении простоты обсуждения в этом месте, вот модифицированный код для show_ptr:

template< typename T >
void show_ptr(const smart_ptr< T >& ptr)
{
   cout << "Теперь значение равно:" << *ptr; // Const
}

Для объекта Currency функция вызовет Currency::operator double, так что cout сработает. Вы проснулись, или Вам нужно освежить в памяти материал про cout и Currency? Если Вы в смущении, то пожалуйста снова прочитайте статью [1].

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

smart_ptr< Currency > cur_ptr(new Currency);
cur_ptr->Cents = 10;
show_ptr(cur_ptr);

Выделенное жирной строкой логически корректно, однако не скомпилируется. Причина проста - cur_ptr не указатель, а обычная переменная. Оператор стрелка может быть вызвана только тогда, когда слева стоит указатель на структуру (или на класс). Но, как видите, Вы используете smart_ptr в качестве обертки указателя вокруг типа Currency. Поэтому эстетически это должно работать. По существу это означает, что Вы должны перезагрузить оператор стрелки в классе smart_ptr!

Type* operator->()
{
   return ptr;
}
 
const Type* operator->() const
{
   return ptr;
}

Так как я уважаю Ваш комфортный уровень знаний языка C++, то не нахожу необходимым объяснять эти две разные реализации перезагрузки. После реализации этого оператора, присваивание cur_ptr->Cents будет работать!

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

Действительно, перезагруженный оператор operator -> в smart_ptr не выдаст ошибку времени компиляции для smart_ptr< int > просто потому что int не может иметь приложенный к нему оператор стрелки. Причина проста, Вы не вызываете этот оператор на объекте smart_ptr< int >, и следовательно компилятор не будет (делать попытки) компилировать для этого smart_ptr< >::operator->() !

Теперь Вы должно быть поняли важность перезагрузки оператора в C++ и на арене шаблонов. В связи с темой шаблонов есть много больше вопросов по операторам, и это действительно помогает в разработке на основе шаблонов, в поддержке компилятора, ранней привязке и т. п.

[Шаблоны класса, наследование (Inheritance)]

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

1. Шаблон класса наследует обычный класс
2. Обычный класс наследует шаблон класса
3. Шаблон класса наследует другой шаблон класса

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

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

Когда происходит наследование, что именно Вы наследуете шаблон класса (Item< T >), или специализацию (Item< int >) ?

Эти две модели появляются одинаково, однако полностью разные. Позвольте мне привести пример.

class ItemExt : public Item< int >
{
}

Здесь Вы видите, что обычный класс ItemExt наследуется из специализации шаблона (Item< int >), и не упрощает никакое другое инстанцирование Item. В можете спросить - что это означает?

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

typedef Item< int > ItemExt;

Так или иначе (через typedef или наследование), когда Вы используете ItemExt, то Вам не нужно (или скажем, Вы не можете) указывать тип:

ItemExt int_item;

int_item всего лишь объект, наследуемый от Item< int >. Это означает, что Вы не можете создать объект другого нижележащего типа, используя наследованный класс ItemExt. Экземпляр ItemExt всегда будет Item< int >, даже если Вы добавите новые методы / члены в унаследованный класс. Новый класс может предоставить новые возможности наподобие печать значения, или сравнения с другими типами и т. д., но класс не позволит использовать гибкость шаблонов. Под этим я подразумеваю, что Вы не можете сделать:

ItemExt< bool > bool_item;

Поскольку ItemExt не шаблон класса, а просто обычный класс.

Если Вы ищите этот вид наследования, то можете сделать так - это все зависит от Ваших требований и перспектив дизайна.

Другой тип наследования был бы наследованием шаблона, где Вы наследовали бы сам шаблон класса и передали бы в него параметры шаблона. Сначала пример:

template< typename T >
class SmartItem : public Item< T >
{
};

Класс SmartItem это другой шаблон класса, который наследуется из шаблона Item. Вы инстанциировали бы SmartItem< > с некоторым тиром, т тот же тип был бы передан в шаблон класса Item. И все это произошло бы на этапе компиляции. Если Вы инстанциируете SmartItem с типом char, то инстанцируются Item< char > и SmartItem< char >!

В качестве другого примера наследования шаблона, позвольте сделать наследование из шаблона класса Array:

template< size_t SIZE >
class IntArray : public Array< int, SIZE >
{
};
 
int main()
{
   IntArray< 20 > Arr;
   Arr[0] = 10;
}

Обратите внимание, что я использовал в качестве первого аргумента шаблона, и SIZE в качестве второго аргумента шаблона на базе шаблона класса Array. Аргумент SIZE это только аргумент для IntArray, и второй аргумент для базового класса Array. Это допустимая, интересная особенность, упрощающая автоматическую генерацию кода с помощью компилятора. Однако IntArray был бы всегда массивом из int, но программист может указать размер массива.

Похожим образом Вы можете наследовать из Array и так:

template< typename T >
class Array64 : public Array< T, 64 >
{
};
 
int main()
{
  Array64< float > Floats;
  Floats[2] = 98.4f;
}

Хотя в только что приведенных примерах унаследованный класс не делает ничего дополнительного, но все равно очень нужно наследование. Если вы думаете, что основанный на шаблоне typedef будет делать от же самое, то ошибаетесь!

template< typename T >
typedef Array< T, 64 > Array64;typedef< size_t SIZE >
typedef Array< int, SIZE > IntArray;

Основанный на шаблоне typedefs не допустим. Хотя я не вижу никакой причины, почему компилятор не мог бы предоставить такую возможность. Поверх шаблонов typedef-ы могут вести себя по-разному в зависимости от контекста (например, основываясь на параметрах шаблона). Но основанные на шаблонах typedef-ы на глобальном уровне не разрешены.

Несмотря на то, что это не относится к обсуждению наследования шаблонов, Вы можете получить typedef также без использования наследования. Но в этом случае Вы также должны определить новый класс.

template< size_t SIZE >
struct IntArrayWrapper
{
   typedef Array< int, SIZE > IntArray;
};

Использование немного отличается:

IntArrayWrapper< 40 >::IntArray Arr;
Arr[0] = 10;

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

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

Обратите внимание, что почти во всех случаях основанные на шаблоне классы не имели бы виртуальных функций; поэтому нет никаких дополнительных лишних расходов при использовании наследования. Наследование является просто моделированием типов данных, и в простых случаях унаследованный класс был бы также POD (Plain Old Data, простые старые данные). Виртуальные функции вместе с шаблонами будут описаны позже.

В настоящий момент у меня есть 2 продуманные модели наследования:

• Обычный класс, наследующий шаблон класса
• Шаблон класса, наследующий другой шаблон класса

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

Обратите внимание, что только ItemExt является примером для 'Обычный класс, наследующий шаблон класса'. Все другие примеры относятся к 'Шаблон класса, наследующий другой шаблон класса'.

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

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

Плохой пример:

class SinglyLinkedList
{
   // Предположим, что этот класс реализует отдельно связанный список,
   // но использует механизм void* где размер данных sizeof задается
   // в конструкторе.
};
 
template< class T >
class SmartLinkedList : public SinglyLinkedList
{
};

Теперь Вы можете сказать, что объект SmartLinkedList< > имеет родственную связь с SinglyLinkedList, которая побеждает все предназначение шаблонов. Основанный на шаблоне класс не должен зависеть от не шаблонного класса. Шаблоны являются абстракцией вокруг некоторого типа данных для некоторого алгоритма, модели программирования, структуры данных.

Фактически шаблоны избегают фичи наследования в OOP (объектно-ориентированное программирование) в целом. То есть большинство абстракций представлено одним классом. Я этим не подразумеваю, что шаблоны не используют наследование. Фактически многие возможности вокруг шаблонов полагаются на функцию наследования языка C++ - но это не использовало бы наследование функций (inheritance-of-features) в классическом смысле OOP.

Шаблоны классов использовали бы наследование правил, наследование моделирования, наследование дизайна и т. д. Один пример делал бы базовый класс, имеющий copy-конструктор и оператор присваивания как private, без каких-то полей данных в нем. Теперь Вы можете унаследовать этот Правильный класс, и сделать все нужные классы не копируемыми!

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

[Указатели на функции и функции обратного вызова]

Как Вы знаете, указатель на функцию является одним из механизмов языка C/C++ для достижения динамического полиморфизма. Не обязательно, но обычно вместе с функцией обратного вызова (callback) - Вы устанавливаете частную определенную пользователем функцию как callback, которая была бы вызвана позже. Фактическая функция, которая будет вызвана на самом деле, будет определена во время выполнения программы, и следовательно происходит поздняя привязка специфической вызываемой функции.

Чтобы понять, рассмотрим простой кусок кода.

typedef void (*DisplayFuncPtr)(int);
 
void RenderValues(int nStart, int nEnd, DisplayFuncPtr func)
{
   for(;nStart < = nEnd; ++nStart)
       func(nStart); // Показать, используя желаемую функцию отображения.
}
 
void DisplayCout(int nNumber)
{
   cout << nNumber << " ";
}
 
void DisplayPrintf(int nNumber)
{
   printf("%d ", nNumber);
}
 
int main()
{
   RenderValues(1,40, DisplayCout);    // взятие адреса необязательно
   RenderValues(1,20, &DisplayPrintf);
   return 0;
}

В этом коде DisplayFuncPtr дает прототип для желаемой функции, и только для лучшей читаемости. Функция RenderValues выведет на экран числа, используя заданную функцию. Я вызвал эту функцию с разными callback-ами (DisplayCout и DisplayPrintf) из функции main. Отложенная привязка происходит в следующем операторе:

func(nStart);

Здесь func может указывать либо на две функции отображения (или любую UDF, т. е. User-Defined Function, функция заданная пользователем). Этот тип динамической привязки имеет несколько проблем:

• Прототип callback-функции должен полностью совпадать. Если Вы вдруг поменяете void DisplayCout(int) на void DisplayCout(float), то компилятор расстроится:

error C2664: 'RenderValues' : cannot convert parameter 3 from 'void (__cdecl *)(double)' to 'DisplayFuncPtr'

• Даже при том, что возвращаемое значение func не используется в RenderValues, компилятор не разрешит любую callback-функцию, которая возвратит не void.

• И еще одно, что беспокоит меня! Соглашение о вызовах (calling convention) также должно соответствовать. Если функция укажет cdecl в качестве callback-функции, то функция будет реализована как stdcall (__stdcall), что не будет допустимо.

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

И вот решение на основе шаблонов дает возможность преодолеть все упомянутые проблемы.

template< typename TDisplayFunc >
void ShowValues(int nStart, int nEnd, TDisplayFunc func)
{
   for(;nStart < = nEnd; ++nStart)
       func(nStart); // Показать, используя желаемую функцию отображения.
}

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

void DisplayWithFloat(float);
int DisplayWithNonVoid(int);
void __stdcall DisplayWithStd(int);
 
...
 
ShowValues(1,20, DisplayWithFloat);
ShowValues(1,40, DisplayWithNonVoid);
ShowValues(1,50, DisplayWithStd);

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

int* __stdcall DisplayWithStd(double);

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

Вместе с упомянутыми выше проблемами в случае традиционного подхода в C-стиле с решением в виде указателя на функцию / callback, следующее не разрешено в качестве аргумента отображающей функции:

Функторы, т. е. функциональные объекты - класс может реализовать оператор () с требуемой сигнатурой. Например:

struct DisplayHelper
{
   void operator()(int nValue)
   {
   }
};

Следующий код недопустим:

DisplayHelper dhFunctor;
RenderValues(1,20,dhFunctor); // Нельзя преобразовать...

Но когда Вы передадите dhFunction (функтор, он же функциональный объект) в шаблон функции ShowValues, компилятор не будет жаловаться. Поскольку, как я сказал ранее, TDisplayFunc может быть любого типа, которая может быть вызвана с аргументом int.

ShowValues(1,20, dhFunctor);

Лямбды - локально определенные функции (фича C++11). Лямбды также не будут разрешены в качестве аргумента указателя на функцию для функций C-стиля. Следующий код ошибочен.

RenderValues(1,20, [](int nValue)
{
   cout << nValue;
});

Но он будет замечательно допустим для шаблона функции ShowValues.

ShowValues(1,20, [](int nValue)
{
   cout << nValue;
});

Конечно, использование лямбда требует наличия C++11 совместимого компилятора (VC10 и более свежий, GCC 4.5 и более свежий).

Интересно, что шаблон функции может быть разработан другим путем - где Вы не должны передавать функтор как параметр функции. Вместо этого Вы можете передать его как сам аргумент шаблона.

template< typename TDisplayFunc >
void ShowValuesNew(int nStart, int nEnd)
{
   TDisplayFunc functor; // функтор создается здесь 
 
   for(;nStart < = nEnd; ++nStart)
      functor(nStart);
}
 
...
 
ShowValuesNew< DisplayHelper >(1,20); // 1 шаблон, 2 аргумента функции

В этом случае у меня есть переданная структура DisplayHelper в качестве аргумента типа для шаблона. Сама функция теперь принимает 2 аргумента. Создание функтора теперь происходит самой функцией шаблона. Единственный недостаток в том, что Вы теперь передать только структуру и классы, в которых есть определение оператора (). Вы не можете передать обычную функцию в ShowValuesNew. Однако Вы можете передать тип лямбды, используя ключевое слово decltype.

auto DispLambda = [](int nValue)
{
   printf("%d ", nValue);
};
 
ShowValuesNew< decltype(DispLambda) >(1,20);

Поскольку тип любой лямбды находится около std::function, которая является типом класса, и следовательно позволено создание объекта (TDisplayFunc functor;).

Теперь Вы поняли, что использование указателей на функции - весьма ограниченный подход. Достоинство его только в том, что он позволяет уменьшить размер кода, и дает возможность поместить функцию в некую библиотеку, и позже вызывать эту функцию, передавая разные callback-и. Вызываемый callback действительно очень ограничен. Поскольку базовая функция определена в одном месте, у компилятора нет свободы в оптимизации кода на основе переданных функций (callback-ов), особенно когда базовая функция находится в другой библиотеке (DLL / SO). Конечно, если базовая функция большая, и ограниченная модель желательна / допустима, то Вы могли бы использовать принцип работы с указателями на функции.

Подход на базе шаблонов, с другой стороны, действительно защитит раннюю привязку (early-binding). Ранняя привязка является ядром и сердцем программирования на основе шаблонов. Как упоминалось ранее, код с шаблонами не будет интенсивно нагружать процессор и разрастаться, наподобие большой обработки данных, движок игры, пакетная обработка изображений, подсистема безопасности - однако это помощник для всех этих систем. Таким образом, природа ранней привязки в действительности помогает оптимизировать код, так как все построение кода находится на территории компилятора.

[Шаблоны и виртуальные функции]

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

Просто для логического понимания этого разделения, рассмотрите следующий код.

class Sample
{
public:
   template< class T >
   virtual void Processor()  // ERROR!
   {  
   }
};

Этот код запрашивает шаблон метода Sample::Processor< T > быть виртуальным, для которого это нецелесообразно. Как демонстрационный класс это используется и наследуется. Так, например, если Вы создадите новый класс SampleEx и унаследуете его от Sample, и сделаете попытку реализовать эту виртуальную функцию. Какую специализацию Вы бы переопределили? A Processor< int > или Processor< string >, для примера?

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

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

Как Вы использовали бы базовые классы, основанные на шаблонах, имея наследование, но без виртуальных функций? И все же упростили бы этот базовый класс, зная и вызывая методы унаследованного класса?

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

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

class WorkerCore
{
public:
   void ProcessNumbers(int nStart, int nEnd)
   {
      for (;nStart < = nEnd; ++nStart)
      {
         ProcessOne(nStart);
      }
   }
 
   virtual void ProcessOne(int nNumber) = 0;
};
 
class ActualWorker : public WorkerCore
{
   void ProcessOne(int nNumber) 
   {
      cout << nNumber * nNumber;
   }
};
 
...
 
WorkerCore* pWorker =new ActualWorker;
pWorker->ProcessNumbers(1,200);

Вы знаете, что класс WorkerCore является абстрактным, и указатель на этот тип может указывать на производный (унаследованный) класс. ProcessOne является функцией, которая должна была бы делать действительную работу. Привязка действительной функции (в ProcessNumbers) зависит от того, куда этот указатель фактически указывает. Это отличный пример позднего связывания языка.

Для этой простой задачи Вы бы не хотели получить тяжелые вычислительные затраты - предпочли бы раннее связывание. И спасение прибывает вместе с изящной фичей шаблонов! Тщательно разберитесь в следующем коде.

template< class TDerived >
class WorkerCoreT 
{
public:
   void ProcessNumbers(int nStart, int nEnd)
   {
     for (;nStart < = nEnd; ++nStart)
     {
        TDerived& tDerivedObj = (TDerived&)*this;
    
        tDerivedObj.ProcessOne(nStart);
     }
   }
};
 
class ActualWorkerT : public WorkerCoreT< ActualWorkerT >
{
public:
   void ProcessOne(int nNumber)
   {
      cout << nNumber * nNumber;
   }
};

Сначала поймите выделенное жирным шрифтом:

• TDerived в базовом класса: указывает фактический тип для производного класса. Производный класс, когда наследуется, должен это указать.
• Приведение типа (typecasting) в ProcessNumbers: с тех пор как WorkerCoreT в действительности объект TDerived, мы можем безопасно сделать typecast его к TDerived. И затем вызвать метод ProcessOne, используя ссылку на объект.
• Спецификация < ActualWorkerT >: производный класс говорит базовому, что "Я здесь". Эта строка важна, иначе тип TDerived был бы неправильным, как и приведение типа.

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

'ProcessOne' : is not a member of 'ActualWorkerT'

Хотя здесь вовлечено приведение типа (typecasting), нет никаких лишних затрат ресурсов во время выполнения, нет runtime-полиморфизма, драмы с указателями на функции и т. п. Упомянутая функция существует в производном классе, она доступна из базового класса, и не ограничена условием быть void (int). Это могло бы быть, как упомянуто в секции про указатели на функции, int (float), или какой-нибудь еще вариант, который может быть вызван с параметром int.

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

[Введение в STL]

STL означает Standard Template Library (стандартная библиотека шаблонов), которая является частью стандартной библиотеки C++ (C++ Standard Library). С точки зрения программиста даже при том, что это (необязательная) часть библиотеки C++, большинство других возможностей (классы, функции) зависят от самой STL. Как предполагает слово "template", STL реализована главным образом поверх шаблонов C++ - здесь реализованы шаблоны классов и шаблоны функций.

STL содержит набор классов коллекций (collection classes) для представления массивов, связанных списков (linked lists), деревьев (trees), наборов (sets), привязок (maps) и т. д. STL также содержит helper-функции (обертки удобства) чтобы действовать на классы контейнера (наподобие нахождения максимума, суммы, или отдельного элемента), и другие вспомогательные функции. Итераторы (iterators) - важные классы, которые позволяют просматривать коллекцию классов. Позвольте мне дать простой пример.

vector< int > IntVector;

Здесь vector это шаблон класса, который функционально эквивалентен массивам. Он принимает один аргумент (он обязательный) - тип. В этом операторе декларируется IntVector, чтобы он был vector< > для типа int. Несколько замечаний:

• vector вместе с другими элементами STL, прибывает под пространством имен std (namespace std::).
• Чтобы использовать vector, Вам нужно подключить (директивой #include) заголовок для vector (обратите внимание, расширения у файла заголовка нет, т. е. это не vector.h).
• vector сохраняет свои элементы в непрерывной памяти - подразумевается, что к любому элементу можно получить прямой доступ. Все очень так же, как и у массива.

Перейдем непосредственно к примеру кода:

#include < vector >
 
int main()
{
   std::vector< int > IntVector;
 
   IntVector.push_back(44);
   IntVector.push_back(60);
   IntVector.push_back(79);
 
   cout << "Elements in vector: " << IntVector.size();
}

Замечания о содержимом кода, что выделено жирным шрифтом:

• Должен быть подключен специальный заголовок, чтобы можно было использовать класс vector.
• Спецификация пространства имен: std.
• Метод vector::push_back используется для добавления элементов в vector. Сначала в vector нет элементов, Вы вставляете элементы, используя push_back. Есть и другие техники для добавления элементов, но главный метод это push_back.
• Чтобы определить текущий размер vector (не объем, а количество элементов), мы используем метод vector::size. Так что программа отобразит число 3.

Если бы Вы должны были реализовать vector, то реализовали бы его примерно так:

template< typename Type >
class Vector
{ 
   Type* pElements;  // Динамическое выделение памяти, в зависимости от требований.
   int ElementCount; // Количество элементов в vector.public:
   Vector() : pElements(NULL), ElementCount(0)
   {}
 
   size_t size() const { return ElementCount; };
 
   void push_back(const Type& element); // Добавление элемента, выделение больше
                                        // памяти, если требуется.
};

Здесь нет никаких аэрокосмических исследований, Вы все это знаете. Реализация push_back должна была бы выделить дополнительную память, если требуется, и установить / добавить (set/add) элемент в указанном месте. Это вызывает очевидный вопрос: сколько нужно выделить памяти, чтобы вставить каждый новый элемент? И здесь приходит субъект Capacity (объем).

У vector также есть не часто используемый метод: capacity (объем). Объем vector-а это текущая выделенная память (учитывающая количество элементов), и он может быть получен вызовом этой функции. Изначальный объем, или дополнительная память, выделенная на каждом push_back, зависит от реализации (т. е. как разработчики компиляторов VC или GCC это реализовали). Метод capacity всегда возвратит большую или равную величину, чем вернет метод size.

Я предлагаю Вам самим реализовать методы push_back и capacity. Добавьте также другие поля данных или методы, которые Вы хотели бы добавить.

Одно из главных преимуществ vector вектор в том, что он может использоваться как стандартный массив; за исключением размера массива (т. е. количества элементов в vector-е), который не постоянен (может меняться динамически при выполнении программы). Вы можете думать об этом как о динамически выделяемом массиве, где Вы выделяете нужную память (или перевыделяете, если это нужно), отслеживая размер массива, проверяете ошибки выделения памяти, и нуждаетесь в освобождении памяти по окончании работы. Класс std::vector обрабатывает все это, но все же для всех типов данных, которые соответствуют требованиям этого шаблона класса ("Requirements of this class template").

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

IntVector[0] = 59;               // Модификация первого элемента
cout << IntVector[1];            // Отображение второго элемента
int *pElement = &IntVector[2];   // Указатель содержит адрес третьего элемента
cout << *pElement;               // Отображение третьего элемента через указатель

Это все четко означает, что в vector есть перезагруженный оператор [], примерно так:

Type& operator[](size_t nIndex)
{
    return pElements[nIndex]; 
}
 
const Type& operator[](size_t nIndex)
{
    return pElements[nIndex];
}

Я не показал здесь базовые проверки на допустимость операций (basic validations). Важно отметить, что эти две перезагрузки основаны на const. Класс std::vector также имеет 2 перезагрузки - одна возвращает ссылку на действительный элемент, другая возвращает const-ссылку. Первая позволяет модификацию действительного сохраненного элемента (см. комментарий "Модификация первого элемента" в этом примере кода), и вторая не позволяет модификацию элемента (константа позволяет доступ только на чтение).

А как показать все элементы vector? ОК, следующий код будет работать для vector< int > :

for (int nIndex = 0 ; nIndex < IntVector.size(); nIndex++)
{
   cout << "В элементе [" << nIndex << 
                    "] находится " << IntVector[nIndex] << "\n";
}

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

template< typename VUType >  // Нижележащий тип вектора!
void DisplayVector(const std::vector< VUType > & lcVector)
{
   for (int nIndex = 0 ; nIndex < lcVector.size(); nIndex++)
   {
      cout << "В элементе [" << nIndex << "] находится " << lcVector[nIndex] << "\n";
   }
}

Теперь мы имеем код итерации по вектору, основанный на шаблоне, и который может отобразить любой вектор - vector< float >, vector< string > или vector< Currency >, так что cout может отобразить сам тип или нижележащий тип. Пожалуйста, сами разберитесь со смыслом выделенным жирным шрифтом кода!

Следующий код добавлен только для лучшей фиксации и понимания.

...
IntVector.push_back(44);
IntVector.push_back(60);
IntVector.push_back(79);
 
DisplayVector(IntVector);  // DisplayVector< int >(const vector< int >&);

Будет ли работать реализация DisplayVector на всех типах контейнера, наподобие sets и map? Нет, не будет! Я скоро объясню это.

Другой контейнер в STL это set. Шаблон set< > сохранил бы любые уникальные элементы типа T. Чтобы использовать это, Вам нужно подключить заголовочный файл < set >. Пример:

std::set< int > IntSet;
 
IntSet.insert(16);
IntSet.insert(32);
IntSet.insert(16);
IntSet.insert(64);
 
cout << IntSet.size();

Использование то же самое, что и для vector, за исключением того, что Вам нужно использовать метод insert. Причина проста и оправдана: новые элементы в set могут быть помещены в любое место, не обязательно просто в конце - и Вы не можете вынудит элемент быть вставленным в конец.

Вывод этого кода будет 3, не 4. Значение 16 было вставлено дважды, и set проигнорирует второй запрос на вставку, потому что такой элемент уже есть. Так что в IntSet есть только 16, 32 и 64.

ОК, эта статья не про STL, но про шаблоны. Я привел резюме класса set также по причине, которую хочу объяснить. Вы можете найти соответствующую документацию, статьи, примеры кода и т. д. в сети для изучения STL. Для писка используйте следующие ключевые слова: vector, map, set, multimap, unordred_map, count_if, make_pair, tuple, for_each и т. п. Позвольте мне перейти снова к шаблонам.

Как бы Вы провели итерацию по всем элементам набора set? Следующий код не будет работать для set.

for (int nIndex = 0; nIndex < IntSet.size(); nIndex)
{
   cout << IntSet[nIndex];  // ERROR!
}

В отличие от класса vector, класс set не определяет оператор []. Вы не можете получить доступ к элементу на базе его индекса - индекс для set отсутствует. Порядок следования элементов в set возрастающий: от самого маленького к самому большому. Здесь есть строгое упорядочивание, класс компаратора и т. п., но рассматривайте это (возрастание) как поведение по умолчанию имеющегося объекта.

Так что с этой точки зрения если в set< int > имеются элементы (40,60,80), когда Вы после этого сделаете insert 70, то последовательность элементов станет (40, 60, 70, 80). Таким образом, логически индекс не подходит для set.

И здесь прибывает другой важный аспект STL: итераторы. Все классы-контейнеры имеют поддержку итераторов, чтобы можно было перебрать элементы в коллекции. Разные виды итераторов представлены различными классами. Сначала позвольте Вам представить пример кода итератора для стандартного массива.

int IntArray[10] = {1,4,8,9,12,12,55,8,9};
 
for ( int* pDummyIterator = &IntArray[0];    // НАЧАЛО
      pDummyIterator < = &IntArray[9];       // продолжать до ПОСЛЕДНЕГО элемента
      pDummyIterator++)
{
    cout << *pDummyIterator << " ";
}

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

vector< int >::iterator lcIter;
for (lcIter = IntVector.begin(); 
     lcIter != IntVector.end(); 
     ++lcIter)
{
    cout << (*lcIter);
}

Тщательно поймите смысл выделенного жирным шрифтом:

• iterator это класс. Специфически typedef внутри vector< int >. Так что переменная типа vector< int >::iterator может только делать итерации vector< int >, но не vector< float > или set< int >.
• begin и end это методы, которые возвращают итератор того же самого типа. Экземпляр vector< Currency > вернул бы vector< Currency >::iterator, где он будет установлен на начало или конец.
      Метод begin вернет итератор, который указывает на первый элемент в контейнере. Думайте о нем как о &IntArray[0].
      Метод end вернет итератор, который указывает на следующий после последнего элемент контейнера. Думайте об этом как о &IntArray[SIZE], где размер массива IntArray равен SIZE. Теперь Вы знаете, что для размера массива 10, &IntArray[10] вернул бы (логически) указание на элемент, следующий за &IntArray[9].
      Выражение ++lcIter вызывает оператор ++ на объекте итератора, который перемещает фокус итератора на следующий элемент коллекции. Это весьма похоже на арифметику указателей ++ptr.
      Цикл начинается с итератора, указывающего на начало, и продолжается, пока итератор не будет указывать на конец.
• Выражение *lcIter вызывает унарный оператор * на итераторе, который возвращает ссылку / const-ссылку на элемент, на который сейчас указывает итератор. Для приведенного выше примера это просто вернет int.

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

Точно таким же способом Вы можете делать итерацию set:

set< int >::iterator lcIter;
for (lcIter = IntSet.begin(); 
     lcIter != IntSet.end(); 
     ++lcIter)
{
   cout << (*lcIter);
}

Если я попрошу Вас написать цикл итерации для:

• vector< float >
• set< Currency >
• vector< Pair >

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

template< typename Container, typename Type >
void DisplayCollection(const Container< Type >& lcContainer)
{
   Container< Type >::iterator lcIter;
   for (lcIter = lcContainer.begin();  lcIter != lcContainer.end();  ++lcIter)
   {
      cout << (*lcIter);
   } 
}

Выглядит логически корректно, но не будет компилироваться. Подобно функции DisplayVector, эта функция будет пытаться получить аргумент lcContainer, имея Container как класс-коллекцию, и его нижележащий тип как Type.

Синтаксис DisplayVector был:

template< typename VUType >
void DisplayVector(const std::vector< VUType >& lcVector)

Где фактический тип передается в функцию как полное выражение: vector< VUType >&. Передаваемый тип не просто vector&.

Синтаксис DisplayCollection примерно такой:

template< typename Container, typename Type >
void DisplayCollection(const Container< Type >& lcContainer)

Здесь тип, передаваемый в функцию (не шаблон) полный: Container< Type >&. Предположим, это можно вызвать так:

vector< float > FloatVector;
 
DisplayCollection< vector, float >(FloatVector);

Передаваемый тип (первый аргумент) в шаблон просто vector, который не является полным типом. Некоторая специализация вектора (наподобие vector< float >) сделана квалифицированной для полного типа. Поскольку первый аргумент типа шаблона не может быть классифицирован как тип, то мы не можем его использовать таким способом. Хотя есть методы передать шаблон класса самому себе (наподобие просто vector) и сделать его завершенным типом на базе других аргументов / аспектов. Так или иначе вот измененный прототип DisplayCollection:

template< typename Container >
void DisplayCollection(const Container& lcContainer);

Да, настолько просто! Но реализация потребует теперь некоторых изменений. Давайте постепенно внесем эти изменения.

template< typename Container >
void DisplayCollection(const Container& lcContainer)
{
   cout << "Элементов в коллекции: " << lcContainer.size() << "\n";
}

Все контейнеры STL имеют реализованный метод size, и они возвратят тип size_t. Так что безотносительно того, какой контейнер передается (set, map, deque и т. д.) - метод size все равно будет работать.

Итерация по коллекции:

Container::const_iterator lcIter;
 for (lcIter = lcContainer.begin(); 
     lcIter != lcContainer.end(); 
     ++lcIter)
{
    cout << (*lcIter);
}

Несколько вещей, которые стоит выучить:

Так как аргумент (lcContainer) передается с квалификатором const, он будет обработан в этой функции как non-mutable объект. Это означает, что Вы не можете вставить, удалить или переназначить ничего в контейнере. Если передается vector, lcContainer.push_back был бы ошибкой, поскольку является const. Далее это означает, что Вы не можете делать его итерацию, используя mutable итератор.

• Используя класс iterator, Вы можете менять его содержимое. Это называется как mutable-итератор.
• Используйте const_iterator, когда Вам не нужно менять содержимое, или когда Вы не можете использовать mutable-итератор. Когда объект / контейнер сам по себе const (т. е. non-mutable), Вы должны использовать const_iterator.
• Важно! Объект const_iterator совсем не то же самое, что и постоянный объект итератора. Это означает: const_iterator != const iterator - обратите внимание на пробел!

Что компилятор возвратил бы, iterator или const_iterator, когда я вызываю те же самые методы: begin и end? Хороший вопрос, и на него есть простой ответ:

class SomeContainerClass
{
   iterator begin();
   iterator end();
 
   const_iterator begin() const;
   const_iterator end() const;
};

Где объект будет const, const-версия вызванного метода - простое правило языка C++!

Теперь стоит рассмотреть еще один важный момент. Только что приведенный пример не будет компилироваться всеми компиляторами (особенно следующая строка):

Container::const_iterator lcIter

Visual C++ 2008 компилирует нормально, однако GCC сообщает о следующей ошибке:

error: need 'typename' before 'Container::const_iterator' because 'Container' is a dependent scope

Чтобы понять причину, рассмотрите следующий класс:

class TypeNameTest
{
public:
   static int IteratorCount;
   typedef long IteratorCounter;
};
 
int main()
{
   TypeNameTest::IteratorCounter = 10; // ERROR
   TypeNameTest::IteratorCount var;    // ERROR
}

Используя нотацию ClassName::Symbol, мы можем получить доступ как typedef-символу, так и статически определенному символу. Для простых классов компилятор может различить ситуации, программист может разрешить и потенциально нет никакой неоднозначности.

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

Таким образом, мы должны использовать ключевое слово typename:

typename Container::const_iterator lcIter; // const_iterator это тип,
                                           // не статический символ в Container

Почему мы не можем вместо этого использовать ключевое слово class, и почему VC++ компилирует нормально?

Ну тут мы имеем дело с разработчиками, которые почему-то иногда совсем не точно придерживаются стандарта. Для VC++ не нужно использовать typename, а для GCC нужно (и он говорит об этом!). GCC даже примет class вместо typename, а VC не примет class. Но к счастью, оба придерживаются стандарту и принимают ключевое слово typename!

Новый стандарт C++ (C++11) делает некую отсрочку, специально при работе с STL, шаблонами и сложными определениями итератора. Это ключевое слово auto. Цикл итерации может быть модифицирован так:

for (auto lcIter = lcContainer.begin();  lcIter != lcContainer.end();  ++lcIter)
{
   cout << (*lcIter);
}

Действительный тип lcIter будет определен автоматически, во время компиляции. Вы можете прочитать про ключевое слово auto в Internet.

[Шаблоны и разработка библиотеки]

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

Когда Вы попытаетесь сделать экспорт шаблона функции, такой как DisplayCollection через библиотеку (.LIB, .DLL или .SO), компилятор и линкер могут отобразить это экспортированной указанной функцией. Линкер может выбросить предупреждение или ошибку, связанную с некоторыми символами (например DisplayCollection), что они не были экспортированы или не найдены. Пока здесь не было вызова к шаблону (шаблонам) функции в самой библиотеке, но фактически не будет сгенерирован код, так что ничего не будет экспортировано.

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

Так что нельзя экспортировать код шаблона из библиотеки, не раскрывая исходный код, и не передавая его (обычно через header-файлы). Хотя есть возможность представить исходный код только для содержимого шаблона, не для содержимого библиотеки, играя void-указателями и ключевым словом sizeof. Основная библиотека может быть фактически сделана как private путем экспортирования её из библиотеки, поскольку код ядра может быть основан не на шаблонах.

External Templates (внешние шаблоны) - фича все еще ожидаемая как часть стандарта C++, пока не поддерживается большинством компиляторов.

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

[Explicit Instantiation]

Эта фича в частности важна, если Вы экспортируете Вашу библиотеку, основанную на шаблонах, либо по реализации только в заголовке, либо через реализацию в режиме обертки (wrapper-mode implementation, где ядро скрыто в бинарниках, но экспортирует содержимое через шаблоны). С фичей явного инстанцирования (Explicit Instantiation), Вы можете инструктировать компилятор (и таким образом, и линкер) генерировать код для каких-то специфичных комбинаций аргументов шаблона. Это значит, что Вы требуете специализацию шаблона без фактической инстанциации его в Вашем коде. Рассмотрим простой пример.

template class Pair< int, int >;

Этот оператор просто запрашивает компилятор инстанцировать Pair с аргументами < int, int > для всех методов класса. Это подразумевает, что компилятор сгенерирует код для:

• Для полей данных - first и second.
• Для всех трех конструкторов (как было упомянуто в этой части статьи и в части [1]).
• Для операторов > и == (как было о них упомянуто).

Чтобы проверить это, Вы можете посмотреть сгенерированный двоичный код (выполняемый, DLL / SO), используя на операционной системе Windows подходящий инструментарий наподобие Dependency Walker [3] - Вы увидите, был ли код сгенерирован, или нет. Более простой метод установить, выполнил ли компилятор / линкер действительное выполнение явной инстанциации - позволить компилятору остановить компиляцию по ошибке. Например:

template struct Pair< Currency, int >;

Убедитесь, что тип first является Currency. Компилятор попытается сгенерировать код для всех методов, и потерпит ошибку на операторе ==, говоря что он (Currency) не имеет определенным этот оператор:

bool operator == (const Pair< Type1, Type2 >& Other) const
{
   return first == Other.first &&  // ERROR Currency.operator== isn't available.
          second == Other.second;
}

Компилятор потерпит неудачу на этом методе, просто потому что это произошло раньше с любым другим методом, потерпевшим ошибку (в этом случае до оператора < ).

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

Например, Вы представляете строковый класс (наподобие std::string, или CString). И этот класс находится поверх аргумента шаблона - тип символа - ANSI или Unicode. Вот простое определение шаблона класса String:

template< typename CharType >
class String
{
    CharType m_str[1024];public:
    CharType operator[](size_t nIndex)
    {
       return m_str[nIndex];
    }
 
    size_t GetLength() const
    {
        size_t nIndex = 0;
        while(m_str[nIndex++]);
 
        return nIndex;
    }
};

И очень простой пример использования:

String< char > str;
str.GetLength();

И Вы знаете, что этот код сгенерирует только следующее:

• String< char >::m_str
• String< char >::String - конструктор по умолчанию, предоставленный компилятором.
• String< char >::GetLength - метод

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

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

Для этого Вы просто запрашиваете явное инстанциирование для всех типов, которые Вы, как предполагается, экспортируете через библиотеку.

template class String< char >;
template class String< wchar_t >;

Для удобства программирования Вы можете сделать typedef для разных типов String. Тип std::string является, фактически, typedef-ом, и экспортируется следующим образом:

typedef basic_string< char, ... > string;
typedef basic_string< wchar_t, ... > wstring;
 
// Явная инстанциация
template class /*ATTR*/ basic_string< char, ... >;
template class /*ATTR*/ basic_string< wchar_t, ... >;

Базовым является класс basic_string, который является шаблоном класса. Для упрощения несколько аргументов здесь не показано, и вендоры могут иметь разную сигнатуру сброса аргументов шаблона (для basic_string). Вторая группа показывает явную инстанциацию для этих типов. Закомментированная часть, /*ATTR*/ - зависит от разработчика компилятора. Это может быть выражение, что эти инстанцирования действительно фактически входят входят в скомпилированную библиотеку, или только действуют только как заголовочный файл. В реализации VC++ эти две инстанциации в действительности находятся в DLL.

[Ссылки]

1. Программирование шаблонов C++ для идиотов, часть 1.
2. An Idiot's Guide to C++ Templates - Part 2 site:codeproject.com.
3. Dependency Walker - помощник в разрешении зависимостей.

 

Комментарии  

 
0 #2 oleg 17.05.2023 18:04
Возможно и поучительный текст, но уж примеры чрезвычайно нелепые - вероятно поэтому "для идиотов")))
Цитировать
 
 
+1 #1 mildok 31.07.2018 08:37
Как и в первой статье, употребляется "Перезагруженный " вместо "перегруженный", и т. д. :-)

microsin: в первой статье исправил, но здесь не стал исправлять по двум важным причинам - 1. Лень. 2. Не уверен, что от этого исправления смысл статьи станет понятнее.
Цитировать
 

Добавить комментарий


Защитный код
Обновить

Top of Page