在上一课中我们看到,std::shared_ptr 允许多个智能指针共同拥有同一资源。然而,在某些情况下,这会带来麻烦。考虑下面这个例子:两个独立对象中的 shared_ptr 彼此指向对方:
示例:共享所有权
#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") }; // 创建名为 “Lucy” 的 Person
auto ricky { std::make_shared<Person>("Ricky") }; // 创建名为 “Ricky” 的 Person
partnerUp(lucy, ricky); // 让 “Lucy” 指向 “Ricky”,反之亦然
return 0;
}
在这个例子中,我们用 make_shared() 动态分配了两个 Person 对象 “Lucy” 和 “Ricky”,以确保在 main 结束时它们能被销毁。随后调用 partnerUp,把 Lucy 对象中的 shared_ptr 指向 Ricky,反之亦然。共享指针本来就是为了共享,因此 Lucy 的拥有者指针和 Ricky 的 m_partner 指针都指向 “Lucy”(反之亦然)本身没有问题。
然而,程序并未按预期执行:
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 检查是否还有其他 shared_ptr 共同拥有 “Ricky”。答案是“有”(Lucy 的 m_partner)。因此 “Ricky” 不会被释放(否则 Lucy 的 m_partner 将变成悬空指针)。此时只剩一个 shared_ptr 指向 “Ricky”(Lucy 的 m_partner),而指向 “Lucy” 的仍有两个(lucy 和 Ricky 的 m_partner)。
接着 lucy 智能指针离开作用域,同样发现还有 shared_ptr(Ricky 的 m_partner)共同拥有 “Lucy”,于是 “Lucy” 也未被释放。最终程序结束——“Lucy” 和 “Ricky” 都没有被析构!实际上,“Lucy” 阻止了 “Ricky” 被销毁,“Ricky” 也阻止了 “Lucy” 被销毁。
只要 shared_ptr 形成循环引用,就会出现这种情况。
循环引用
循环引用(亦称环状引用或循环)是指一串引用,其中每个对象都指向下一个,最后一个又指回第一个,从而形成引用环。这些引用不一定是 C++ 引用,可以是裸指针、唯一 ID 或任何能标识对象的方式。
在 shared_ptr 语境下,引用就是指针。
上述例子正是如此:“Lucy” 指向 “Ricky”,而 “Ricky” 又指回 “Lucy”。如果换成三个对象,A→B→C→A 亦同理。其后果是:每个对象都把下一个对象“拽住”,最后一个又把第一个拽住,结果谁都无法被释放!
更简化的例子
甚至单个 std::shared_ptr 也可能出现循环引用——对象内部的 shared_ptr 指向自身,这依旧是循环(只不过是最简形式)。尽管实践中很少发生,但为便于理解,示例如下:
#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 的存在而不为 0,Resource 无法被释放。此时要想释放 Resource,只能把 m_ptr 换成别的值,但 ptr1 已离开作用域,我们已无法访问 m_ptr,于是 Resource 成为内存泄漏。程序输出:
Resource acquired
再无下文。
那么,std::weak_ptr 有什么用?
std::weak_ptr 正是为了解决上述“循环所有权”问题而设计。weak_ptr 是观察者——它能观察并访问与 std::shared_ptr(或其他 weak_ptr)相同的对象,但不参与所有权。请记住:当某个 shared_ptr 离开作用域时,它只关心是否还有其他 shared_ptr 共同拥有该对象;weak_ptr 不算!
用 std::weak_ptr 来解决 Person 的“人身”问题:
#include <iostream>
#include <memory> // for std::shared_ptr 和 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;
}
输出符合预期:
Lucy created
Ricky created
Lucy is now partnered with Ricky
Ricky destroyed
Lucy destroyed
功能上与前一个有问题的例子几乎一致,但现在当 ricky 离开作用域时,它发现没有别的 shared_ptr 指向 “Ricky”(Lucy 的 weak_ptr 不算),于是 “Ricky” 被释放;lucy 同理。
使用 std::weak_ptr
weak_ptr 的一个缺点是它不能直接使用(没有 operator->)。若想使用 weak_ptr,必须先把它转换成 std::shared_ptr,再使用该 shared_ptr。可用 lock() 成员函数完成转换。下面示例展示了这一点:
#include <iostream>
#include <memory> // for std::shared_ptr 和 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 搭档的 shared_ptr
std::cout << ricky->getName() << "'s partner is: " << partner->getName() << '\n';
return 0;
}
输出:
Lucy created
Ricky created
Lucy is now partnered with Ricky
Ricky's partner is: Lucy
Ricky destroyed
Lucy destroyed
不必担心 main 中的 shared_ptr 变量 partner 造成循环依赖——它只是一个局部变量,函数结束即离开作用域,引用计数减 1。
用 std::weak_ptr 避免悬空指针
考虑这种情况:一个普通“裸”指针仍保存某对象地址,而该对象已被销毁。该指针悬空,解引用会导致未定义行为,且我们无法判断一个非空指针是否悬空——这也是裸指针危险的重要原因。
由于 std::weak_ptr 不会延长对象生命周期,它同样可能指向已被 shared_ptr 释放的资源。但 weak_ptr 有一个巧妙之处:它能访问对象的引用计数,因此可以判断所指向对象是否仍有效!引用计数非零,对象有效;引用计数为零,对象已销毁。
最简单的检测方式是 expired() 成员函数:若 weak_ptr 指向无效对象则返回 true,否则返回 false。
示例对比:
// 感谢读者 Waldo 提供早期版本
#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 被销毁
// 返回指向无效对象的裸指针
Resource* getDumbPtr()
{
auto ptr{ std::make_unique<Resource>() };
return ptr.get();
} // ptr 离开作用域,Resource 被销毁
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;
}
输出:
Resource acquired
Resource destroyed
Our dumb ptr is: non-null
Resource acquired
Resource destroyed
Our weak ptr is: expired
getDumbPtr() 与 getWeakPtr() 都用智能指针分配 Resource,确保函数结束时 Resource 释放。getDumbPtr() 返回 Resource*,结果得到悬空指针;getWeakPtr() 返回 std::weak_ptr,同样指向已销毁对象。
main 中先检测 dumb 是否为 nullptr。由于 dumb 仍保存已释放资源的地址,检测失败;我们无法判断其是否悬空,若解引用将产生未定义行为。
随后检测 weak.expired(),由于被指向对象的引用计数为 0,返回 true。于是 main 能判断 weak 指向无效对象,并据此分支处理!
注意:若 weak_ptr 已过期,不应再对其调用 lock(),因为对象已销毁,无可共享。此时 lock() 会返回指向 nullptr 的 shared_ptr。
结论
需要多个智能指针共同拥有资源时,可使用 std::shared_ptr;当最后一个 shared_ptr 离开作用域,资源被释放。若希望观察并使用共享资源但不参与所有权,则可使用 std::weak_ptr。
测验
问题 1
修复“更简化的例子”中的程序,使 Resource 得以正确释放。不得修改 main() 中的代码。
原程序如下:
#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;
}
请给出解答。