Skip to the content.

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


Проблема “одинаковых классов” - допустим у нас есть класс, для хранения и работы с динамическим массивом целых чисел (у него есть конструктор, деструктор и методы для работы с массивом), если вдруг нам понадобится класс для работы с динамическим массивом вещественных чисел, то окажется что он очень похож на класс для целых чисел (грубо говоря int везде поменяется на float). Таким образом, мы дублируем код (а значит и ошибки), что не очень хорошо.

Решение проблемы в стиле С: макросы

#define DEFINE_ARRAY(Name, Type) \
struct Name                      \
{                                \
  explicit Name(size_t size) :   \
        data_(new Type[size]),   \ 
        size_(size) {}           \
                                 \
  Type operator[](size_t i)      \
  {                              \
      return data_[i];           \
  }                              \
  ...
private:                         \
    Type * data_;                \
    size_t dize_;                \
}                                \

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

Так же все макросы должны быть однострочными, но это в данном случае неудобно, поэтому мы используем \

DEFINE_ARRAY(ArrayInt, int);
DEFINE_ARRAY(ArrayFloat, float);

int main()
{
    ArrayInt a(10);
    ArrayInt b(20);
    ...
    return 0;
}

Такое решение несёт в себе все проблемы которые есть у макросов: сложно отлавливать ошибки (поскольку ошибки не в написанном нами коде, а в сгенерированном препроцессором), нет никаких проверок (например, что в макрос передан действительно тип данных).

Решение проблемы в стиле С++: шаблоны классов

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

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

Инстанциирование шаблона - компиляция шаблона. Происходит один раз, при первом вызове шаблона (с конкретными параметрами), а все остальные вызовы компилятор уже будет знать про этот класс. (К примеру при первом вызове __Array __ происходит инстанциирование, тип подставится в шаблон и он станет классом, при повторном вызове __Array __ инстанциирования не будет, однако при вызове __Array __ будет еще одно инстанциирование).

template <class Type>
struct Array                      
{                                
    explicit Array(size_t size) :   
        data_(new Type[size]),    
        size_(size) {}          

    Type operator[](size_t i)      
    {                              
        return data_[i];           
    }                              
  ...
private:                         
    Type * data_;                
    size_t dize_;                
}                                
Array <int> a(10); // инстанциирование
Array <float> b(20); // инстанциирование
Array <int> a(24); // инстанциирования не будет, компилятор уже знает про этот класс
...
return 0;

Шаблоны классов с несколькими параметрами

template <class Type, 
          class SizeT = size_t, // что бы можно было повлиять на тип размера массива
          class CRet = Type>    // тип, который возвращается из operator[]
                                // значения по умолчанию size_t и Type
struct Array                      
{                                
    explicit Array(SizeT size) :   
        data_(new Type[size]),    
        size_(size) {}          

    CRet operator[](SizeT i)      
    {                              
        return data_[i];           
    }                              
  ...
private:                         
    Type * data_;                
    SizeT dize_;                
}      
Array <int> a(10); // Array <int, size_t, int>
Array <Array<int>, 
       size_t, 
       Array <int> const &> b(30); 
// массив хранящий массивы и возвращающий из [] ссылку, а не значение (что бы небыло копирования)

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

typedef Array <int> Ints;
typedef Array <Ints, size_t, Ints const &> IInts;

IInts a(10);

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


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

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

int   squarei(int x)   { return x * x; }
float squaref(float x) { return x * x; }

В яп С++ без использования ООП можно было бы использовать перегрузку:

int   square(int x)   { return x * x; }
float square(float x) { return x * x; }

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

В яп С++ используя ООП:

struct INumber
{
  virtual INumber * multuply(INumber * x) const = 0;
};

// так как int и float встроенные типы и не являются классами, нам прийдётся написать вокруг них обертки:
struct Int   : INumber { ... }; // реализация multiply
struct Float : INumber { ... }; // реализация multiply

INumber * square(INumber * x) { return x->multiply(x); }

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

В яп С++ используя шаблоны:

template <typename Num>
Num square(Num x) { return x * x; }

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

template <typename Num>
Num square(Num n) { return n * n; }

template <typename Type>
void sort(Type * p, Type * q);

// перегрузка шаблонной sort
template <typename Type>
void sort(Array <Type> & ar);

Для шаблонных функций (для шаблонных классов начиная с С++17) работает механизм вывода шаблонных параметров (deduce), когда компилятор может по передаваемым в функцию аргументам сам решить какой тип ему нужно подставить в шаблон:

int a = square<int>(3); // явно указали int
int b = square(a) + square(4); // компилятор решит что нужно подставить int
float * m = new float [10];
sort(m, m + 10); // компилятор решит что нужно подставить float
sort(m, &a); // error: компилятор не поймёт что подставлять float или int

Шаблоны методов


Дополним шаблон класса шаблонами методов:

template <class Type>
struct Array
{
  template <class Other>
  Array(Array<Other> const & other) :
        data_(new Type[other.size()]),
        size_(other.size())
  {
      for (size_t i = 0; i != size_; ++i)
            data_[i] = other[i];
  }
  
  template <class Other>
  Array & operator=(Array<Other> const & other);
  ...
};

// такой метод полезный например в таком случае:
Array <int> a;
Array <double> b(a);

// определение метода снаружи:
template<class Type>
template<class Other>
Array <Type> & Array<Type>::operator=(Array<Other> const & other) { ... };

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

Специализация шаблонов


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

Полная специализация шаблонов классов

template <class T>
struct Array
{
  ...
  T * data_;
};
template<> // означает полную специализацию шаблона
// следующая реализация позволяет хранить в int аж 8 bool'ов, т.е на каждый bool выделять бит, вместо байта как обычно
struct Array<bool> // реализация для T = bool
{
  static int const INTBITS = 8 * sizeof(int); // кол-во бит в одном int (например 32)
  
  explicit Array(size_t size) : 
            size_(size), // количество bool'ов (к примеру 1000)
            data_(new int[size_ / INTBITS + 1]) // 1000 / 32 + 1 = 32 интов нам хватит для хранения 1000 булов
            // +1 поскольку 1000 / 32 = 31.25 => 0.25 булов будем хранить в +1 инте
            {}
  
  bool operator[](size_t i) const // получить значение bool'a (например нам нужен 100-й бит)
  {
      return data_[i / INTBITS] & (1 << (i % INTBITS)); 
      // i / INTBITS = 100 / 32 = 3
      // i % INTBITS = 100 % 32 = 4
      // соответственно нам нужен 3-й int и в нём 4 бит, для этого:
      // 00000001 << 4 => 00010000 - маска, соответствующая четвертому биту в инте
      // data_[3] & 00010000 - применяем маску к int'у и получаем наш bool  
  }
  
  // аналогичными масками можно записывать значение bool'ов в int
  
private:
    size_t size_;
    int * data_;
};

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

Полная специализация шаблонов функций

template<class T>
void swap(T & a, T & b)
{
    T tmp(a);
    a = b;
    b = tmp;
}

// полная специализация шаблона функции
template<>
void swap<Database>(Database & a, Database & b)
{
    a.swap(b); 
    // у Database запрещены конструктор копирования и оператор присваивания
    // однако есть метод swap
}

Отличие полной специализации и перегрузки:

template<class T>
void foo(T a, T b)
{
    cout << "same types" << endl;
}

// перегрузка
template<class T, class V>
void foo(T a, V b)
{
    cout << "different types" << endl;
}

// полная специализация
template<>
void foo<int, int>(int a, int b)
{
    cout << "both parameters are int" << endl;
}

int main()
{
    foo(3, 4); // что будет вызвано раньше специализация или перегрузка?
    return 0;
}

Передаём функции два значения типа int. Оказывается что вначале отрабатывает механизм перегрузки, а потом механизм специализации. Соответственно, отрабатывает перегрузка (специализация не рассматривается), между функциями 1 и 2 компилятор выберет первую, поскольку она подразумевает что оба параметра одинакового типа. Фунция 3 никогда не будет вызвана, если не вызывать её явно: foo<int, int>(3, 5);. Безусловно, если бы 3 функция была перегрузкой, то была бы выбрана она, как наиболее подходящая.

Выбор функции на этапе компиляции:

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

Частичная специализация шаблонов классов

Разные реализации для класса и для указателя на класс:

template<class T>
struct Array
{ ... };

template<class T>
struct Array<T *>
{ ... };

Еще о шаблонах


Нетиповые шаблонные параметры

Шаблонными параметрами могут быть не только типы, но и целочисленные значения, указатели/ссылки на значения с внешней линковкой и шаблоны.

template<class T, size_t N, size_t M> // значения N и M должны быть известны на этапе компиляции!
template<ofstream & log> // ссылка должна ссылаться на известное глобальное значение!

Шаблоны в качестве шаблонных параметров:

// int -> string
string toString(int i);

// функция, которая превращает все элементы массва в string
// проблема: работает только с шаблоном Array<>
Array<string> toStrings(Array<int> const & ar)
{
    Array<string> result(ar.size());
    for (size_t i = 0, size = ar.size(); i != size; ++i)
        result.get(i) = toString(ar.get(i));
    return result;
}

// работает с любым контейнером, однако от него требуется: 
// методы size(), get() и конструктор с одним параметром - size
template<template <class> class Container>
Container<string> toStrings(Container<int> const &c)
{
    Container<string> result(c.size());
    for (size_t i = 0, size = c.size(); i != size; ++i)
        result.get(i) = toString(c.get(i));
    return result;
}

Чаще всего шаблоны описывают в заголовочных файлах!