Добавить в корзинуПозвонить
Найти в Дзене

Всё что вы хотели знать об умных указателях в С++

Сегодня поговорим об использовании интеллектуальных указателей в реальных проектах. Разберем вопросы о том каковы основные сценарии использования weak_ptr в паре с shared_ptr, как устроены shared_ptr и weak_ptr "под капотом", изучим накладные расходы при использовании shared_ptr и weak_ptr, а также большое количество связанных с этим проблем. Предполагается наличие у читателя предварительных знаний о назначении интеллектуальных указателей. Использование std::weak_ptr вместе с std::shared_ptr — это стандартный способ решения проблем с владением памятью, когда применение одних только shared_ptr может привести к утечкам или неопределенному поведению. Вот три основных сценария, когда без weak_ptr не обойтись: 1. Разрыв циклических зависимостей Это самая частая причина. Если два объекта ссылаются друг на друга через shared_ptr, их счетчик ссылок никогда не станет нулевым, и они не будут удалены из памяти. Пример — система классов с отношением типа «родительский класс — класс потомок».

Сегодня поговорим об использовании интеллектуальных указателей в реальных проектах. Разберем вопросы о том каковы основные сценарии использования weak_ptr в паре с shared_ptr, как устроены shared_ptr и weak_ptr "под капотом", изучим накладные расходы при использовании shared_ptr и weak_ptr, а также большое количество связанных с этим проблем. Предполагается наличие у читателя предварительных знаний о назначении интеллектуальных указателей.

Использование std::weak_ptr вместе с std::shared_ptr — это стандартный способ решения проблем с владением памятью, когда применение одних только shared_ptr может привести к утечкам или неопределенному поведению.

Вот три основных сценария, когда без weak_ptr не обойтись:

1. Разрыв циклических зависимостей

Это самая частая причина. Если два объекта ссылаются друг на друга через shared_ptr, их счетчик ссылок никогда не станет нулевым, и они не будут удалены из памяти. Пример — система классов с отношением типа «родительский класс — класс потомок». Родитель обычно владеет потомком при помощи указателя типа shared_ptr. Потомок должен знать о родителе, но не владеет им. Если сделать ссылку на родителя через shared_ptr, возникнет цикл. Следовательно в потомке используем weak_ptr для хранения ссылки на родителя.

2. Кэширование объектов

Когда вам нужно хранить список объектов, которые могут быть удалены в другом месте программы, weak_ptr подходит как нельзя кстати. Кэш хранит weak_ptr. Если объект еще существует в программе (его держит кто-то другой через shared_ptr), кэш может получить доступ к нему через метод lock(). Если объект уже удален, кэш определит, что указатель пуст, и не будет пытаться его использовать.

3. Применение паттерна «наблюдатель»

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

void useObject(std::weak_ptr<Entity> weakEntity) {
// Пытаемся получить доступ к объекту
if (auto sharedEntity = weakEntity.lock()) {
// Объект еще жив, можем работать
sharedEntity->doSomething();

} else {
// Объект уже удален
std::cout << "Object is gone!" << std::endl;
}
}

Если вместо weak_ptr использовать shared_ptr, то возникнет циклическая зависимость, когда Узел A ссылается на Узел B, а Узел B — на Узел A. Рассмотрим пример.
struct Node {
std::string name;
std::shared_ptr<Node> next; // Ссылка на следующий узел

Node(std::string n) : name(n) { std::cout << name << " created\n"; }
~Node() { std::cout << name << " destroyed\n"; }
};

int main() {
auto nodeA = std::make_shared<Node>("Node A");
auto nodeB = std::make_shared<Node>("Node B");

// Создаем цикл
nodeA->next = nodeB;
nodeB->next = nodeA;
// ОШИБКА: Программа завершается, но деструкторы не вызовутся
// nodeA держит nodeB, а nodeB держит nodeA. Счетчик ссылок никогда не
// станет 0.
return 0;
}

Чтобы разорвать цикл, один из указателей должен быть «не владеющим». В структурах типа «дерево» или «список» обычно ссылки вниз (от родителя к детям) делают владеющими, а ссылки вверх (от ребенка к родителю) — слабыми.

struct Node {
std::string name;
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // Теперь это слабая ссылка

Node(std::string n) : name(n) { std::cout << name << " created\n"; }
~Node() { std::cout << name << " destroyed\n"; }
};
int main() {
{
auto nodeA = std::make_shared<Node>("Node A");
auto nodeB = std::make_shared<Node>("Node B");

nodeA->next = nodeB; // Счетчик nodeB = 2
nodeB->prev = nodeA; // Счетчик nodeA остается 1, т.к. weak_ptr его не
//увеличивает

} // Здесь nodeA выходит из области видимости.
// Его счетчик становится 0, в результате nodeA удаляется.
// При удалении nodeA его указатель next на nodeB исчезает.
// nodeB выходит из области видимости и тоже удаляется.
std::cout << "End of scope\n";
return 0;
}

Главные правила выбора:

  1. shared_ptr — если объект не может существовать без этой связи (владение).
  2. weak_ptr — если объекту нужно знать о ком-то, но он не отвечает за его время жизни.

Когда нельзя использовать weak_ptr? Не стоит заменять им всё подряд. Если вы попытаетесь вызвать метод через weak_ptr без проверки lock(), и объект уже удален, вы получите пустой указатель и, как следствие, неопределенное поведение. weak_ptr — это всегда про необязательное (временное) присутствие объекта.

Автоматическое удаление памяти, управляемой shared_ptr, осуществляется при помощи специальной структурой, называемой управляющим блоком (control block). Внутри управляющего блока обычно находятся как минимум два целочисленных счетчика:

  1. Shared сount, задающий сколько shared_ptr владеют объектом. Когда он станет 0, объект будет уничтожен.
  2. Weak сount, определяющий сколько weak_ptr наблюдают за объектом. Когда оба этих счетчика станут равны 0, будет уничтожен сам управляющий блок.

Представим жизненный цикл shared_ptr в виде последовательности шагов.

Шаг 1: Создание shared_ptr
auto sp = std::make_shared<T>();

Выделяется память под объект типа T и control block. При этом shared count = 1, weak count = 0.

Шаг 2: Копирование
auto sp2 = sp;

Shared Count атомарно увеличивается на 1 и принимает значение 2.

Шаг 3: Создание weak_ptr
std::weak_ptr<T> wp = sp;

Shared Count остается равным 2. weak count атомарно увеличивается, становясь равным 1.

Шаг 4: Разрушение всех shared_ptr
sp.reset(); // shared count = 1

sp2.reset();// shared count = 0

Срабатывает деструктор объекта ~T(). Память объекта может быть освобождена (если не используется make_shared), но управляющий блок остается в памяти, т.к. Weak Count = 1.

Шаг 5: Разрушение weak_ptr
wp.reset(); // Weak Count = 0

Управляющий блок видит, что счетчики равны нулю, и само уничтожается, освобождая память.

Счетчики в shared_ptr являются атомарными переменными (std::atomic). Это позволяет безопасно копировать shared_ptr в разных потоках: инкремент и декремент счетчика происходят без "состояния гонки". Атомарные операции медленнее обычных.

Важный вопрос, который возникает при использовании weak_ptr, - как работает метод lock? При его вызове происходит следующее:

  1. Проверяется shared count в управляющем блоке.
  2. Если shared count > 0, он атомарно увеличивается и возвращается shared_ptr с увеличенным shared count.
  3. Если shared count равен 0, возвращается пустой shared_ptr.

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

-2

Вы можете использовать метод use_count() у shared_ptr или weak_ptr, чтобы увидеть текущее состояние shared count счетчика как показано в следующей программе.

int main() {
auto sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp;
std::cout << "Owners: " << sp.use_count() << "\n"; // Выведет 1
{
auto sp2 = sp;
std::cout << "Owners: " << wp.use_count() << "\n"; // Выведет 2
}
std::cout << "Owners: " << wp.use_count() << "\n"; // Снова 1
}

Важный нюанс: почему use_count() для weak_ptr возвращает shared count? Метод use_count() класса weak_ptr возвращает shared count для того, чтобы вы могли понять, имеет ли смысл пытаться делать lock(). Прямого способа узнать количество «слабых» наблюдателей через стандартный API нет, так как это внутренняя деталь реализации, не влияющая на логику работы с объектом.

При использовании shared_ptr важно помнить о том, что как только Shared Count становится равен 0, вызывается деструктор объекта. Память, которую занимали его данные объекта, немедленно освобождается. Но управляющий блок остается в памяти до тех пор, пока жив хотя бы один weak_ptr (т.е. пока weak count > 0). Это нужно для того, чтобы другой weak_ptr мог безопасно проверить счетчик и понять, что объект уже удален. Когда последний weak_ptr исчезает и weak count становится нулевым, удаляется управляющий блок.

Описанные правила можно резюмировать при помощи следующей таблицы.

-3

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

Почему std::make_shared ведет себя именно так с точки зрения выделения памяти? Когда память выделяется при помощи вызова new, например так: std::shared_ptr<Node> ptr(new Node());

Происходит два отдельных выделения памяти в куче:

  1. Выделяется память под сам объект Node.
  2. Выделяется память под управляющий блок (счетчики).

Когда shared count становится нулевым, блок памяти объекта можно сразу вернуть операционной системе, даже если управляющий блок еще живет из-за weak_ptr.

При вызове std::make_shared происходит однократное выделение памяти.
Запрашивается один участок, в который помещаются управляющий блок и сам объект. Одно выделение памяти работает быстрее двух. Процессору проще работать с данными, когда они лежат рядом (меньше промахов кэша). Поскольку это один непрерывный участок памяти, его нельзя "отдать" операционной системе по частям. Если shared count == 0, вызывается деструктор объекта. Он очищает внутренние ресурсы, например, закрывает файлы или очищает векторы внутри объекта, но сама память, которую занимал объект, не освобождается. Она будет занята до тех пор, пока weak count больше нуля.

Если ваш объект очень большой (например, массив на 100 МБ внутри структуры) и на него долго будут ссылаться weak_ptr, то std::make_shared может быть плохой идеей, так как 100 МБ будут "висеть" в памяти впустую.

Для небольших объектов (что бывает в 99% случаев) всегда лучше использовать std::make_shared, так как выигрыш в производительности важнее.

Чтобы проверить этот эффект, нам нужно создать пользовательский распределитель памяти (аллокатор), который будет выводить в консоль сообщения о выделении (allocate) и освобождении (deallocate) памяти. Сравним поведение std::shared_ptr<T>(new T) и std::allocate_shared<T> (аналог make_shared, но с поддержкой аллокатора).

#include <iostream>
#include <memory>

// 1. Пользовательский распределитель памяти
template <typename T>
struct TrackingAllocator {
using value_type = T;

TrackingAllocator() = default;
template <typename U> TrackingAllocator(const TrackingAllocator<U>&) {}

T* allocate(std::size_t n) {
std::cout << "[Allocating " << n * sizeof(T) << " bytes]" << std::endl;
return static_cast<T*>(::operator new(n * sizeof(T)));
}

void deallocate(T* p, std::size_t n) {
std::cout << "[Deallocating " << n * sizeof(T) << " bytes]" << std::endl;
::operator delete(p);
}
};

struct LargeObject {
char data[100]; // Эмулируем полезную нагрузку
LargeObject() { std::cout << " Constructor called" << std::endl; }
~LargeObject() { std::cout << " Destructor called" << std::endl; }
};

int main() {
std::weak_ptr<LargeObject> observer;

std::cout << "--- Scenario: allocate_shared (One block) ---" << std::endl;
{
auto shared = std::allocate_shared<LargeObject>(TrackingAllocator<LargeObject>());
observer = shared;
std::cout << " Resetting shared_ptr..." << std::endl;
shared.reset();
std::cout << " shared_ptr is null now, but is memory freed?" << std::endl;
}
std::cout << " observer (weak_ptr) is going out of scope now..." << std::endl;
observer.reset(); // Только здесь мы увидим "Deallocating"

std::cout << "\n--- Scenario: new (Two blocks) ---" << std::endl;
{
// Примечание: 'new' не использует наш аллокатор для объекта,
// только управляющий блок внутри shared_ptr будет отслеживаться
// (если использовать спец. конструктор)
// Для чистоты эксперимента просто сравним логику деструктора.
auto shared = std::shared_ptr<LargeObject>(new LargeObject());
observer = shared;
std::cout << " Resetting shared_ptr..." << std::endl;
shared.reset(); // Здесь объект удалится (delete) сразу!
std::cout << " shared_ptr is null now." << std::endl;
}
return 0;
}

В консоли можно увидеть 2 сценария: для allocate_shared и new:

-4

В первом сценарии вы видите [Deallocating] только когда исчезает последний "наблюдатель" .

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

Этот эксперимент наглядно показывает цену производительности:

  • make_shared экономит время на выделении памяти, но удерживает ее до тех пор, пока жив хотя бы один weak_ptr.
  • Если ваши объекты весят мегабайты, а weak_ptr живут долго (например, в глобальном кэше), используйте new или будьте готовы к повышенному потреблению RAM.

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

Давайте разберем, что именно происходит «под капотом». Когда вы добавляете в класс хотя бы одну виртуальную функцию, компилятор добавляет в объект скрытое поле — vptr (указатель на таблицу виртуальных функций). На 64-битной системе это прибавит 8 байт к размеру самого объекта. В отличие от обычных указателей, shared_ptr умеет правильно удалять объект даже если у базового класса нет виртуального деструктора.

Как это работает? Когда вы создаете shared_ptr<Base> p(new Derived()), управляющий блок запоминает тип Derived в момент создания. Внутри блока сохраняется указатель на функцию-удалитель (deleter), которая знает реальный размер и тип объекта.

Посмотрим на код, который измеряет размеры объектов и показывает магию удаления:
struct Base {
int x;
// Виртуального деструктора НЕТ!
~Base() { std::cout << "Base destroyed\n"; }
};

struct Derived : public Base {
int y;
~Derived() { std::cout << "Derived destroyed\n"; }
};

struct BaseWithVirtual {
int x;
virtual void foo() {} // Добавляем vptr
virtual ~BaseWithVirtual() = default;
};

int main() {
std::cout << "Size of Base: " << sizeof(Base) << " bytes\n";
std::cout << "Size of Derived: " << sizeof(Derived) << " bytes\n";
std::cout << "Size of BaseWithVirtual: " << sizeof(BaseWithVirtual) << " bytes (8 bytes for vptr)\n";

std::cout << "\n--- Testing Deleter Magic ---\n";
{
// У класса Base НЕТ виртуального деструктора.
// Обычный delete Base* удалил бы только часть объекта.
std::shared_ptr<Base> p = std::make_shared<Derived>();
std::cout << "Resetting shared_ptr<Base>...\n";
} // МАГИЯ: Сначала вызовется ~Derived(), а потом ~Base(),
// потому что shared_ptr запомнил тип при создании!
return 0;
}

Подводя резюме по размерам можно сказать следующее:

  1. Управляющий блок обычно занимает 16–24 байта (два счетчика по 8 байт + указатель на таблицу виртуальных функций самого блока или удалитель). Его размер фиксирован и не зависит от сложности вашего класса.
  2. sizeof(Base) = 4 байта (только int).
    sizeof(BaseWithVirtual) = 16 байт (4 байта int + 8 байт vptr + 4 байта выравнивание/padding).
  3. Сам std::shared_ptr весит 16 байт (8 на адрес объекта + 8 на адрес управляющего блока). Это так называемый «толстый указатель».

Если в программе миллионы маленьких объектов (например, хранящих int), накладные расходы shared_ptr (16 байт на указатель + ~24 байта на блок) будут в 10 раз больше, чем сами данные. В таких случаях лучше использовать массивы (std::vector) или пулы объектов.

Теперь рассмотрим unique_ptr. В отличие от shared_ptr он может не занимать лишней памяти (иметь такой же размер, как и T*), и не может так работать с деструкторами.

std::unique_ptr спроектирован по принципу «не плати за то, что не используешь». В отличие от shared_ptr, он не создает никаких управляющих блоков в куче. Почему unique_ptr весит как обычный указатель? В стандартной реализации sizeof(std::unique_ptr<T>) равен 8 байтам (на 64-битной системе), что идентично размеру сырого указателя T*. Это возможно потому, что:

  • Нет счетчиков. Владелец всегда один, считать нечего
  • Тип удалителя (deleter) является частью шаблонного класса unique_ptr и не хранится в памяти как объект. Разницу в объеме памяти, занимаемой объектами можно проиллюстрировать на примере простой программы:

int main() {
int* rawPtr = nullptr;
std::unique_ptr<int> uniquePtr;
std::shared_ptr<int> sharedPtr;

std::cout << "Raw pointer: " << sizeof(rawPtr) << " bytes" << std::endl; // 8
std::cout << "unique_ptr: " << sizeof(uniquePtr) << " bytes" << std::endl; // 8
std::cout << "shared_ptr: " << sizeof(sharedPtr) << " bytes" << std::endl; // 16
}

Почему unique_ptr не может работать с деструкторами как shared_ptr Помните, как shared_ptr<Base> мог правильно удалить Derived, даже если деструктор не был виртуальным? Это происходило потому, что в управляющем блоке в куче хранилась информация о типе. У unique_ptr нет блока в куче. Он полагается исключительно на статические типы во время компиляции. Проиллюстрируем сказанное на примере.

struct Base { ~Base() { std::cout << "~Base\n"; } };
struct Derived : Base { ~Derived() { std::cout << "~Derived\n"; } };
{
std::unique_ptr<Base> p = std::make_unique<Derived>();
}
// ВЫВОД: только "~Base"
// УТЕЧКА: Часть объекта Derived не будет удалена!

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

Если вы дадите unique_ptr пользовательский удалитель, например, лямбда-функцию, которая захватывает переменные, размер указателя вырастет, так как ему придется хранить этот объект внутри себя.

auto lambdaDeleter = [x = 10, y = 20](int* p) { delete p; };
std::unique_ptr<int, decltype(lambdaDeleter)> p(new int(5), lambdaDeleter);
std::cout << "Size with lambda: " << sizeof(p) << " bytes\n";

На моем ПК программа выдает следующий результат: Size with lambda: 16 bytes

Однако, если вы используете обычную функцию или пустую структуру (functor) в качестве удалителя, компилятор применит EBO (Empty Base Optimization), и размер указателя останется 8 байт.

Разницу в характеристиках unique_ptr и shred_ptr можно проиллюстрировать в виде следующей таблицы.

-5

Теперь разберем что значит тип удалителя (deleter) является частью типа указателя. Это означает, что информация о том, как именно нужно удалять объект (вызывать delete, free или закрывать дескриптор файла), указана в объявлении типа и известна на этапе компиляции. В C++ тип std::unique_ptr выглядит так:
template<typename T, typename Deleter = std::default_delete<T>> class unique_ptr;

Поскольку Deleter — это часть типа, вы не можете просто так присвоить один unique_ptr другому, если у них разные способы удаления, например:

void my_free(int* p) { free(p); }

// Тип 1: использует стандартный delete
std::unique_ptr<int> p1(new int(5));

// Тип 2: использует функцию my_free
std::unique_ptr<int, void(*)(int*)> p2((int*)malloc(4), my_free);

// p1 = std::move(p2); // ОШИБКА КОМПИЛЯЦИИ: это разные типы данных!

В std::shared_ptr удалитель НЕ является частью типа. Вы можете использовать разные удалители для std::shared_ptr. Так происходит потому, что способ удаления хранится внутри управляющего блока в куче и не является частью типа. Когда компилятор видит вызов деструктора unique_ptr, он выполняет следующие действия:

  1. Смотрит на тип указателя.
  2. Видит там конкретный удалитель (например, std::default_delete).
  3. Может встроить вызов этого удалителя прямо в код.

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

Если ваш удалитель — это класс без данных, например стандартный default_delete, C++ использует оптимизацию пустого базового класса. Благодаря тому, что тип известен заранее, компилятор понимает: "Хранить тут нечего, место под удалитель выделять не буду". Поэтому unique_ptr и занимает 8 байт.

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

В shared_ptr удалитель можно менять. Это дает гибкость, но требует больше памяти и больше времени на выполнение.

Чтобы пользовательский удалитель не занимал лишнего места, его нужно реализовывать как функтор, а не как указатель на функцию или лямбду с захватом. В этом случае сработает EBO (Empty Base Optimization), и ваш unique_ptr по-прежнему будет весить как обычный указатель (8 байт).

Покажем использование такого удалителя на примере.
// Удалитель как структура не содержащая данные
struct FileCloser {
void operator()(FILE* fp) const {
if (fp) {
std::cout << " Closing file via functor...\n";
fclose(fp);
}
}
};

int main() {
// Указываем тип удалителя в шаблоне
std::unique_ptr<FILE, FileCloser> uptr(fopen("test.txt", "w"));

std::cout << "Size of unique_ptr with functor: " << sizeof(uptr) << " bytes\n";
// Результат: 8 байт (на 64-битной системе)
return 0;
}

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

void close_file_func(FILE* fp) { fclose(fp); }

int main() {
// Тип удалителя — указатель на функцию
std::unique_ptr<FILE, void(*)(FILE*)> uptr(fopen("test.txt", "w"), close_file_func);
std::cout << "Size with function pointer: " << sizeof(uptr) << " bytes\n";
// Результат: 16 байт
}

Компилятор C++ видит, что FileCloser — это "пустой" тип (без данных). Поэтому не нужно хранить экземпляр FileCloser внутри указателя. Можно просто вызвать метод этого типа, когда придет время.

Когда это полезно в реальной жизни? Это идеальный способ оборачивать старые C-библиотеки (работа с файлами, сокетами, графическими контекстами или памятью через malloc/free).

Рассмотрим примеры.

//Определяем тип удалителя для закрытия файла
struct FileCloser {
void operator()(FILE* fp) const {
if (fp) {
fclose(fp);
std::cout << "File closed automatically.\n";
}
}
};

// Псевдоним типа для удобства
using UniqueFile = std::unique_ptr<FILE, FileCloser>;

// Функция-фабрика
UniqueFile openSecureFile(const char* filename) {
FILE* f = fopen(filename, "w");
if (!f) return nullptr;
// Возвращаем объект. Move-семантика сработает автоматически.
return UniqueFile(f);
}

int main() {
{
UniqueFile myFile = openSecureFile("data.txt");
if (myFile) {
fprintf(myFile.get(), "Hello, RAII!");
}
} // Файл закроется здесь сам
}

Если у вас много разных ресурсов (файлы, сокеты, память), вы можете создать один общий шаблон, чтобы не писать operator() каждый раз, например:

template <auto Fn>
struct ResourceDeleter {
template <typename T>
void operator()(T* ptr) const {
if (ptr) Fn(ptr);
}
};
// Теперь создание новых типов занимает одну строчку:
using SmartFile = std::unique_ptr<FILE, ResourceDeleter<fclose>>;
using SmartMalloc = std::unique_ptr<int, ResourceDeleter<free>>;

int main() {
SmartFile f(fopen("test.log", "a"));
SmartMalloc m((int*)malloc(sizeof(int)));
// Оба весят по 8 байт и используют свои функции очистки!
}

Замечания по коду:

  1. Используйте using, это скрывает детали реализации (какой именно удалитель используется) от пользователя вашей функции.
  2. Не возвращайте std::move(uptr): Просто пишите return uptr. Современные компиляторы применяют NRVO (оптимизацию возвращаемого значения), и лишний move может им только помешать.
  3. Для C-библиотек: Всегда проверяйте результат на nullptr перед тем, как оборачивать его в unique_ptr.

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

Это интересная задача, так как std::unique_ptr по умолчанию рассчитан на работу с указателями. Если ваш ресурс — это просто число int (как дескриптор файла или сокет в Linux), нам нужно немного схитрить.

Вот как сделать, чтобы unique_ptr занимал 8 байт и работал через RAII:

struct FileDescriptorDeleter {
struct pointer {
int fd;
pointer(int v) : fd(v) {}
pointer(std::nullptr_t) : fd(-1) {}
// unique_ptr использует этот оператор для проверки if (ptr)
bool operator!=(std::nullptr_t) const { return fd != -1; }
bool operator==(std::nullptr_t) const { return fd == -1; }
// Позволяет достать int через .get()
operator int() const { return fd; }
};

void operator()(pointer p) const {
if (p.fd != -1) close(p.fd);
}
};

using UniqueFd = std::unique_ptr<int, FileDescriptorDeleter>;

int main() {
UniqueFd empty;
std::cout << "Empty FD: " << (int)empty.get() << std::endl; // Выведет -1

UniqueFd fd(open("test.txt", O_CREAT | O_RDWR, 0644));
if (fd) {
std::cout << "Opened FD: " << (int)fd.get() << std::endl;
}
return 0; // Закроет только валидный FD
}

Внутри FileDescriptorDeleter необходимо определить структуру-обертку pointer над типом int, которая ведет себя как int и инициализируется значением -1. Эта структура должна имитировать поведение указателя. Для этого в ней необходимо объявить операторы == и !=. Внутри unique_ptr.h есть сравнение if (__ptr != nullptr). Компилятор увидит оператор != и успешно скомпилирует код. operator() используется при удалении unique_ptr для закрытия файла.

На что стоит обратить внимание:

  1. Отсутствие значения. По умолчанию unique_ptr считает, что отсутствие значения — это nullptr (или 0 для int). Но дескрипторы часто возвращают -1 при ошибке. Самый правильный способ — создать обертку типа pointer, которая инициализируется значением -1.
  2. Размер. Такой UniqueSocket будет занимать 4 или 8 байт (в зависимости от выравнивания), так как внутри он хранит только один int.

Такую технику можно использовать с WinAPI, а также при работе с OpenGL, где ID текстур и буферов — это числа типа GLuint. Такие ресурсы нужно удалять через glDelete.