Krzysztof Jasiński
Motorola Solutions Systems Polska Sp.z.o.o
Krzysztof JasińskiSenior Engineer @ Motorola Solutions Systems Polska Sp.z.o.o

Kontrolowanie czasu życia obiektów w nowoczesnym C++

Dowiedz się, jak kontrolować czas życia obiektów w nowoczesnym C++, używając konkretnych funkcji i klas.
10.02.202110 min
Kontrolowanie czasu życia obiektów w nowoczesnym C++

W kontekście zarządzania pamięcią język C++ często kojarzy się z brakiem garbage collectora, wyciekami pamięci czy odwoływaniem się do niezainicjalizowanych lub zwolnionych bloków pamięci. Na ogół praca z pamięcią dynamiczną wydaje się być czymś skomplikowanym w porównaniu z tym, co oferują inne znane języki, takie jak np. Java lub C#. 

Po części można się zgodzić z tym, że tak było przed pojawieniem się oficjalnego standardu C++11 w 2011 roku, wraz z którym język ten wkroczył w erę nowoczesnego C++. Po części, ponieważ wiele mechanizmów, również w kontekście zarządzania pamięcią, było już dostępnych wcześniej w bibliotece Boost.

Standard C++11 dał nam możliwość korzystania z inteligentnych wskaźników (Smart Pointers), które w zasadzie można uznać za prosty garbage collector, bazujący na liczniku referencji do zaalokowanego bloku pamięci. Jeśli licznik dochodzi do zera, pamięć jest automatycznie zwalniana. Myślę, że podstawowe użycie Smart Pointers jest dla większości z Was znane. Pojawiają się jednak sytuacje, kiedy proste użycie inteligentnych wskaźników nie wystarcza, lub przynajmniej wymaga dużej ostrożności i narzuca określony rodzaj architektury aplikacji. 

W tym kontekście nie chcemy się przecież niepotrzebnie ograniczać. Musimy mieć możliwość:

  • Budowania aplikacji z luźno powiązanych komponentów wykorzystując funkcje zwrotne, szczególnie przydatne w sytuacjach, gdy kod jest asynchroniczny. Należy wtedy pamiętać o czasie życia każdego obiektu i kontrolować go, jednocześnie uważając na pułapkę cyklicznych referencji.
  • Wykonania specyficznej akcji po tym, gdy konkretny obiekt zostanie zniszczony. Może to być zalogowanie informacji, wypisanie z listy, wysłanie powiadomienia itp.
  • Umożliwienia obiektom stworzonym przy użyciu inteligentnego wskaźnika kontrolowania własnego czasu życia lub pobierania wskaźnika do samego siebie.


Opisanych wyżej sytuacji może być więcej i dlatego warto przyjrzeć się bliżej bibliotece odpowiedzialnej za zarządzanie pamięcią dynamiczną <memory>. W dalszej części artykułu skupię się właśnie na wybranych elementach tej biblioteki:

  • Shared_ptr/Weak_ptr
  • Shared_ptr oraz konfigurowalny deleter
  • klasa pomocnicza - enable_shared_from_this

Po co właściwie weak pointer?

Jak pewnie większość z Was wie, biblioteka memory oferuje kilka rodzajów inteligentnych wskaźników. Najpopularniejszym jest shared_ptr, czyli wskaźnik, który może być współdzielony pomiędzy wieloma obiektami. Zlicza referencje do tych obiektów i de facto kontroluje, kiedy zaalokowana pamięć zostanie zwolniona. Jeśli taki wskaźnik został wielokrotnie skopiowany, trudniejsze staje się kontrolowanie czasu życia zaalokowanej pamięci - wiemy jedynie, że zostanie ona zwolniona, kiedy licznik referencji dojdzie do zera. 

Często jednak potrzebujemy większej kontroli nad tym, kiedy konkretny obiekt zostanie zniszczony (a zaalokowana pamięć zwolniona). W takiej sytuacji z pomocą przychodzi nam inny typ wskaźnika - weak pointer. W odróżnieniu od share pointera weak pointer nie jest właścicielem referencji do zaalokowanej pamięci, a jego utworzenie lub kopiowanie nie wpływa na licznik referencji shared pointera. W celu uzyskania dostępu do wskazywanej pamięci weak pointer musi zostać wcześniej przekonwertowany do shared pointera. 

Najczęściej prezentowanym zastosowaniem weak pointera jest przerwanie pętli cyklicznych referencji. Krótko mówiąc, jest to sytuacja, kiedy co najmniej dwa różne obiekty posiadają shared pointery wskazujące na siebie nawzajem. Jeśli takie obiekty zostaną porzucone, to licznik referencji każdego z nich nigdy nie dojdzie do zera, obiekty nie zostaną zniszczone i w najlepszym wypadku nastąpi wyciek pamięci.

Ja chciałbym się jednak skupić na innym zastosowaniu weak pointera - uzyskania większej kontroli nad czasem życia obiektów oraz luźniejszego powiązania komponentów aplikacji.

Jak już wiemy, aby uzyskać dostęp do zaalokowanej pamięci przy użyciu weak pointera, musimy najpierw dokonać jego “konwersji” do shared pointera. Bardzo ważne jest tutaj to, że taka operacja nie zawsze musi się powieść. 

Jeśli wcześniej shared pointer został zniszczony - licznik referencji doszedł do zera - operacja pobrania shared pointera z weak pointera się nie powiedzie. Dostaniemy domyślnie skonstruowany - niezainicjalizowany -  shared pointer. Istotne jest to, że operacja pobierania shared pointera z weak pointera - lock() - jest atomowa i dzięki temu bezpieczna w aplikacjach wielowątkowych (tak długo, jak używamy różnych obiektów shared pointera). 

Jeśli nie udaje nam się pobrać shared pointera to nie musi to oznaczać, że obiekt, na który on wskazywał został już zniszczony - wykonał się destruktor, a pamięć została zdealokowana. Możemy być jednak pewni, że proces ten jest już w trakcie lub zaraz się rozpocznie.

Połączenie użycia shared pointera oraz weak pointera daje nam możliwość luźniejszego powiązania komponentów aplikacji. Często mamy przecież do czynienia z sytuacją, kiedy czas życia obiektów komunikujących się ze sobą nie jest taki sam i potrzebujemy mieć możliwość zniszczenia jednego z nich wcześniej. Aby przedstawić to jaśniej, posłużmy się następującym przykładem:

Mamy zdefiniowane trzy typy klas: 

  • Connection- służy do komunikacji sieciowej. Obiekt klasy Connection może być współdzielony przez wiele innych obiektów.
  • LinkSession- jest pobierany z obiektu Connection i umożliwia rejestrację na odbieranie wiadomości dla danego typu sesji oraz wysyłanie wiadomości.
  • ControlLinkSession- obsługuje sesję dla ruchu kontrolnego. Wykorzystuje obiekt Connection do pobrania LinkSession - trzyma jego shared pointer. Rejestruje również funkcję callback do obsługi ruchu przychodzącego w pobranym obiekcie typu LinkSession.


Zdefiniowanych klas-sesji do obsługi innego typu ruchu może być więcej, np. MediaLinkSession, DataLinkSession. Będę się jednak starał utrzymać przykład maksymalnie prosty i jednocześnie przedstawiający realną sytuację. Obrazuje to poniższy diagram klas:

Klasa Connection posiada interfejs IConnection umożliwiający pobranie shared pointera do obiektu typu LinkSession - getLinkSession() - i rejestrację funkcji callback do obsługi danych przychodzących. Klasa ControlLinkSession ma dostęp do klasy Connection poprzez shared pointer. Korzysta ona z interfejsu IConnection, aby pobrać obiektu typu LinkSession.

Poniżej jako przykład zamieszczam implementację kluczowych fragmentów klas Connection oraz ControlLinkSession:

struct Callbacks
{
    std::function<void(const std::string&)> receivedMessage;
};
 
class Connection : public IConnection
{
public:
    std::shared_ptr<LinkSession> getLinkSession(const SessionTypes& sessionType)
    {
        auto pLinkSession = std::make_shared<LinkSession>([this](const std::string& message){ this->send(message); });
        m_linkSessions.insert_or_assign(sessionType, pLinkSession);
        return pLinkSession;
    }
private:
    void received(const SessionTypes& sessionType, const std::string& message) 
    {  
        if(const auto& pSessionLinkWeakIt = m_linkSessions.find(sessionType); pSessionLinkWeakIt != std::end(m_linkSessions))
        {
            if(const auto& pSessionLink = pSessionLinkWeakIt->second.lock(); pSessionLink != nullptr) pSessionLink->getCallbacks().receivedMessage(message);
            else m_linkSessions.erase(pSessionLinkWeakIt);
        }
    }
private:
    std::unordered_map<SessionTypes, std::weak_ptr<LinkSession>> m_linkSessions;
};
 
class ControlLinkSession
{
public:
    ControlLinkSession(std::shared_ptr<IConnection> pConnection)
    : m_pConnection(pConnection)
    {
        if(m_pConnection != nullptr)
        {
            if(auto controlLinkSession = m_pConnection->getLinkSession(m_sessionType); controlLinkSession != nullptr)
            {
                m_pLinkSession = controlLinkSession;
                m_pLinkSession->setCallbacks(Callbacks{ [this](const std::string& message) { this->processMessage(message); }});
            }
        }
    }
private:
    std::shared_ptr<IConnection> m_pConnection;
    const SessionTypes m_sessionType = SessionTypes::controlLink;
    std::shared_ptr<LinkSession> m_pLinkSession;
};


Dzięki takim powiązaniom oraz użyciu shared i weak pointerów:

  1. Mamy pewność, że obiekt typu Connection będzie istniał co najmniej tak długo, jak obiekt typu ControlLinkSession - tego oczekujemy, ponieważ odpowiada on za utrzymanie połączenia i wysyłanie/odbieranie wiadomości.
  2. Jednocześnie w dowolnym momencie możemy zniszczyć obiekt typu ControlLinkSession, nie musząc informować o tym klasy Connection.


Nie ma również konieczności ręcznego wyrejestrowywania callbacków - jeśli operacja lock() się nie powiedzie, klasa Connection zadba o to, aby wyrejestrować (usunąć) weak pointer do obiektu LinkSession

Jednocześnie w przypadku większej ilości relacji pomiędzy klasami, zabezpieczamy się przed pułapką referencji cyklicznych. Innymi sytuacjami, kiedy warto rozważyć użycie shared pointera i weak pointera, są:

  • Obserwacja stanu innych obiektów
  • Niebezpieczeństwo referencji cyklicznej
  • Logowanie informacji
  • Obsługa logiki zamykania aplikacji lub usuwania dużej liczby obiektów bez konieczności oczekiwania na zakończenie tej operacji.

Sam zdecyduj jak będzie wyglądało usuwanie shared pointera

Kolejną interesującą funkcjonalnością, jaką oferuję shared pointer, jest możliwość konfiguracji deletera. Deleter to funktor - może być również wyrażenie lambda - który jest wykonywany podczas usuwania shared pointera. Możemy w nim zaimplementować dowolną logikę i mamy pewność, że wykona się, kiedy shared pointer zostanie zniszczony - licznik referencji dojdzie do 0. 

Ważne, aby pamiętać, że taki funktor nadpisuje domyślne zachowanie shared pointera podczas niszczenia obiektu, więc sami musimy pamiętać o zwolnieniu zaalokowanej pamięci.

Możemy wykorzystać to podejście i zrefaktorować kod z wcześniejszego przykładu. Poniżej zamieszczam wyłącznie zmieniony fragment kodu - zmiany są pogrubione.

class Connection : public IConnection
{
public:
    std::shared_ptr<LinkSession> getLinkSession(const SessionTypes& sessionType)
    {
        const auto deleter = [this, sessionType](LinkSession* pLinkSession)
        {
            this->m_linkSessions.erase(sessionType);
            delete pLinkSession;
        };
        auto pLinkSession = std::shared_ptr<LinkSession>(new LinkSession([this](const std::string& message){this->send(message);}), deleter);
        m_linkSessions.insert({sessionType, pLinkSession});
        return pLinkSession;
    }
private:
    void received(const SessionTypes& sessionType, const std::string& message) 
    {  
        if(const auto& pSessionLinkWeakIt = m_linkSessions.find(sessionType); pSessionLinkWeakIt != std::end(m_linkSessions))
        {
            if(const auto& pSessionLink = pSessionLinkWeakIt->second.lock(); pSessionLink != nullptr) pSessionLink->getCallbacks().receivedMessage(message);
//usunięta linia
        }
    }
};


Jak widzimy, zastosowanie deletera pozwoliło na natychmiastowe wyrejestrowanie (usunięcie) obiektu typu LinkSession. Mogliśmy więc usunąć fragment kodu odpowiedzialny za ręczne usuwanie weak pointera do LinkSession z kontenera m_linkSessions

Musimy być jednak ostrożni, ponieważ pojawia się tutaj pewien problem - do deletera przekazujemy wskaźnik this obiektu typu Connection. Możemy więc mieć sytuację, że pConnection został już zniszczony i dopiero później wywołuje się deleter, który nie wie, że this już nie istnieje. Z taką sytuacją spotkamy się, jeśli w definicji klasy ControlLinkSession składowa m_pConnection będzie się znajdowała poniżej m_pControlLinkSession

Kiedy obiekt klasy ControlLinkSession będzie się niszczył, najpierw zniszczy się m_pConnection, a dopiero później m_pControlLinkSession - pod warunkiem, że nikt inny nie trzyma kopii shared pointera m_pConnection. Zabezpieczymy się przed tą sytuacją już za chwilę, w ostatniej części.

Podsumowując, skonfigurowany deleter może okazać się bardzo przydatny do wygenerowania pewnych akcji, specyficznych dla zniszczonego obiektu, przykładowo:

  • Logowanie informacji dla użytkownika
  • Wyczyszczenie list lub usunięcie zależnych obiektów
  • Przełączenie aplikacji w inny stan

Pozwól obiektom decydować o czasie ich życia

Wykorzystując często inteligentne wskaźniki, na pewno natkniemy się na sytuację, w której sam obiekt stworzony i zarządzany przez shared pointer potrzebuje mieć dostęp do tego shared pointera, np. Żeby przekazać go do innego obiektu, funkcji zwrotnej, zwrócić na zewnątrz. 

W pierwszej chwili mogłoby się wydawać, że właściwie do tego celu można wykorzystać wskaźnik this. Prawdopodobnie dość szybko przekonalibyśmy się, że jest to duży błąd, a aplikacja ulega awarii. Podając wskaźnik this do shared pointera, tak naprawdę tworzymy nowy shared pointer, który zarządza tą samą pamięcią, ale nie wie nic o tym pierwszym, przy którego użyciu obiekt został początkowo stworzony (wskaźniki te mają własne liczniki referencji). Z pomocą przychodzi biblioteka <memory>, a dokładnie klasa pomocnicza enable_shared_from_this

Jej użycie jest proste - jeśli dowolna klasa dziedziczy po enable_shared_from_this, to uzyskuje dostęp do dwóch metod: shared_from_this() oraz weak_from_this(), które pozwalają na pobranie shared/weak pointera. Należy pamiętać, że warunkiem koniecznym jest to, aby obiekt był zarządzany przez shared pointer oraz to, że metod tych nie możemy wywoływać w konstruktorze. Aby móc z nich korzystać, obiekt musi być już stworzony.

To rozwiązanie przyda się w wielu przypadkach, a nam pozwoli na zdefiniowanie klasy, której obiekty same będą decydowały, kiedy powinny zostać zniszczone. Takie podejście może być bardzo przydatne w sytuacji, kiedy obiekty mają zlecane pewne zadanie i po jego wykonaniu mogą zostać usunięte. Przykładem może być połączenie np. https. 

Taki obiekt połączenia powinien wykonać zadaną akcję i zwrócić rezultat lub ewentualny błąd do obiektu, który go zainicjalizował przy pomocy ustawionych wcześniej callbacków. Sam obiekt połączenia decyduje, kiedy zadanie jest wykonane i może zostać zniszczony. Jeśli w pewnych sytuacjach potrzebowalibyśmy wcześniej anulować tę akcję, to możemy użyć weak pointera.

Wykorzystajmy klasę enable_shared_from_this i zmodyfikujmy poprzedni kod tak, aby był bardziej ‘bezpieczny’, a obiekt typu ControlLinkSession mógł sam zdecydować o tym, kiedy może zostać bezpiecznie zniszczony. Poniżej zamieszczam wyłącznie zmieniony fragment kodu - zmiany są pogrubione.

class Connection : public IConnection, public std::enable_shared_from_this<Connection>
{
public:
    std::shared_ptr<LinkSession> getLinkSession(const SessionTypes& sessionType)
    {
        const auto deleter = [pThisWeak = weak_from_this(), sessionType](LinkSession* pLinkSession)
        {
            if(const auto& pThis = pThisWeak.lock(); pThis != nullptr) pThis->m_linkSessions.erase(sessionType);
            delete pLinkSession;
        };
        auto pLinkSession = std::shared_ptr<LinkSession>(new LinkSession([pThis = shared_from_this()](const std::string& message){pThis->send(message);}), deleter);
        m_linkSessions.insert({sessionType, pLinkSession});
        return pLinkSession;
    }
};
 
class ControlLinkSession : public std::enable_shared_from_this<ControlLinkSession>
{
public:
    void finalize()
    {
        m_pKeepMeAlive = shared_from_this();
        //continue finalize logic...
    }
private:
    void processMessage(const std::string& message)
    {
        if(message.find("<session finalized>") != std::string::npos) finalized();
    }
    void finalized()
    {
        m_pKeepMeAlive.reset();
    }
private:
    std::shared_ptr<ControlLinkSession> m_pKeepMeAlive;
};


Powyższe modyfikacje dały nam dwie rzeczy:

  1. Inicjalizowanie deletera podczas pobierania obiektu typu LinkSession jest bardziej bezpieczne. Teraz przechwytujemy w lambdzie weak pointer i podczas wykonywania deletera sprawdzamy, czy właściwy shared pointer jeszcze istnieje. Podobnie do konstruktora LinkSession, przekazujemy lambdę, która przechwytuje shared pointer do obiektu Connection - wcześniej był to wskaźnik this.
  2. Obiekty typu ControlLinkSession same decydują, kiedy są gotowe do zniszczenia. Takie obiekty możemy porzucić - w naszym przypadku po zawołaniu metody finalize(), a one same się zniszczą, gdy zakończą swoją pracę - zgodnie z zaimplementowaną logiką.


Pozostał jeszcze jeden element, który moglibyśmy poprawić. Podczas ustawiania funkcji callback na obiekcie typu LinkSession, przekazujemy do lambdy bezpośrednio wskaźnik this obiektu ControlLinkSession

Bezpieczniej byłoby wykorzystać tutaj weak_from_this(), lecz w tym celu musielibyśmy przerobić naszą klasę - nie możemy wywoływać weak_from_this() w konstruktorze! Nie chcę tego robić, aby nie wprowadzać zbyt wielu zmian. W naszym przykładzie nic groźnego się nie stanie, ponieważ pobrany obiekt LinkSession jest trzymany przez ControlLinkSession, więc nie dojdzie do sytuacji, w której ControlLinkSession już nie istnieje, a LinkSession nadal tak. Problem mógłby się pojawić w aplikacji wielowątkowej i asynchronicznej.

Podsumowanie

Mam nadzieję, że każdy z Was we własnym projekcie znajdzie miejsce do zastosowania omówionych funkcji i klas, które udostępnia standard.

Pamiętajmy, że jest to jedynie niewielki wycinek biblioteki <memory>, dlatego zachęcam do przyjrzenia się pozostałym klasom inteligentnych wskaźników oraz klasom i funkcjom pomocniczym. Tutaj pojawiły się również ciekawe zmiany wprowadzone przez najnowszą wersję standardu C++20, który został już wstępnie zatwierdzony i jest na etapie ostatnich prac. Duża część nowych funkcjonalności jest już także wspierana przez kompilatory, w tym gcc.

Dziękuję za uwagę!

<p>Loading...</p>