Skip to content

Instantly share code, notes, and snippets.

@alzobnin
Last active October 20, 2020 09:11
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save alzobnin/e0bbb0e3b5f72a778a115924ec2df3f1 to your computer and use it in GitHub Desktop.
Save alzobnin/e0bbb0e3b5f72a778a115924ec2df3f1 to your computer and use it in GitHub Desktop.

Разбор задачи «Коварная матрица»

Условие

Ваш друг пишет свою реализацию шаблонного класса «Матрица»: https://clck.ru/AmxPF. Правда, он зачем-то выбрал очень странную реализацию матрицы на основе двумерного динамического массива, а не вектора. Однако вы можете быть уверенными, что инициализацию и освобождение динамической памяти для отдельно взятой матрицы ваш друг написал правильно.

Пока в классе есть только конструктор, деструктор и оператор []. Ещё ваш друг написал функцию FillMatrix, возвращающую матрицу, заполненную особым образом, а также оператор << для печати матрицы на экране.

Но почему-то вот такой простой код не работает:

#include "matrix.h"
#include <iostream>

int main() {
    size_t m, n;
    std::cin >> m >> n;
    Matrix<int> A(m, n);
    // ...
    A = FillMatrix<int>(m, n);
    std::cout << A << "\n";
}

Вам нужно добиться, чтобы он заработал. Вы можете только дописывать что-то к классу Matrix: ваш код будет вставлен начиная с 46-й строки в файле matrix.h.

Решение

В классе Matrix не написан оператор присваивания. Поэтому компилятор неявно предоставил по умолчанию свою версию этого оператора. Эта версия просто копирует все поля как есть. Она могла бы выглядеть так:

Matrix& operator = (const Matrix& other) {
  data = other.data;
  rows = other.rows;
  columns = other.columns;
  return *this;
}

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

Этот оператор вызывается в строке A = FillMatrix<int>(m, n);. В результате получается, что два объекта типа Matrix<int> содержат указатель на одни и те же данные: это временный объект с результатом работы функции, и измененный A. Каждый из них уверен, что является ответственным за удаление памяти. Сначала умирает временный объект и в своём деструкторе освобождает память, на которую ссылается data. Далее либо A попытается использовать эту память, либо сам умрёт и попытается освободить её повторно. В любом случае это приведёт к неопределенному поведению программы.

Рассмотрим способы решения проблемы.

  1. Можно было бы хранить данные матрицы в подходящем контейнере (например, векторе векторов). Тогда при копировании или присваивании матрицы будет вызван конструктор копирования вектора, который сделает глубокую копию. Этот способ предпочтителен, но мы не можем им воспользоваться: по условию задачи мы можем лишь дописывать новые функции в классе.
  2. Можно было бы сделать глубокие конструктор копирования и оператор присваивания, выделяющие память под новую матрицу и копирующие туда элементы старой матрицы. На самом деле это сложно и не нужно, потому что есть третий способ.
  3. Можно запретить копирование и присваивание матриц в общем случае! Но чтобы пример заработал, можно разрешить его только для случаев, когда справа стоит временный (безымянный) объект. Например, таковым является результат, возвращаемый из функции. Такой объект живёт только пока вычисляется выражение с присваиванием, а потом сразу умирает. Поэтому у него можно просто отобрать владение матрицей, оставив его в согласованном, но пустом состоянии.

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

Начнём с конструктора. Запрещаем автогенерацию конструктора копирования в общем случае, но делаем особую версию для случая, когда на вход приходит временный объект:

Matrix(const Matrix&) = delete;

Matrix(Matrix&& other) {
  data = other.data;
  rows = other.rows;
  columns = other.columns;
  other.data = nullptr;
  other.rows = 0;
  other.columns = 0;
}

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

Matrix& operator = (const Matrix&) = delete;

Matrix& operator = (Matrix&& other) {
  std::swap(data, other.data);
  std::swap(rows, other.rows);
  std::swap(columns, other.columns);
  return *this;
}

Примечания

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

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

Почему, например, конструктор из условия не написан просто так?

Matrix(size_t m, size_t n): rows(m), columns(n) {
    data = new T * [rows];
    for (size_t i = 0; i != rows; ++i) {
        data[i] = new T[columns];
    }
}

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

  1. его может сгенерировать new, если не хватит памяти;
  2. его может сгенерировать конструктор неизвестного нам типа T, который вызывается внутри new;

В первом new проблем не возникает: сам new действует как транзакция, и если в нём произойдёт сбой, то утечки не будет. Проблема будет, если сбой произойдёт в последующих new. Память, выделенную ранее, придётся подчистить, а исключение перебросить дальше, чтобы объект матрицы не был создан. Это делается в блоке catch просто с помощью throw:

Matrix(size_t m, size_t n): rows(m), columns(n) {
    data = new T * [rows];
    size_t i = 0;
    try {
        for (; i != rows; ++i)
            data[i] = new T[columns];
    } catch (...) {
        for (size_t k = 0; k != i; ++k)
            delete [] data[k];
        delete [] data;
        throw;
    }
}

Заметим, что проблема возникает из-за того, что наш класс владеет сразу несколькими низкоуровневыми массивами. Идиома RAII предлагает каждый из таких ресурсов оборачивать в отдельный класс-обёртку. Нам бы в качестве такой обёртки подошел бы просто std::vector.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment