C++11 智能指针语义

C++11 Smart Pointer Semantics

本文关键字:语义 指针 智能 C++11      更新时间:2023-10-16

我已经使用指针几年了,但我最近才决定过渡到C++11的智能指针(即唯一,共享和弱)。我对它们做了相当多的研究,这些是我得出的结论:

  1. 独特的指针很棒。它们管理自己的内存,并且像原始指针一样轻量级。尽可能更喜欢unique_ptr而不是原始指针。
  2. 共享指针很复杂。由于引用计数,它们具有大量开销。通过常量引用传递它们,否则对你的方式错误感到遗憾。它们不是邪恶的,但应该谨慎使用。
  3. 共享指针应拥有对象;当不需要所有权时,请使用弱指针。锁定weak_ptr与shared_ptr复制构造函数具有等效的开销。
  4. 继续忽略 auto_ptr 的存在,现在无论如何都已弃用。

因此,考虑到这些原则,我开始修改我的代码库,以利用我们新的闪亮智能指针,完全打算清除尽可能多的原始指针。然而,对于如何最好地利用 C++11 智能指针,我感到困惑。

例如,让我们假设我们正在设计一个简单的游戏。我们决定将虚构的纹理数据类型加载到 TextureManager 类中是最佳选择。这些纹理很复杂,因此按值传递它们是不可行的。此外,让我们假设游戏对象需要特定的纹理,具体取决于其对象类型(即汽车、船只等)。

之前,我会将纹理加载到矢量(或其他容器,如 unordered_map)中,并将指向这些纹理的指针存储在每个相应的游戏对象中,以便它们可以在需要渲染时引用它们。让我们假设纹理保证比它们的指针更持久。

那么,我的问题是,在这种情况下,如何最好地利用智能指针。我看到几个选项:

  1. 将纹理直接存储在容器中,然后在每个游戏对象中构造一个unique_ptr。

    class TextureManager {
      public:
        const Texture& texture(const std::string& key) const
            { return textures_.at(key); }
      private:
        std::unordered_map<std::string, Texture> textures_;
    };
    class GameObject {
      public:
        void set_texture(const Texture& texture)
            { texture_ = std::unique_ptr<Texture>(new Texture(texture)); }
      private:
        std::unique_ptr<Texture> texture_;
    };
    

    但是,我对此的理解是,将从传递的引用复制构造一个新纹理,然后由unique_ptr拥有。这让我觉得非常不受欢迎,因为我拥有与使用它的游戏对象一样多的纹理副本 - 击败指针点(没有双关语)。

  2. 不要直接存储纹理,而是将其共享指针存储在容器中。使用 make_shared 初始化共享指针。在游戏对象中构造弱指针。

    class TextureManager {
      public:
        const std::shared_ptr<Texture>& texture(const std::string& key) const
            { return textures_.at(key); }
      private:
        std::unordered_map<std::string, std::shared_ptr<Texture>> textures_;
    };
    class GameObject {
      public:
        void set_texture(const std::shared_ptr<Texture>& texture)
            { texture_ = texture; }
      private:
        std::weak_ptr<Texture> texture_;
    };
    

    与unique_ptr情况不同,我不必复制构建纹理本身,但渲染游戏对象的成本很高,因为我每次都必须锁定weak_ptr(与复制构造新shared_ptr一样复杂)。

总而言之,我的理解是这样的:如果我要使用独特的指针,我将不得不复制构造纹理;或者,如果我使用共享指针和弱指针,我必须在每次绘制游戏对象时复制构造共享指针。

我知道智能指针本质上会比原始指针更复杂,所以我一定会在某个地方蒙受损失,但这两种成本似乎都高于应有的水平。

谁能指出我正确的方向?

抱歉阅读时间过长,感谢您的时间!

即使在 C++11 中,原始指针作为对对象的非所有权引用仍然完全有效。在你的例子中,你说的是"让我们假设纹理保证比它们的指针更耐用。这意味着使用指向游戏对象中纹理的原始指针是完全安全的。在纹理管理器中,自动存储纹理(在保证内存中恒定位置的容器中)或存储在 unique_ptr s 的容器中。

如果指针寿命保证无效,则有意义地将纹理存储在管理器中的shared_ptr中,并在游戏对象中使用 shared_ptr s 或 weak_ptr s,具体取决于游戏对象相对于纹理的所有权语义。您甚至可以反转 - 将shared_ptr存储在对象中,weak_ptr存储在管理器中。这样,管理器将充当缓存 - 如果请求纹理并且其weak_ptr仍然有效,它将提供它的副本。否则,它将加载纹理,发出shared_ptr并保持weak_ptr

总结您的用例:*) 保证对象比其用户寿命更长*) 对象一旦创建,就不会被修改(我认为这是您的代码所暗示的)*) 对象可按名称引用,并保证您的应用程序将要求的任何名称都存在(我正在推断 - 我将在下面处理如果这不是真的该怎么办。

这是一个令人愉快的用例。您可以在整个应用程序中对纹理使用值语义!这具有出色的性能和易于推理的优点。

一种方法是让你的 TextureManager 返回一个 Texture const*。考虑:

using TextureRef = Texture const*;
...
TextureRef TextureManager::texture(const std::string& key) const;

由于底层 Texture 对象具有应用程序的生存期,永远不会被修改,并且始终存在(指针永远不会为 nullptr),因此您可以将 TextureRef 视为简单值。您可以传递它们,返回它们,比较它们,并制作它们的容器。它们非常容易推理并且非常有效。

这里的烦恼是你有值语义(这很好),但指针语法(对于具有值语义的类型来说,这可能会令人困惑)。换句话说,要访问 Texture 类的成员,您需要执行以下操作:

TextureRef t{texture_manager.texture("grass")};
// You can treat t as a value. You can pass it, return it, compare it,
// or put it in a container.
// But you use it like a pointer.
double aspect_ratio{t->get_aspect_ratio()};

处理此问题的一种方法是使用类似于 pimpl 习惯用法的东西并创建一个类,该类只不过是指向纹理实现的指针的包装器。这需要做更多的工作,因为您最终将为纹理包装器类创建一个 API(成员函数),该 API 转发到实现类的 API。但优点是你有一个同时具有值语义和值语法的纹理类。

struct Texture
{
  Texture(std::string const& texture_name):
    pimpl_{texture_manager.texture(texture_name)}
  {
    // Either
    assert(pimpl_);
    // or
    if (not pimpl_) {throw /*an appropriate exception */;}
    // or do nothing if TextureManager::texture() throws when name not found.
  }
  ...
  double get_aspect_ratio() const {return pimpl_->get_aspect_ratio();}
  ...
  private:
  TextureImpl const* pimpl_; // invariant: != nullptr
};

Texture t{"grass"};
// t has both value semantics and value syntax.
// Treat it just like int (if int had member functions)
// or like std::string (except lighter weight for copying).
double aspect_ratio{t.get_aspect_ratio()};

我假设在你的游戏环境中,你永远不会要求一个不能保证存在的纹理。如果是这种情况,那么您可以断言该名称存在。但如果不是这种情况,那么您需要决定如何处理这种情况。我的建议是使指针不能为 nullptr 的包装类的不变值。这意味着如果纹理不存在,则从构造函数中抛出。这意味着您在尝试创建 Texture 时会处理问题,而不必在每次调用包装类的成员时检查空指针。

在回答您最初的问题时,智能指针对于生存期管理很有价值,如果您只需要传递对生存期保证比指针更持久的对象的引用,则智能指针并不是特别有用。

你可以有一个std::map of std::unique_ptrs来存储纹理。然后,您可以编写一个 get 方法,该方法按名称返回对纹理的引用。这样,如果每个模型都知道其纹理的名称(它应该),您可以简单地将名称传递到 get 方法并从映射中检索引用。

class TextureManager 
{
  public:
    Texture& get_texture(const std::string& key) const
        { return *textures_.at(key); }
  private:
    std::unordered_map<std::string, std::unique_ptr<Texture>> textures_;
};

然后,您可以在游戏对象类中使用纹理,而不是纹理*、weak_ptr等。

这样纹理管理器可以像缓存一样,可以重写 get 方法以搜索纹理,如果找到,则从地图返回它,否则先加载它,将其移动到地图,然后返回一个 ref 给它

在我开始之前,因为我不小心写了一本小说......

TL;DR 使用共享指针来找出责任问题,但要非常谨慎地对待周期性关系。如果我是你,我会使用共享指针表来存储你的资产,需要这些共享指针的所有内容也应该使用共享指针。这消除了用于读取的弱指针的开销(因为游戏中的开销就像为每个对象每秒创建一个 60 次的新智能指针)。这也是我和我的团队采取的方法,而且非常有效。您还说您的纹理保证比对象存活,因此如果您的对象使用共享指针,则无法删除纹理。

如果我能投入我的2美分,我想告诉你我在自己的视频游戏中用智能指针进行的几乎相同的尝试;好的和坏的。

此游戏的代码采用与您的解决方案#2几乎相同的方法:一个充满位图智能指针的表格。

不过,我们

有一些分歧;我们决定将位图表分成两部分:一部分用于"紧急"位图,另一部分用于"简易"位图。紧急位图是不断加载到内存中的位图,将在战斗中使用,我们现在需要动画并且不想去硬盘,硬盘有非常明显的卡顿。简易表是 hdd 上位图的文件路径字符串表。这些将是加载在相对较长部分开头的大型位图游戏玩法;比如角色的行走动画或背景图像。

在这里使用原始指针存在一些问题,特别是所有权。看,我们的资产表有一个Bitmap *find_image(string image_name)函数。此函数将首先在紧急表中搜索与条目匹配的image_name。如果找到,太好了!返回位图指针。如果未找到,请搜索简易表。如果我们找到与您的图像名称匹配的路径,请创建位图,然后返回该指针。

使用最多的班级绝对是我们动画课。这是所有权问题:动画何时应删除其位图?如果它来自简单的表格,那么没有问题;该位图是专门为您创建的。删除它是你的责任!

但是,如果您的位图来自紧急表,则无法删除它,因为这样做会阻止其他人使用它,并且您的程序会像 E.T. 游戏一样关闭,您的销售也会随之下降。

如果没有智能指针,这里唯一的解决方案是让 Animation 类无论如何克隆其位图。这允许安全删除,但会降低程序的速度。这些图像不是应该对时间敏感吗?

但是,如果资产类要返回shared_ptr<Bitmap>,那么您就没有什么可担心的。你看,我们的资产表是静态的,所以无论如何,这些指针都会持续到程序结束。我们将函数更改为shared_ptr<Bitmap> find_image (string image_name),并且再也不用克隆位图。如果位图来自简洁的表格,则该智能指针是同类中唯一的一个,并且随动画一起删除。如果是紧急位图,则在动画销毁时,该表仍保留引用,并保留数据。

这是快乐的部分,这是丑陋的部分。

我发现共享和独特的指针很棒,但它们肯定有其警告。对我来说,最大的问题是无法明确控制何时删除数据。共享指针保存了我们的资产查找,但在实现时扼杀了游戏的其余部分。

看,我们有一个内存泄漏,并认为"我们应该在任何地方使用智能指针!大错特错。

我们的游戏有GameObjects,由Environment控制。每个环境都有一个 GameObject * 的向量,每个对象都有一个指向其环境的指针。

你应该看看我要去哪里。

对象具有将自身从环境中"弹出"的方法。以防他们需要移动到一个新的区域,或者可能是传送,或者通过其他物体。

如果环境是对象的唯一引用持有者,则对象无法在不被删除的情况下离开环境。这在创建射弹时很常见,尤其是传送射弹。

对象也在删除其环境,至少如果它们是最后一个离开环境的对象。大多数游戏状态的环境也是一个具体的对象。我们在堆栈上调用删除!是的,我们是业余爱好者,起诉我们。

根据我的经验,当你懒得调用 delete 并且只有一个东西会拥有你的对象时,使用 unique_pointers,当你希望多个对象指向一个对象,但无法决定谁必须删除它时,使用 shared_pointers,并且非常警惕与shared_pointers的周期性关系。