Проблемы с умными указателями в C++

Добавлено 29 октября 2021 в 00:29
Проблемы с умными указателями в C++

Содержание

Изучая, как использовать новый стандарт C++, я столкнулся с несколькими интригующими случаями с умными указателями. Приведение типов? Обработка массивов? Передача в функции?

Давайте рассмотрим некоторые общие проблемы, чтобы не прострелить себе ногу :)

Некоторые предварительные договоренности

Чтобы представить дальнейшие концепции, давайте возьмем простой тестовый класс с одним членом данных:

struct Test 
{
   Test() { std::cout << "Test::Test\n"; }
   ~Test() { std::cout << "Test::~Test destructor\n"; }

   int val_ { 0 };
};

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

Как не использовать умные указатели

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

В этом контексте опасны и неправильны следующие варианты использования:

Test test;

// 1. указатель на ресурс в стеке!
std::unique_ptr<Text> ptr(&test);  // !!!

// 2. указатель на ресурс другого указателя
std::unique_ptr<Test> ptr(new Test());
std::unique_ptr<Test> otherPtr(ptr.get()); // !!

Все ошибки видите?

  1. В первом случае test живет в стеке, поэтому компилятор знает, где его следует удалить. Если вы передадите указатель в умный указатель, то в конце времени жизни этого умного указателя он попытается вызвать удаление для этого указателя! Это неопределенное поведение!
  2. Во втором случае показаны два умных указателя, которые «совместно используют» один указатель. Когда область видимости otherPtr заканчивается, он удаляет ресурс, а затем ptr попытается удалить этот же ресурс во второй раз. Это тоже неопределенное поведение. Если вы хотите поделиться ресурсом, используйте shared_ptr.

Почему auto_ptr удален в C++17?

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

auto_ptr был одним из первых типов умных указателей, представленных в C++ (точнее, в C++98). Он был разработан, чтобы служить простым уникальным указателем (только один владелец, без какого-либо счетчика ссылок), но люди пытались использовать его также в виде общего указателя. Реализация auto_ptr не удовлетворила ни одной из этих функций!

Краткий пример ниже:

void dangerous(std::auto_ptr<Test> myPtr) 
{
    myPtr->m_value = 11;
}

void AutoPtrTest() 
{
    std::auto_ptr<Test> myTest(new Test());
    dangerous(myTest);
    myTest->m_value = 10;
}

Попробуйте скомпилировать и запустить этот код… что произойдет? Он вылетает сразу после того, как мы выходим из функции dangerous! Мы могли бы предположить, что в dangerous некоторый счетчик ссылок для нашего указателя увеличивается, но у auto_ptr нет такого.

В моем случае у меня получилось:

Program returned: 139 // ошибка сегментации

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

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

Комитет C++ отметил auto_ptr в C++11 устаревшим – компилятор должен выдавать предупреждение об этом. А в C++17 этот тип удален. Если вы попытаетесь скомпилировать приведенный выше пример под флагом std=c++17 (или выше), вы получите следующие предупреждения или ошибки:

warning: 'auto_ptr<Test>' is deprecated: use 'std::unique_ptr' instead

Более того, из-за вводящей в заблуждение семантики копирования auto_ptr нельзя было использовать в стандартных контейнерах. Поэтому вы не могли создать std::vector<std::auto_ptr<Test>>. Это отлично работает с новыми умными указателями, включая unique_ptr.

Почему unique_ptr работает хорошо?

К счастью, в C++11 появился новый набор умных указателей! Когда мы изменим auto_ptr на std::unique_ptr<Test> в предыдущем примере, мы получим ошибку компиляции (а не времени выполнения), в которой говорится, что мы не можем передать указатель на другую функцию. И это правильное поведение.

unique_ptr реализован правильно из-за семантики перемещения. Мы можем перемещать (но не копировать) владение от одного указателя в другой. Нам также необходимо знать, когда и где мы передаем владение.

В нашем примере мы можем использовать

dangerous(std::move(myTest));

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

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

Полный пример:

#include <memory>
#include <iostream>

struct Test 
{
   Test() { std::cout << "Test::Test\n"; }
   ~Test() { std::cout << "Test::~Test destructor\n"; }

   int val_ { 0 };
};

void dangerous(std::unique_ptr<Test> myPtr) 
{
    myPtr->val_ = 11;
    std::cout << "dangerous() ends...\n";
}

void uniquePtrTest() 
{
    std::unique_ptr<Test> myTest(new Test());
    dangerous(std::move(myTest));
    // myTest->val_ = 10; // недопустимо
    std::cout << "after dangerous()\n";
}

int main() 
{
    uniquePtrTest();
}

Как использовать массивы с unique_ptr?

Первое, что нужно знать:

std::unique_ptr<int> p(new int[10]);  // не будет работать!

Приведенный выше код будет скомпилирован, но только, когда ресурсы необходимо будет удалить, будет вызываться delete (а не delete[]!).

Как гарантировать, чтобы вызывался delete[]?

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

std::unique_ptr<int[]> p(new int[10]);  
p[0] = 10; 

В нашем конкретном примере:

std::unique_ptr<Test[]> tests(new Test[3]);

// или лучше:
auto ptr = std::make_unique<Test[]>(3);

И мы получим ожидаемый результат:

Test::Test
Test::Test
Test::Test
Test::~Test destructor
Test::~Test destructor
Test::~Test destructor

Если вы хотите передать адрес первого элемента, вы должны использовать &(pointerToArray[0]). Запись pointerToArray не сработает.

Как использовать массивы с shared_ptr?

Поддержка массивов в shared_ptr появилась после unique_ptr и, наконец, стала доступна с C++17. Она работает аналогично первому умному указателю:

#include <memory>
#include <iostream>

struct Test 
{
   Test() { std::cout << "Test::Test\n"; }
   ~Test() { std::cout << "Test::~Test destructor\n"; }

   int val_ { 0 };
};

int main() 
{
    std::shared_ptr<Test[]> ptr(new Test[3]);
    std::cout << "finishing main...\n";
}

В C++20 make_shared также была обновлена для обработки типов массивов:

auto ptr = std::make_shared<Test[]>(3);

(Обратите внимание, что на октябрь 2021 года make_shared для массивов поддерживается только компилятором MSVC).

До C++17 shared_ptr не работал с массивами. Но вы можете использовать собственный удалитель. Например:

std::shared_ptr<Test> sp(new Test[2], [](Test *p) { delete []p;});

Зачем создавать shared_ptr с помощью make_shared?

Уникальные указатели предоставляют свои возможности только за счет осмысленного использования синтаксиса C++ (с использованием закрытых конструктора копирования, присваивания и т.д.); им не нужна дополнительная память. Но с shared_ptr нам нужно связать с нашим объектом какой-то счетчик ссылок. Как это сделать эффективно?

Когда мы делаем:

std::shared_ptr<Test> sp(new Test());
std::shared_ptr<Test> sp2 = std::make_shared<Test>();

Мы получим ожидаемый результат:

Test::Test
Test::Test
Test::~Test destructor
Test::~Test destructor

Так в чем разница? Почему бы не использовать синтаксис, аналогичный созданию unique_ptr? Ответ кроется в процессе размещения. В первой инструкции нам нужно выделить место для объекта и счетчика ссылок. Есть только одно размещение (с использованием размещения new), и счетчик ссылок использует тот же блок памяти, что и объект, на который указывает указатель.

Просмотр локальных переменных в VS 2012
Рисунок 1 – Просмотр локальных переменных в VS 2012

Выше вы можете увидеть изображение с представлением локальных переменных в Visual Studio. Сравните адреса данных объекта и блока счетчика ссылок. Для sp2 мы видим, что они очень близки друг к другу.

В C++14 есть приятное улучшение: функция make_unique! Таким образом, создание умных указателей стало немного более «унифицированным». У нас есть make_shared и make_unique.

Почему не стоит создавать shared_ptr с помощью make_shared?

Хотя make_shared является лучшим выбором и должна работать в 99% случаев, об одном вам следует знать.

Это связано с взаимодействием weak_ptr и shared_ptr.

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

Если вы используете make_shared, объект размещается в том же блоке памяти, что и блок управления, и, таким образом, память вашего объекта не будет освобождена.

Это редкая ситуация, и она может даже никоим образом не навредить вам (деструкторы всё равно вызываются), но хорошо бы знать об этом факте.

Полное объяснение с примерами здесь: Как weak_ptr может предотвратить полное освобождение памяти управляемого объекта.

Как передавать умные указатели в функции?

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

Мне нужно передать владение или просто указатель на объект?

Если вам нужно работать с самим объектом и не менять указатель, не менять владельца и т.д., то передайте указатель для «наблюдения».

Например:

void importantFunction(Test* ptr) // или ссылка
{ 
    ptr->val_ = 10;
}

auto ptr = make_unique<Test>();
importantFunction(ptr.get());

Это также соответствует следующей рекомендации из C++ Core Guidelines:

F.7: Для совместного использования используйте аргументы T* или T&, а не умные указатели

Как насчет случаев, когда вы хотите изменить сам указатель?

В этом случае вы передаете ссылку:

void importantFunction(std::unique_ptr<Test>& ptr) 
{
    ptr.reset(nullptr);
}

auto ptr = make_unique<Test>();
importantFunction(ptr);
// ptr может быть нулевым

Следующие рекомендации из C++ Core Guideline:

R.33: Принимайте параметр unique_ptr<widget>&, чтобы показать, что функция изменяет расположение widget.

Есть и другие варианты:

  • Передача unique_ptr по значению – это называется принимающей функцией, и это передает владения указателем.
  • Передача shared_ptr по значению – это совместно использует указатель, поэтому его счетчик ссылок обновляется. Такая операция относительно тяжелая, поэтому используйте ее только в том случае, если вам нужно владение внутри функции.
  • Передача shared_ptr по ссылке – аналогично случаю unique_ptr, это сообщает вызывающему, что функция может сбросить указатель.

Как приводить тип умных указателей?

Возьмем типовой пример с простым наследованием:

class BaseA 
{
protected:
    int a{ 0 };
    
public:
    virtual ~BaseA() { }

    void A(int p) { a = p; }
};

class ChildB : public BaseA 
{
private:
    int b{ 0 };
public:
    void B(int p) { b = p; }
};

Вы без проблем можете создать умный указатель на BaseA и инициализировать его с помощью ChildB:

std::shared_ptr<BaseA> ptrBase = std::make_shared<ChildB>();
ptrBase->A(10);

Но как получить указатель на класс ChildB из ptrBase? Хотя это не очень хорошая практика, иногда мы знаем, что это необходимо.

Вы можете попробовать это:

ChildB *ptrMan = dynamic_cast<ChildB *>(ptrBase.get());
ptrMan->B(10);

Это должно работать. Но так вы получите только «обычный» указатель! Счетчик ссылок для исходного ptrBase не увеличивается. Теперь вы можете наблюдать за объектом, но не являетесь его владельцем.

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

std::shared_ptr<ChildB> ptrChild = std::dynamic_pointer_cast<ChildB>(ptrBase);
if (ptrChild) 
{
    ptrChild->B(20);
    std::cout << "use count A: " << ptrBase.use_count() << std::endl;
    std::cout << "use count B: " << ptrChild.use_count() << std::endl;
}

Используя std::dynamic_pointer_cast, вы получаете правильный общий указатель. Теперь вы тоже владелец ресурса. Счетчик ссылок для ptrBase и ptrChild в этом случае равен «2».

А как насчет приведения unique_ptr?

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

Резюме

Умные указатели удобны, но мы, как пользователи, тоже должны быть умными :)

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

Дополнительная информация

Для получения дополнительной информации об умных указателях смотрите главу «Семантика перемещения и умные указатели» из серии статей «Изучаем C++».

Теги

C++ / CppC++11C++14C++17C++20std::auto_ptrstd::make_sharedstd::make_uniquestd::shared_ptrstd::unique_ptrstd::weak_ptrУмные указатели

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

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