为什么在std::shared_ptr实现中需要两个指向被管理对象的原始指针?

43
这是cppreference关于std::shared_ptr的实现说明部分的一段引用,其中提到有两个不同的指针(如加粗所示):一个可以由get()返回,另一个则保存在控制块中。
在典型的实现中,std::shared_ptr只保存了两个指针:
1. 存储的指针(由get()返回的指针) 2. 指向控制块的指针
控制块是一个动态分配的对象,包含以下内容: 1. 指向受管理对象或它自己的指针 2. 删除器(类型擦除) 3. 分配器(类型擦除) 4. 拥有受管理对象的shared_ptrs数量 5. 引用受管理对象的weak_ptrs数量 shared_ptr直接持有的指针是由get()返回的指针,而控制块持有的指针或对象是当共享所有者数量达到零时将被删除的指针。这些指针不一定相等。
我的问题是,为什么需要管理对象的两个不同指针(即加粗的两个指针),除了指向控制块的指针之外?get()返回的指针不足以胜任吗?为什么这些指针不一定相等?

如果我理解正确,这意味着实际上可能涉及到三个指针:1. 由get()返回的某些内容;2. 可能是控制块内部指向最终将被删除的对象的指针;3. 指向控制块的指针。其中两个由shared ptr本身持有;第三个位于控制块内部。 - Peter - Reinstate Monica
这是一个不同的问题,正如我在之前的评论中指出的那样。它与控制块 vs. 对象无关,而是与存在“两个”指向“对象”的指针有关,这两个指针甚至可能不同(否则就没有必要持有两个指针了)。再加上指向控制块的指针。(答案显然与别名 shared_ptr 构造函数有关。) - Peter - Reinstate Monica
据我理解,https://dev59.com/TYTba4cB1Zd3GeqP3T49#26351926 中的答案是关于两种构造方法之间的区别,但仍然没有解释为什么首先需要两种可能不同的分配。 - btshengsheng
1
@PeterA.Schneider 好的,我发现这个问题很令人困惑和模糊。它肯定可能会问一些与我所想的不同的东西。 - David K
4个回答

54
这是因为你可以拥有一个指向不同对象的 shared_ptr,而这是设计上所需的。这是通过在 cppreference 上列出的第8个构造函数实现的。
template< class Y >
shared_ptr( const shared_ptr<Y>& r, T *ptr );

使用此构造函数创建的shared_ptrr共享所有权,但指向ptr。考虑以下(虚构但说明性的)代码:
std::shared_ptr<int> creator()
{
  using Pair = std::pair<int, double>;

  std::shared_ptr<Pair> p(new Pair(42, 3.14));
  std::shared_ptr<int> q(p, &(p->first));
  return q;
}

一旦此函数退出,只有对 pair 的 int 子对象的指针可用于客户端代码。但由于 q 和 p 之间存在共享所有权,指针 q 保持整个 Pair 对象的存活状态。
一旦需要进行解分配,必须将指向整个 Pair 对象的指针传递给删除器。因此,Pair 对象的指针必须与删除器一起存储在控制块中。
对于一个不那么牵强附会的例子(可能更接近功能的原始动机),考虑指向基类的情况。例如:
struct Base1
{
  // :::
};

struct Base2
{
  // :::
};

struct Derived : Base1, Base2
{
 // :::
};

std::shared_ptr<Base2> creator()
{
  std::shared_ptr<Derived> p(new Derived());
  std::shared_ptr<Base2> q(p, static_cast<Base2*>(p.get()));
  return q;
}

当然,std::shared_ptr 的真正实现已经有了所有的隐式转换,因此在 creator 中进行的 pq 的操作并不是必需的,但我保留这里以类似第一个示例。


6
派生类向基类的转换是一个不那么牵强附会的例子。 - Nicol Bolas
13
我认为这个例子已经足够好了。关键是我们指向作为原子实体进行管理的某个部分,而一对可能是最简单和最明确的例子。继承在这里只是“成为某事的一部分”的一种特殊形式。 - Peter - Reinstate Monica
1
@Angew:我不讨厌你。我讨厌C++。事实上,这几乎是最干净的例子,这证明了我的观点! - Lightness Races in Orbit
我同意@LightnessRacesinOrbit的观点,如果你创建了一对,你应该将这个所有权作为一对来处理,而不是试图在所有者之间分割对象。这似乎从一开始就是一个糟糕的设计。但有时委员会会失败。 - David Haim
@LightnessRacesinOrbit 我同意 "ew" 这部分,其余的是我添加的 :) - David Haim
非常有趣。我一直认为shared_ptr中的指针是一种优化,可以避免通过控制块进行双重间接引用。 - Adrian McCarthy

2

附加链接到@Angew的答案:

Peter Dimov、Beman Dawes和Greg Colvin通过第一个库技术报告(TR1)提出了shared_ptr和weak_ptr,希望将其纳入标准库。该提案被接受,并最终成为C++标准的一部分,包括2011年的版本。

boost智能指针历史

在这个提案中,作者指出了“共享指针别名”的用法:

高级用户通常需要创建一个shared_ptr实例p,它与另一个(master)shared_ptr q共享所有权,但指向不是*q的基类的对象。*p可以是*q的成员或元素,例如。本节提出了一个额外的构造函数,可用于此目的。

因此,他们在控制块中添加了一个额外的指针。


0

控制块不可避免地需要支持弱指针。在对象销毁时通知所有弱指针并不总是可行的(事实上,几乎总是不可行的)。因此,弱指针需要有东西可以指向,直到它们全部消失。因此,某个内存块必须保留下来。那个内存块就是控制块。有时它们可能会一起分配,但将它们分开分配可以让您回收一个潜在昂贵的对象,同时保留廉价的控制块。

一般规则是,只要存在单个共享指针或弱指针引用它,控制块就会持续存在,而当没有共享指针指向对象时,该对象就可以被回收。

这也允许在分配后将对象带入共享所有权的情况。make_shared可能能够将这两个概念捆绑在一起成为一个内存块,但shared_ptr<T>(new T)必须先分配T,然后在事后找出如何共享它。当这是不可取的时候,boost有一个相关的intrusive_ptr概念,它直接在对象内部进行引用计数,而不是使用控制块(您必须自己编写增量和减量运算符才能使其工作)。

我见过一些没有控制块的共享指针实现。相反,这些共享指针之间会建立一个链表。只要链表中包含一个或多个共享指针,对象就仍然存在。然而,在多线程场景下,这种方法更加复杂,因为你需要维护链表而不是简单的引用计数。在许多需要重复分配共享指针的场景中,它的运行时也可能更差,因为链表更加笨重。
另外,高性能的实现方式可以对控制块进行池化分配,将使用它们的成本降至几乎为零。

1
问题是“为什么控制块包含存储指针的副本”,而不是“为什么有控制块?” - M.M

-1
让我们看一个 std::shared_ptr<int>。这是一个引用计数的智能指针,指向一个 int*。现在,int* 没有引用计数信息,而 shared_ptr 对象本身也不能持有引用计数信息,因为它可能在引用计数降至零之前就被销毁了。
这意味着我们必须有一个中间对象来保存控制信息,该对象保证在引用计数降至零之前始终存在。
话虽如此,如果您使用 make_shared 创建 shared_ptr,则 int 和控制块都将在连续的内存中创建,使得解除引用更加高效。

1
你没有回答问题,因为根据你的解释,只有指向控制块的指针就足够了,而 get() 可以返回指向控制块内实际对象的指针。 - Slava
3
据我理解这个问题(并试图澄清它),问题不在于为什么有“指向控制块的指针”和“指向对象的指针”。问题在于为什么有“指向控制块的指针”,然后是“存储在shared_ptr内部的指向对象的指针”,最后是“存储在控制块内部的指向对象的指针”。 - Angew is no longer proud of SO
@Angew的问题是,为什么只需要一个原始指针就足够了,而现在却有两个原始指针。 - Slava
@Slava 我认为原始问题存在歧义。是的,它询问为什么有两个指针,但它询问为什么我们不能只存储由 get() 返回的指针。也就是说,OP似乎认为控制块要么是不必要的,要么可以由 get() 指针引用。在这个答案描述的情况下,这两种选项都不可行。 - David K
@DavidK的第一个问题“(除了指向控制块的指针)”消除了这种歧义。看起来OP错误地认为get()返回指向控制块的指针。 - Slava
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接