22.7 — Problemy z zależnościami cyklicznymi ze std::shared_ptr i std::weak_ptr

W poprzedniej lekcji widzieliśmy, jak std::shared_ptr pozwoliło nam mieć wiele inteligentnych wskaźników będących współwłaścicielami tego samego zasobu. Jednak w niektórych przypadkach może to stać się problematyczne. Rozważmy następujący przypadek, w którym wspólne wskaźniki w dwóch oddzielnych obiektach wskazują każdy inny obiekt:

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

W powyższym przykładzie dynamicznie przydzielamy dwie Osoby, „Lucy” i „Ricky”, za pomocą make_shared() (aby mieć pewność, że Lucy i Ricky zostaną zniszczone na końcu main()). Następnie nawiązujemy z nimi współpracę. To ustawia std::shared_ptr wewnątrz „Lucy” na „Ricky”, a std::shared_ptr wewnątrz „Ricky” na „Lucy”. Wskaźniki współdzielone mają być współdzielone, więc nie ma nic złego w tym, że zarówno wspólny wskaźnik Lucy, jak i wskaźnik współdzielony m_partner Ricka wskazują na „Lucy” (i odwrotnie).

Jednak ten program nie działa zgodnie z oczekiwaniami:

Lucy created
Ricky created
Lucy is now partnered with Ricky

I to wszystko. Nie doszło do żadnych dealokacji. Uch, och. Co się stało?

Po wywołaniu partnerUp() istnieją dwa wspólne wskaźniki wskazujące na „Ricky” (Ricky i m_partner Lucy) oraz dwa wspólne wskaźniki wskazujące na „Lucy” (Lucy i m_partner Ricky'ego).

Na końcu main() Ricky wspólny wskaźnik najpierw wychodzi poza zakres. Kiedy tak się dzieje, Ricky sprawdza, czy istnieją inne wspólne wskazówki, które są współwłaścicielami Osoby „Ricky”. Są (m_partner Lucy). Z tego powodu nie zwalnia „Ricky” (gdyby tak było, m_partner Lucy skończyłby jako wiszący wskaźnik). W tym momencie mamy teraz jeden wspólny wskaźnik do „Ricky” (m_partner Lucy) i dwa wspólne wskaźniki do „Lucy” (Lucy i m_partner Ricky'ego).

Następnie wspólny wskaźnik Lucy wykracza poza zakres i dzieje się to samo. Udostępniony wskaźnik Lucy sprawdza, czy istnieją inne wspólne wskaźniki będące współwłaścicielami Osoby „Lucy”. Istnieją (m_partner Ricky'ego), więc „Lucy” nie jest zwalniana. W tym momencie istnieje jeden wspólny wskaźnik do „Lucy” (m_partner Ricky’ego) i jeden wspólny wskaźnik do „Ricky” (m_partner Lucy).

Następnie program się kończy — i żadna Osoba „Lucy” ani „Ricky” nie została zwolniona! Zasadniczo „Lucy” chroni „Ricky’ego” przed zniszczeniem, a „Ricky” chroni „Lucy” przed zniszczeniem.

Okazuje się, że może się to zdarzyć za każdym razem, gdy wspólne wskaźniki tworzą odniesienie cykliczne.

Odniesienia cykliczne

A Odwołanie cykliczne (zwana także cykliczne referencja lub a cykl) to seria odniesień, w której każdy obiekt odwołuje się do następnego, a ostatni obiekt odwołuje się z powrotem do pierwszego, powodując pętlę referencyjną. Referencje nie muszą być rzeczywistymi referencjami w C++ — mogą to być wskaźniki, unikalne identyfikatory lub inne sposoby identyfikowania konkretnych obiektów.

W kontekście wspólnych wskaźników referencje będą wskaźnikami.

Dokładnie to widzimy w powyższym przypadku: „Lucy” wskazuje na „Ricky”, a „Ricky” wskazuje na „Lucy”. Za pomocą trzech wskaźników uzyskasz to samo, gdy A wskazuje na B, B wskazuje na C, a C wskazuje na A. Praktyczny efekt posiadania wspólnych wskaźników w cyklu jest taki, że każdy obiekt w końcu utrzymuje przy życiu następny obiekt — przy czym ostatni obiekt utrzymuje przy życiu pierwszy obiekt. Zatem żaden obiekt w serii nie może zostać zwolniony, ponieważ wszyscy myślą, że jakiś inny obiekt nadal tego potrzebuje!

Przypadek redukcyjny

Okazuje się, że ten problem cyklicznego odniesienia może wystąpić nawet w przypadku pojedynczego std::shared_ptr -- std::shared_ptr odwołującego się do obiektu, który go zawiera, nadal jest cyklem (tylko redukcyjnym). Chociaż jest to dość mało prawdopodobne, aby coś takiego kiedykolwiek miało miejsce w praktyce, pokażemy Ci dla dodatkowego zrozumienia:

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

W powyższym przykładzie, gdy ptr1 wykracza poza zakres, zasób nie zostaje zwolniony, ponieważ m_ptr zasobu współdzieli zasób. W tym momencie jedynym sposobem na uwolnienie zasobu byłoby ustawienie m_ptr na coś innego (aby nic już nie dzieliło zasobu). Ale nie możemy uzyskać dostępu do m_ptr, ponieważ ptr1 jest poza zakresem, więc nie mamy już sposobu, aby to zrobić. Zasób spowodował wyciek pamięci.

W ten sposób program wypisuje:

Resource acquired

i to wszystko.

Więc do czego w ogóle służy std::weak_ptr?

std::weak_ptr został zaprojektowany, aby rozwiązać opisany powyżej problem „własności cyklicznej”. Std::weak_ptr jest obserwatorem — może obserwować i uzyskiwać dostęp do tego samego obiektu co std::shared_ptr (lub inny std::weak_ptrs), ale nie jest uważany za właściciela. Pamiętaj, że gdy wskaźnik std::shared wykracza poza zakres, uwzględniane jest tylko to, czy inne std::shared_ptr są współwłaścicielami obiektu. std::weak_ptr się nie liczy!

Rozwiążmy nasz problem osobisty za pomocą 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;
}

Ten kod zachowuje się poprawnie:

Lucy created
Ricky created
Lucy is now partnered with Ricky
Ricky destroyed
Lucy destroyed

Funkcjonalnie działa prawie identycznie jak problematyczny przykład. Jednak teraz, gdy Ricky wykracza poza zakres, widzi, że nie ma innych std::shared_ptr wskazujących na „Ricky” (std::weak_ptr z „Lucy” się nie liczy). Dlatego zwolni „Ricky”. To samo dotyczy Lucy.

Używanie std::weak_ptr

Jedną wadą std::weak_ptr jest to, że std::weak_ptr nie można bezpośrednio wykorzystać (nie mają operatora->). Aby użyć std::weak_ptr, musisz najpierw przekonwertować go na std::shared_ptr. Następnie możesz użyć std::shared_ptr. Aby przekonwertować std::weak_ptr na std::shared_ptr, możesz użyć funkcji członkowskiej lock(). Oto powyższy przykład, zaktualizowany, aby to pokazać:

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

Wypisuje:

Lucy created
Ricky created
Lucy is now partnered with Ricky
Ricky's partner is: Lucy
Ricky destroyed
Lucy destroyed

Nie musimy się martwić zależnościami cyklicznymi ze zmienną std::shared_ptr „partner”, ponieważ jest to po prostu zmienna lokalna wewnątrz funkcji. Ostatecznie na końcu funkcji wyjdzie poza zakres, a liczba referencji zostanie zmniejszona o 1.

Unikanie zwisających wskaźników za pomocą std::weak_ptr

Rozważmy przypadek, w którym normalny „głupi” wskaźnik przechowuje adres jakiegoś obiektu, a następnie obiekt ten ulega zniszczeniu. Taki wskaźnik zwisa, a wyłuskanie wskaźnika doprowadzi do niezdefiniowanego zachowania. I niestety nie możemy określić, czy wskaźnik zawierający adres inny niż null jest zwisający, czy nie. To w dużej mierze powód, dla którego głupie wskaźniki są niebezpieczne.

Ponieważ std::weak_ptr nie utrzyma przy życiu posiadanego zasobu, podobnie możliwe jest, że std::weak_ptr pozostanie wskazujący zasób, który został zwolniony przez std::shared_ptr. Jednak std::weak_ptr ma fajnego asa w rękawie — ponieważ ma dostęp do liczby referencji dla obiektu, może określić, czy wskazuje na prawidłowy obiekt, czy nie! Jeśli liczba odwołań jest różna od zera, zasób jest nadal ważny. Jeśli licznik referencji wynosi zero, oznacza to, że zasób został zniszczony.

Najłatwiejszym sposobem sprawdzenia, czy std::weak_ptr jest poprawny, jest użycie expired() funkcji członkowskiej, która zwraca true jeśli std::weak_ptr wskazuje na nieprawidłowy obiekt i false w przeciwnym razie.

Oto prosty przykład pokazujący ta różnica w zachowaniu:

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

Wypisuje:

Resource acquired
Resource destroyed
Our dumb ptr is: non-null
Resource acquired
Resource destroyed
Our weak ptr is: expired

Oba getDumbPtr() i getWeakPtr() użyj inteligentnego wskaźnika do alokacji zasobu — ten inteligentny wskaźnik gwarantuje, że przydzielony zasób zostanie zniszczony na końcu funkcji. Kiedy getDumbPtr() zwraca zasób*, zwraca wiszący wskaźnik (ponieważ std::unique_ptr zniszczył zasób na końcu funkcji). Kiedy getWeakPtr() zwraca std::weak_ptr, std::weak_ptr podobnie wskazuje na nieprawidłowy obiekt (ponieważ std::shared_ptr zniszczył zasób na końcu funkcji).

Wewnątrz funkcji main() najpierw sprawdzamy, czy zwrócony głupi wskaźnik to nullptr. Ponieważ głupi wskaźnik nadal przechowuje adres zwolnionego zasobu, test ten kończy się niepowodzeniem. Nie ma możliwości main() stwierdzenia, czy ten wskaźnik zwisa, czy nie. W tym przypadku, ponieważ jest to wskaźnik wiszący, gdybyśmy usunęli referencję do tego wskaźnika, spowodowałoby to niezdefiniowane zachowanie.

Następnie sprawdzamy, czy weak.expired() Jest true. Ponieważ liczba referencji dla obiektu wskazywanego przez weak Jest 0 (ponieważ wskazywany obiekt został już zniszczony), jest to true. Kod w main() może zatem stwierdzić, że weak wskazuje nieprawidłowy obiekt i możemy odpowiednio warunkować nasz kod!

Zauważ, że jeśli std::weak_ptr wygasł, to nie powinniśmy go wywoływać lock() , ponieważ wskazywany obiekt został już zniszczony, więc nie ma obiektu do udostępnienia. Jeśli wywołasz lock() na wygasłym std::weak_ptr, zwróci std::shared_ptr do nullptr.

Wnioski

std::shared_ptr. Można go użyć, gdy potrzebujesz wielu inteligentnych wskaźników, które mogą być współwłaścicielami zasobu. Zasób zostanie zwolniony, gdy ostatni std::shared_ptr wyjdzie poza zakres. std::weak_ptr może być użyte, gdy potrzebujesz inteligentnego wskaźnika, który może zobaczyć i używać udostępnionego zasobu, ale nie uczestniczy w posiadaniu tego zasobu.

Czas quizu

Pytanie nr 1

  1. Napraw program przedstawiony w sekcji „Przypadek redukcyjny”, tak aby zasób został prawidłowo zwolniony. Nie zmieniaj kodu w main().

Oto ponownie program dla ułatwienia odniesienia:

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

Pokaż rozwiązanie

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
139 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze