Почему 'std::make_shared' всегда использует выделение глобальной памяти даже с перегруженными операторами new/delete класса?

При использовании std::make_shared<C> перегруженные операторы new/delete класса не вызываются.

При использовании std::shared_ptr<C>, std::unique_ptr<C> и std::make_unique<C> используются перегруженные операторы new/delete класса.

Если посмотреть на документацию, это совершенно правильно и хорошо документировано.

cppreference объясняет поведение:

std::make_shared использует ::new, поэтому, если какое-либо специальное поведение было настроено с использованием специфичного для класса оператора new, оно будет отличаться от std::shared_ptr<T>(new T(args...)).

Ниже приведен некоторый псевдокод, чтобы лучше выделить поведение:

#include <memory>

class C {
 public:
  void* operator new(size_t size) {
    void* p = ::operator new(size);
    std::cout << "C::new() -> " << p << "\n";
    return p;
  }

  void operator delete(void* p) {
    std::cout << "C::delete() -> " << p << "\n";
    ::operator delete(p);
  }
};

std::shared_ptr<C> ptr = std::make_shared<C>();

С внешней точки зрения это кажется непоследовательным и подверженным ошибкам. Всегда следует использовать операторы new/delete класса перегрузки.

Итак, в чем причина такого поведения?

И где спецификация C++ с подробным описанием поведения std::make_shared?

Спасибо за вашу помощь.


person Patrick Hodoul    schedule 13.09.2019    source источник
comment
Они добавили std::allocate_shared для этого   -  person Cory Kramer    schedule 13.09.2019


Ответы (3)


Итак, в чем рациональность поведения?

Причина, по которой это делается, заключается в том, что make_shared не просто выделяет ваш объект, но также выделяет блок управления shared_ptr. Чтобы сделать это максимально эффективным, он вызывает new один раз и выделяет достаточно памяти для блока управления и объекта за один раз. В противном случае ему пришлось бы вызывать new дважды, что удваивает накладные расходы на выделение.

Если вы хотите использовать собственный распределитель, вам нужно использовать std::allocate_shared. и он будет использовать ваш пользовательский распределитель, чтобы сделать одно выделение памяти для создания файла shared_ptr.


Другой вариант — использовать std::make_unique для создания unique_ptr, а затем использовать его для инициализации shared_ptr. Это работает, потому что unique_ptr не имеет управляющего блока, поэтому std::make_unique выделяет в виде

unique_ptr<T>(new T(std::forward<Args>(args)...))

Это даст вам

std::shared_ptr<C> ptr = std::make_unique<C>();

который выводит

C::new() -> 0xf34c20
C::delete() -> 0xf34c20
person NathanOliver    schedule 13.09.2019
comment
Иногда C++ просто заставляет вас стонать от сильной боли. - person einpoklum; 13.09.2019
comment
@einpoklum По большей части это безболезненно. Это еще одна причина не перегружать new и delete для вашего типа. Если они действительно хотят, они могут использовать unique_ptr (добавлю это к ответу через минуту). - person NathanOliver; 13.09.2019
comment
Людям больно осознавать разницу между make_shared и allocate_shared... - person einpoklum; 13.09.2019
comment
Альтернатива с make_unique ничем не лучше, чем просто использовать new для инициализации shared_ptr. - person SergeyA; 13.09.2019
comment
@SergeyA У него есть одно важное преимущество: мне не нужно было набирать new, чтобы заставить его делать то, что хотел ОП. Мне нравится не набирать new :-) - person NathanOliver; 13.09.2019
comment
Я могу это понять. Тем не менее, new по-прежнему будет поражать вас, когда вы используете его для нового размещения. Жизнь несправедлива. - person SergeyA; 13.09.2019

Непосредственной причиной такого поведения является тот факт, что std::make_shared выполняет однократное выделение для выделения как управляющего блока, так и объекта. У него нет другого выбора, кроме как использовать для этого глобальный оператор new.

Также хочу отметить, что лично я считаю возможность перегружать операторы new/delete для класса одной из самых непродуманных фич C++. Класс не должен предписывать методы распределения своей памяти. Скорее, выделение класса должно быть делегировано конкретной задаче, а хорошо спроектированный класс должен вести себя одинаково хорошо, независимо от того, размещен ли он в динамическом хранилище, в автоматическом хранилище, в файле с отображением памяти или на флэш-карте.

person SergeyA    schedule 13.09.2019
comment
У вас случайно нет ссылки на какое-то обсуждение этой функции? - person einpoklum; 13.09.2019
comment
@einpoklum, вы имеете в виду перегрузку нового/удаления для класса? У меня нет ссылки, это мое личное мнение. - person SergeyA; 13.09.2019

Другие ответы объясняют, почему std::make_shared не использует специфичные для вашего класса operator new и operator delete. Это просто примечание.

Если вы хотите использовать ссылочные указатели счетчиков с вашим объектом и использовать выделение/освобождение вашего класса, другим вариантом является использование boost::intrusive_ptr.

#include <iostream>
#include <boost/intrusive_ptr.hpp>
#include <boost/smart_ptr/intrusive_ref_counter.hpp>

class C : public boost::intrusive_ref_counter<C, boost::thread_safe_counter> {
public:
    static void* operator new(size_t size) {
        void* p = ::operator new(size);
        std::cout << "C::new() -> " << p << "\n";
        return p;
    }

    static void operator delete(void* p) {
        std::cout << "C::delete() -> " << p << "\n";
        ::operator delete(p);
    }
};

int main() {
    boost::intrusive_ptr<C> c(new C);
}

Недостатки boost::intrusive_ptr:

  • Это навязчиво. Класс должен иметь счетчик ссылок в качестве одного из своих членов или быть производным от boost::intrusive_ref_counter. Вам не обязательно менять определение класса, можно использовать класс-оболочку со счетчиком, производным от вашего класса (но при этом снова теряются функции распределения/освобождения класса).
  • Нет слабых указателей.

Преимущества:

  • sizeof(boost::intrusive_ptr<T>) == sizeof(T*), тогда как sizeof(std::shared_ptr<T>) == 2 * sizeof(T*).
  • У вас есть выбор потокобезопасного или небезопасного счетчика ссылок. Для объектов, которые никогда не пересекают границы потоков, использование безопасного для потоков счетчика расточительно: атомарные приращения и декременты являются наиболее затратными (как и все атомарные операции чтения-модификации-записи). std::shared_ptr<T> всегда использует 2 (два) потокобезопасных счетчика в многопоточных приложениях. В хорошо разработанных приложениях только объекты нескольких классов сообщений пересекают границы потоков.
  • Счетчик ссылок хранится внутри вашего объекта, что является лучшим случаем с точки зрения локальности ссылки / наиболее удобного для кэширования. std::make_shared делает это и за вас, но вы теряете функции выделения/освобождения своего класса.
  • Может быть назначен и заменен атомарным способом без ожидания.
person Maxim Egorushkin    schedule 13.09.2019
comment
Это хороший ответ, но не на вопрос OP, а именно: «Почему XYZ…». Подумайте о том, чтобы задать дополнительный вопрос о преодолении этого ограничения, опубликовать свой ответ там и комментарий здесь, указывающий на людей там. Обещаю плюс :-) - person einpoklum; 13.09.2019
comment
@einpoklum Это спекулятивный ответ с Другие ответы объясняют, почему... / Если вы хотите использовать указатели на ссылки... предостережение. Я мог бы задать вопрос и сам на него ответить, но это не мой стиль. - person Maxim Egorushkin; 13.09.2019
comment
Я понимаю, но другие люди, скорее всего, пропустят ваш ответ, если он находится под вопросом «Почему». - person einpoklum; 13.09.2019
comment
@einpoklum Я понимаю и спасибо за подсказку, но я просто люблю делиться, когда захочу. - person Maxim Egorushkin; 13.09.2019