С появления 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({}, // создаем приватный токен, не называя его!
// У нас нет доступа только к имени
{}, {});
}