shared_ptr 现实生活中的用例

shared_ptr Real life use-cases

shared_ptr 当我们希望动态分配的项目有多个所有者时使用。

问题是,我无法想象我们需要多个所有者的任何场景。我可以想象的每个用例都可以用 unique_ptr.

来解决

有人可以提供一个现实生活中的用例示例,其中包含需要 shared_ptr 的代码(并且需要,我的意思是作为智能指针的最佳选择)? "real life" 我指的是一些实际和务实的用例,而不是过于抽象和虚构的东西。

采用在 class、C 的成员函数 f 中调用的任何 lambda,您要在其中处理将作为引用传递给 lambda [&] 的对象。当您在 f 内等待 lambda 完成时,C 超出了范围。该功能消失了,您有一个悬垂的参考。当 lambda 下一次访问引用时,分段错误与您定义的行为一样接近。您不能将唯一的下注者传递到 lambda 中。一旦移动,您就无法从 f 访问它。解决方案:共享指针和[=]。我编写数据库的核心代码。在 multi-threaded 基础架构中,我们始终需要共享指针。不要忘记原子引用计数器。但您的普遍怀疑是值得赞赏的。共享投注者几乎总是在不需要时使用。

在 CAD 应用程序中,当多个模型碰巧具有相同的网格时(例如,在用户 copy-pasted 这些模型之后),我使用 shared_ptr 来节省 RAM 和 VRAM。作为奖励,多个线程可以同时访问网格,因为 shared_ptr 和 weak_ptr 在正确使用时都是线程安全的。

下面是一个简单的例子。由于多种原因(GPU 缓冲区、鼠标拾取、某些用户输入触发的后台处理以及许多其他原因),实际代码要复杂得多,但我希望这足以让您了解 shared_ptr 的合理性。

// Can be hundreds of megabytes in these vectors
class Mesh
{
    std::string name;
    std::vector<Vector3> vertices;
    std::vector<std::array<uint32_t, 3>> indices;
    BoundingBox bbox;
};

// Just 72 or 80 bytes, very cheap to copy.
// Can e.g. pass copies to another thread for background processing.
// A scene owns a collection of these things.
class Model
{
    std::shared_ptr<Mesh> mesh;
    Matrix transform;
};

在我的程序的用户界面中,我有 "control point values" 的概念(control point value 表示我的程序控制的硬件上控件的当前状态),以及(当然)这个概念"widgets"(widget 是一个 GUI 组件,它将控制点的当前状态呈现给监视器,供用户查看 and/or 操作)。

由于它是一个需要控制的非常复杂的系统,我们有

  • 许多不同类型的 control point values(浮点数、整数、字符串、布尔值、二进制 blob 等)
  • 许多不同类型的 widget(文本显示、推子、电平表、旋钮、按钮等)
  • 给定的 widget 可以选择多种不同的方式将特定的 control point value 呈现为文本(大写、小写、更多或更少的精度数字等)

如果我们只是做显而易见的事情,每次我们需要上述的新组合时都编写一个新的子类,我们最终会得到数千个子类的几何爆炸类,因此非常大的代码库,难以理解或维护。

为了避免这种情况,我将 "how to translate a control point value into human-readable text in some particular way" 的知识分离到它自己独立的不可变对象中,任何人都可以使用它来进行翻译,例如

// My abstract interface
class IControlPointToTextObject
{
public:
   virtual std::string getTextForControlPoint(const ControlPoint & cp) const = 0;
};

// An example implementation
class RenderFloatingPointValueAsPercentage : public IControlPointToTextObject
{
public:
   RenderFloatingPointValueAsPercentage(int precision) : m_precision(precision)
   {
      // empty
   }

   virtual std::string getTextForControlPoint(const ControlPoint & cp) const = 0
   {
      // code to create and return a percentage with (m_precision) digits after the decimal point goes here....
   }

private:
   const int m_precision;
};

...到目前为止,还不错;现在例如当我想让一个文本小部件显示一个控制点值作为小数点后 3 位的百分比时,我可以这样做:

TextWidget * myTextWidget = new TextWidget;
myTextWidget->setTextRenderer(std::unique_ptr<IControlPointToTextObject>(new RenderFloatingPointValueAsPercentage(3)));

...我得到了我想要的。但是我的 GUI 可能会变得相当复杂,并且它们可能有大量(数千)个小部件,并且使用上述方法我必须为每个小部件创建一个单独的 RenderFloatingPointValueAsPercentage 对象,即使大多数 RenderFloatingPointValueAsPercentage 对象最终将成为彼此相同。这有点浪费,所以我将 widget 类 改为接受 std::shared_ptr,现在我可以这样做:

std::shared_ptr<IControlPointToTextObject> threeDigitRenderer = std::make_shared<RenderFloatingPointValueAsPercentage>(3);

myWidget1->setTextRenderer(threeDigitRenderer);
myWidget2->setTextRenderer(threeDigitRenderer);
myWidget3->setTextRenderer(threeDigitRenderer);
[...]

不用担心对象的生命周期,没有悬挂指针,没有内存泄漏,没有不必要地创建重复的渲染器对象。祝你好:)

假设我想为一种是或包含递归 "expression" 定义的语言实现 GLR parser。解析不仅要检查输入是否符合语法,还要输出一些可以用来做分析、求值、编译等的东西。我需要一些东西来表示每个表达式或子表达式语法符号的结果。每个语法规则的实际语义都可以用多态性来表示,所以这需要某种指向基数的指针 class Expression.

那么自然表示就是std::shared_ptr<Expression>Expression 对象可以是另一个复合 Expression 的子表达式,在这种情况下,复合 Expression 是子表达式的所有者。或者 Expression 对象可以由正在进行的算法的解析堆栈拥有,用于尚未与其他部分组合的语法产生式。但实际上两者并非同时存在。如果我正在编写 LALR 解析器,我可能会使用 std::unique_ptr<Expression>,将子表达式从解析堆栈转移到复合表达式构造函数,因为每个语法符号都被减少了。

具体需要shared_ptr提出GLR算法。在某些时候,当到目前为止扫描的输入有多个可能的解析时,该算法将复制解析堆栈以尝试对每种可能性进行试探性解析。随着试探性解析的进行,每个可能性都可能需要用完它自己的解析堆栈中的一些中间结果来形成一些复合表达式的子表达式,所以现在我们可能有相同的 Expression 被一些数字使用解析堆栈和一些不同的复合 Expression 对象。希望除了一个试探性解析之外的所有解析最终都会失败,这意味着失败的解析堆栈将被丢弃。丢弃的解析栈直接和间接包含的Expression个对象可能在那个时候被销毁,但其中一些可能被其他解析栈直接或间接使用。

只用 std::unique_ptr 就可以完成所有这些,但要复杂得多。每当需要拆分解析堆栈时,您都可以进行深度克隆,但这可能很浪费。您可以让它们归其他主容器所有,并让解析堆栈 and/or 复合表达式只使用指向它们的哑指针,但是知道何时清理它们会很困难(并且可能最终会重复简化的实现std::shared_ptr)。我认为 std::shared_ptr 是明显的赢家。

在我们的模拟器产品中,我们使用一个框架在模拟组件(称为端点)之间传递消息。这些端点可以驻留在进程内的多个线程上,甚至可以驻留在模拟集群中的多台机器上,消息通过 RDMA 或 TCP 连接的网格进行路由。 API 大致如下:

class Endpoint {
public:
    // Fill in sender address, etc., in msg, then send it to all
    // subscribers on topic.
    void send(std::unique_ptr<Message> msg, TopicId topic);

    // Register this endpoint as a subscriber to topic, with handler
    // called on receiving messages on that topic.
    void subscribe(TopicId topic,
        std::function<void(std::shared_ptr<const Message>)> handler);
};

一般来说,发送方端点一旦执行了send,就不需要等待任何接收方的任何响应。因此,如果我们试图在整个消息路由过程中保留一个所有者,那么将所有权保留在 send 的调用者中是没有意义的,否则 send 必须等到所有接收者都处理完消息,这会引入不必要的往返延迟。另一方面,如果多个接收者订阅了 topic,将唯一所有权分配给其中的任何一个也没有意义,因为我们不知道他们中的哪一个需要消息最长。这将使路由基础设施本身成为唯一的所有者;但同样,在那种情况下,路由基础设施将不得不等待所有接收者完成,而基础设施可能有大量消息要传递给多个线程,并且它还希望能够将消息传递给接收者和能够转到下一条要传递的消息。另一种选择是保留一组指向发送的消息的唯一指针,等待线程处理它们,并让接收者在完成时通知消息路由器;但这也会带来不必要的开销。

另一方面,通过在此处使用 shared_ptr,一旦路由基础结构将消息传送到端点的传入队列,它就可以释放所有权以在各个接收方之间共享。然后,thread-safe 引用计数确保在所有接收者完成处理后释放 Message。在远程机器上有订阅者的情况下,序列化和传输组件在执行其工作时可能是消息的另一个共享所有者;然后,在接收机器上,接收和反序列化组件可以将其创建的 Message 副本的所有权传递给该机器上接收者的共享所有权。

看看这个现实生活中的例子。当前框架在多个消费者之间共享,使用智能指针,事情变得简单。

class frame { };

class consumer { public: virtual void draw(std::shared_ptr<frame>) = 0; };

class screen_consumer_t :public consumer { public:  void draw(std::shared_ptr<frame>) override {} };
class matrox_consumer_t :public consumer { public:  void draw(std::shared_ptr<frame>) override {} };
class decklink_consumer_t :public consumer { public:  void draw(std::shared_ptr<frame>) override {} };

int main() {
    std::shared_ptr<frame> current_frame = std::make_shared<frame>();

    std::shared_ptr<consumer> screen_consumer = std::make_shared<screen_consumer_t>();
    std::shared_ptr<consumer> matrox_consumer = std::make_shared<matrox_consumer_t>();
    std::shared_ptr<consumer> decklink_consumer = std::make_shared<decklink_consumer_t>();

    std::vector<consumer> consumers;
    consumers.push_back(screen_consumer);
    consumers.push_back(matrox_consumer);
    consumers.push_back(decklink_consumer);

    //screen_consumer->draw(current_frame);
    //matrox_consumer->draw(current_frame);
    //decklink_consumer->draw(current_frame);

    for(auto c: consumers) c->draw(current_frame);


}

已编辑:

另一个例子可以是 Minimax 树,为了避免循环冗余 weak_ptr 可以结合 shared_ptr 使用:

struct node_t
{
    std::unique_ptr<board_t> board_;
    std::weak_ptr<node_t> parent_;
    std::vector<std::shared_ptr<node_t>> children_;
};

你看过这些关于copy-on-write vector:

的文章吗

https://iheartcoding.net/blog/2016/07/11/copy-on-write-vector-in-c/

copy-on-write PIMPL:

https://crazycpp.wordpress.com/2014/09/13/pimplcow/

和通用copy-on-write指针:

https://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Copy-on-write

他们都在内部使用 shared_ptr

std::shared_ptrreference counting technique in C++. For use-cases of reference counting see linked wikipedia article. One usage of reference counting is garbage collection 在编程语言中的一种实现。因此,如果您决定使用 C++ 编写具有垃圾收集功能的新编程语言,您可以使用 std::shared_ptr 来实现它,尽管您还必须处理循环。

简单地说:真的没有。

为了更详细的解释,让我们转向形式推理。众所周知,C++是一门Turing-complete确定性语言。同样具有强大计算能力的工具的一个流行的简单示例是 Brainfuck(通常非常方便地建立 Turing-complete 你最喜欢的语言选择)。如果我们查看 Brainfuck 的描述(它确实非常小,这使得它对于之前提到的目的非常方便),我们很快就会发现其中没有任何类似 shared_ptr 的概念。所以答案是:不,没有绝对需要它们的 real-life 示例。无需 shared_ptrs.

即可完成所有可计算的事情

如果我们彻底地继续这个过程,我们将同样容易地摆脱其他不必要的概念,即 unique_ptr、std::unordered_map、异常、range-loops 等等。