Hlavní navigace

Práce s pamětí C++: chytré ukazatele a proč je použít

31. 7. 2024
Doba čtení: 10 minut

Sdílet

 Autor: Depositphotos
Představíme „chytré“ ukazatele (smart pointers) v C++ a vysvětlíme důvody, proč je používat. Ačkoliv jazyk C++ podporuje používání ukazatelů podobně jako jazyk C, není to dobrý nápad. Zajímá vás proč?

Pravděpodobně již víme, že proměnné našeho programu jsou umístěny fyzicky v paměti počítače. Pokud vytvoříme proměnnou typu int32_t, kompilátor alokuje 4B prostoru v paměti (samozřejmě dochází i k nějakému zarovnání pro optimalizaci, ale to nyní zanedbáme). Co když nevíme, kolik paměti bude náš program potřebovat? První možností by bylo alokovat co největší množství paměti a pak jen využít tolik, kolik potřebujeme. To samozřejmě není praktické – je to plýtvání zdroji.

Představme si, že program pro grafickou úpravu by vždy alokoval několik GB paměti, jen aby měl jistotu, že je schopen pracovat i s větším obrázkem. Pokud spustíme několik instancí tohoto grafického programu, jen abychom upravili pár menších obrázků, vyčerpáme paměť v počítači.

Již velmi dlouho umožňují programovací jazyky alokovat paměť za běhu. Podle potřeby řekneme operačnímu systému, kolik bychom chtěli paměti a pokud jí OS má, tak nám jí přidělí. Jakmile paměť nepotřebujeme, můžeme jí vrátit (respektive, měli bychom).

K práci s takto „dynamicky alokovanou“ pamětí slouží ukazatele. Trik je v tom, že ukazatel má předem známou velikost – je to vlastně adresa na paměť, do které poté uložíme data (a velikost adresy je stejná pro jeden int i pro instanci třídy o několika GB).

V programovacím jazyku C++ bylo dynamické alokování paměti značně zjednodušeno oproti programovacímu jazyku C. Jednoduchý příklad alokace paměti vidíme v následujícím kódu:

#include
#include

int main()
{
    int *p = new int;
    *p = 10;
    delete p;
    return 0;
}

Při běhu programu, kdy dojde na alokování paměti klíčovým slovem new  , si systém sám spočte, kolik bajtů paměti potřebujeme (ví, jak velký je datový typ int). To je značné zlepšení oproti C, kde bylo třeba funkci malloc() předat celkový počet bajtů, které chceme alokovat (příklad volání: (int*)malloc(sizeof(int))).

Kde je tedy problém?

Práce s ukazateli vypadá na první pohled triviálně. Bohužel rostoucí velikost programů a počet lidí na programech pracujících (a jejich rozdílné schopnosti) vedou k nesprávnému použití ukazatelů. Chyby způsobené tímto nesprávným použitím se mohou projevit až za delší dobu a nemusí být snadné je odhalit. Pojďme si jednotlivé typy chyb projít.

Memory leak (únik paměti)

Velmi často může docházet k neuvolnění alokované paměti (nepoužitím klíčového slova delete) nebo nesprávnému uvolnění ( delete vs delete[]). Tato špatně uvolněná paměť se označuje jako „memory leak“ (leak = únik). Následující ukázka kódu memory leak demonstruje.

int main()
{
    //cast 1:
    int *p1;
    for (int i=0; i < 10; ++i)
    {
        p1 = new int;
        *p1 = i;
    }

    //cast 2:
    int *p2 = new int[10];
    delete p2; //spravne ma byt delete[] p2; protoze uvolnujeme alokovane pole
    return 0;
}

V předchozí ukázce alokuje for cyklus novou paměť do ukazatele p1, aniž by uvolnil paměť na kterou p1 ukazuje. Pokud program poběží dost dlouho, může zcela vyčerpat zdroje systému.

V druhé části kódu vidíme alokaci pole integerů do ukazatele p2. Bohužel nedojde ke korektnímu uvolnění paměti (pole se správně uvolňuje operátorem delete[].

Dangling pointer (visící pointer)

Dalším typem chyby bývá ukazatel ukazující do již neplatné paměti. Program bude nadále fungovat, ale pouze do doby, dokud se někdo nepokusí tento ukazatel dereferencovat (přistoupit na paměť, na kterou ukazuje). Tato chyba se většinou stává pokud dva ukazatele ukazují na stejné místo v paměti a na jeden z nich použijeme operátor delete bez toho, aniž bychom druhý nastavili na hodnotu NULL (v C++ by se měla používat hodnota nullptr). Příklad je v následující ukázce kódu.

int* foo()
{
    int a = 42;
    return &a;
}

int main()
{
    int *p = foo();

    int *p1 = new int;
    *p1 = 7;
    int *p2 = p1;
    delete p1;

    return 0;
}

Po zavolání funkce foo() máme v ukazateli p adresu proměnné a z funkce foo(). Tato paměť však již neexistuje (zanikla při výstupu z funkce). Ukazujeme tedy na neplatnou paměť.

Wild pointer (divoké ukazatele)

Jméno pravděpodobně odkazuje na divoké zvíře, od kterého nevíme co čekat – stejně jako od tohoto ukazatele. Pokud založíme proměnnou typu ukazatel, může v ní být neinicializovaná hodnota. Pokud bychom tento ukazatel poté dereferencovali (pokusili se na něj přistoupit), velmi pravděpodobně dojde k chybě v běhu programu (přístup na nevalidní paměť).

Malá poznámka: protože jsou neinicializované proměnné problém (a bezpečnostní riziko), je jejich použití považováno za chybu a ne varování. Aby bylo možné kód vyzkoušet, je třeba najít správná nastavení pro vaše vývojové prostředí. Například ve VisualStudio na OS Windows je třeba ve vlastnostech projektu vypnout „SDL“ a v sekci „C/C++“ podsekce „Code Generation“ nastavit „Disable Security Check“.

int main()
{
    int* p;
    std::cout << std::hex << "0x" << (uint64_t)p << std::endl;
    //vytisklo 0x7fff171e0699, hodnota u vas muze byt jina
    return 0;
}

V předchozí ukázce vidíme neinicializovaný ukazatel na typ int. Zkusíme vypsat jeho hodnotu a získáme „divné číslo“. Poznámka: je dost možné, že získáte i hodnotu 0 – záleží jaký kompilátor používáte.

Buffer overflow (přetečení bufferu)

Poslední z přehlídky chyb plynoucích ze špatného použití ukazatelů je přetečení bufferu. Již víme, že data jsou v počítači v jakýchsi „chlívcích“ pěkně za sebou. Co nám tedy teoreticky brání zkusit přes ukazatel přejít na předchozí/následující hodnotu?

V následující ukázce vidíme, že ukazatel p je nastaven na adresu proměnné a. Protože se jedná o ukazatel, nic nám nebrání použít operátor []. Tím jsme schopní přečíst hodnotu proměnné b.

int main()
{
    int a = 5;
    int b = 123;
    int* p = &a;

    std::cout << p[1] << std::endl;

    return 0;
}

Chytré ukazatele

Chytré ukazatele (anglicky „smart pointers“) jsou způsob, jak výše zmiňovaným chybám předejít. Využívají toho, že C++ vždy zavolá desktruktor objektu, pokud objekt „passes out of scope“, což lze přeložit jako „skončí jeho životnost“ nebo je explicitně zavolán operátor  delete.

Zavolání destruktoru je garantováno, a proto lze do destruktoru přidat uvolnění paměti. Vznikne garance, že paměť je uvolněna automaticky.

std::unique_ptr

Jak název napovídá, je std::unique_ptr „unikátní“. Třída std::unique_ptr nemá implementovaný kopírovací konstruktor a operátor přiřazení. Tím je zajištěno, že jakákoliv špatná práce s tímto smart pointerem je detekována již při kompilaci.

#include <memory>
#include <iostream>

int main()
{
    std::unique_ptr p0(new int);
    std::cout << p0.get() << std::endl;
    std::cout << &p0 << std::endl;

    std::unique_ptr p1(new int);
    *p1 = 42;

    return 0;
}
#include <memory>

int main()
{
    std::unique_ptr p1(new int);

    std::unique_ptr p2 = p1;
    std::unique_ptr p3(p1);

    return 0;
}
#include &memory>

int main()
{
    std::unique_ptr p1(new int);

    std::unique_ptr p2 = std::move(p1);

    return 0;
}

První ukázka kódu ukazuje základní použití std::unique_ptr. Vytvoříme dva objekty std::unique_ptr, jedná se unikátní ukazatel na datový typ int. První objekt jménem p0 použijeme pouze k vypsání jeho adresy metodou .get(). Zároveň si vypíšeme adresu samotného objektu p0  – vidíme že se liší od ukazatele získaného metodou .get(), to je samozřejmě správně – std::unique_ptr je objekt obalující původní ukazatel.

V první ukázce také vidíme objekt jménem p1, u něj vidíme, jak se do objektu přiřadí hodnota. K tomu se použije přetížený operátor * (operátor dereference).

Ve druhé ukázce vidíme, jak nepředávat objekt std::unique_ptr. Přiřazení operátorem = selže již při kompilaci, protože by tím došlo k překopírování adresy a tím by na jeden objekt ukazovaly dva ukazatele a to nechceme (objekt se nejmenuje „unikátní“ jen tak).

I pokus o zavolání kopírovacího konstruktoru (vytváření objektu p3) skončí chybou kompilace – opět ze stejného důvodu.

Ve třetí ukázce vidíme, jak správně předat objekt std::unique_ptr  přesunutím (anglicky „move“). Tím se hodnota nezkopíruje, ale přesune (nevzniknou dva ukazatele, zůstane jen jeden).

std::shared_ptr

Pokud potřebujeme, aby na objekt existovalo více ukazatelů, je vhodné použít std::shared_ptr, tento smart pointer udržuje počet referencí na daný objekt. Každé přiřazení/kopírovací konstruktor zvedá počítadlo o jeden a každé zavolání destruktoru počítadlo sníží. Pokud počítadlo dosáhne hodnoty 0, je objekt zničen.

#include
#include

int main()
{

    std::shared_ptr p0(new int);
    std::shared_ptr p1 = std::make_shared();

    *p1 = 3;
    *p0 = 4;

    std::cout << "Pocet objektuv p0 = " << p0.use_count() << std::endl;
    std::cout << "Pocet objektuv p1 = " << p1.use_count() << std::endl;
    return 0;
}

Kód výše je pouze ukázkou, jak vytvořit a použít objekt std::shared_ptr. Poté si vypíšeme hodnotu počítadla referencí (metoda .use_count() ). Přístup k objektu je realizován operátorem dereference (stejně jako v případě std::unique_ptr).

#include <memory>
#include <iostream>

int main()
{

    std::shared_ptr<int> p0(new int);

    *p0 = 4;

    std::cout << "Pocet objektuv p0 = " << p0.use_count() << std::endl;

    std::shared_ptr<int> p3 = p0;
    std::shared_ptr<int> p4(p0);
    std::cout << "Pocet objektuv p0 = " << p0.use_count() << std::endl;

    {
        std::shared_ptr<SFoo> p6(p0);
        std::shared_ptr<SFoo> p7(p3);
        std::cout << "Pocet objektuv p0 = " << p0.use_count() << std::endl;
        std::cout << "Konec scope" << std::endl;
    }
    std::cout << "Pocet objektuv p0 = " << p0.use_count() << std::endl;

    return 0;
}

V kódu výše vidíme, jak použití kopírovacího konstruktoru a operátoru přiřazení zvyšuje hodnotu počítadla referencí a jak konec scope naopak počítadlo referencí snižuje. Druhé zmiňované lze vidět u objektů p6 a p7. Tyto objekty jsou umístěné mezi novými složenými závorkami – to vytvoří extra scope. To lze využít pro mnoho užitečných věcí – mimo jiné lze znovu použít jméno proměnné.

#include <memory>
#include <iostream>

int main()
{

    std::shared_ptr<int> p0(new int);
    *p0 = 4;

    std::shared_ptr<int> p3 = p0;
    std::shared_ptr<int> p4(p0);

    std::cout << "Hodnota *p0 == " << *p0 << std::endl;
    std::cout << "Hodnota *p4 == " << *p4 << std::endl << std::endl;
    *p4 = 8;
    std::cout << "Hodnota *p0 == " << *p0 << std::endl;
    std::cout << "Hodnota *p4 == " << *p4 << std::endl;
    return 0;
}

Tento kód demonstruje jistou vlastnost, na kterou je třeba si dát pozor. Třída std::shared_ptr neimplementuje COW (princip „copy-on-write“). Změna, kterou provedete na objektu, je viditelná i z ostatních objektů.

std::weak_ptr

Tento třetí typ smart pointeru realizuje „slabou referenci“. Občas se hodí udržovat ukazatel na nějaký objekt bez toho, aniž bychom s objektem pracovali (například nás zajímá, jestli stále existují v paměti konkrétní data).

Objekt, na který máme slabou referenci, může být kdykoliv uvolněn, my jen musíme být schopni to detekovat a k tomu se std::weak_ptr  hodí.

Další vhodné použití std::weak_ptr je pro rozbití „kruhové závislosti“. Představme si frontu realizovanou pomocí kruhu s std::shared_ptr. Pokud bychom chtěli kruh rozpojit, máme problém (všechny std::shared_ptr mají závislost navzájem). Použitím jednoho std::weak_ptr neexistuje kruhová závislost (rozbili jsme ji) a celou strukturu můžeme zničit.

Smart pointer std::weak_ptr si lze představit jako std::shared_ptr, který nezvyšuje počítadlo referencí.

Je důležité zmínit, že s objektem obaleným v std::weak_ptr  lze pracovat pouze po konverzi na std::shared_ptr.

#include <iostream>
#include <memory>

void kontrola_weak_ptr(const std::weak_ptr& wp)
{
    if (wp.use_count())
        std::cout << "Objekt existuje" << std::endl;
    else
        std::cout << "Objekt jiz neexistuje" << std::endl;
}

void pouzij_weak_ptr(const std::weak_ptr& wp)
{
    if (std::shared_ptr spt = wp.lock())
        std::cout << "*spt == " << *spt << std::endl;
    else
        std::cout << "gw is expired\n";
}

int main()
{
    std::weak_ptr gw;
    {
        auto sp = std::make_shared(42);
        gw = sp;

        kontrola_weak_ptr(gw);
        pouzij_weak_ptr(gw);
    }

    kontrola_weak_ptr(gw);
    pouzij_weak_ptr(gw);
    return 0;
}

Tento kód vytvoří objekt gw typu std::weak_ptr. Poté v extra scope vytvoříme std::shared_ptr a pomocí operátoru přiřazení přiřadíme do gw. Dokud jsme v tomto scopu, zavoláme dvě různé funkce. Ta první zkontroluje platnost objektu pomocí metody  .use_count().

Druhá funkce se pokusí vytvořit ze std::weak_ptr objekt typu std::shared_ptr (jak jsem zmínil výše, bez toho nelze s objektem obaleným std::weak_ptr pracovat). Tato konverze se provádí voláním metody  .lock().

Volání obou funkcí provedeme i mimo skope a můžeme vidět, že neexistence objektu je detekována.

Doufám, že dnešní článek dostatečně shrnul důvody, proč smart pointery používat a zároveň bude nápovědou, jak je používat.

Byl pro vás článek přínosný?

Autor článku

Začínal na programovacích jazycích Pascal a PHP. Na ČVUT FIT se zamiloval do low-level C, které používá pro programování MCU. Programovací jazyk C++ považuje za dokonalý a rád by ho uměl pořádně.