M.1 – Введение в умные указатели и семантику перемещения

Добавлено 17 сентября 2021 в 22:20

Рассмотрим функцию, в которой мы динамически размещаем значение:

void someFunction()
{
    Resource *ptr = new Resource(); // Resource - это структура или класс
 
    // здесь что-то делаем с ptr
 
    delete ptr;
}

Хотя приведенный выше код кажется довольно простым, довольно легко забыть освободить ptr. Даже если вы не забыли удалить ptr в конце функции, существует множество способов, которыми ptr может быть не удален, если функция завершится раньше времени. Это может произойти за счет преждевременного возврата:

#include <iostream>
 
void someFunction()
{
    Resource *ptr = new Resource();
 
    int x;
    std::cout << "Enter an integer: ";
    std::cin >> x;
 
    if (x == 0)
        return; // функция возвращается раньше, и ptr не будет удален!
 
    // здесь что-то делаем с ptr
 
    delete ptr;
}

или через выброшенное исключение:

#include <iostream>
 
void someFunction()
{
    Resource *ptr = new Resource();
 
    int x;
    std::cout << "Enter an integer: ";
    std::cin >> x;
 
    if (x == 0)
        throw 0; // функция возвращается раньше, и ptr не будет удален!
 
    // здесь что-то делаем с ptr
 
    delete ptr;
}

В двух показанных выше программах выполняется преждевременная инструкция return или throw, в результате чего функция завершается без удаления переменной ptr. Следовательно, происходит утечка памяти, выделенной для переменной ptr (и эта утечка будет происходить снова каждый раз, когда эта функция вызывается и возвращается, не доходя до конца).

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

На помощь приходят классы умных указателей?

Одна из лучших особенностей классов заключается в том, что они содержат деструкторы, которые автоматически запускаются, когда объект класса выходит за пределы области видимости. Поэтому, если вы выделяете (или приобретаете) память в конструкторе, вы можете освободить ее в деструкторе и гарантировать, что память будет освобождена, когда объект класса будет уничтожен (независимо от того, выходит ли он за пределы области видимости, удаляется явно, и так далее…). Это лежит в основе парадигмы программирования RAII, о которой мы говорили в уроке «12.9 – Деструкторы».

Итак, можем ли мы использовать класс для помощи в управлении указателями и их очистки? Можем!

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

Вот первый набросок идеи:

#include <iostream>
 
template<class T>
class Auto_ptr1
{
    T* m_ptr;
public:
    // Передаем указатель на свой объект через конструктор
    Auto_ptr1(T* ptr=nullptr)
        :m_ptr(ptr)
    {
    }
    
    // Деструктор позаботится о том, чтобы он был освобожден
    ~Auto_ptr1()
    {
        delete m_ptr;
    }
    
    // Перегрузка разыменования и operator->, чтобы мы могли использовать Auto_ptr1 как m_ptr.
    T& operator*() const { return *m_ptr; }
    T* operator->() const { return m_ptr; }
};
 
// Пример класса, чтобы доказать, что это работает
class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
};
 
int main()
{
    Auto_ptr1<Resource> res(new Resource()); // Обратите внимание на выделение памяти здесь
 
    //  ... но явного удаления не требуется
 
    //Также обратите внимание, что для ресурса в скобках не требуется символ *,
    // поскольку он предоставляется шаблоном
 
    return 0;
} // res здесь выходит из области видимости и уничтожает размещенный для нас Resource

Эта программа печатает:

Resource acquired
Resource destroyed

Рассмотрим, как работают эти программа и класс. Сначала мы динамически создаем Resource и передаем его в качестве параметра нашему шаблонному классу Auto_ptr1. С этого момента наша переменная Auto_ptr1 res владеет этим объектом Resource (Auto_ptr1 имеет композиционную связь с m_ptr). Поскольку res объявлена как локальная переменная и имеет область видимости блока, она выйдет из области видимости, когда этот блок закончится, и будет уничтожена (не беспокойтесь о том, чтобы не забыть освободить ее память). И поскольку это класс, при уничтожении его объекта будет вызван деструктор Auto_ptr1. Этот деструктор гарантирует, что указатель Resource, который хранится объектом, будет удален!

Пока Auto_ptr1 определен как локальная переменная (с автоматической продолжительностью жизни, отсюда часть имени класса "Auto"), Resource будет гарантированно уничтожен в конце блока, в котором он объявлен, независимо от того, как функция завершается (даже если она завершается преждевременно).

Такой класс называется умным указателем. Умный указатель – это класс композиции, который предназначен для управления динамически выделяемой памятью и гарантирования освобождения этой памяти, когда объект умного указателя выходит за пределы области видимости. (Соответственно, встроенные указатели иногда называют «тупыми указателями», потому что они не могут выполнить после себя очистку.)

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

#include <iostream>

template<class T>
class Auto_ptr1
{
    T* m_ptr;
public:
    // Передаем указатель на свой объект через конструктор
    Auto_ptr1(T* ptr=nullptr)
        :m_ptr(ptr)
    {
    }

    // Деструктор позаботится о том, чтобы он был освобожден
    ~Auto_ptr1()
    {
        delete m_ptr;
    }

    // Перегрузка разыменования и operator->, чтобы мы могли использовать Auto_ptr1 как m_ptr.
    T& operator*() const { return *m_ptr; }
    T* operator->() const { return m_ptr; }
};

// Пример класса, чтобы доказать, что это работает
class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
    void sayHi() { std::cout << "Hi!\n"; }
};

void someFunction()
{
    Auto_ptr1<Resource> ptr(new Resource()); // ptr теперь владеет объектом Resource

    int x;
    std::cout << "Enter an integer: ";
    std::cin >> x;

    if (x == 0)
        return; // функция возвращается раньше времени

    // здесь что-то делаем с ptr
    ptr->sayHi();
}

int main()
{
    someFunction();

    return 0;
}

Если пользователь вводит ненулевое целое число, показанная выше программа напечатает:

Resource acquired
Hi!
Resource destroyed

Если пользователь вводит ноль, эта программа преждевременно завершает работу и печатает:

Resource acquired
Resource destroyed

Обратите внимание, что даже в том случае, когда пользователь вводит ноль и функция преждевременно завершается, объект Resource всё равно удаляется корректно.

Поскольку переменная ptr является локальной переменной, он будет уничтожен при завершении функции (независимо от того, как она завершится). И поскольку деструктор Auto_ptr1 удаляет Resource, мы уверены, что Resource будет удален правильным образом.

Критический недостаток

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

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

Хорошо, время вышло.

Вместо разговоров, мы вам покажем его. Рассмотрим следующую программу:

#include <iostream>

// То же, что и выше
template<class T>
class Auto_ptr1
{
    T* m_ptr;
public:
    Auto_ptr1(T* ptr=nullptr)
        :m_ptr(ptr)
    {
    }

    ~Auto_ptr1()
    {
        delete m_ptr;
    }

    T& operator*() const { return *m_ptr; }
    T* operator->() const { return m_ptr; }
};

class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
    Auto_ptr1<Resource> res1(new Resource());
    // В качестве альтернативы не инициализируйте res2, а затем присвойте res2 = res1;
    Auto_ptr1<Resource> res2(res1); 

    return 0;
}

Эта программа печатает:

Resource acquired
Resource destroyed
Resource destroyed

Очень вероятно (но не обязательно) ваша программа на этом этапе завершится со сбоем. Теперь видите проблему? Поскольку мы не предоставили конструктор копирования или оператор присваивания, C++ сам предоставляет их нам. А функции, которые он предоставляет, выполняют поверхностное копирование. Поэтому, когда мы инициализируем res2 с помощью res1, обе переменные Auto_ptr1 указывают на один и тот же объект Resource. Когда res2 выходит за пределы области видимости, она удаляет этот объект Resource, оставляя res1 с висячим указателем. Когда res1 удаляет свой (уже удаленный) объект Resource, происходит сбой!

Вы столкнетесь с аналогичной проблемой и с подобной функцией:

void passByValue(Auto_ptr1<Resource> res)
{
}

int main()
{
    Auto_ptr1<Resource> res1(new Resource());
    passByValue(res1)

    return 0;
}

В этой программе res1 будет скопирован по значению в параметр res функции passByValue, что приведет к дублированию указателя на объект Resource. И снова сбой!

Ясно, что это нехорошо. Как мы можем решить эту проблему?

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

Но как тогда вернуть Auto_ptr1 из функции обратно вызывающей функции?

??? generateResource()
{
     Resource *r = new Resource();
     return Auto_ptr1(r);
}

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

Другой вариант – переопределить конструктор копирования и оператор присваивания для создания глубоких копий. Таким образом, мы, по крайней мере, гарантированно избежим дублирования указателей на один и тот же объект. Но копирование может быть дорогостоящим (и может быть нежелательным или даже невозможным), и мы не хотим делать ненужные копии объектов только для того, чтобы вернуть Auto_ptr1 из функции. Кроме того, при присваивании или инициализации «глупого» указателя объект, на который он указывает, не копируется, так почему же нам ожидать, что умные указатели будут вести себя иначе?

Что же делать?

Семантика перемещения

Что, если вместо того, чтобы заставить наш конструктор копирования и оператор присваивания копировать указатель («семантика копирования»), мы вместо этого передадим/переместим владение указателем от источника к объекту назначения? Это основная идея семантики перемещения. Семантика перемещения означает, что класс передаст право собственности на объект, а не создаст копию.

Давайте обновим наш класс Auto_ptr1, чтобы показать, как это можно сделать:

​#include <iostream>

template<class T>
class Auto_ptr2
{
    T* m_ptr;
public:
    Auto_ptr2(T* ptr=nullptr)
        :m_ptr(ptr)
    {
    }

    ~Auto_ptr2()
    {
        delete m_ptr;
    }

    // Конструктор копирования, реализующий семантику перемещения
    Auto_ptr2(Auto_ptr2& a) // обратите внимание: не const
    {
        // переносим наш тупой указатель из источника в наш локальный объект
        m_ptr = a.m_ptr; 
        // убеждаемся, что источник больше не владеет указателем
        a.m_ptr = nullptr;
    }

    // Оператор присваивания, реализующий семантику перемещения
    Auto_ptr2& operator=(Auto_ptr2& a) // обратите внимание: не const
    {
        if (&a == this)
            return *this;

        // сначала убеждаемся, что мы освободили любую память,
        // которую объект назначения уже удерживает с помощью указателя
        delete m_ptr; 
        // затем переносим наш тупой указатель из источника в локальный объект
        m_ptr = a.m_ptr; 
        // убеждаемся, что источник больше не владеет указателем
        a.m_ptr = nullptr; 
        return *this;
    }

    T& operator*() const { return *m_ptr; }
    T* operator->() const { return m_ptr; }
    bool isNull() const { return m_ptr == nullptr;  }
};

class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
    Auto_ptr2<Resource> res1(new Resource());
    Auto_ptr2<Resource> res2; // Начинаем как nullptr

    std::cout << "res1 is " << (res1.isNull() ? "null\n" : "not null\n");
    std::cout << "res2 is " << (res2.isNull() ? "null\n" : "not null\n");

    res2 = res1; // res2 принимает владение, res1 устанавливается в ноль

    std::cout << "Ownership transferred\n";

    std::cout << "res1 is " << (res1.isNull() ? "null\n" : "not null\n");
    std::cout << "res2 is " << (res2.isNull() ? "null\n" : "not null\n");

    return 0;
}

Эта программа печатает:

Resource acquired
res1 is not null
res2 is null
Ownership transferred
res1 is null
res2 is not null
Resource destroyed

Обратите внимание, что наш перегруженный operator= передал право владения m_ptr от res1 к res2! Следовательно, мы не получаем дубликатов указателя, и всё аккуратно очищается.

std::auto_ptr и почему это была плохая идея

Теперь самое время поговорить об std::auto_ptr. std::auto_ptr, представленный в C++98 и удаленный в C++17, был первой попыткой C++ создать стандартизированный умный указатель. std::auto_ptr решил реализовать семантику перемещения так, как это делает класс Auto_ptr2.

Однако std::auto_ptr (и наш класс Auto_ptr2) имеет ряд проблем, делающих его использование опасным.

Во-первых, поскольку std::auto_ptr реализует семантику перемещения через конструктор копирования и оператор присваивания, передача std::auto_ptr в функции по значению приведет к тому, что ваш ресурс будет перемещен в параметр функции (и будет уничтожен в конце функции, когда параметры функции выходят из области видимости). Затем, когда вы возвращаетесь к аргументу auto_ptr в вызывающей функции (не осознавая, что его ресурс был передан и удален), вы внезапно разыменовываете нулевой указатель. Сбой программы!

Во-вторых, std::auto_ptr всегда удаляет свое содержимое, не используя удаление для массива. Это означает, что auto_ptr будет работать неправильно с динамически размещаемыми массивами, потому что он использует неправильный способ освобождения памяти. Хуже того, это не помешает вам передать ему динамический массив, которым он затем будет неправильно управлять, что приведет к утечке памяти.

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

Из-за приведенных выше недостатков std::auto_ptr стал считаться устаревшим в C++11 и был удален в C++17.

Прогресс

Основная проблема с std::auto_ptr заключается в том, что до C++11 в языке C ++ просто не было механизма, позволяющего отличить «семантику копирования» от «семантики перемещения». Переопределение семантики копирования для реализации семантики перемещения приводит к странным случаям и непреднамеренным ошибкам. Например, вы можете написать res1 = res2 и не знать, изменится ли res2 или нет!

По этой причине в C++11 была официально определена концепция «перемещения», и в язык была добавлена «семантика перемещения», чтобы правильно отличать копирование от перемещения. Теперь, когда мы подготовили почву для понимания того, почему семантика перемещения может быть полезна, мы исследуем тему семантики перемещения в оставшейся части этой главы. Мы также исправим наш класс Auto_ptr2, используя семантику перемещения.

В C++11 std::auto_ptr был заменен множеством других типов умных указателей, «учитывающих перемещение»: std::unique_ptr, std::weak_ptr и std::shared_ptr. Мы также рассмотрим два самых популярных из них: unique_ptr (который является прямой заменой auto_ptr) и shared_ptr.

Теги

C++ / CppLearnCppstd::auto_ptrДля начинающихОбучениеПрограммированиеСемантика копированияСемантика перемещенияУказатель / Pointer (программирование)Умные указатели

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.