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

Я довольно далеко продвинулся в разработке игры с использованием SDL, OpenGL и C++ и ищу способы оптимизировать то, как игра переключается между шейдерами GLSL для множества разных объектов разных типов. Это больше вопрос C++, чем вопрос OpenGL. Тем не менее, я все еще хочу предоставить как можно больше контекста, так как я чувствую, что необходимо некоторое обоснование того, почему предложенный класс Shader, который мне нужен, должен быть создан/удален таким, какой он есть.

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

Необходимость класса Shader:

Я видел много онлайн-реализаций шейдеров OpenGL, которые создаются, компилируются и удаляются в одной и той же функции, когда игровые объекты создаются во время игры. Это оказалось неэффективным и слишком медленным в определенных частях моей игры. Таким образом, мне требовалась система, которая создает и компилирует шейдеры во время загрузки, а затем периодически использует/переключает их во время игры, а затем удаляет.

Это привело к созданию класса (Shader), управляющего шейдерами OpenGL. Каждый экземпляр класса должен управлять одним уникальным шейдером OpenGL и содержать некоторое сложное поведение, связанное с типом шейдера, откуда он загружается, где он используется, какие юниформ-переменные он принимает и т. д.

При этом самая важная роль этого класса состоит в том, чтобы хранить переменную GLuint id, которая возвращается из glCreateShader(), и управлять всеми вызовами OpenGL, которые относятся к шейдеру OpenGL, с помощью этого id. Я понимаю, что это фактически бесполезно, учитывая глобальный характер OpenGL (поскольку в любом месте программы технически можно вызвать glDeleteShader() с соответствующим id и нарушить класс), однако в целях намеренной инкапсуляции всех вызовов OpenGL в очень определенные области на протяжении всего кодовой базы эта система резко уменьшит сложность кода.

С чего начинаются проблемы...

Самый автоматический способ управлять этим GLuint id состоит в том, чтобы вызывать glCreateShader() при создании объекта и glDeleteShader() при его разрушении. Это гарантирует (в рамках OpenGL), что шейдер OpenGL будет существовать в течение всего времени существования объекта шейдера C++, и устраняет необходимость вызывать некоторые функции void createShader() и deleteShader().

Это все хорошо, однако вскоре возникают проблемы при рассмотрении того, что произойдет, если этот объект будет скопирован. Что делать, если копия этого объекта уничтожена? Это означает, что glDeleteShader() будет вызываться и эффективно ломать все копии объекта шейдера.

А как насчет простых ошибок вроде случайного вызова std::vector::push_back() в векторе шейдеров? Различные методы std::vector могут вызывать конструктор/конструктор копирования/деструктор своего типа, что может привести к той же проблеме, что и выше.

Хорошо, тогда... как насчет того, чтобы создать несколько методов void createShader() и deleteShader(), даже если это беспорядочно? К сожалению, это просто отсрочит указанную выше проблему, поскольку снова любые вызовы, изменяющие шейдер OpenGL, десинхронизируют/полностью сломают все копии класса шейдера с одним и тем же id. Я ограничил вызовы OpenGL glCreateShader() и glDeleteShader() в этом примере, чтобы все было просто, однако я должен отметить, что в классе есть много других вызовов OpenGL, которые заставляют создавать различные экземпляры/статические переменные, которые слишком отслеживают копии экземпляров. сложно оправдать это таким образом.

Последнее замечание, которое я хочу сделать перед тем, как перейти к приведенному ниже дизайну классов, заключается в том, что для такого большого проекта, как необработанная игра на C++, OpenGL и SDL, я бы предпочел, чтобы любые потенциальные ошибки OpenGL, которые я делаю, генерировали ошибки компилятора, а не графические проблемы, которые являются сложнее отследить. Это может быть отражено в дизайне класса ниже.

Первая версия класса Shader:

Именно по вышеуказанным причинам я выбрал:

  • Сделайте конструктор private.
  • Предоставьте общедоступную функцию static create, которая вместо конструктора возвращает указатель на новый объект Shader.
  • Сделайте конструктор копирования private.
  • Сделайте operator= private (хотя это может и не понадобиться).
  • Сделайте деструктор приватным.
  • Поместите вызовы glCreateShader() в конструктор и glDeleteShader() в деструктор, чтобы шейдеры OpenGL существовали на протяжении всего времени жизни этого объекта.
  • Поскольку функция create вызывает ключевое слово new (и возвращает указатель на него), место с внешним вызовом Shader::create() должно вызывать delete вручную (подробнее об этом чуть позже).

Насколько я понимаю, первые два пункта используют фабричный шаблон и будут генерировать ошибку компилятора, если будет предпринята попытка создать тип класса, не являющийся указателем. Затем третий, четвертый и пятый маркеры предотвращают копирование объекта. Затем седьмой пункт гарантирует, что шейдер OpenGL будет существовать в течение того же времени жизни, что и объект шейдера C++.

Умные указатели и основная проблема:

Единственное, что я не очень люблю в вышеизложенном, это вызовы new/delete. Они также делают вызовы glDeleteShader() в деструкторе объекта неуместными, учитывая инкапсуляцию, которую пытается достичь класс. Учитывая это, я решил:

  • измените функцию create, чтобы она возвращала std::unique_ptr типа Shader вместо указателя Shader.

Тогда функция create выглядела так:

std::unique_ptr<Shader> Shader::create() {
    return std::make_unique<Shader>();
}

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

std::unique_ptr<Shader> Shader::create() {
    return std::unique_ptr<Shader>(new Shader());
}

Но... теперь std::unique_ptr требует, чтобы деструктор был общедоступным! Это... лучше, но, к сожалению, это означает, что деструктор может быть вызван вручную вне класса, что, в свою очередь, означает, что функция glDeleteShader() может быть вызвана вне класса.

Shader* p = Shader::create();
p->~Shader(); // Even though it would be hard to do this intentionally, I don't want to be able to do this.
delete p;

Финальный класс:

Для простоты я удалил большинство переменных экземпляра, аргументы функции/конструктора и другие атрибуты, но вот как выглядит окончательный предложенный класс (в основном):

class GLSLShader {

public:
    ~GLSLShader() { // OpenGL delete calls for id }; // want to make this private.

    static std::unique_ptr<GLSLShader> create() { return std::unique_ptr<GLSLShader>(new GLSLShader()); };

private:
    GLSLShader() { // OpenGL create calls for id };

    GLSLShader(const GLSLShader& glslShader);
    GLSLShader& operator=(const GLSLShader&);

    GLuint id;

};

Меня все устраивает в этом классе, кроме того, что деструктор публичный. Я проверил этот дизайн, и увеличение производительности очень заметно. Несмотря на то, что я не могу себе представить, чтобы я когда-либо вручную вызывал деструктор объекта Shader, мне не нравится, что он публично выставлен. Я также чувствую, что могу случайно что-то упустить, например, рассмотрение std::vector::push_back во втором разделе.

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

  1. Сделайте std::unique_ptr или std::make_unique friend класса Shader. Я читал такие темы, как этот, однако это делается для того, чтобы сделать конструктор доступным, а не деструктор. Я также не совсем понимаю недостатки / дополнительные соображения, необходимые для превращения std::unique_ptr или std::make_unique в friend (лучший ответ на эту тему + комментарии)?

  2. Не используйте умные указатели вообще. Возможно, есть способ, чтобы моя функция static create() возвращала необработанный указатель (с использованием ключевого слова new), который автоматически удаляется внутри класса/когда Shader выходит за рамки и вызывается деструктор?

Большое спасибо за уделенное время.


person Jaymaican    schedule 04.03.2021    source источник
comment
Я не прочитал весь ваш вопрос, но я думаю, что достаточно, чтобы получить представление. Мне кажется, все, что вам действительно нужно, это фабричный метод, который возвращает std::shared_ptrs? Разве это не решит все ваши проблемы?   -  person super    schedule 04.03.2021
comment
Кроме того, идея защиты пользователей от ручного вызова деструктора по меньшей мере сомнительна. Если они захотят сломать ваш класс, они всегда смогут это сделать. Никто никогда случайно не вызовет деструктор вручную.   -  person super    schedule 04.03.2021
comment
Защищайтесь от Мерфи, а не от Макиавелли. В этом упражнении продумывается дизайн того, что (почти с абсолютной уверенностью) не будет проблемой.   -  person StoryTeller - Unslander Monica    schedule 04.03.2021
comment
Вручную вызвать деструктор? Кто так делает? Если кто-то такой злонамеренный или невежественный, он может просто отредактировать ваш заголовочный файл и удалить private:. Или добавить друга. Или делать то, что, черт возьми, они хотят. @StoryTeller-UnslanderМоника прекрасно сказала: защищайтесь от Мерфи, а не от Макиавелли. В любом случае, кто вообще будет программировать этот интерфейс? Вы не совершите ошибку, вызвав деструктор вручную...   -  person davidbak    schedule 04.03.2021
comment
Благодарю за ваш ответ. Мой ход мыслей изначально пошел по этому пути из-за проблемы с вектором, с которой я столкнулся во втором разделе моего поста. Если бы я не сконструировал свои объекты Shader так, как у меня есть, и имел бы vector<Shader>, вызов функции push_back() для этого вектора вызовет деструкторы и сломает объекты. Я думал, что, возможно, есть какая-то другая вещь, о которой я не думаю, которая может автоматически вызывать деструктор, например, vector.push_back(), поэтому я подумал, что безопаснее найти способ сделать его приватным. Что вы думаете об этом @super?   -  person Jaymaican    schedule 04.03.2021
comment
Ваш класс GLSLShader нельзя ни копировать, ни перемещать. Невозможно создать его экземпляры в векторе. Как push_back будет проблемой?   -  person StoryTeller - Unslander Monica    schedule 04.03.2021
comment
Спасибо за ответ @StoryTeller-UnslanderMonica, люблю цитату из предыдущего, кстати. Я понимаю, что push_back не будет проблемой. Я упомянул об этом в связи с тем, почему я изменил дизайн класса после второго раздела моего поста, поэтому я сказал, что если бы я не создавал свои объекты Shader так, как я это сделал. Меня беспокоили не векторы/push_back, а другая концепция, которую я не рассматривал с момента обновления дизайна. Может быть, что-то еще в STL, которое по-прежнему сможет вызывать деструктор, когда я этого не хочу, и т. д.   -  person Jaymaican    schedule 04.03.2021
comment
Вместо того, чтобы полагаться на std::unique_ptr, вы можете реализовать конструктор/назначение перемещения для GLSLShader (возможно, используя std::optional<GLuint> для определения недопустимого идентификатора).   -  person Jarod42    schedule 04.03.2021
comment
@Jaymaican Я понимаю твои мысли. Но тот факт, что у вас есть смарт-указатель для управления временем жизни объекта, снимает все эти заботы. Смарт-указатель никогда не будет копировать базовый объект (если только это не будет сделано явно пользователем, и это также было бы невозможно здесь, поскольку у вас есть частный копирующий ctor и назначение). Если фабричный метод является единственным способом создания экземпляров, у вас есть все основания.   -  person super    schedule 04.03.2021


Ответы (2)


Это контекстная задача.

Вы решаете неправильную проблему.

GLuint id, будет вызывать glCreateShader() при построении объекта и glDeleteShader()

Исправьте проблему здесь.

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

Вот так:

// "pointers" in unique ptrs must be comparable to nullptr.
// So, let us make an integer qualify:
template<class Int>
struct nullable{
  Int val=0;
  nullable()=default;
  nullable(Int v):val(v){}
  friend bool operator==(std::nullptr_t, nullable const& self){return !static_cast<bool>(self);}
  friend bool operator!=(std::nullptr_t, nullable const& self){return static_cast<bool>(self);}
  friend bool operator==(nullable const& self, std::nullptr_t){return !static_cast<bool>(self);}
  friend bool operator!=(nullable const& self, std::nullptr_t){return static_cast<bool>(self);}
  operator Int()const{return val;}
};

// This both statelessly stores the deleter, and
// tells the unique ptr to use a nullable<Int> instead of an Int*:
template<class Int, void(*deleter)(Int)>
struct IntDeleter{
  using pointer=nullable<Int>;
  void operator()(pointer p)const{
    deleter(p);
  }
};

// Unique ptr's core functionality is cleanup on destruction
// You can change what it uses for a pointer. 
template<class Int, void(*deleter)(Int)>
using IntResource=std::unique_ptr<Int, IntDeleter<Int,deleter>>;

// Here we statelessly remember how to destroy this particular
// kind of GLuint, and make it an RAII type with move support:
using GLShaderResource=IntResource<GLuint,glDeleteShader>;

теперь этот тип знает, что это шейдер, и очищает его от нуля.

GLShaderResource id(glCreateShader());
SomeGLFunction(id.get());

извинения за любые опечатки.

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

struct GLSLShader {
  // public!
  ~GLSLShader() = default;
  GLSLShader() { // OpenGL create calls for id };
private: // does this really need to be private?
  GLShaderResource id;
};

так проще.

std::vector<GLSLShader> v;

и это просто работает. Наш GLShaderResource полурегулярный (перемещение только обычного типа, без поддержки сортировки), и vector этим доволен. Правило 0 означает, что GLSLShader, которому он принадлежит, также является полурегулярным и поддерживает RAII — выделение ресурсов является инициализацией — что, в свою очередь, означает, что он правильно очищает себя после хранения в std контейнерах.

Тип Regular означает, что он ведет себя как int -- как типичный тип значения. Стандартная библиотека C++ и большая часть C++ любят, когда вы используете обычные или полурегулярные типы.

Обратите внимание, что это в основном нулевые накладные расходы; sizeof(GLShaderResource) совпадает с GLuint и ничего не помещается в кучу. У нас есть куча механизмов типа времени компиляции, обертывающих простые 32-битные целые числа; этот механизм типов во время компиляции генерирует код, но не делает данные более сложными, чем 32-битные.

Живой пример.

Накладные расходы включают в себя:

  1. В соответствии с некоторыми соглашениями о вызовах передача обертки struct только int передается иначе, чем int.

  2. При уничтожении мы проверяем каждый из них, чтобы узнать, является ли он 0, чтобы решить, хотим ли мы вызвать glDeleteShader; компиляторы иногда могут доказать, что что-то гарантированно равно нулю, и пропустить эту проверку. Но он не скажет вам, удалось ли ему это осуществить. (OTOH, люди, как известно, плохо доказывают, что они отслеживали все ресурсы, поэтому несколько проверок во время выполнения — не самое худшее).

  3. Если вы делаете полностью неоптимизированную сборку, при вызове функции OpenGL будет несколько дополнительных инструкций. Но после любого ненулевого уровня inlineинга компилятором они исчезнут.

  4. Тип не тривиален (термин в стандарте C++) в нескольких отношениях (копируемый, разрушаемый, конструируемый), что делает такие вещи, как memset, незаконными в соответствии со стандартом C++; вы не можете обращаться с ней как с необработанной памятью несколькими низкоуровневыми способами.


Проблема!

Многие реализации OpenGL имеют указатели на glDeleteShader/glCreateShader и т. д., и вышеизложенное основано на том, что они являются фактическими функциями, а не указателями, макросами или чем-то еще.

Есть два простых обходных пути. Первый — добавить & к аргументам deleter выше (два места). У этого есть проблема, что это работает только тогда, когда они на самом деле являются указателями, а не когда они являются реальными функциями.

Создать код, который работает в обоих случаях, немного сложно, но я думаю, что почти каждая реализация GL использует указатели на функции, так что вы должны быть хороши, если только вы не хотите сделать реализацию библиотечного качества. В этом случае вы можете написать несколько вспомогательных типов, которые создают указатели функций constexpr, которые вызывают указатель функции (или нет) по имени.


Наконец, по-видимому, некоторые деструкторы требуют дополнительных параметров. Вот набросок.

using GLuint=std::uint32_t;

GLuint glCreateShaderImpl() { return 7; }
auto glCreateShader = glCreateShaderImpl;
void glDeleteShaderImpl(GLuint x) { std::cout << x << " deleted\n"; }
auto glDeleteShader = glDeleteShaderImpl;

std::pair<GLuint, GLuint> glCreateTextureWrapper() { return {7,1024}; }

void glDeleteTextureImpl(GLuint x, GLuint size) { std::cout << x << " deleted size [" << size << "]\n"; }
auto glDeleteTexture = glDeleteTextureImpl;

template<class Int>
struct nullable{
  Int val=0;
  nullable()=default;
  nullable(Int v):val(v){}
  nullable(std::nullptr_t){}
  friend bool operator==(std::nullptr_t, nullable const& self){return !static_cast<bool>(self);}
  friend bool operator!=(std::nullptr_t, nullable const& self){return static_cast<bool>(self);}
  friend bool operator==(nullable const& self, std::nullptr_t){return !static_cast<bool>(self);}
  friend bool operator!=(nullable const& self, std::nullptr_t){return static_cast<bool>(self);}
  operator Int()const{return val;}
};

template<class Int, auto& deleter>
struct IntDeleter;

template<class Int, class...Args, void(*&deleter)(Int, Args...)>
struct IntDeleter<Int, deleter>:
  std::tuple<std::decay_t<Args>...>
{
  using base = std::tuple<std::decay_t<Args>...>;
  using base::base;
  using pointer=nullable<Int>;
  void operator()(pointer p)const{
    std::apply([&p](std::decay_t<Args> const&...args)->void{
        deleter(p, args...);
    }, static_cast<base const&>(*this));
  }
};

template<class Int, void(*&deleter)(Int)>
using IntResource=std::unique_ptr<Int, IntDeleter<Int,deleter>>;

using GLShaderResource=IntResource<GLuint,glDeleteShader>;

using GLTextureResource=std::unique_ptr<GLuint,IntDeleter<GLuint, glDeleteTexture>>;

int main() {
    auto res = GLShaderResource(glCreateShader());
    std::cout << res.get() << "\n";
    auto tex = std::make_from_tuple<GLTextureResource>(glCreateTextureWrapper());
    std::cout << tex.get() << "\n";
}
person Yakk - Adam Nevraumont    schedule 04.03.2021
comment
Спасибо за этот блестящий ответ. Я хотел бы изменить название своего поста, чтобы большее количество будущих пользователей, которым нужна общая помощь в своих классах шейдеров/программ GLSL, с большей вероятностью увидели это. У меня просто есть пара быстрых дополнительных вопросов. Не могли бы вы кратко объяснить роль ключевого слова friend в обнуляемом struct? Также интересно, какую роль играет ключевое слово const в operator Int()const{return val;} и что было бы, если бы его не было? Спасибо еще раз. - person Jaymaican; 07.03.2021
comment
Извините, только на одну быструю дополнительную заметку. Я пытался реализовать это и получил ошибку компиляции... error C2975: 'deleter': invalid template argument for 'IntResource', expected compile-time constant expression. Я должен отметить, что я использую GLEW v2.1. В glew.h есть #define glDeleteShader GLEW_GET_FUN(__glewDeleteShader). Выдает ошибку: argument of type "PFNGLDELETESHADERPROC" is incompatible with template parameter of type "void (*)(GLuint)". Будет ли лучшим способом исправить это обертка для glDeleteShader? void glDeleteShaderWrapper(GLuint id) { glDeleteShader(id); }? - person Jaymaican; 07.03.2021
comment
@Jaymaican Итак, здесь glDeleteShader - это указатель функции, отличный от constexpr. Но указатель на этот указатель функции будет contexpr. Итак... добавьте еще один уровень косвенности к void(*deleter)(Int). Так же просто, как void(*&deleter)(Int), сработало в тестовом примере (делайте это везде, где deleter используется в приведенном выше коде, а не только в одном месте, иначе вы получите ошибки преобразования в другом месте). Кроме того, вы можете обернуть его в лямбду. - person Yakk - Adam Nevraumont; 07.03.2021
comment
Привет и еще раз спасибо - у меня есть дополнительный вопрос, с которым, я уверен, могут столкнуться будущие пользователи. В OpenGL есть вызовы, такие как glGenVertexArrays(), которые принимают два аргумента (размер и идентификатор). Вы хотели бы отслеживать как размер, так и идентификатор для правильного удаления позже. Следуя приведенной выше реализации, как вы могли бы поддерживать эквивалент GLShaderResource=IntResource<GLuint,glDeleteShader>;, который вместо типа GLuint использует struct, содержащий GLuint, и GLsizei с соответствующим удалением, которое принимает более одного аргумента? - person Jaymaican; 17.03.2021
comment
@Jaymaican может создать оболочку ресурса std::tuple<some_nullable_type, Ts...>, которая берет кортеж и вызывает средство удаления с помощью std::apply? Или, может быть, немного менее общий; это будет зависеть от того, как часто вы используете размер. Если размер используется только (или почти только) при уничтожении, засуньте его в детерфер и сделайте не-безгражданным. - person Yakk - Adam Nevraumont; 17.03.2021
comment
Спасибо еще раз. Размер будет почти исключительно использоваться в разрушении. Не могли бы вы немного подробнее рассказать о том, как засунуть его в средство удаления и сделать его не апатридом? Я изо всех сил пытаюсь понять, как и где я буду засовывать/вводить значение в средство удаления, а также где это значение хранится (размер). (Включает ли это модификацию структуры IntDeleter?) - person Jaymaican; 17.03.2021
comment
@Jaymaican см. выше. Я заставил средство удаления наследоваться от (возможно, пустого) кортежа, а затем передать эти аргументы кортежа функции удаления. Когда вы создаете уникальный ptr, первым является указатель (int), второй аргумент отправляется в кортеж. Я написал оболочку, которая создает текстуру (предположительно с помощью API openGL), а затем возвращает кортеж из int и размера; затем мы используем make from tuple для создания уникального ptr, передавая размер средству удаления. Подробнее см. на странице cppreference, посвященной уникальному конструктору ptr с функцией удаления. - person Yakk - Adam Nevraumont; 18.03.2021

Реализуйте deleter самостоятельно, и пусть удаляющий станет другом вашего класса. Затем отредактируйте свою декларацию следующим образом:

static std::unique_ptr<GLSLShader, your_deleter> create();
person aleck099    schedule 04.03.2021
comment
Я бы сказал, что злые люди, которые сделали бы p->~Shader(), все еще могут сделать your_deleter{}(p) или что-то подобное. - person Jarod42; 04.03.2021
comment
Спасибо @aleck099, это то решение, которое я изначально искал, и оно в значительной степени решает проблему, о которой я упоминал в посте. Просто примечание для будущих читателей, другие также подняли несколько хороших моментов в комментариях к исходному сообщению о том, почему это не является большой проблемой в первую очередь. - person Jaymaican; 04.03.2021
comment
На самом деле никто не будет вызывать dtor напрямую, так что на самом деле нет необходимости делать какие-либо dtor приватными. - person aleck099; 04.03.2021