Pozor při uchovávání referencí na vnitřnosti dočasných objektů v C++

Od Petr Zemek, 2017-12-03

Při revizi kódu se pravidelně setkávám s následující chybou, kdy dochází k uchovávání referencí na dočasné objekty. Problém se vyskytuje nejen v C++, ale i C. Já jej ovšem budu ilustrovat v C++.

(De)motivační příklad č. 1 (std::string)

Mějme následující kód:

std::string x_n_times(std::size_t n) {
    return std::string(n, 'x');
}
 
void needs_c_strings(const char* cs1, const char* cs2) {
    std::cerr << std::strlen(cs1) + std::strlen(cs2) << '\n';
}
 
int main() {
    // Takto ne!
    auto cs1 = x_n_times(2).c_str();
    auto cs2 = x_n_times(3).c_str();
 
    needs_c_strings(cs1, cs2);
}

V kódu je funkce, která vrací std::string. Její implementace pro nás není v tuto chvíli podstatná, ale vězte, že nám vrátí řetězec obsahující takový počet písmen x, kolik jsme jí předali při volání. Jelikož po jejím zavolání potřebujeme zavolat funkci, která jako parametry očekává Céčkové řetězce (const char*), tak je potřeba na std::string řetězcích zavolat metodu c_str(). Ve funkci pak pouze vytiskneme součet délek obou řetězců.

Když si kód přeložíme a spustíme, tak nám buď spadne, nebo vypíše nekorektní výsledek (6 místo 5). Proč? Zkuste popřemýšlet a poté čtěte dál.

Proč program spadne?

Pojďme se nejdříve podívat na situaci, kdy nám program spadne. Když si jej spustíme přes valgrind, tak dostaneme takovéto varování:

Invalid read of size 1
   at 0x4C30122: strlen
   by 0x108CF4: needs_c_strings(char const*, char const*)
   by 0x108DA1: main
 Address 0x5aecc80 is 0 bytes inside a block of size 101 free'd
   at 0x4C2E64B: operator delete(void*)
   by 0x108D8E: main
 Block was alloc'd at
   at 0x4C2D52F: operator new(unsigned long)
   by 0x4F5DACC: std::__cxx11::basic_string<...>::_M_construct(unsigned long, char)
   by 0x108C81: x_n_times[abi:cxx11](unsigned long)
   by 0x108D72: main

Z něj lze vyčíst, že při volání strlen() čteme paměť, která již byla uvolněná. Následující řádek

auto cs1 = x_n_times(2).c_str();

totiž znamená toto:

  • Zavolej x_n_times(2).
  • Na vráceném std::string zavolej c_str(), kterážto metoda vrátí ukazatel do interního úložiště onoho řetězce.
  • Jelikož jsme std::string z x_n_times(2) nikde neuložili, tak po skončení příkazu zanikne (je zavolán jeho destruktor, který uvolní interní úložiště).

Takže po skončení příkazu máme ukazatel na paměť, která již byla uvolněná... Jajks!

Co s tím?

Řešením je si navrácený std::string uložit, aby žil po celou dobu, co jej (resp. jeho vnitřnosti) používáme. Např. takto:

// OK
auto s1 = x_n_times(2);
auto cs1 = s1.c_str();
auto s2 = x_n_times(3);
auto cs2 = s2.c_str();
 
needs_c_strings(cs1, cs2);

Nyní již bude vše v pořádku.

Proč program někdy nespadne, ale vytiskne nekorektní výsledek?

Co je však zajímavější, tak někdy se lze setkat s tím, že program nespadne, valgrind žádnou chybu nezahlásí, ale program místo 5 vytiskne 6. Huh. Jak to?

Důvodem je, že po skončení

auto cs1 = x_n_times(2).c_str();

dojde k uvolnění paměti pro dočasný std::string, která ale hned následně může být využita pro konstrukci druhého std::string z řádku

auto cs2 = x_n_times(3).c_str();

Oba ukazatele cs1 i cs2 tak ukazují na stejnou paměť (pro druhý řetězec), která ukazuje na řetězec o velikosti 3 znaky. Proto součet délek obou Céčkových řetězců dá 6 místo 5.

Když si zkusíte vytvořit dva řetězce, jejichž délka se velmi liší (a není tak možné znovu využít paměť z prvního řetězce), tak program již typicky spadne (či minimálně valgrind zahlásí chybu):

auto cs1 = x_n_times(10).c_str();
auto cs2 = x_n_times(100).c_str();

To jen tak pro úplnost, kdyby na to náhodou někdo narazil a divil se, proč mu první program nepadá, ale dává nekorektní výsledek :).

(De)motivační příklad č. 2 (std::vector)

Mějme následující kód:

std::vector<int> foo() {
    return {1, 2, 3, 4, 5};
}
 
int main() {
    for (auto i = foo().begin(), e = foo().end(); i != e; ++i) {
        std::cerr << *i << '\n';
    }
}

Pokud jej přeložíme a spustíme, tak program buď spadne, nebo vypíše nekorektní výsledek. Důvod je ten, že každý z iterátorů ukazuje do jiného vektoru. Funkce foo() totiž vrací výsledek hodnotou, a tudíž se při každém zavolání vytvoří nový vektor.

Řešením je si výsledek funkce foo() před iterací uložit. Lze to udělat buď takto

auto v = foo();
for (auto i = v.begin(), e = v.end(); i != e; ++i) {
    std::cerr << *i << '\n';
}

nebo s využitím range-based for loop takto (doporučuji):

for (auto x : foo()) {
    std::cerr << x << '\n';
}

Kromě toho, že je kód nyní korektní, je i čitelnější.

Zdrojové kódy

Všechny zdrojové kódy jsou k dispozici u mě na GitHubu, takže si vše můžete vyzkoušet.

Obsah tohoto pole je soukromý a nebude veřejně zobrazen.

Filtrované HTML (využíváno)

  • Povolené HTML značky: <a href hreflang> <em> <strong> <cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <table>
  • Zvýraznění syntaxe kódu lze povolit přes následující značky: <code>, <blockcode>, <bash>, <c>, <cpp>, <haskell>, <html>, <java>, <javascript>, <latex>, <perl>, <php>, <python>, <ruby>, <rust>, <sql>, <text>, <vim>, <xml>, <yaml>.
  • Řádky a odstavce se zalomí automaticky.
  • Webové a e-mailové adresy jsou automaticky převedeny na odkazy.
CAPTCHA
6 + 11 =
Vyřešte tento jednoduchý matematický příklad a vložte výsledek. Např. pro 1+3 vložte 4.
Nějak se mi tady rozmohl spam, takže poprosím o ověření.