std::weak_ptr在什么情况下有用?

364
我开始学习C++11的智能指针,但我没有看到std::weak_ptr的任何有用的用途。有人可以告诉我什么时候使用std::weak_ptr是有用/必要的吗?

15个回答

390

std::weak_ptr是解决悬空指针问题的一种非常好的方式。仅使用原始指针无法确定引用的数据是否已被释放。相反,通过让std::shared_ptr管理数据,并向数据的用户提供std::weak_ptr,用户可以通过调用expired()lock()来检查数据的有效性。

仅使用std::shared_ptr无法做到这一点,因为所有std::shared_ptr实例共享数据的所有权,在所有std::shared_ptr实例被删除之前不会被移除。以下是使用lock()检查悬空指针的示例:

#include <iostream>
#include <memory>

int main()
{
    // OLD, problem with dangling pointer
    // PROBLEM: ref will point to undefined data!

    int* ptr = new int(10);
    int* ref = ptr;
    delete ptr;

    // NEW
    // SOLUTION: check expired() or lock() to determine if pointer is valid

    // empty definition
    std::shared_ptr<int> sptr;

    // takes ownership of pointer
    sptr.reset(new int);
    *sptr = 10;

    // get pointer to data without taking ownership
    std::weak_ptr<int> weak1 = sptr;

    // deletes managed object, acquires new pointer
    sptr.reset(new int);
    *sptr = 5;

    // get pointer to new data without taking ownership
    std::weak_ptr<int> weak2 = sptr;

    // weak1 is expired!
    if(auto tmp = weak1.lock())
        std::cout << "weak1 value is " << *tmp << '\n';
    else
        std::cout << "weak1 is expired\n";
    
    // weak2 points to new data (5)
    if(auto tmp = weak2.lock())
        std::cout << "weak2 value is " << *tmp << '\n';
    else
        std::cout << "weak2 is expired\n";
}

输出

weak1 is expired
weak2 value is 5

1
好的,就好像如果你在本地将一个(拥有)指针设置为null(删除内存),所有指向相同内存的其他(弱)指针也会被设置为null。 - user
1
std::weak_ptr::lock 创建一个新的 std::shared_ptr,共享所管理对象的所有权。 - Sahib Yar
了解了概念。但是实际使用案例是什么?有例子吗? - Fennekin
@Fennekin weak_ptr 的使用情况与基于引用的语言(如 Java)中的弱引用几乎相同,也就是说,非常少! - curiousguy

292
一个很好的例子就是缓存。对于最近访问的对象,您希望将它们保留在内存中,因此您会持有一个强引用指向它们。定期地,您会扫描缓存并决定哪些对象最近没有被访问。您不需要在内存中保存这些对象,因此可以丢弃强引用。但是,如果该对象正在使用,并且其他代码持有一个强引用指向它,如果缓存丢弃了对该对象的唯一引用,那么它就再也找不到了。因此,缓存会对它需要查找的对象保持弱引用,以便在它们仍然存在时可以找到它们。这正是弱引用所做的--它允许您在对象仍然存在时定位它,但如果没有任何其他东西需要它,则不会使其继续存在。

17
所以,std::weak_ptr 只能指向另一个指针所指向的位置,并且当所指对象被删除或不再被任何其他指针所指向时,它会指向 nullptr? - user1434698
41
基本上是这样的。当你有一个弱指针时,你可以尝试将其转换为一个强指针。如果该对象仍然存在(因为它仍然至少有一个强指针),那么该操作就会成功,并且给您一个指向该对象的强指针。如果该对象不存在(因为所有的强指针都消失了),则该操作失败(通常您会放弃该弱指针)。 - David Schwartz
25
一个强指针可以使对象保持存活状态,而一个 weak_ptr 可以查看它,而不会干扰对象的生命周期。 - The Vivandiere
5
另一个例子,我至少用过几次的是,在实现观察者模式时,有时将主题(subject)维护一个弱指针列表并自行进行列表清理会更加方便。这样可以省去显式删除已删除观察者(observer)所需的一些工作量,更重要的是,销毁观察者时不需要拥有关于subjects的信息,这通常可以大大简化事情。 - Jason C
5
等一下,缓存保存 shared_ptr 对象,并在需要从内存中清除时将其从列表中移除有什么问题吗?所有用户仍然会持有相同的 shared_ptr 对象,并且只要所有用户完成使用,缓存的资源就会被清除。 - rubenvb
显示剩余17条评论

180

另一个答案,希望更简单易懂。(供其他谷歌搜索者参考)

假设你有TeamMember对象。

很明显,这是一种关系: Team对象将具有指向其Members的指针。并且成员也很可能会有一个回指针指向他们的Team对象。

然后,你就会出现依赖环。如果你使用shared_ptr,当你放弃对它们的引用时,对象将不再自动释放,因为它们以循环方式相互引用。这就是内存泄漏。

你可以通过使用weak_ptr来打破这种情况。 "所有者"通常使用shared_ptr,而"被拥有者"则使用一个指向其父级别的weak_ptr,并在需要访问其父级别时将其暂时转换为shared_ptr

存储一个弱指针:

weak_ptr<Parent> parentWeakPtr_ = parentSharedPtr; // automatic conversion to weak from shared

需要时使用它。

shared_ptr<Parent> tempParentSharedPtr = parentWeakPtr_.lock(); // on the stack, from the weak ptr
if( !tempParentSharedPtr ) {
  // yes, it may fail if the parent was freed since we stored weak_ptr
} else {
  // do stuff
}
// tempParentSharedPtr is released when it goes out of scope

2
这怎么会是内存泄漏呢?如果团队被销毁,它将销毁其成员,因此 shared_ptr 的引用计数将为 0 并且也被销毁了。 - paulm
10
@paulm 团队不会摧毁“它的”成员。 shared_ptr 的整个意义在于共享所有权,因此没有人有特定的责任来释放内存,当不再使用时,内存会自动释放。除非存在循环引用......您可能有几个团队共享同一个球员(过去的团队?)。如果团队对象“拥有”成员,则首先无需使用 shared_ptr - Offirmo
2
@paulm 你是正确的。但由于在这个例子中,团队也是被其“成员”引用的 shared_ptr,那么它什么时候会被销毁呢?你所描述的是没有循环的情况。 - Offirmo
15
不太糟糕,我想。如果一个成员可以属于多个团队,使用引用就行不通了。 - Mazyod
2
在调用“lock”以获取shared_ptr之前,还可以调用方法“expired”来确保对象是否存活。 - Rajesh
显示剩余4条评论

31

以下是一个例子,由 @jleahy 给我提供:假设您有一组异步执行的任务,并由 std::shared_ptr<Task> 管理。 您可能想定期执行某些操作,因此计时器事件可能会遍历 std::vector<std::weak_ptr<Task>> 并为任务提供要做的事情。但是,同时任务可能已经决定不再需要并且终止了。计时器可以通过从弱指针创建共享指针并使用该共享指针(前提是它不是空的)来检查任务是否仍然存在。


6
听起来是一个很好的例子,但您能否详细解释一下您的例子?我认为当任务完成时,它应该已经从std::vector<std::weak_ptr<Task>>中删除,而不需要进行定期检查。因此,不确定std::vector<std::weak_ptr<>>在这里是否非常有用。 - RoundPi
类似于队列的注释:假设您有对象并将它们排队等待某些资源,那么在等待期间可能会删除对象。因此,如果您将weak_ptrs排队,则无需担心从队列中删除条目。当遇到时,weak_ptrs将被使无效并丢弃。 - uuu777
2
@zzz777:使对象失效的逻辑可能甚至不知道观察者队列或向量的存在。因此,观察者在弱指针上执行单独的循环,对仍然存活的指针进行操作,并从容器中删除死亡的指针... - Kerrek SB
1
@KerekSB:是的,在队列的情况下,您甚至不需要一个单独的循环 - 当资源可用时,您可以丢弃过期的weak_ptrs(如果有),直到获得有效的weak_ptr(如果有)。 - uuu777
1
你也可以让线程自行从集合中移除,但这会创建依赖并需要锁定。 - curiousguy
(场景1:拥有weak_ptr,通过它实例化一个临时的shared_ptr,然后使用该临时的shared_ptr。场景2:检查主shared_ptr是否有效或为nullptr/NULL?难道你不觉得场景2更直接(而且可能更快)吗?这件事情让我感到很困惑,我仍然想知道为什么我们需要weak_ptr?(除了解决循环依赖关系)。非常感谢!) - Milan

24
在使用指针时,了解可用的不同类型的指针以及何时使用每种类型的指针是很重要的。有两类四种指针,分别如下:
原始指针: - 原始指针 [即 SomeClass* ptrToSomeClass = new SomeClass();]
智能指针: - 独占指针 [即
std::unique_ptr<SomeClass> uniquePtrToSomeClass ( new SomeClass() );
] - 共享指针 [即
std::shared_ptr<SomeClass> sharedPtrToSomeClass ( new SomeClass() );
] - 弱指针 [即
std::weak_ptr<SomeClass> weakPtrToSomeWeakOrSharedPtr ( weakOrSharedPtr );
]
原始指针(有时称为“遗留指针”或“C指针”)提供了“裸指针”行为,并且是错误和内存泄漏的常见源头。原始指针没有跟踪资源所有权的手段,开发人员必须手动调用“delete”来确保它们不会创建内存泄漏。如果共享资源,则可能很难知道是否仍然有任何对象指向该资源。因此,通常应避免使用原始指针,并仅在代码的性能关键部分中使用具有有限范围的原始指针。
独占指针是一种基本的智能指针,它“拥有”底层的原始指针,并负责在“拥有”独占指针的对象超出范围时调用delete并释放分配的内存。名称“unique”指的是在给定时间只有一个对象可以“拥有”独占指针。所有权可以通过移动命令转移到另一个对象,但独占指针永远不能被复制或共享。因此,在只有一个对象需要指向指针的情况下,独占指针是替代原始指针的好选择,并且这使得开发人员无需在拥有对象的生命周期结束时释放内存。
共享指针是另一种类型的智能指针,类似于独占指针,但允许许多对象拥有共享指针。与独占指针一样,共享指针负责在所有对象停止指向资源时释放分配的内存。它通过称为引用计数的技术来实现。每当一个新对象接管共享指针时,引用计数就会增加一。同样,当对象超出范围或停止指向资源时,引用计数将减少一。当引用计数达到零时,分配的内存将被释放。因此,共享指针是一种非常强大的智能指针类型,应该在多个对象需要指向同一资源时使用。
最后,弱指针是另一种智能指针类型,它不直接指向资源,而是指向另一个指针(弱指针或共享指针)。弱指针无法直接访问对象,但它们可以告诉您对象是否仍然存在或已过期。弱指针可以暂时转换为共享指针以访问所指向的对象(前提是它仍然存在)。为了说明这一点,请考虑以下示例:
  • 您很忙,有重叠的会议:会议A和会议B
  • 您决定去参加会议A,您的同事去参加会议B
  • 您告诉同事,如果会议B在会议A结束后仍在进行,您将加入
  • 以下两种情况可能发生:
    • 会议A结束,会议B仍在进行中,因此您加入
    • 会议A结束,会议B也已结束,因此您无法加入

在这个例子中,您拥有对会议B的弱指针。您不是会议B的“所有者”,因此它可以在没有您的情况下结束,并且除非您检查,否则您不知道它是否已经结束。如果它没有结束,您可以加入并参与,否则您就不能加入。这与拥有对会议B的共享指针不同,因为您将成为会议A和会议B的“所有者”(同时参加两个会议)。

该示例说明了弱指针的工作原理,并且在对象需要成为外部观察者但不想承担共享所有权的责任时非常有用。这在两个对象需要相互指向(即循环引用)的情况下特别有用。使用共享指针,两个对象都无法释放,因为它们仍然被对方强烈地指向。当其中一个指针是弱指针时,持有弱指针的对象仍然可以在需要时访问另一个对象,前提是它仍然存在。


不应该避免使用原始指针。应该避免通过原始指针拥有一个对象,但在引用而不拥有某个东西的情况下,原始指针非常有用。例如,一个指向父对象的对象。 - Cris Luengo

22

当您在调用异步处理程序时不能保证目标对象仍然存在时,Boost.Asioweak_ptr 结合使用非常有用。诀窍是将一个 weak_ptr 绑定到异步处理程序对象中,使用 std::bind 或 lambda 捕获。

void MyClass::startTimer()
{
    std::weak_ptr<MyClass> weak = shared_from_this();
    timer_.async_wait( [weak](const boost::system::error_code& ec)
    {
        auto self = weak.lock();
        if (self)
        {
            self->handleTimeout();
        }
        else
        {
            std::cout << "Target object no longer exists!\n";
        }
    } );
}

这是 Boost.Asio 示例中经常看到的 self = shared_from_this() 惯用法的变种,其中一个挂起的异步处理程序将不会延长目标对象的生命周期,但如果目标对象被删除,它仍然是安全的。


为什么找到这个答案花了这么长时间...顺便说一句,你没有使用this的捕获。 - Orwellophile
@Orwellophile 已修复。在处理程序内调用同一类中的方法时,使用 self = shared_from_this() 習慣成自然。 - Emile Cormier

21

shared_ptr :保存真实对象。

weak_ptr :使用lock连接到实际所有者,否则返回一个空的shared_ptr

weak ptr

粗略地说,weak_ptr 的作用类似于房屋中介的角色。没有中介,想要租一间房子我们可能需要在城市里随便找一些房子。而房屋中介可以确保我们只访问那些仍然可访问和可用于租赁的房子。


场景1:拥有weak_ptr,通过它实例化一个临时的shared_ptr,然后使用该临时shared_ptr。场景2:检查主shared_ptr是否有效或为nullptr/NULL?你不认为场景2更直接(可能更快)吗?这件事让我很困惑,我仍然在想为什么我们需要weak_ptr(除了解决循环依赖)。非常感谢! - Milan
1
就我而言,std::weak_ptr将保证lock是原子性的。因此,情况2可能会错过一些情况:在您检查它是否有效之后,您将将其分配给另一个变量;如果线程切换并拦截了分配,并且在原始std::shared_ptr清空其资源之间发生了这种情况,那么您将在分配中获得一个空指针,但在代码块中将其视为有效,这可能会导致奇怪的崩溃。情况1不会出现这种情况,因为所有操作都是同时进行的(相当于)。 - o_oTurtle

18

weak_ptr 也适合检查对象的正确删除 - 特别是在单元测试中。 典型的用法可能看起来像这样:

std::weak_ptr<X> weak_x{ shared_x };
shared_x.reset();
BOOST_CHECK(weak_x.lock());
... //do something that should remove all other copies of shared_x and hence destroy x
BOOST_CHECK(!weak_x.lock());

9
除了其它已经提到的有效用例外,std::weak_ptr 在多线程环境中是一个非常好用的工具,因为:
  • 它不拥有对象,因此不会阻碍在另一个线程中进行删除
  • std::shared_ptrstd::weak_ptr 结合使用可以安全地避免悬垂指针问题,与 std::unique_ptr 结合使用的原始指针相比更加可靠
  • std::weak_ptr::lock() 是一个原子操作(参见:关于 weak_ptr 的线程安全性
考虑一项任务:同时加载目录中的所有图像(约10,000张)到内存中(例如作为缩略图缓存)。显然,最好的方法是有一个控制线程来处理和管理这些图像,以及多个工作线程来加载这些图像。现在这是一个很容易的任务。下面是一个非常简单的实现(省略了 join() 等细节,并且在真实的实现中需要以不同方式处理线程等)。
// a simplified class to hold the thumbnail and data
struct ImageData {
  std::string path;
  std::unique_ptr<YourFavoriteImageLibData> image;
};

// a simplified reader fn
void read( std::vector<std::shared_ptr<ImageData>> imagesToLoad ) {
   for( auto& imageData : imagesToLoad )
     imageData->image = YourFavoriteImageLib::load( imageData->path );
}

// a simplified manager
class Manager {
   std::vector<std::shared_ptr<ImageData>> m_imageDatas;
   std::vector<std::unique_ptr<std::thread>> m_threads;
public:
   void load( const std::string& folderPath ) {
      std::vector<std::string> imagePaths = readFolder( folderPath );
      m_imageDatas = createImageDatas( imagePaths );
      const unsigned numThreads = std::thread::hardware_concurrency();
      std::vector<std::vector<std::shared_ptr<ImageData>>> splitDatas = 
        splitImageDatas( m_imageDatas, numThreads );
      for( auto& dataRangeToLoad : splitDatas )
        m_threads.push_back( std::make_unique<std::thread>(read, dataRangeToLoad) );
   }
};

但如果你想中断图片的加载,例如因为用户选择了不同的目录,或者甚至想要销毁管理器,情况就会变得更加复杂。

你需要进行线程通信,并在更改 m_imageDatas 字段之前停止所有的加载器线程。否则,即使它们已经过时,加载器仍将继续加载,直到所有图片都完成。在简化的示例中,这并不太难,但在实际环境中,事情可能会变得更加复杂。

这些线程可能是多个管理器使用的线程池的一部分,其中一些正在停止,一些没有,等等。简单的参数 imagesToLoad 将是一个被锁定的队列,这些管理器从不同的控制线程推送其图像请求,读取器以任意顺序在另一端弹出这些请求。因此,通信变得困难、缓慢和容易出错。在这种情况下避免任何额外的通信的一种非常优雅的方式是使用 std::shared_ptrstd::weak_ptr 结合使用。

// a simplified reader fn
void read( std::vector<std::weak_ptr<ImageData>> imagesToLoad ) {
   for( auto& imageDataWeak : imagesToLoad ) {
     std::shared_ptr<ImageData> imageData = imageDataWeak.lock();
     if( !imageData )
        continue;
     imageData->image = YourFavoriteImageLib::load( imageData->path );
   }
}

// a simplified manager
class Manager {
   std::vector<std::shared_ptr<ImageData>> m_imageDatas;
   std::vector<std::unique_ptr<std::thread>> m_threads;
public:
   void load( const std::string& folderPath ) {
      std::vector<std::string> imagePaths = readFolder( folderPath );
      m_imageDatas = createImageDatas( imagePaths );
      const unsigned numThreads = std::thread::hardware_concurrency();
      std::vector<std::vector<std::weak_ptr<ImageData>>> splitDatas = 
        splitImageDatasToWeak( m_imageDatas, numThreads );
      for( auto& dataRangeToLoad : splitDatas )
        m_threads.push_back( std::make_unique<std::thread>(read, dataRangeToLoad) );
   }
};

这种实现方式几乎和第一种一样简单,不需要任何额外的线程通信,并且可以成为线程池/队列中的一部分。由于跳过了过期的图像,处理未过期的图像,线程在正常运行期间永远不需要停止。您可以随时安全地更改路径或销毁管理器,因为读取器检查是否拥有的指针已过期。


8

我看到了很多有趣的答案,解释了引用计数等内容,但是缺少一个简单的示例,演示如何使用weak_ptr来防止内存泄漏。在第一个示例中,我在循环引用的类中使用了shared_ptr。当这些类超出范围时,它们不会被销毁。

#include<iostream>
#include<memory>
using namespace std;

class B;

class A
{
public:
    shared_ptr<B>bptr;
    A() {
        cout << "A created" << endl;
    }
    ~A() {
        cout << "A destroyed" << endl;
    }
};

class B
{
public:
    shared_ptr<A>aptr;
    B() {
        cout << "B created" << endl;
    }
    ~B() {
        cout << "B destroyed" << endl;
    }
};

int main()
{
    {
        shared_ptr<A> a = make_shared<A>();
        shared_ptr<B> b = make_shared<B>();
        a->bptr = b;
        b->aptr = a;
    }
  // put breakpoint here
}

如果你运行代码片段,你会看到类被创建了,但不会被销毁:
A created
B created

现在我们将shared_ptr改为weak_ptr:
class B;
class A
{
public:
    weak_ptr<B>bptr;

    A() {
        cout << "A created" << endl;
    }
    ~A() {
        cout << "A destroyed" << endl;
    }
};

class B
{
public:
    weak_ptr<A>aptr;

    B() {
        cout << "B created" << endl;
    }
    ~B() {
        cout << "B destroyed" << endl;
    }
};

    int main()
    {
        {
            shared_ptr<A> a = make_shared<A>();
            shared_ptr<B> b = make_shared<B>();
            a->bptr = b;
            b->aptr = a;
        }
      // put breakpoint here
    }

这次,当使用`weak_ptr`时,我们可以看到适当的类销毁:
A created
B created
B destroyed
A destroyed

2
像这样的工作示例,胜过千言万语。谢谢。 - daparic

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