Допустим, у нас есть следующий код:

LegacyList* pMyList = new LegacyList();
...
pMyList->ReleaseElements();
delete pMyList;

Чтобы полностью удалить объект, нам нужно выполнить некоторые дополнительные действия

Как это сделать в стиле C++11? Как здесь использовать unique_ptr или shared_ptr ?

Введение

Все мы знаем, что смарт поинтеры (или умные указатели) - это очень хорошие штуки, и нам следует использовать их вместо грубых new и delete. Но что, если удаление указателя - это далеко не все, что нам нужно сделать для полного уничтожения объекта? В нашем коротком примере мы должны вызвать ReleaseElements(), чтобы полностью очистить список.

Примечание: мы могли бы просто переработать LegacyList, чтобы он высвобождал свои данные внутри своего деструктора. Но в целях этой статьи предположим, что LegacyList нельзя изменить (это какой-то трудно исправляемый легаси код или он из сторонней библиотеки).

ReleaseElements - это чисто мой ситуативный пример для этой статьи. Вместо этого здесь могут быть замешаны и другие вещи: логирование, закрытие файла, завершение соединения, возврат объекта в C-подобную библиотеку… или вообще: любая процедура высвобождения ресурсов, RAII.

Чтобы в большей степени погрузиться в контекст моего примера, давайте обсудим следующее использование LegacyList:

class WordCache {
public:
    WordCache() { m_pList = nullptr; }
    ~WordCache() { ClearCache(); }

    void UpdateCache(LegacyList *pInputList) { 
        ClearCache();
        m_pList = pInputList;
        if (m_pList)
        {
            // что-то делаем со списком...
        }
    }

private:
    void ClearCache() { 
        if (m_pList) { 
            m_pList->ReleaseElements();
            delete m_pList; 
            m_pList = nullptr; 
        } 
    }

    LegacyList *m_pList; // принадлежит объекту

};

Потрогать исходный код вы можете здесь: с помощью онлайн-компилятора Coliru.

Это немного старомодный C++ класс. Класс владеет указателем m_pList, поэтому его нужно обнулять в конструкторе. Чтобы облегчить нам жизнь, существует метод ClearCache(), который вызывается из деструктора или из UpdateCache().

Основной метод UpdateCache() принимает указатель на список и получает его в свое распоряжение. Указатель удаляется в деструкторе или при повторном обновлении кэша.

Самый банальный пример использования:

WordCache myTestClass;

LegacyList* pList = new LegacyList();
// заполняем список...
myTestClass.UpdateCache(pList);

LegacyList* pList2 = new LegacyList();
// снова заполняем список
// pList должен быть удален, pList2 переходит в наше распоряжение
myTestClass.UpdateCache(pList2);

В приведенном выше коде не должно быть утечек памяти, но нам нужно внимательно следить за тем, что происходит с указателем pList. Это определенно не похоже на современный C++!

Давайте обновим немного модернизируем код, чтобы он правильно использовал RAII (в данном случае смарт аоинтеры). Использование unique_ptr или shared_ptr кажется простым, но здесь возникает одна небольшая сложность: как выполнить дополнительный код, необходимый для полного удаления LegacyList ?

Что нам нужно, так это кастомный делитер (Custom Deleter).

Custom Deleter для shared_ptr

Я начну с shared_ptr, потому что этот тип указателя более гибок и прост в использовании.

Что нам нужно сделать, чтобы использовать кастомный делитер? Просто передайте его при создании указателя:

std::shared_ptr<int> pIntPtr(new int(10), 

    [](int *pi) { delete pi; }); // делитер

Приведенный выше код довольно тривиален и по большей части избыточен. На самом деле, это более или менее дефолтный делитер, потому что он просто вызывает delete для указателя. Но вы можете передать любую вызываемую вещь (лямбда, функтор, указатель на функцию) в качестве делитера при создании shared_ptr.

В случае LegacyList давайте создадим функцию:

void DeleteLegacyList(LegacyList* p) {

    p->ReleaseElements(); 

    delete p;

}

Модернизированный класс теперь выглядит очень лаконично:

class ModernSharedWordCache {

public:

    void UpdateCache(std::shared_ptr<LegacyList> pInputList) { 

        m_pList = pInputList;

        // что-то делаем со списком...

    }

private:

    std::shared_ptr<LegacyList> m_pList;

};
  • Нет необходимости в конструкторе - указатель по умолчанию инициализируется значением nullptr.

  • Нет необходимости в деструкторе - указатель очищается автоматически.

  • Нет необходимости в ClearCache - просто сбросьте указатель, и вся память и ресурсы будут высвобождены должным образом.

При создании указателя нам необходимо передать эту функцию:

ModernSharedWordCache mySharedClass;

std::shared_ptr<LegacyList> ptr(new LegacyList(),

                                DeleteLegacyList)

mySharedClass.UpdateCache(ptr);

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

Где хранится кастомный делитер?

Когда вы используете кастомный делитер, это не отражается на размере вашего shared_ptr. Если вы помните, он должен быть примерно 2 x sizeof(ptr) (8 или 16 байт)... так где же этот делитер прячется?

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

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

Могу ли я использовать make_shared?

К сожалению, вы можете передать кастомный делитер только в конструкторе shared_ptr, и нет возможности использовать make_shared. Это может быть небольшим недостатком, потому что, как я описал в Зачем создавать shared_ptr с make_shared? - make_shared аллоцирует объект и его блок управления рядом друг с другом в памяти. Без make_shared вы, вероятнее всего, получите два отдельных блока выделенной памяти.

Апдейт: я получил очень хороший комментарий на Reddit от quicknir, который заметил, что я ошибаюсь в этом вопросе, и есть кое-что, что вы можете использовать вместо make_shared.

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

Custom Deleter для unique_ptr

С unique_ptr дела обстоят немного сложнее. Самое главное - что тип делитера будет частью типа unique_ptr .

По умолчанию мы получаем std::default_delete:

template <
    class T,
    class Deleter = std::default_delete<T>
> class unique_ptr;

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

Что выбрать в качестве делитера?

Что лучше всего использовать в качестве делитера? Рассмотрим следующие варианты:

  1. std::function

  2. Указатель на функцию

  3. Функтор, не захватывающий состояние

  4. Функтор, захватывающий состояние

  5. Лямбда

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

А вы можете догадаться? (Ответ в конце статьи)

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

Для нашего примера воспользуемся функтором:

struct LegacyListDeleterFunctor {  

    void operator()(LegacyList* p) {

        p->ReleaseElements(); 

        delete p;

    }

};

А вот использование в обновленном классе:

class ModernWordCache {

public:

    using unique_legacylist_ptr = 

       std::unique_ptr<LegacyList,  

          LegacyListDeleterFunctor>;

public:

    void UpdateCache(unique_legacylist_ptr pInputList) { 

        m_pList = std::move(pInputList);

        // что-то делаем со списком...

    }

private:

    unique_legacylist_ptr m_pList;

};

Код немного сложнее, чем версия с shared_ptr - нам нужно определить правильный тип указателя. Ниже я покажу, как использовать этот новый класс:

ModernWordCache myModernClass;
ModernWordCache::unique_legacylist_ptr pUniqueList(new LegacyList());
myModernClass.UpdateCache(std::move(pUniqueList));

Все, что нам нужно запомнить, поскольку это уникальный указатель, - это то, что мы перемещаем указатель, а не копируем его.

Могу ли я использовать make_unique?

Так же, как и в случае с shared_ptr, вы можете передать кастомный делитер только в конструкторе unique_ptr, и, следовательно, вы не можете использовать make_unique. К счастью, make_unique используется только для удобства (неправильно!) и не дает никаких преимуществ в производительности/памяти по сравнению с обычной конструкцией.

Апдейт: я был слишком самоуверен в отношении make_unique :) У таких функций всегда есть цель. Посмотрите здесь GotW #89 Solution: Smart Pointers - вопрос гуру 3:

make_unique важен, потому что:

Во-первых:

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

Во-вторых: make_unique обеспечивает безопасность исключений: Exception safety and make_unique

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

Если вас интересуют смарт поинтеры - взгляните на мою удобную справочную карту. Она охватывает все, что вам нужно знать об unique_ptr, shared_ptr и weak_ptr, красиво оформлено в PDF-файле:

Загрузите бесплатную копию моей справочной карты по смарт поинтерам C++!

Что следует помнить:

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

Заключение

В этой статье я показал вам, как использовать кастомные делитеры со смарт поинтерами C++: shared_ptr и unique_ptr. Эти делитеры можно использовать везде, где “нормального” delete ptr недостаточно: когда вы оборачиваете FILE*, используете какие-нибудь C-подобные структуры (SDL_FreeSurface, free(), destroy_bitmap из библиотеки Allegro и т. д.).

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

Gist с кодом находится здесь: fenbf/smart_ptr_deleters.cpp

Для более практических примеров делитеров вы можете почитать эту статью: Wrapping Resource Handles in Smart Pointers.

  • Дайте мне знать, каковы ваши самые частые затруднения со смарт поинтерами?

  • Что мешает вам их использовать?

Ссылки


Ответ на вопрос о размере указателя:
  1. std::function - тяжелая штука, на x64, gcc мне показала 40 байт.

  2. Указатель на функцию - это просто указатель, поэтому теперь unique_ptr содержит два указателя: для объекта и для этой функции… так что 2 * sizeof(ptr) = 8 или 16 байт.

  3. Функтор без состояния (а также лямбда без состояния) - это на самом деле очень крутая штука. Вы, наверное, сказали бы: размер равен размеру двух указателей… но это не так. Благодаря empty base optimization - EBO окончательный размер равен размеру одного указателя, так что это минимально возможный вариант.

  4. Функтор с состоянием - если внутри функтора есть какое-то состояние, то мы не можем делать никаких оптимизаций, поэтому размер будет ptr + sizeof(functor)

  5. Lambda (statefull) - аналогично функтору с состоянием


Перевод статьи подготовлен в преддверии старта курса C++ Developer. Professional.

Комментарии (3)


  1. Playa
    26.12.2021 23:55
    +23

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

    Макс, хватит. Не лезь больше с переводами в хаб C++, у тебя не получается.


    1. in_heb
      27.12.2021 00:05
      +6

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


  1. Chaos_Optima
    28.12.2021 14:29

    жесть какая, не хватает статей по типу 1+1