std::shared_ptr的循环依赖性问题和std::weak_ptr
本节阅读量:
在上一课中,我们看到std::shared_ptr允许多个智能指针共同拥有同一资源。然而,在某些情况下,这可能会成为问题。考虑以下情况:两个独立对象中的std::shared_ptr都指向另一个对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
#include <iostream>
#include <memory> // for std::shared_ptr
#include <string>
class Person
{
std::string m_name;
std::shared_ptr<Person> m_partner; // 初始化为空
public:
Person(const std::string &name): m_name(name)
{
std::cout << m_name << " created\n";
}
~Person()
{
std::cout << m_name << " destroyed\n";
}
friend bool partnerUp(std::shared_ptr<Person> &p1, std::shared_ptr<Person> &p2)
{
if (!p1 || !p2)
return false;
p1->m_partner = p2;
p2->m_partner = p1;
std::cout << p1->m_name << " is now partnered with " << p2->m_name << '\n';
return true;
}
};
int main()
{
auto lucy { std::make_shared<Person>("Lucy") }; // 创建 Person "Lucy"
auto ricky { std::make_shared<Person>("Ricky") }; // 创建 Person "Ricky"
partnerUp(lucy, ricky); // 让 "Lucy" 指向 "Ricky" 同时 "Ricky" 指向 "Lucy"
return 0;
}
|
在上面的示例中,我们使用make_shared()动态分配了两个Person:“Lucy”和“Ricky”(期望Lucy和Ricky在main()末尾被销毁)。然后让它们互相指向。这会将“Lucy”中的std::shared_ptr设置为指向“Ricky”,并将“Ricky”中的std::shared_ptr设置为指向“Lucy”。
然而,该程序没有按预期执行:
1
2
3
|
Lucy created
Ricky created
Lucy is now partnered with Ricky
|
糟糕。发生了什么事?
调用partnerUp()后,有两个shared_ptr指向“Ricky”(ricky变量和lucy的m_partner),也有两个shared_ptr指向“Lucy”(lucy变量和ricky的m_partner)。
在main()末尾,ricky首先超出作用域。发生这种情况时,ricky会检查是否还有其他共享指针共同拥有Person “Ricky”。答案是有(lucy的m_partner)。因此,它不会释放“Ricky”(如果释放了,lucy的m_partner最终会成为悬空指针)。此时,有一个shared_ptr指向“Ricky”(lucy的m_partner),还有两个shared_ptr指向“Lucy”(lucy变量和ricky的m_partner)。
接下来,lucy超出作用域,同样的事情再次发生。lucy检查是否还有其他shared_ptr共同拥有Person “Lucy”。答案是有(ricky的m_partner),因此“Lucy”未被释放。此时,有一个shared_ptr指向“Lucy”(ricky的m_partner),也有一个shared_ptr指向“Ricky”(lucy的m_partner)。
然后程序结束,但“Lucy”和“Ricky”都没有被释放!本质上,“Lucy”最终让“Ricky”无法被销毁,而“Ricky”也最终让“Lucy”无法被销毁。
事实证明,只要共享指针形成循环引用,就可能发生这种情况。
循环引用
循环引用是一系列引用,其中每个对象引用下一个对象,最后一个对象引用回第一个对象,从而导致引用循环。
在shared_ptr的上下文中,这里的“引用”指的是shared_ptr。
这正是我们在上面的例子中看到的:“Lucy”指向“Ricky”,“Ricky”指向“Lucy”。如果有三个指针,当A指向B,B指向C,C又指向A时,也会得到相同结果。共享指针形成循环的实际效果是,每个对象最终都会让下一个对象保持存活,而最后一个对象又让第一个对象保持存活。因此,序列中的任何对象都不能被释放,因为它们都认为其他对象仍然需要它!
另一个案例
事实证明,这种循环引用问题甚至可以发生在单个std::shared_ptr中:引用包含自身的std::shared_ptr也是一个循环引用。尽管实践中不太可能发生这种情况,但它有助于加深理解:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
#include <iostream>
#include <memory> // for std::shared_ptr
class Resource
{
public:
std::shared_ptr<Resource> m_ptr {}; // 初始化为空
Resource() { std::cout << "Resource acquired\n"; }
~Resource() { std::cout << "Resource destroyed\n"; }
};
int main()
{
auto ptr1 { std::make_shared<Resource>() };
ptr1->m_ptr = ptr1; // m_ptr 现在指向被管理的对象自身
return 0;
}
|
在上面的示例中,当ptr1超出作用域时,资源不会被释放,因为m_ptr正在共享资源。此时,释放资源的唯一方法是将m_ptr设置为其他值(从而不再共享资源)。但由于ptr1已经超出作用域,无法再访问m_ptr,所以也就没有办法这样做。资源因此发生内存泄漏。
因此,程序打印:
那么,std::weak_ptr到底是什么?
std::weak_ptr旨在解决上面描述的“循环引用”问题。std::weak_ptr是一个观察者:它可以观察和访问std::shared_ptr(或其他std::weak_ptr)所指向的对象,但它不被视为所有者。请记住,当std::shared_ptr超出作用域时,它只考虑是否还有其他std::shared_ptr共同拥有该对象。std::weak_ptr不计入所有权!
让我们使用std::weak_ptr解决上面的问题:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
#include <iostream>
#include <memory> // for std::shared_ptr and std::weak_ptr
#include <string>
class Person
{
std::string m_name;
std::weak_ptr<Person> m_partner; // 注: 这里现在是 std::weak_ptr
public:
Person(const std::string &name): m_name(name)
{
std::cout << m_name << " created\n";
}
~Person()
{
std::cout << m_name << " destroyed\n";
}
friend bool partnerUp(std::shared_ptr<Person> &p1, std::shared_ptr<Person> &p2)
{
if (!p1 || !p2)
return false;
p1->m_partner = p2;
p2->m_partner = p1;
std::cout << p1->m_name << " is now partnered with " << p2->m_name << '\n';
return true;
}
};
int main()
{
auto lucy { std::make_shared<Person>("Lucy") };
auto ricky { std::make_shared<Person>("Ricky") };
partnerUp(lucy, ricky);
return 0;
}
|
此代码行为正常:
1
2
3
4
5
|
Lucy created
Ricky created
Lucy is now partnered with Ricky
Ricky destroyed
Lucy destroyed
|
代码上,它与有问题的示例几乎相同。然而,现在当ricky超出作用域时,它发现没有其他指向“Ricky”的std::shared_ptr(“Lucy”中的std::weak_ptr不计入所有权)。因此,它会释放“Ricky”。lucy也是如此。
使用std::weak_ptr
std::weak_ptr的一个缺点是不能直接使用(它没有operator->)。要使用std::weak_ptr,必须先将其转换为std::shared_ptr,然后再使用std::shared_ptr。要将std::weak_ptr转换为std::shared_ptr,可以使用lock()成员函数。下面用上面的示例展示这一点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
#include <iostream>
#include <memory> // for std::shared_ptr and std::weak_ptr
#include <string>
class Person
{
std::string m_name;
std::weak_ptr<Person> m_partner; // 注:这里现在是 std::weak_ptr
public:
Person(const std::string &name) : m_name(name)
{
std::cout << m_name << " created\n";
}
~Person()
{
std::cout << m_name << " destroyed\n";
}
friend bool partnerUp(std::shared_ptr<Person> &p1, std::shared_ptr<Person> &p2)
{
if (!p1 || !p2)
return false;
p1->m_partner = p2;
p2->m_partner = p1;
std::cout << p1->m_name << " is now partnered with " << p2->m_name << '\n';
return true;
}
std::shared_ptr<Person> getPartner() const { return m_partner.lock(); } // 使用 lock() 将 weak_ptr 转换成 shared_ptr
const std::string& getName() const { return m_name; }
};
int main()
{
auto lucy { std::make_shared<Person>("Lucy") };
auto ricky { std::make_shared<Person>("Ricky") };
partnerUp(lucy, ricky);
auto partner = ricky->getPartner(); // 获取 Ricky 的 partner 的 shared_ptr
std::cout << ricky->getName() << " partner is: " << partner->getName() << '\n';
return 0;
}
|
这将打印:
1
2
3
4
5
6
|
Lucy created
Ricky created
Lucy is now partnered with Ricky
Ricky partner is: Lucy
Ricky destroyed
Lucy destroyed
|
不必担心std::shared_ptr变量partner造成循环依赖,因为它只是函数内部的局部变量。它最终会在函数结束时超出作用域,引用计数也会减少。
使用std::weak_ptr避免悬空指针
考虑这样的情况:一个普通原始指针保存着某个对象的地址,然后该对象被销毁。这样的指针就是悬空指针,解引用它将导致未定义行为。不幸的是,没有办法确定一个持有非空地址的指针是否悬空。这正是原始指针危险的重要原因之一。
由于std::weak_ptr不会让所观察的资源保持存活,因此std::weak_ptr也可能仍然指向已经由std::shared_ptr释放的资源。然而,std::weak_ptr有一个巧妙机制:因为它可以访问对象的引用计数,所以它能确定自己是否指向有效对象!如果引用计数不为零,则资源仍然有效;如果引用计数为零,则资源已被销毁。
测试std::weak_ptr是否有效的最简单方法是使用expired()成员函数。如果std::weak_ptr指向无效对象,则返回true;否则返回false。
下面是一个简单的例子,展示了这种行为差异:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
#include <iostream>
#include <memory>
class Resource
{
public:
Resource() { std::cerr << "Resource acquired\n"; }
~Resource() { std::cerr << "Resource destroyed\n"; }
};
// 返回无效对象的 std::weak_ptr
std::weak_ptr<Resource> getWeakPtr()
{
auto ptr{ std::make_shared<Resource>() };
return std::weak_ptr<Resource>{ ptr };
} // ptr 超出作用域, 资源销毁
// 返回无效对象的原始指针
Resource* getDumbPtr()
{
auto ptr{ std::make_unique<Resource>() };
return ptr.get();
} // ptr 超出作用域, 资源销毁
int main()
{
auto dumb{ getDumbPtr() };
std::cout << "Our dumb ptr is: " << ((dumb == nullptr) ? "nullptr\n" : "non-null\n");
auto weak{ getWeakPtr() };
std::cout << "Our weak ptr is: " << ((weak.expired()) ? "expired\n" : "valid\n");
return 0;
}
|
这将打印:
1
2
3
4
5
6
|
Resource acquired
Resource destroyed
Our dumb ptr is: non-null
Resource acquired
Resource destroyed
Our weak ptr is: expired
|
当getDumbPtr()返回Resource*时,它返回的是一个悬空指针(因为std::unique_ptr在函数末尾销毁了Resource)。当getWeakPtr()返回std::weak_ptr时,该std::weak_ptr同样指向无效对象(因为std::shared_ptr在函数末尾销毁了资源)。
在main()中,我们首先测试返回的原始指针是否为nullptr。由于原始指针仍然保存已释放资源的地址,因此该测试失败。main()无法判断该指针是否悬空。在这种情况下,因为它是一个悬空指针,所以如果解引用它,将导致未定义行为。
接下来,测试weak.expired()是否为真。由于weak指向对象的引用计数为0(因为所指向的对象已被销毁),因此结果为true。因此,main()中的代码可以判断weak_ptr指向的是无效对象,并根据需要进行条件处理!
请注意,如果std::weak_ptr已经失效,则不应对其调用lock(),因为所指向的对象已经被销毁,没有可共享的对象。如果对失效的std::weak_ptr调用lock(),它将返回指向nullptr的std::shared_ptr。
结论
当您需要多个智能指针共同拥有资源时,可以使用std::shared_ptr。当最后一个std::shared_ptr超出作用域时,资源会被释放。当您希望智能指针可以查看和使用共享资源,但不参与该资源所有权时,可以使用std::weak_ptr。