22.1 -- Wprowadzenie do inteligentnych wskaźników i semantyki ruchów

Rozważmy funkcję, w której dynamicznie alokujemy wartość:

void someFunction()
{
    Resource* ptr = new Resource(); // Resource is a struct or class

    // do stuff with ptr here

    delete ptr;
}

Chociaż powyższy kod wydaje się dość prosty, dość łatwo jest zapomnieć o zwolnieniu ptr. Nawet jeśli będziesz pamiętać o usunięciu ptr na końcu funkcji, istnieje mnóstwo sposobów, dzięki którym ptr może nie zostać usunięty, jeśli funkcja zakończy się wcześniej. Może się to zdarzyć poprzez wcześniejszy powrót:

#include <iostream>

void someFunction()
{
    Resource* ptr = new Resource();

    int x;
    std::cout << "Enter an integer: ";
    std::cin >> x;

    if (x == 0)
        return; // the function returns early, and ptr won’t be deleted!

    // do stuff with ptr here

    delete ptr;
}

lub poprzez zgłoszony wyjątek:

#include <iostream>

void someFunction()
{
    Resource* ptr = new Resource();

    int x;
    std::cout << "Enter an integer: ";
    std::cin >> x;

    if (x == 0)
        throw 0; // the function returns early, and ptr won’t be deleted!

    // do stuff with ptr here

    delete ptr;
}

W powyższych dwóch programach wykonywana jest instrukcja wczesnego powrotu lub rzutu, powodując zakończenie funkcji bez usunięcia zmiennej ptr. W rezultacie pamięć przydzielona zmiennej ptr uległa wyciekowi (i będzie wyciekać ponownie za każdym razem, gdy funkcja ta zostanie wywołana i powróci wcześniej).

W istocie tego rodzaju problemy występują, ponieważ zmienne wskaźnikowe nie mają wbudowanego mechanizmu czyszczenia po sobie.

Inteligentne klasy wskaźników na ratunek?

Jedną z najlepszych rzeczy w klasach jest to, że zawierają one destruktory, które są automatycznie wykonywane, gdy obiekt klasy wyjdzie z zakres. Jeśli więc przydzielisz (lub nabędziesz) pamięć w konstruktorze, możesz zwolnić ją w destruktorze i mieć pewność, że pamięć zostanie zwolniona, gdy obiekt klasy zostanie zniszczony (niezależnie od tego, czy wyjdzie poza zakres, zostanie jawnie usunięty itp.). To jest sedno paradygmatu programowania RAII, o którym mówiliśmy na lekcji 19.3 -- Destructors.

Czy zatem możemy użyć klasy, która pomoże nam zarządzać wskaźnikami i je czyścić? Możemy!

Rozważmy klasę, której jedynym zadaniem było przechowywanie i „posiadanie” przekazanego do niej wskaźnika, a następnie zwalnianie tego wskaźnika, gdy obiekt klasy wyjdzie poza zakres. Dopóki obiekty tej klasy były tworzone jedynie jako zmienne lokalne, mogliśmy zagwarantować, że klasa prawidłowo wyjdzie poza zakres (niezależnie od tego, kiedy i jak zakończą się nasze funkcje), a posiadany wskaźnik zostanie zniszczony.

Oto pierwsza wersja pomysłu:

#include <iostream>

template <typename T>
class Auto_ptr1
{
	T* m_ptr {};
public:
	// Pass in a pointer to "own" via the constructor
	Auto_ptr1(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}
	
	// The destructor will make sure it gets deallocated
	~Auto_ptr1()
	{
		delete m_ptr;
	}

	// Overload dereference and operator-> so we can use Auto_ptr1 like m_ptr.
	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
};

// A sample class to prove the above works
class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
};

int main()
{
	Auto_ptr1<Resource> res(new Resource()); // Note the allocation of memory here

        // ... but no explicit delete needed

	// Also note that we use <Resource>, not <Resource*>
        // This is because we've defined m_ptr to have type T* (not T)

	return 0;
} // res goes out of scope here, and destroys the allocated Resource for us

Ten program wypisuje:

Resource acquired
Resource destroyed

Zastanów się, jak działa ten program i klasa. Najpierw dynamicznie tworzymy zasób i przekazujemy go jako parametr do naszej szablonowej klasy Auto_ptr1. Od tego momentu nasza zmienna Auto_ptr1 res jest właścicielem obiektu Resource (Auto_ptr1 ma relację kompozycji z m_ptr). Ponieważ res jest zadeklarowany jako zmienna lokalna i ma zasięg blokowy, po zakończeniu bloku wyjdzie poza zakres i zostanie zniszczony (nie musisz się martwić, że zapomnisz go zwolnić). A ponieważ jest to klasa, kiedy zostanie zniszczona, zostanie wywołany destruktor Auto_ptr1. Dzięki temu destruktorowi wskaźnik zasobu, który przechowuje, zostanie usunięty!

Dopóki Auto_ptr1 jest zdefiniowany jako zmienna lokalna (z automatycznym czasem trwania, stąd część nazwy klasy „Auto”), zasób będzie gwarantowany do zniszczenia na końcu bloku, w którym jest zadeklarowany, niezależnie od tego, w jaki sposób funkcja zakończy się (nawet jeśli zakończy się wcześniej).

Taka klasa nazywana jest inteligentnym wskaźnikiem. A Inteligentny wskaźnik to klasa kompozycji zaprojektowana do zarządzania dynamicznie przydzielaną pamięcią i zapewniania, że ​​pamięć zostanie usunięta, gdy obiekt inteligentnego wskaźnika wyjdzie poza zakres. (W związku z tym wbudowane wskaźniki są czasami nazywane „głupimi wskaźnikami”, ponieważ nie potrafią po sobie sprzątać).

Wróćmy teraz do powyższego przykładu funkcji SomeFunction() i pokażmy, jak klasa inteligentnych wskaźników może rozwiązać nasze wyzwanie:

#include <iostream>

template <typename T>
class Auto_ptr1
{
	T* m_ptr {};
public:
	// Pass in a pointer to "own" via the constructor
	Auto_ptr1(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}
	
	// The destructor will make sure it gets deallocated
	~Auto_ptr1()
	{
		delete m_ptr;
	}

	// Overload dereference and operator-> so we can use Auto_ptr1 like m_ptr.
	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
};

// A sample class to prove the above works
class Resource
{
public:
    Resource() { std::cout << "Resource acquired\n"; }
    ~Resource() { std::cout << "Resource destroyed\n"; }
    void sayHi() { std::cout << "Hi!\n"; }
};

void someFunction()
{
    Auto_ptr1<Resource> ptr(new Resource()); // ptr now owns the Resource
 
    int x;
    std::cout << "Enter an integer: ";
    std::cin >> x;
 
    if (x == 0)
        return; // the function returns early
 
    // do stuff with ptr here
    ptr->sayHi();
}

int main()
{
    someFunction();

    return 0;
}

Jeśli użytkownik wprowadzi niezerową liczbę całkowitą, powyższy program wyświetli:

Resource acquired
Hi!
Resource destroyed

Jeśli użytkownik wprowadzi zero, powyższy program zakończy się wcześniej, drukowanie:

Resource acquired
Resource destroyed

Zauważ, że nawet w przypadku, gdy użytkownik wprowadzi zero, a funkcja zakończy się wcześniej, zasób nadal będzie prawidłowo zwalniany.

Ponieważ zmienna ptr jest zmienną lokalną, ptr zostanie zniszczony po zakończeniu funkcji (niezależnie od sposobu zakończenia). A ponieważ destruktor Auto_ptr1 oczyści zasób, mamy pewność, że zasób zostanie poprawnie wyczyszczony.

Krytyczna wada

Klasa Auto_ptr1 ma krytyczną wadę czającą się za jakimś automatycznie wygenerowanym kodem. Zanim zaczniesz czytać dalej, sprawdź, czy potrafisz zidentyfikować, co to jest. Poczekamy…

(Wskazówka: zastanów się, które części klasy zostaną wygenerowane automatycznie, jeśli ich nie podasz)

(Muzyka Jeopardy)

OK, czas minął.

Zamiast ci mówić, my ci pokażemy. Rozważmy następujący program:

#include <iostream>

// Same as above
template <typename T>
class Auto_ptr1
{
	T* m_ptr {};
public:
	Auto_ptr1(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}
	
	~Auto_ptr1()
	{
		delete m_ptr;
	}

	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
};

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

int main()
{
	Auto_ptr1<Resource> res1(new Resource());
	Auto_ptr1<Resource> res2(res1); // Alternatively, don't initialize res2 and then assign res2 = res1;

	return 0;
} // res1 and res2 go out of scope here

Ten program wypisuje:

Resource acquired
Resource destroyed
Resource destroyed

Bardzo prawdopodobne (ale niekoniecznie) w tym momencie Twój program ulegnie awarii. Widzisz teraz problem? Ponieważ nie dostarczyliśmy konstruktora kopiującego ani operatora przypisania, C++ udostępnia je za nas. A funkcje, które zapewnia, wykonują płytkie kopie. Kiedy więc inicjujemy res2 za pomocą res1, obie zmienne Auto_ptr1 są skierowane na ten sam zasób. Kiedy res2 wyjdzie poza zakres, usuwa zasób, pozostawiając res1 z wiszącym wskaźnikiem. Kiedy res1 usunie swój (już usunięty) zasób, nastąpi niezdefiniowane zachowanie (prawdopodobnie awaria)!

Napotkasz podobny problem z taką funkcją:

void passByValue(Auto_ptr1<Resource> res)
{
}

int main()
{
	Auto_ptr1<Resource> res1(new Resource());
	passByValue(res1);

	return 0;
}

W tym programie res1 zostanie skopiowane według wartości do parametru res, więc oba res1.m_ptr i res.m_ptr będą miały ten sam adres.

Po wywołaniu res jest niszczone w koniec funkcji, res1.m_ptr pozostaje zwisający. Kiedy res1.m_ptr zostanie później usunięty, spowoduje to niezdefiniowane zachowanie.

Bez wątpienia nie jest to dobre. Jak możemy temu zaradzić?

Jedną rzeczą, którą moglibyśmy zrobić, byłoby jawne zdefiniowanie i usunięcie konstruktora kopiującego i operatora przypisania, zapobiegając w ten sposób przede wszystkim tworzeniu jakichkolwiek kopii. Zapobiegłoby to przechodzeniu przez wartość (co jest dobre, prawdopodobnie i tak nie powinniśmy przekazywać ich przez wartość).

Ale w takim razie jak zwrócić Auto_ptr1 z funkcji do osoby wywołującej?

??? generateResource()
{
     Resource* r{ new Resource() };
     return Auto_ptr1(r);
}

Nie możemy zwrócić naszego Auto_ptr1 przez referencję, ponieważ lokalny Auto_ptr1 zostanie zniszczony na końcu funkcji, a osoba wywołująca pozostanie z nieaktualną referencją. Moglibyśmy zwrócić wskaźnik r jako Resource*, ale wtedy moglibyśmy zapomnieć o usunięciu r później i o to właśnie chodzi w używaniu inteligentnych wskaźników. To odpada. Zwrócenie Auto_ptr1 według wartości jest jedyną opcją, która ma sens — ale wtedy otrzymujemy płytkie kopie, zduplikowane wskaźniki i awarie.

Inną opcją byłoby przeciążenie konstruktora kopiującego i operatora przypisania w celu utworzenia głębokich kopii. W ten sposób przynajmniej zagwarantujemy uniknięcie duplikowania wskaźników do tego samego obiektu. Jednak kopiowanie może być kosztowne (i może nie być pożądane lub nawet możliwe), a nie chcemy tworzyć niepotrzebnych kopii obiektów tylko po to, aby zwrócić Auto_ptr1 z funkcji. Poza tym przypisanie lub zainicjowanie głupiego wskaźnika nie kopiuje wskazywanego obiektu, więc dlaczego mielibyśmy oczekiwać, że inteligentne wskaźniki będą zachowywać się inaczej?

Co zrobimy?

Przesuń semantykę

A co jeśli zamiast kopiować wskaźnik przez naszego konstruktora kopiującego i operatora przypisania („semantyka kopiowania”), zamiast tego przeniesiemy/przeniesiemy własność wskaźnika ze źródła do obiektu docelowego? To jest główna idea semantyki przenoszenia. Przesuń semantykę oznacza, że ​​klasa przeniesie własność obiektu zamiast tworzyć kopię.

Zaktualizujmy naszą klasę Auto_ptr1, aby pokazać, jak można to zrobić:

#include <iostream>

template <typename T>
class Auto_ptr2
{
	T* m_ptr {};
public:
	Auto_ptr2(T* ptr=nullptr)
		:m_ptr(ptr)
	{
	}
	
	~Auto_ptr2()
	{
		delete m_ptr;
	}

	// A copy constructor that implements move semantics
	Auto_ptr2(Auto_ptr2& a) // note: not const
	{
		// We don't need to delete m_ptr here.  This constructor is only called when we're creating a new object, and m_ptr can't be set prior to this.
		m_ptr = a.m_ptr; // transfer our dumb pointer from the source to our local object
		a.m_ptr = nullptr; // make sure the source no longer owns the pointer
	}
	
	// An assignment operator that implements move semantics
	Auto_ptr2& operator=(Auto_ptr2& a) // note: not const
	{
		if (&a == this)
			return *this;

		delete m_ptr; // make sure we deallocate any pointer the destination is already holding first
		m_ptr = a.m_ptr; // then transfer our dumb pointer from the source to the local object
		a.m_ptr = nullptr; // make sure the source no longer owns the pointer
		return *this;
	}

	T& operator*() const { return *m_ptr; }
	T* operator->() const { return m_ptr; }
	bool isNull() const { return m_ptr == nullptr; }
};

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

int main()
{
	Auto_ptr2<Resource> res1(new Resource());
	Auto_ptr2<Resource> res2; // Start as nullptr

	std::cout << "res1 is " << (res1.isNull() ? "null\n" : "not null\n");
	std::cout << "res2 is " << (res2.isNull() ? "null\n" : "not null\n");

	res2 = res1; // res2 assumes ownership, res1 is set to null

	std::cout << "Ownership transferred\n";

	std::cout << "res1 is " << (res1.isNull() ? "null\n" : "not null\n");
	std::cout << "res2 is " << (res2.isNull() ? "null\n" : "not null\n");

	return 0;
}

Ten program wypisuje:

Resource acquired
res1 is not null
res2 is null
Ownership transferred
res1 is null
res2 is not null
Resource destroyed

Zauważ, że nasz przeciążony operator= przekazał własność m_ptr z res1 na res2! W rezultacie nie powstają zduplikowane kopie wskaźnika i wszystko zostaje uporządkowane.

Przypomnienie

Usunięcie nullptr jest w porządku, ponieważ nic nie daje.

std::auto_ptr i dlaczego był to zły pomysł

Teraz byłby odpowiedni moment, aby porozmawiać o std::auto_ptr. std::auto_ptr, wprowadzony w C++ 98 i usunięty w C++ 17, był pierwszą próbą C++ stworzenia ujednoliconego inteligentnego wskaźnika. std::auto_ptr zdecydowało się zaimplementować semantykę przenoszenia, tak jak robi to klasa Auto_ptr2.

Jednak std::auto_ptr (i nasza klasa Auto_ptr2) ma wiele problemów, które czynią używanie jej niebezpiecznym.

Po pierwsze, ponieważ std::auto_ptr implementuje semantykę przenoszenia poprzez konstruktor kopiowania i operator przypisania, przekazując std::auto_ptr według wartości do funkcja spowoduje, że zasób zostanie przeniesiony do parametru funkcji (i zostanie zniszczony na końcu funkcji, gdy parametry funkcji wyjdą poza zakres). Następnie, gdy chcesz uzyskać dostęp do argumentu auto_ptr od osoby wywołującej (nie zdając sobie sprawy, że został on przesłany i usunięty), nagle dokonujesz dereferencji do wskaźnika zerowego. Awaria!

Po drugie, std::auto_ptr zawsze usuwa swoją zawartość za pomocą usuwania innego niż tablica. Oznacza to, że auto_ptr nie będzie działać poprawnie z tablicami alokowanymi dynamicznie, ponieważ używa niewłaściwego rodzaju dezalokacji. Co gorsza, nie przeszkodzi to w przekazaniu mu dynamicznej tablicy, którą następnie będzie źle zarządzał, co doprowadzi do wycieków pamięci.

W końcu auto_ptr nie współpracuje dobrze z wieloma innymi klasami w standardowej bibliotece, w tym z większością kontenerów i algorytmów. Dzieje się tak, ponieważ te standardowe klasy bibliotek zakładają, że kopiując element, faktycznie tworzy on kopię, a nie ruch.

Z powodu wyżej wymienionych niedociągnięć std::auto_ptr jest przestarzałe w C++11 i usunięte w C++17.

Idziemy dalej

Głównym problemem związanym z projektem std::auto_ptr jest to, że przed C++11 język C++ po prostu nie miał mechanizmu rozróżniającego „kopiuj semantyka” z „przenieś semantykę”. Zastąpienie semantyki kopiowania w celu zaimplementowania semantyki przenoszenia prowadzi do dziwnych przypadków Edge i niezamierzonych błędów. Na przykład możesz pisać res1 = res2 i nie mieć pojęcia, czy res2 zostanie zmienione, czy nie!

Z tego powodu w C++11 formalnie zdefiniowano pojęcie „przenieś”, a do języka dodano „semantykę przenoszenia”, aby właściwie odróżnić kopiowanie od przenoszenia. Skoro już ustaliliśmy, dlaczego semantyka przenoszenia może być użyteczna, w dalszej części tego rozdziału zajmiemy się semantyką ruchu. Naprawimy także naszą klasę Auto_ptr2, używając semantyki przenoszenia.

W C++ 11 std::auto_ptr zostało zastąpione kilkoma innymi typami inteligentnych wskaźników „świadomych ruchu”: std::unique_ptr, std::weak_ptr i std::shared_ptr. Przyjrzymy się także dwóm najpopularniejszym z nich: Unique_ptr (który jest bezpośrednim zamiennikiem auto_ptr) i Shared_ptr.

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:  
316 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze