Skip to content

Latest commit

 

History

History
151 lines (107 loc) · 12 KB

shared_ptr_constructor.md

File metadata and controls

151 lines (107 loc) · 12 KB

Конструкторы std::shared_ptr

С появления C++11 прошло уже больше 10 лет, так что большинство C++-разработчиков уже все-таки знают про умные указатели. Отдаешь им владение сырым указателем и спишь спокойно — память будет освобождена. И все хорошо.

И даже разницу между std::unique_ptr и std::shared_ptr они знают. Хотя, конечно, у меня был пару лет назад кандидат на собеседовании, который этой разницы не знал, потому что не пользовался STL...

Некопируемый, уникально владеющий std::unique_ptr просто хранит указатель (и, возможно, функцию очистки — deleter) и в своем деструкторе этот deleter вызывает против сохраненного указателя. std::shared_ptr же хитрее, и для поддержания разделяемого владения между копиями ему нужен счетчик ссылок. Ну это все знают. Ничего интересного.

Давайте просто пользоваться и не думать.

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

Простейший пример: нам нужен потокобезопасный объект, который будет внутри защищен мьютексом/атомарными переменными, и мы хотим, чтоб этот объект был свободно перемещаем из контейнера в контейнер, со стека в контейнер и обратно. А std::mutex и std::atomic конструкторов перемещения не имеют. И у нас два варианта действий в этом случае:

class MyComponent1 {
    ComponentData data_;
    // сделать неперемещаемое поле перемещаемым, добавив к нему
    // слой индирекции и отправив данные в кучу
    std::unique_ptr<std::mutex> data_mutex_;
};

// как-то заставить пользователей этого класса создавать объекты только на куче
// и работать с std::unique_ptr<MyComponent2> или std::shared_ptr<MyComponent2>
class MyComponent2 {
    ComponentData data_;
    std::mutex data_mutex_;
};

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

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

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

class MyComponent {
public:
    static auto make(Arg1 arg1, Arg2 arg2) -> std::shared_ptr<MyComponent> {
        // ???
    }

    // баним конструкторы копирования и перемещения, чтоб случайно не вытянуть
    // данные объекта в экземпляр на стеке
    MyComponent(const MyComponent&) = delete;
    MyComponent(MyComponent&&) = delete;
    // и этих друзей тоже баним, но это уже не обязательно
    MyComponent& operator = (const MyComponent&) = delete;
    MyComponent& operator = (MyComponent&&) = delete;

private:
    MyComponent(Arg1, Arg2) { ... };
    ...
};

Пойдем внутрь этой фабричной функции make. Обычно в этом месте выясняется, что опытный C++-разработчик на самом деле не очень опытный. Но это ему никак не мешает. Да и вообще редко кому мешает.

Можно попробовать написать эту функцию так

auto MyComponent::make(Arg1 arg1, Arg2 arg2) -> std::shared_ptr<MyComponent> {
    return std::make_shared<MyComponent>(std::move(arg1), std::move(arg2));
}

Но нас сразу же ждет разочарование в полсотни строк ошибок — std::make_shared не может вызвать наш приватный конструктор!

Не беда! И наш C++ разработчик, не сильно напрягаясь, исправляет ошибку

auto MyComponent::make(Arg1 arg1, Arg2 arg2) -> std::shared_ptr<MyComponent> {
    return std::shared_ptr<MyComponent>(new MyComponent(std::move(arg1), std::move(arg2)));
}

Код компилируется, работает. Все свободны?

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

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

При использовании std::make_shared контрольный блок выделяется рядом с создаваемым объектом. То есть выделяется один кусок памяти на как минимум sizeof(MyComponent) + 2 * sizeof(size_t). Это поведение рекомендовано стандартом, но не обязательно. Тем не менее все известные имплементации следуют рекомендации.

При вызове конструктора std::shared_ptr от сырого указателя объект уже как бы создан и контрольный блок рядом с ним не запихнуть. Поэтому будет выделено еще как минимум 2 * sizeof(size_t) памяти. Где-то в другом месте. И тут в ход идут детали реализации аллокаторов, а также пляски с выравниваниями. И в действительности выделяется не sizeof(MyComponent) + 2 * sizeof(size_t), а больше. И в случае прямого вызова конструктора от сырого указателя — значительно больше. Ну а также при расположении контрольного блока рядом с данными иногда начинает заметно играть локальность данных и выигрыш от попадания в кэш. Но это все если объект маленький.

А если большой?

А если большой, вы создавали его через std::make_shared, а потом плодили std::weak_ptr, у вас может начать происходить что-то очень похожее на утечку памяти. Хотя объекты исправно умирают и деструкторы вызываются. Вы же видели это в логе!

Опять-таки: контрольный блок. Если у вас есть живые weak_ptr, привязанные к уже отмершим std::shared_ptr, контрольный блок продолжает жить. Ну чтоб вы могли вызвать std::weak_ptr::expired(), и он вам бы сказал true. Но если контрольный блок сидел в одном куске памяти с умершим объектом, а именно так и получается при создании через std::make_shared, кусок памяти из-под объекта операционной системе возвращаться не будет, пока не помрет сам контрольный блок! Вот вам и утечки.

Также есть разница в том, какой именно operator new будет вызываться. std::make_shared всегда вызывает глобальный. И если вы перегрузили new для своего типа, поведение может быть не тем, что вы бы ожидали.

Все, как обычно, плохо.

Так что же делать если нам очень надо для своего компонента все-таки выполнить одну аллокацию и потенциально сэкономить? Есть ли решение?

Конечно! В C++ всегда есть какое-нибудь страшное решение. Иногда даже без неопределенного поведения. И это даже наш случай.

Есть access token техника, с помощью которой можно осуществить задуманное:

Надо предоставить для std::make_shared публичный конструктор, но который можно вызвать, только имея экземпляр приватного типа (access token)

class MyComponent {
private:
    // access token
    struct private_ctor_token {
        // только MyComponent cможет их создавать, явно обращаясь к конструктору по-умолчанию
        explicit private_ctor_token() = default;
    };
public:
    static auto make(Arg1 arg1, Arg2 arg2) -> std::shared_ptr<MyComponent> {
        return std::make_shared<MyComponent>(private_ctor_token{}, std:: move(arg1), std::move(arg2));
    }


    // этот конструктор приватный, хотя и в публичной секции -- его никто не сможет вызвать, 
    // не имея доступа к приватному токену
    MyComponent(private_ctor_token, Arg1, Arg2) { ... };

    ...

};

И работает.

Стоит обратить внимание, что конструктор токена должен быть помечен как explicit, иначе всю нашу систему безопасности с приватным типом легко обойти вот так:

int main() {
    MyComponent c({}, // создаем приватный токен, не называя его!
                      // У нас нет доступа только к имени 
                  {}, {});
}

Полезные ссылки

  1. https://en.cppreference.com/w/cpp/memory/shared_ptr/make_shared
  2. https://habr.com/ru/post/509004/