22-7 std::shared_ptr 与 std::weak_ptr 的循环依赖问题

在上节课中,我们了解到std::shared_ptr如何使多个智能指针共同拥有同一资源。然而在某些情况下,这种机制可能引发问题。请考虑以下情形:两个独立对象中的共享指针各自指向对方对象:

#include <iostream>
#include <memory> // for std::shared_ptr
#include <string>

class Person
{
	std::string m_name;
	std::shared_ptr<Person> m_partner; // initially created empty

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") }; // create a Person named "Lucy"
	auto ricky { std::make_shared<Person>("Ricky") }; // create a Person named "Ricky"

	partnerUp(lucy, ricky); // Make "Lucy" point to "Ricky" and vice-versa

	return 0;
}

在上例中,我们使用 make_shared() 动态分配了两个 Person 对象“Lucy”和“Ricky”(确保 lucy 和 ricky 在 main() 结束时被销毁)。随后将它们配对。这使得“Lucy”内部的std::shared_ptr指向“Ricky”,而“Ricky”内部的std::shared_ptr指向“Lucy”。共享指针本就用于共享,因此Lucy的共享指针和Ricky的m_partner共享指针同时指向“Lucy”(反之亦然)是完全正常的。

然而该程序运行结果与预期不符:

image

就这样。没有发生任何内存释放。噢,不妙。发生了什么?

在调用partnerUp()之后,存在两个指向“Ricky”的共享指针(ricky和Lucy的m_partner),以及两个指向“Lucy”的共享指针(lucy和Ricky的m_partner)。

在main()结束时,ricky共享指针首先超出作用域。此时ricky会检查是否存在其他共享指针共同拥有“Ricky”对象(确实存在Lucy的m_partner)。因此它不会释放“Ricky”(否则Lucy的m_partner将变成悬空指针)。此时,我们拥有一个指向“Ricky”的共享指针(Lucy的m_partner)和两个指向“Lucy”的共享指针(lucy和Ricky的m_partner)。

接着lucy共享指针退出作用域,同样的情况再次发生。共享指针lucy检查是否存在其他共享指针共同拥有Person“Lucy”。存在(Ricky的m_partner),因此“Lucy”未被释放。此时存在指向“Lucy”的共享指针(Ricky的m_partner)和指向“Ricky”的共享指针(Lucy的m_partner)。

随后程序结束——但Person类对象“Lucy”和“Ricky”均未被释放!本质上,“Lucy”阻止了“Ricky”的销毁,而“Ricky”同样阻止了“Lucy”的销毁。

这种情况在共享指针形成循环引用时随时可能发生。


循环引用

循环引用Circular reference(也称为周期性引用cyclical reference循环cycle)是一系列相互引用的对象,其中每个对象引用下一个对象,而最后一个对象又回指第一个对象,从而形成引用循环。这些引用不必是实际的C++引用——它们可以是指针、唯一标识符或其他任何用于识别特定对象的机制。

在共享指针的语境中,这些引用即为指针。

这正是上文案例的体现:“Lucy”指向“Ricky”,而“Ricky”指向“Lucy”。若使用三个指针,当A指向B、B指向C、C指向A时,同样会形成循环。共享指针形成循环的实际效果是:每个对象都维持着下一个对象的生命周期——最后一个对象维持着第一个对象的生命周期。因此,该系列中的任何对象都无法被释放,因为它们都认为其他对象仍需要它!


一种可还原的情况

事实证明,这种循环引用问题甚至可能发生在单个 std::shared_ptr 上——当 std::shared_ptr 引用包含自身的对象时,仍会形成循环(只是可还原的循环)。尽管这种情况在实际中极少发生,但为加深理解,我们仍将展示该情形:

#include <iostream>
#include <memory> // for std::shared_ptr

class Resource
{
public:
	std::shared_ptr<Resource> m_ptr {}; // initially created empty

	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 is now sharing the Resource that contains it

	return 0;
}

在上例中,当ptr1作用域结束时,资源并未被释放,因为该资源的m_ptr正共享着该资源。此时释放资源的唯一方式是将m_ptr赋值为其他对象(使资源不再被共享)。但由于ptr1已超出作用域,我们无法访问m_ptr,因此再也无法执行此操作。该Resource已演变为内存泄漏。

因此程序输出:

image

就这样。


那么std::weak_ptr究竟有什么用?

std::weak_ptr的设计初衷正是为了解决上述“循环引用”问题。它本质上是观察者——能像std::shared_ptr(或其他std::weak_ptr)一样观察和访问同一对象,但不被视为所有者。请记住:当 std::shared_ptr 作用域结束时,它仅会检查其他 std::shared_ptr 是否共同拥有该对象。而 std::weak_ptr 不会被计入!

现在让我们用 std::weak_ptr 解决这个人物关系问题:

#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; // note: This is now a 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;
}

这段代码运行正常:

image

功能上,它与存在问题的示例几乎完全相同。然而,当 ricky 作用域结束时,系统会检测到没有其他 std::shared_ptr 指向“Ricky”(来自“Lucy”的 std::weak_ptr 不计入其中)。因此,它将释放“Ricky”的内存。Lucy 的情况也是如此。


使用 std::weak_ptr

std::weak_ptr 的一个缺点是无法直接使用(它们没有 operator-> 运算符)。要使用 std::weak_ptr,必须先将其转换为 std::shared_ptr,然后才能使用 std::shared_ptr。可通过 lock() 成员函数将 std::weak_ptr 转换为 std::shared_ptr。以下是更新后的示例代码,演示该转换过程:

#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; // note: This is now a 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(); } // use lock() to convert weak_ptr to 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(); // get shared_ptr to Ricky's partner
	std::cout << ricky->getName() << "'s partner is: " << partner->getName() << '\n';

	return 0;
}

这将输出:

image

我们无需担心std::shared_ptr变量“partner”会引发循环依赖,因为它只是函数内的局部变量。该变量最终会在函数结束时超出作用域,其引用计数也会相应减少1。


使用 std::weak_ptr 避免悬空指针

假设一个普通的“笨指针”持有某个对象的地址,随后该对象被销毁。此时该指针便成为悬空指针,对其解引用将导致未定义行为。遗憾的是,我们无法判断持有非空地址的指针是否悬空。这正是普通指针危险性的重要根源。

由于 std::weak_ptr 不会强制保留所引用的资源,同样可能出现 std::weak_ptr 指向已被 std::shared_ptr 释放的资源的情况。然而,std::weak_ptr 暗藏妙招——由于它能访问对象的引用计数,因此可判断所指向对象是否有效!若引用计数非零,则资源仍有效;若引用计数为零,则资源已被销毁。

检测 std::weak_ptr 有效性的最简便方式是调用 expired() 成员函数:若指针指向无效对象则返回 true,否则返回 false。

以下示例清晰展示了这种行为差异:

// h/t to reader Waldo for an early version of this example
#include <iostream>
#include <memory>

class Resource
{
public:
	Resource() { std::cerr << "Resource acquired\n"; }
	~Resource() { std::cerr << "Resource destroyed\n"; }
};

// Returns a std::weak_ptr to an invalid object
std::weak_ptr<Resource> getWeakPtr()
{
	auto ptr{ std::make_shared<Resource>() };
	return std::weak_ptr<Resource>{ ptr };
} // ptr goes out of scope, Resource destroyed

// Returns a dumb pointer to an invalid object
Resource* getDumbPtr()
{
	auto ptr{ std::make_unique<Resource>() };
	return ptr.get();
} // ptr goes out of scope, Resource destroyed

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;
}

这将输出:

image

getDumbPtr() 和 getWeakPtr() 都使用智能指针分配资源——该智能指针确保分配的资源将在函数结束时被销毁。当 getDumbPtr() 返回 Resource* 时,它会返回一个悬空指针(因为 std::unique_ptr 在函数结束时销毁了该资源)。当 getWeakPtr() 返回 std::weak_ptr 时,该弱指针同样指向无效对象(因为 std::shared_ptr 会在函数结束时销毁 Resource)。

在 main() 中,我们首先检测返回的普通指针是否为 nullptr。由于该指针仍持有已释放资源的地址,此检测将失败。main() 无法判断该指针是否悬空。在此情况下,由于指针已悬空,若尝试解引用将导致未定义行为。

接着我们检测 weak.expired() 是否为 true。由于 weak 所指向对象的引用计数为 0(因被引用的对象已被销毁),此检测结果为 true。主函数中的代码由此可判定 weak 指向无效对象,从而实现代码的条件化处理!

需注意:若 std::weak_ptr 已过期,则不应调用其 lock() 方法,因为被引用的对象已遭销毁,不存在可共享的对象。若强行对过期 std::weak_ptr 调用 lock(),将返回指向 nullptr 的 std::shared_ptr。


结论

当需要多个智能指针共同拥有资源时,可使用 std::shared_ptr。当最后一个 std::shared_ptr 脱离作用域时,资源将被释放。当需要智能指针查看并使用共享资源,但不参与该资源所有权时,可使用 std::weak_ptr。


测验时间

问题 #1

请修正“简化案例”部分中的程序,确保资源被正确释放。请勿修改 main() 中的代码。

为方便参考,程序代码如下:

#include <iostream>
#include <memory> // for std::shared_ptr

class Resource
{
public:
	std::shared_ptr<Resource> m_ptr {}; // initially created empty

	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 is now sharing the Resource that contains it

	return 0;
}

显示答案:

#include <iostream>
#include <memory> // for std::shared_ptr and std::weak_ptr

class Resource
{
public:
	std::weak_ptr<Resource> m_ptr {}; // use std::weak_ptr so m_ptr doesn't keep the Resource alive

	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 is now sharing the Resource that contains it

	return 0;
}
posted @ 2026-01-29 20:44  游翔  阅读(0)  评论(0)    收藏  举报