4.2. Шаблонные классы

Классы, как и функции, могут быть шаблонными. Примерами шаблонов классов являются все контейнеры стандартной библиотеки. В этом параграфе мы напишем шаблонный класс «Матрица».

Классы, как и функции, могут быть параметризованы типами или константами. Такие классы называются шаблонными. Примерами шаблонов классов являются все контейнеры стандартной библиотеки. В этом параграфе мы напишем шаблонный класс «Матрица». Мы также рассмотрим на его примере не связанные с шаблонами вещи: перегрузку по константности и итерацию в цикле range-based for.

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

Выбор шаблонных параметров

Наш класс должен поддерживать работу с разными типами элементов. Поэтому вынесем тип элемента в шаблонные параметры:

template <typename T>
class Matrix;

Дальше нам надо решить, будут ли размеры матрицы известны во время компиляции. Если да, их тоже разумно сделать шаблонными параметрами, а хранить матрицу можно в двумерном контейнере std::array:

#include <array>

template <typename T, int Rows, int Columns>
class Matrix {
private:
    // Массив из Rows строк, каждая из которых — массив из Columns элементов типа T
    std::array<std::array<T, Columns>, Rows> data;
};

int main() {
    Matrix<int, 3, 4> m;  // матрица размера 3 x 4
}

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

#include <vector>

template <typename T>
class Matrix {
private:
    std::vector<std::vector<T>> data;

public:
    size_t GetRows() const {
        return data.size();
    }

    size_t GetColumns() const {
        // У пустого вектора data обращаться к нулевому элементу нельзя
        if (data.empty()) {
            return 0;
        }
        return data[0].size();
    }
};

Мы будем поддерживать в классе инвариант «в строках матрицы одинаковое количество элементов». Поэтому число столбцов можно будет получить через обращение к нулевой строке.

Конструкторы

Напишем конструктор, соблюдающий этот инвариант.

#include <vector>

template <typename T>
class Matrix {
private:
    std::vector<std::vector<T>> data;

    // Вспомогательная функция, чтобы сделать массив прямоугольным
    void MakeRectangle() {
        // Вычисляем максимальную длину строки
        size_t maxSize = 0;
        for (const auto& row : data) {
            if (row.size() > maxSize) {
                maxSize = row.size();
            }
        }

        for (auto& row : data) {  // итерация без const позволяет изменять row
            row.resize(maxSize);  // увеличиваем длины строк при необходимости
        }
    }

public:
    // Конструктор
    Matrix(const std::vector<std::vector<T>>& d): data(d) {  // инициализируем вектор переданным значением
        MakeRectangle();  // соблюдаем инвариант
    }

    // ...
};

Наша матрица теперь может быть сконструирована примерно так:

#include <iostream>

int main() {
    Matrix<int> m({
        {1, 2, 3},
        {4, 5, 6},
    });

    std::cout << m.GetRows() << "\n";  // 2
    std::cout << m.GetColumns() << "\n";  // 3
}

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

#include <vector>

template <typename T>
class Matrix {
private:
    std::vector<std::vector<T>> data;

public:
    Matrix(size_t rows, size_t columns) {
        data.resize(rows);
        for (auto& row : data) {
            row.resize(columns);
        }
    }

    // ...
};

int main() {
    Matrix<double> m(3, 4);  // создаём нулевую матрицу из 3 строк и 4 столбцов
}

Обращение к элементам и перегрузка по константности

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

int main() {
    Matrix<int> m(3, 4);
    int element = m[0][0];
    m[1][1] = 1;
    m[2][3] = 5;
}

Было бы заманчиво определить в матрице оператор [] для обращения по индексу. Он может получать ровно один аргумент (то, что написано в скобках). Каким должно быть его возвращаемое значение? Очевидно, это должно быть нечто, к чему можно снова применить оператор [] со вторым индексом. На эту роль может подойти внутренний вектор элементов std::vector<T>, который задаёт отдельную строку в матрице. Напишем вот такую версию:

#include <vector>

template <typename T>
class Matrix {
private:
    std::vector<std::vector<T>> data;

public:
    const std::vector<T>& operator [] (size_t i) const {
        return data[i];
    }
};

Мы написали константную версию этого оператора. Она возвращает вектор по константной ссылке. Эта версия вполне годится для чтения элемента матрицы, но пока не пригодна для его изменения:

int main() {
    Matrix<int> m(3, 4);
    int element = m[0][0];  // OK
    m[1][1] = 1;  // не скомпилируется: у константного вектора m[1] нельзя изменить элемент
}

Давайте разберёмся, как соотносятся друг с другом два слова const в объявлении этого оператора. Попробуем рассмотреть другие реализации, где одно из этих слов убрано.

  1. Уберём первый const:

    std::vector<T>& operator [] (size_t i) const {
        return data[i];  // ошибка компиляции!
    }
    

    Мы получим ошибку компиляции в теле оператора. В самом деле, константная функция видит поле data у текущего объекта как константное. Значит, и data[i] будет константой. А к константе нельзя привязать обычную, неконстантную ссылку.

  2. Уберём второй const:

    const std::vector<T>& operator [] (size_t i) {
        return data[i];
    }
    

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

    int main() {
        Matrix<int> m(3, 4);
        int element = m[0][0];  // OK
    
        const Matrix<int>& cm = m;
        int element2 = cm[0][0];  // не скомпилируется
    
        m[1][1] = 1;  // не скомпилируется
    }
    
  3. Уберём оба const:

    std::vector<T>& operator [] (size_t i) {
        return data[i];
    }
    

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

    const std::vector<T>& operator [] (size_t i) const {
        return data[i];
    }
    
    std::vector<T>& operator [] (size_t i) {  // перегрузка по константности
        return data[i];
    }
    

    Теперь первая версия будет применяться к константным матрицам, а вторая — к неконстантным.

Перегрузку функций из класса по константности можно понимать чуть иначе.

Предположим, что указатель на текущий объект this передаётся в наш оператор явно (кстати, такая возможность появится в C++23). Тогда константность функции означала бы просто константость этого указателя. Наши функции выглядели бы так:

   const std::vector<T>& operator [] (const Matrix<T>* const this, size_t i) {  // константная версия
       return data[i];
   }

   std::vector<T>& operator [] (Matrix<T>* const this, size_t i) {  // неконстантная версия
       return data[i];
   }

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

Однако нас ожидает подвох. Неконстантная версия оператора [] может привести к нарушению инварианта класса:

int main() {
    Matrix<int> m(3, 4);  // матрица 3 x 4
    m[0].resize(10);  // синтаксически допустимо!
    // Теперь в матрице есть строка из 10 элементов и ещё две строки из четырёх элементов
}

C

Есть два способа, чтобы избежать такой ситуации.

  1. Можно сделать специальный класс, представляющий строку матрицы.
    У этого класса не будет опасных функций, таких как resize у вектора. Из неконстантной версии оператора [] вместо std::vector<T>& можно возвращать объект этого класса.

  2. Можно сделать обращение к элементу не через оператор [], а иначе.
    Например, можно перегрузить «оператор вызова функции» (). Этот оператор в отличие от [] может принимать несколько аргументов.

#include <vector>

template <typename T>
class Matrix {
private:
    std::vector<std::vector<T>> data;

public:
    T& operator () (size_t i, size_t j) {
        return data[i][j];
    }

    // ...
};

int main() {
    Matrix<int> m(3, 4);
    m(1, 1) = 1;
}

Для чтения элементов константных матриц можно оставить как константную версию оператора [], так и добавить перегруженную по константности версию оператора ():

    const T& operator () (size_t i, size_t j) const {
        return data[i][j];
    }

Итерация по матрице

Чтобы можно было писать цикл range-based for по строкам матрицы, нужно добавить к классу функции begin и end, возвращающие итераторы. В нашем случае это могут быть просто итераторы вектора data. Можно представить себе более сложный случай, где требуется обходить матрицу не построчно, а поэлементно. Для этого можно было бы написать свои итераторы. Впрочем, это выходит за рамки этого параграфа.

Сделаем функции begin и end константными, чтобы не столкнуться с проблемой из предыдущего пункта (фактически, пожертвуем возможностью изменять строки матрицы через range-based for).

Некоторая сложность тут возникает с типом возвращаемого значения функций begin и end. Это должен быть такой итератор вектора std::vector<std::vector<T>>, который не позволяет изменять элементы. Такой итератор возвращают константные функции data.cbegin() и data.cend(). Тип этого итератора — std::vector<std::vector<T>>::const_iterator. Так как это имя зависит от неизвестного заранее шаблонного параметра T, то, вообще говоря, компилятору нужно подсказать с помощью ключевого слова typename, что это действительно имя типа:

typename std::vector<std::vector<T>>::const_iterator

Впрочем, в C++20 это требование смягчили: в типе возвращаемого значения функции слово typename можно не писать.

#include <iostream>
#include <vector>

template <typename T>
class Matrix {
private:
    std::vector<std::vector<T>> data;

public:
    // Определим для краткости псевдоним для типа
    using const_iterator = typename std::vector<std::vector<T>>::const_iterator;

    // Используем этот псевдоним в объявлении функций
    const_iterator begin() const {
        return data.cbegin();
    }

    const_iterator end() const {
        return data.cend();
    }
};

int main() {
    Matrix m(3, 4);
    std::cin >> m;

    for (const auto& row : m) {  // работает!
        // обрабатываем строку row
    }
}

Заметим, что этот тип можно вывести проще с помощью конструкции decltype, которая определяет на этапе компиляции тип выражения:

    using const_iterator = decltype(data.cbegin());  // определим псевдоним для типа

    const_iterator begin() const {
        return data.cbegin();
    }

    const_iterator end() const {
        return data.cend();
    }

Потоковый ввод и вывод

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

#include <iostream>

template <typename T>
std::ostream& operator << (std::ostream& out, const Matrix<T>& matrix) {
    const size_t rows = matrix.GetRows();
    const size_t columns = matrix.GetColumns();
    for (size_t i = 0; i != rows; ++i) {
        for (size_t j = 0; j != columns; ++j) {
            if (j > 0) {
                out << "\t";
            }
            out << matrix[i][j];
        }
        out << "\n";
    }
    return out;
}

template <typename T>
std::istream& operator >> (std::istream& in, Matrix<T>& matrix) {
    const size_t rows = matrix.GetRows();
    const size_t columns = matrix.GetColumns();
    for (size_t i = 0; i != rows; ++i) {
        for (size_t j = 0; j != columns; ++j) {
            in >> matrix(i, j);
        }
    }
    return in;
}

В операторе >> мы считаем, что размеры матрицы уже заданы в самой матрице:

int main() {
    Matrix<double> m(3, 4);  // создаём нулевую матрицу из 3 строк и 4 столбцов
    std::cin >> m; // заполняем построчно её 12 элементов из потока ввода
}

Арифметические операции

Напишем для примера перегрузку операторов + и +=. Здесь важно проверить, что складываются матрицы одинакового размера. Так как размер не является шаблонным параметром, мы не можем проверить это на этапе компиляции. Если размеры не совпадают, необходимо сгенерировать исключение. Про обработку исключений мы будем говорить в параграфе «Обработка исключений», а пока просто сгенерируем его оператором throw.

#include <stdexcept>
#include <vector>

template <typename T>
class Matrix {
private:
    std::vector<std::vector<T>> data;

public:
    // ...

    Matrix<T>& operator += (const Matrix<T>& other) {
        const size_t rows = GetRows();
        const size_t columns = GetColumns();
        if (rows != other.GetRows() || columns != other.GetColumns()) {
            throw std::invalid_argument("Matrices have different size!");
        }
        for (size_t i = 0; i != rows; ++i) {
            for (size_t j = 0; j != columns; ++j) {
                data[i][j] += other.data[i][j];
            }
        }
        return *this;
    }
};

template <typename T>
Matrix<T> operator + (const Matrix<T>& m1, const Matrix<T>& m2) {
    auto tmp = m1;
    tmp += m2;
    return tmp;
}

Мы реализовали оператор += как функцию из класса, а оператор + — как внешнюю функцию. Мы могли бы в операторе += воспользоваться уже написанным оператором () для доступа к элементам матрицы, но в этом нет необходимости, так как встроенная функция имеет доступ к приватным полям. Обратите внимание, что в операторе += мы обращаемся к приватному полю data у другого объекта (other) того же типа Matrix<T>. Это вполне допустимо.

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

Подчеркнём ещё раз, что оператор += возвращает ссылку на текущий объект, а оператор + возвращает по значению новую матрицу.

Сравнение матриц

Напишем операторы == и != для сравнения двух матриц. Вообще говоря, в таких матрицах элементы могут быть разных типов (например, int и long). Важно лишь, чтобы сами такие элементы можно было сравнивать. Сделаем поэтому для примера шаблонные параметры матриц разными. Чисто для иллюстрации напишем оператор == как функцию из класса, а оператор != — как внешнюю функцию.

#include <vector>

template <typename T>
class Matrix {
private:
    std::vector<std::vector<T>> data;

public:
    // Шаблонный оператор внутри шаблонного класса
    // Параметр T2 никак не связан с параметром T
    template <typename T2>
    bool operator == (const Matrix<T2>& other) const {
        const size_t rows = GetRows();
        const size_t columns = GetColumns();
        if (rows != other.GetRows() || columns != other.GetColumns()) {
            return false;
        }
        for (size_t i = 0; i != rows; ++i) {
            for (size_t j = 0; j != columns; ++j) {
                if (!((*this)(i, j) == other(i, j))) {
                    return false;
                }
            }
        }
        return true;
    }

    // ...
};

template <typename T1, typename T2>
bool operator != (const Matrix<T1>& m1, const Matrix<T2>& m2) {
    return !(m1 == m2);
}

Если бы не требовалось сравнивать матрицы разных типов, код оператора == мог быть предельно простым:

    bool operator == (const Matrix<T>& other) const {
        return data == other.data;  // векторы умеют сравниваться на равенство
    }

Однако мы решили сделать сам этот оператор шаблонным. Поэтому матрицы *this и other теперь имеют, вообще говоря, разный тип. В первом случае это Matrix<T>, а во втором — Matrix<T2>. Поэтому, во-первых, код оператора == больше не имеет доступа к приватному полю data у объекта other, а во-вторых, векторы разных типов сравниваться друг с другом не умеют. Нам остаётся только вручную сравнить элементы матрицы.

Обратите внимание на сравнение внутри вложенного цикла:

if (!((*this)(i, j) == other(i, j))) {
    return false;
}

Тут мы написали (*this)(i, j), чтобы показать, как вызвать оператор () у текущего объекта. И ещё мы намеренно используем отрицание равенства вместо !=: так как мы сравниваем матрицы с помощью ==, то предполагается, что именно такой оператор будет применяться и к самим элементам. Вообще говоря, если T и T2 — сложные типы, то перегруженного оператора != у них может вообще не быть.

Сравнение матриц с помощью операторов < и > не имеет смысла, поэтому мы не будем их перегружать.

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

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф4.1. Классы

Класс, как и структура, задаёт тип данных, но дополнительно определяет его поведение. Переменные такого типа называются объектами. В этом параграфе мы напишем класс Time и познакомимся с перегрузкой операторов.

Следующий параграф4.3. Жизненный цикл объекта

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