Указатели в C++

С точки зрения спецификации языка существует только 1 вид указателя - сырой (обычный, raw). Однако для удобства управления памятью и временем жизни объектов, созданных в динамической памяти, стандартная библиотека предоставляет несколько типов умных указателей. В данной статье будут рассмотрены области применения и внутреннее устройство обычных и умных указателей.

  • T* - сырой указатель на тип T
  • std::unique_ptr - умный указатель с эксклюзивным владением
  • std::shared_ptr - умный указатель с совместным владением
  • std::weak_ptr - умный указатель, не поддерживающий время жизни объекта

Сырые указатели

Когда использовать

  • При взаимодействии с чистым C
  • В низкоуровневых структурах данных, требующих высокой производительности

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

Как устроен

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

int x = 1; 
int* ptr = &x; // указатель на x

std::cout << *ptr; // *ptr - разыменовывание; в stdout выведется 1 (значение x)

Что можно делать

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

ВАЖНО: Арифметика допускается только если новое значение указателя указывает на тот же объект/массив (любую его часть). Выходить за пределы памяти объекта путем арифметики (кроме past-the-end итератора) - UB. Более того, UB тут даже сама арифметика, до разыменования. То же самое касается сравнения. Оно допускается только в том случае, если оба указателя указывают на части одного и того же объекта (массива). Иначе UB.

int arr[3] = {1, 2, 3};
int* ptr1 = arr;
int* ptr2 = arr + 1; 

auto next = ptr1 + 1; // OK: next указывает на область памяти находящуюся через sizeof(int) байтов после той, что хранится в ptr1
auto prev = ptr2 - 1; // OK: prev указывает на область памяти находящуюся за sizeof(int) байтов до той, что хранится в ptr2
auto end = arr + 3; // OK: end - указатель на следующий элемент после последнего. Допускается!

auto distance = ptr2 - ptr1; // OK: distance покажет количество int элементов между ptr2 и ptr1

auto greater = ptr1 > ptr2; // OK: 
auto less = ptr1 < ptr2; // OK
auto equal = ptr1 == ptr2; // OK: важно отметить, что сравниваются адреса, а не инты, на которые они указывают.

auto valid = ptr1 != nullptr; // OK: проверка на указание на валидную область. Если указатель == nulptr, разыменование его может привести к крашу программы.

Что нельзя делать

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

auto sum = ptr1 + ptr2; // won't compile

Еще одно исключение связано с типом void. Арифметика на указателях на void не допускается. Причина проста: любая арифметика использует размер типа, на который указывает указатель. Void это тип-placeholder, его используют, когда настоящий тип не известен. Не зная размера типа, невозможно корректно задать операции арифметики.

void* ptr = &x; 
auto next = ptr + 1; // won't compile
int x = 1;
int y = 2;
int* px = &x;
int* py = &y;

auto prev = px - 1; // UB: выход за рамки объекта
auto past2 = px + 2; // UB: выход за рамки объекта (+1 ок, +2 уже нет)

auto greater = px > py; // UB
auto less = px < py; // UB
auto equal = px == py // UB

Инициализация

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

restrict

В стандарте C++ не прописано ключевое слово restrict, в отличии от C. Однако, например, gcc поддерживает restrict. Идея в том, что если указатель отмечен этим ключевым словом, то программист дает гарантию компилятору, что никакой другой указатель в программе не указывает на ту же область памяти. Это позволяет применить дополнительные оптимизации. Нарушение "контракта" приводит к UB.

Что может пойти не так

  • Арифметика приводящая к UB
  • Висячий (dangling) указатель: когда память освободили, а указатель все еще на нее указывает
  • Указатель который привели к типу, не соответствующему объекту, на который он указывает

std::unique_ptr

Когда использовать

  • Для выражения семантики эксклюзивного владения динамически выделенной памятью
  • Так же можно использовать для менеджмента любого ресурса благодаря возможности задать кастомную функцию удаления (deleter)

Как использовать

// создание unique_ptr через factory-функцию
{
    auto ptr = std::make_unique<int>(123);
} // в конце области {}, в которой создан ptr, unique_ptr будет удален вместе с выделенной памятью

// то же самое, но с прямым вызовом конструктора
auto ptr = std::unique_ptr<int>(new int(123)); 
auto null = std::unique_ptr<int>(nullptr); // пустой unique_ptr

// использование unique_ptr как обертки над файлом - семантика как с памятью
auto deleter = [](FILE* file) { close_file(file); };
auto file = std::unique_ptr<FILE, decltype(deleter)>(open_file(), deleter);

auto moved = std::move(ptr); // OK: передача владение ресурсом от ptr к moved
auto copied = moved; // BAD: не скомпилируется, тк копировать unique_ptr нельзя
{
    auto ptr = std::make_unique<int>(123);
    auto released = ptr.release(); // ptr отдает владение указателем и сам становиться null

    // ptr.get() == nullptr
    delete released; // иначе утечка, ведь мы забрали владение из unique_ptr
}

auto ptr = std::make_unique<int>(123);
auto null = std::unique_ptr<int>(nullptr); 

auto ptr2bool = bool(ptr); // true, потому что в ptr не нулевой указатель
auto null2bool = bool(null); // false, потому что в null невалидный указатель

Как устроен

В unique_ptr всегда хранится 1 обычный указатель, но так же может хранится кастомная функция удаления (deleter). Если deleter без состояния (обычная функция, лямбда без захваченных переменных, пустая структура), то он может не занимать место в памяти - вся информация будет хранится в типе, а не инстансе объекта. Однако если deleter с состоянием, то размер unique_ptr увеличиться на размер делитера.

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

Инициализация

  • Всегда предпочитайте использовать std::make_unique, а не выделять память отдельно и передавать в unique_ptr при конструкции. Это не всегда возможно, например когда конструктор типа, на который указывает unique_ptr, приватный.
  • Не стоит использовать unique_ptr как optional, всегда инициализируйте его объектом. С кодом, где unique_ptr не может быть нулевым, работать проще и шанс совершить ошибку (разыменовать null) ниже.

Что может пойти не так

  • Разыменование unique_ptr, который еще не проинициализировали валидным указателем
  • Разыменование unique_ptr, из которого уже переместили его ресурс

std::shared_ptr

Когда использовать

  • Для выражения семантики совместного владения
  • Для контроля времени жизни объекта, который используется из разных компонент (или даже потоков)

Как использовать

// создание shared_ptr через factory-функцию
{
    auto ptr = std::make_shared<int>(123);
} // в конце области {}, в которой создан ptr, shared_ptr будет удален вместе с выделенной памятью

// то же самое, но с прямым вызовом конструктора
auto ptr = std::make_shared<int>(new int(123)); 
auto null = std::make_shared<int>(nullptr); // пустой shared_ptr

// использование shared_ptr как обертки над файлом - все как у unique_ptr
auto deleter = [](const FILE* file) { close_file(file); };
auto some_huge_file = std::shared_ptr<const FILE>(open_file(), deleter);

auto moved = std::move(ptr); // OK: передача владение ресурсом от ptr к moved
auto use_count = moved.use_count() // use_count - количество сильных ссылок. В данном случае 1, тк ptr обнулен и сильная ссылка только в moved
auto copied = moved; // OK: в отличии от unique_ptr, копировать можно
auto use_count_after_copy = copied.use_count() // 2: moved & copied

Как устроен

shared_ptr состоит из двух обычных указателей. Первый, как и в случае с unique_ptr, указывает на память хранимого объекта. А второй на контрольный блок, в котором хранятся два счетчика ссылок (слабых и сильных), а так же deleter.

Счетчики ссылок работают следующим образом:

  1. При создании не нулевого shared_ptr, оба инкрементируются до 1
  2. При копировании (не внутреннего объекта, а shared_ptr!) инкрементируются еще на единицу (в итоге количество копий равно значению счетчика)
  3. При вызове деструктора shared_ptr (опять же, не объекта, на который он указывает, а указателя!) оба счетчика декрементируются на единицу
  4. Если в деструкторе, после декремента, значение сильного счетчика равно 0, то удаляется сам объект. Если же значение слабого счетчика равно 0, то удаляется контрольный блок.

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

Счетчики ссылок потокобезопасны. Это значит, что можно хранить указатели на один объект в разных потоках, и никаких утечек или UB не будет. Однако это не значит, что сам доступ к хранимому объект потокобезопасен - тут уже синхронизируйте сами.

Логика кастомного делитера такая же, как у unique_ptr. Однако deleter у shared_ptr не является частью типа самого указателя. Причина этого в том, что у unique_ptr deleter хранится в теле указателя, а у shared_ptr в отдельном аллоцируемом блоке.

Инициализация

  • Всегда предпочитайте make_shared. Помимо стиля, есть и практические причины: в таком случае память под объект и контролируемый блок будут выделены в одном месте. Соответственно это 1 аллокация, а не 2, и лучшая локальность (кэш процессора вам будет благодарен). Кстати, это обожают спрашивать интервьюверы, так что запоминайте : )
    • Есть так же аргумент в пользу большей exception safety, но он актуален только до C++17. При вызове функции f(std::shared_ptr(new int(42)), g()), порядок исполнения может быть следующим: 1. new int(42); 2. g(). Если g() выкинет исключение, то будет утечка памяти. В этом примере использование make_shared решает проблему.
    • Единственный минус - если область памяти общая, то даже 1 слабой ссылки, без сильных, достаточно чтобы не было деаллокаций (объект будет уничтожен, но память останется выделена). Для больших объектов это может быть ощутимо.
  • Так же не советую инициализировать нулем, как и unique_ptr.

Что может пойти не так

  • Разыменование shared_ptr, который еще не проинициализировали валидным указателем
  • Разыменование shared_ptr, из которого уже переместили его ресурс
  • Создание циклических ссылок, приводящих к невозможности освобождения памяти.
// Пример циклической ссылки
struct B;
struct A {
    std::shared_ptr<B> b;
};

struct B {
    std::shared_ptr<A> a;
};

{
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();

    a->b = b;
    b->a = a;
} // при выходе из области будут уничтожены указатели a & b, однако в объектах хранятся указатели друг на друга. Если явно не обнулить один из них, то эти объекты будут жить до завершения программы

std::weak_ptr

Когда использовать

  • Для избежания циклических ссылок между shared_ptr. Самый простой пример - имплементация enable_shared_from_this.

Как использовать

auto ptr = std::make_shared<int>(123);
auto weak = std::weak_ptr<int>(ptr); // Добавляем слабую ссылку

auto copy = weak; // OK
auto moved = std::move(weak); // OK

auto expired = copy.expired(); // если объект уже удален (сильных ссылок нет), то true
auto shared = copy.lock(); // если есть хоть одна сильная ссылка, то мы можем создать shared_ptr из weak_ptr. Если сильный ссылок нет, то lock вернет пустой shared_ptr, поэтому надо всегда его проверять

Как устроен

Устройство, обычно, как у shared_ptr. Разница только в том, что инкремент и декремент при создании и уничтожении происходит только у слабой ссылки.

Что может пойти не так

  • Использование shared_ptr из lock() без проверки на валидность

Еще контент

Подписывайтесь на мой телеграм канал, я выкладываю новости из мира C++ и авторские статьи по типу этой: t.me/krestovii_podhod