【问题标题】:How to allow a std::unique_ptr to access a class's private destructor or implement a C++ factory class with a private destructor?如何允许 std::unique_ptr 访问类的私有析构函数或使用私有析构函数实现 C++ 工厂类?
【发布时间】:2021-03-04 08:01:25
【问题描述】:

我非常热衷于使用 SDL、OpenGL 和 C++ 开发游戏,并且正在寻找方法来优化游戏在 GLSL 着色器之间切换的方式,以处理不同类型的许多不同对象。这更像是一个 C++ 问题,而不是 OpenGL 问题。但是,我仍然想提供尽可能多的上下文,因为我觉得需要一些理由来说明为什么我需要的提议的 Shader 类需要按原样创建/删除。

前四部分是我的理由、旅程和尝试,但我的问题可能仅通过最后一部分来回答,我特意把它写成一个 tldr。

Shader 类的必要性:

在游戏过程中创建游戏对象时,我已经看到许多在同一函数中创建、编译和删除 OpenGL 着色器的在线实现。事实证明,这在我的游戏的特定部分效率低下且速度太慢。因此,我需要一个系统,它可以在加载期间创建和编译着色器,然后在游戏期间间歇性地在它们之间使用/交换,然后再被删除。

这导致创建了一个管理 OpenGL 着色器的类 (Shader)。该类的每个实例都应管理一个唯一的 OpenGL 着色器,并包含围绕着色器类型的一些复杂行为,从哪里加载,在哪里使用,它采用的统一变量等。

话虽如此,这个类最重要的作用是存储从glCreateShader()返回的GLuint变量id,并用这个id管理所有与OpenGL着色器相关的OpenGL调用。我知道考虑到 OpenGL 的全局性质,这实际上是徒劳的(因为程序中的任何地方都可以在技术上调用 glDeleteShader() 与匹配的 id 并破坏类),但是为了有意将所有 OpenGL 调用封装到非常具体的该系统将在整个代码库中大大降低代码复杂性。

问题从哪里开始...

管理此GLuint id 的最“自动”方式是在对象的构造上调用glCreateShader(),在对象的销毁上调用glDeleteShader()。这保证(在 OpenGL 限制内)OpenGL 着色器将存在于 C++ Shader 对象的整个生命周期中,并且无需调用某些 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 函数更改为返回Shader 类型的std::unique_ptr,而不是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_ptrstd::make_unique 成为Shader 类的friend。我一直在阅读诸如this one 之类的线程,但是这是为了使构造函数可以访问,而不是析构函数。我也不太明白将std::unique_ptrstd::make_unique 设为friend(该线程的最佳答案+ cmets)所需的缺点/额外考虑因素?

  2. 根本不使用智能指针。有没有办法让我的 static create() 函数返回一个原始指针(使用 new 关键字),当 Shader 超出范围并调用析构函数时,它会在类 / 内自动删除?

非常感谢您的宝贵时间。

【问题讨论】:

  • 我没有阅读你所有的问题,但我认为足以理解这个想法。在我看来,您真正需要的只是一个返回std::shared_ptrs 的工厂方法?这不是解决你所有的问题吗?
  • 此外,试图“保护用户”免于手动调用析构函数的想法至少可以说是可疑的。如果他们想打破你的班级,他们总是能够做到的。没有人会不小心手动调用析构函数。
  • 保护墨菲,而不是马基雅维利。这个练习过度考虑了一些(几乎绝对肯定)不会成为问题的设计。
  • “手动调用析构函数”?谁这样做?如果有人恶意或无能,他们可以编辑您的头文件并删除private:。或者加个好友。或者做他们想做的任何事情。 @StoryTeller-UnslanderMonica 说得很好:“保护墨菲,而不是马基雅维利。”不管怎样,谁会针对这个接口编程呢? 不会犯手动调用析构函数的错误...
  • @Jaymaican 我理解你的想法。但是,您有一个智能指针来管理对象的生命周期这一事实消除了所有这些担忧。智能指针永远不会复制底层对象(除非由用户明确完成,而且这在此处也是不可能的,因为您有私有副本 ctor 和分配)。如果工厂方法是创建实例的唯一方法,那么您的基础已经涵盖。

标签: c++ destructor smart-pointers friend-class


【解决方案1】:

这是一个上下文挑战。

你解决了错误的问题。

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());

对任何错别字深表歉意。

在您的班级中添加内容,复制 ctors 被阻止,移动 ctors 做正确的事情,dtors 自动清理,等等。

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 容器中时会正确地自行清理。

“常规”类型意味着它“表现得像 int”——就像原型值类型一样。当您使用正则或半正则类型时,C++ 的标准库和大部分 C++ 都喜欢它。

请注意,这基本上是零开销; sizeof(GLShaderResource)GLuint 相同,堆上没有任何内容。我们有一堆编译时类型的机器,包装了一个简单的 32 位整数;编译时类型机器生成代码,但不会使数据比 32 位更复杂。

Live example.

开销包括:

  1. 一些调用约定使得传递 struct 仅包装 int 与传递 int 不同。

  2. 在销毁时,我们会检查每一个是否为0,以决定是否要调用glDeleteShader;编译器有时可以证明某些东西保证为零并跳过该检查。但它不会告诉你它是否确实成功了。 (OTOH,众所周知,人类在证明他们跟踪所有资源方面很糟糕,所以一些运行时检查并不是最糟糕的事情)。

  3. 如果您正在执行完全未优化的构建,则在调用 OpenGL 函数时会有一些额外的指令。但是在编译器发出任何非零级别的inlineing 之后,它们都会消失。

  4. 该类型在某些方面(可复制、可销毁、可构造)不是“微不足道的”(C++ 标准中的一个术语),这使得像 memset 这样的操作在 C++ 标准下是非法的;您不能以一些低级的方式将其视为原始内存。


一个问题!

许多 OpenGL 实现都有指向 glDeleteShader/glCreateShader 等的指针,而以上依赖于它们是实际的函数,而不是指针或宏或其他任何东西。

有两种简单的解决方法。第一种是将&amp; 添加到上面的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";
}

【讨论】:

  • 感谢您的精彩回答。我希望我可以更改我的帖子的名称,以便更多寻求 GLSL 着色器/程序类的整体帮助的未来用户更有可能看到这一点。我只有几个快速跟进的问题。您介意简单解释一下friend 关键字在可空struct 中的作用吗?还想知道const 关键字在operator Int()const{return val;} 中的作用是什么,如果它不存在会发生什么?再次感谢。
  • 抱歉,请快速补充一下。我试图实现这一点并得到一个编译错误...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); }?
  • @Jaymaican 所以这里glDeleteShader 是一个非constexpr 函数指针。但是指向该函数指针的指针将是contexpr。所以...向void(*deleter)(Int) 添加另一个间接级别。就像void(*&amp;deleter)(Int) 在测试用例中工作一样简单(在上面的代码中使用deleter 的所有地方都这样做,而不仅仅是一个地方,否则你会在其他地方得到转换错误)。或者,您可以将其包装在 lambda 中。
  • 您好,再次感谢 - 我有一个后续问题,我相信未来的用户可能会遇到。 OpenGL 中有一些调用,例如 glGenVertexArrays(),它接受两个参数(一个大小和一个 id)。您可能希望跟踪大小和 id 以便以后正确删除。继上述实现之后,您如何支持 GLShaderResource=IntResource&lt;GLuint,glDeleteShader&gt;; 的等效项,而不是 GLuint 类型,使用包含 GLuintGLsizeistruct 以及需要多个删除器的适当删除器论据?
  • @Jaymaican 创建一个std::tuple&lt;some_nullable_type, Ts...&gt; 资源包装器,它接收元组并使用std::apply 调用删除器?或者可能不那么通用;这取决于您使用尺寸的频率。如果大小仅在销毁时使用(或几乎仅使用),则将其推入删除器并使其不是无状态的。
【解决方案2】:

自己实现一个删除器,让删除器成为你班级的朋友。 然后像这样编辑您的声明:

static std::unique_ptr<GLSLShader, your_deleter> create();

【讨论】:

  • 我会说 邪恶 那些会做p-&gt;~Shader() 的人仍然可以做your_deleter{}(p) 或类似的事情。
  • Thankyou @aleck099 这是我最初寻找的解决方案类型,并且几乎解决了我在帖子中提到的问题。只是给任何未来的读者的一个注释,其他人也在原始帖子的 cmets 中提出了一些关于为什么这首先不是一个大问题的好观点。
  • 事实上没有人会直接调用dtor,所以实际上不需要将任何dtor设为私有。
猜你喜欢
  • 2013-02-18
  • 2013-10-16
  • 1970-01-01
  • 2011-02-05
  • 2014-10-08
  • 1970-01-01
  • 2017-10-08
  • 1970-01-01
相关资源
最近更新 更多