Cvičení 9: Kopírování polymorfních objektů, výjimky

Průvodce studiem Minule jsme viděli, jak nám objektově orientované programování umožňuje vytvářet hierarchie heterogenních objektů. Pod ukazatelem či referencí na Writable se mohly skrývat objekty různých odvozených tříd – String, Number a Group. Ačkoliv jsme měli pouhý odkaz na předka, virtuální destruktor nám umožnil všechny objekty správně smazat. Virtuální metoda write nám umožnila vypsat všechny objekty pomocí jednotného rozhraní.

std::unique_ptr<Writable> w(new String("abc"));
w->write(std::cout); // zavolá se správné write
// konec života w: zavolá se správný destruktor

Dnes se naučíme kopírovat objekty pomocí odkazu na předka. Jak už jsme zmínili minule, kopírování je v takovéto situaci problematické. Naštěstí existuje standardní a osvědčený způsob, jak se s ním vypořádat.

Výchozí kód

Než budeme vytvářet kopie, začneme přesuny, které bývají jednodušší a umožní nám získat hodnotu zpět z ukazatele.

Group g("(", ", ", ")");
g.push_back(new Number(456));
g.push_back(new String("def"));
Group g2 = std::move(g);

  • Naimplementujte přesunující konstruktor (a přesunující přiřazení) třídy Group. Následující kód musí fungovat:

int main() {
    Group g("[", " | ", "]");
    g.push_back(std::make_unique<Number>(123));
    g.push_back(std::make_unique<String>("abc"));
 
    Group g2("(", ", ", ")");
    g2.push_back(std::make_unique<Number>(456));
    g2.push_back(std::make_unique<String>("def"));
    g.push_back(std::make_unique<Group>(std::move(g2)));
 
    std::cout << g << '\n';
}

Poněkud složitější je vytvořit kopírující konstruktor třídy Group. Pojďmě si to vyzkoušet.

  • Naimplementujte kopírující konstruktor (a kopírující přiřazení) třídy Group. Následující kód musí fungovat:

int main() {
    Group g("[", " | ", "]");
    g.push_back(std::make_unique<Number>(123));
    g.push_back(std::make_unique<String>("abc"));
 
    Group g2("(", ", ", ")");
    g2.push_back(std::make_unique<Number>(456));
    g2.push_back(std::make_unique<String>("def"));
    g.push_back(std::make_unique<Group>(g2));
 
    g2.push_back(std::make_unique<String>("ghi"));
 
    std::cout << g << '\n';  // [123 | abc | (456, def)]
    std::cout << g2 << '\n'; // (456, def, ghi)
}

Během implementace jsme zjistili, že potřebujeme vytvořit kopii objektu, na který odkazujeme jako na předka (pomocí Writable* nebo Writable&). To je ale problém, protože není jasné, který konstruktor zavolat. Řešením byla virtuální metoda clone, která má za úkol provést kopii správným způsobem. Metoda clone se ale dá trochu vylepšit, využitím kovariantních návratových typů.

Řešení

Pro zájemce

Kovariantní návratové typy

C++ umožňuje používat tzv. kovariantní návratové typy. To znamená, že nahrazující metoda dědící třídy může mít jiný návratový typ, než nahrazená metoda rodiče. Návratové typy se ale nemohou měnit libovolně, musí se jednat o ukazatel a musí se jednat o ukazatel na třídu která dědí z třídy, na kterou vracela ukazatel metoda rodiče.

V našem případě to můžeme využít takto:

class Writable {
public:
    ...
    virtual Writable* clone() const = 0;
};
 
class String : public Writable {
public:
    ...
    virtual String* clone() const {
        return new String(*this);
    };
    ...
};
Takto napsaná metoda clone nám umožňuje získat ukazatel stejného typu, jako staticky víme, že máme a nemusíme tedy provádět přetypování za běhu programu.

Řešení používající kovariantní návratový typ

Kovariantní návratové typy se mohou vztahovat i na jiné hierarchie tříd:

class FTPServer {
public:
    virtual FTPClient* acceptConnection();
};
 
class SFTPServer : public FTPServer {
public:
    virtual SFTPClient* acceptConnection();
};

Pokud ale budeme chtít vracet chytrý ukazatel, aby se uživatel třídy nemusel starat o smazání objektu, narazíme na problém. Jak jsme říkali, kovariantní návratové typy se týkají ukazatelů, ale std::unique_ptr z hlediska jazyka není ukazatel, ale obyčejný objekt. To znamená, že pokud chceme aby clone vracelo chytrý ukazatel, clone musí vracet std::unique_ptr<Writable>.

class Writable {
public:
    ...
    virtual std::unique_ptr<Writable> clone() const = 0;
};
 
class String : public Writable {
public:
    ...
    virtual std::unique_ptr<Writable> clone() const {
        return std::make_unique<String>(*this);
    };
    ...
};
Jak je ale vidět v ukázce, rvalue reference unique_ptr se kovariantně chovají.

Řešení používající std::unique_ptr

Výjimky

Jak už víte z přednášky, obsluha chyb v C++ může mít tři podoby: speciální návratové hodnoty, chybové příznaky a výjimky. Dnes si ukážeme základní práci s výjimkami: házení a obsluhu.

Standardní výjimky

Rozšiřme předchozí příklad o házení výjimek. Objekty typu Number jsou totiž ošklivé a rozmazlené a některá čísla jim prostě nechutnají. Vyjímku vyhodíme tak, že použijeme klíčové slovo throw:

#include <stdexcept>
...
throw std::invalid_argument("I don't like that number!");

  • Vyhoďte výjimku v konstruktoru třídy Number, pokud pro argument n platí std::cos(n) < 0.

Vyhodili jsme typ std::invalid_argument. Existuje řada takovýchto standardních výjimek, např. std::runtime_error, std::bad_alloc, a další. Protože jsou všechny součástí hierarchie objektů se společným předkem std::exception, následující je zachytí všechny:

try {
    // zde může nastat výjimka
} catch (const std::exception& e) {
    std::cerr << "An exception was thrown:\n";
    std::cerr << e.what() << '\n';
}
// volitelně:
catch (...) {
   std::cerr << "A totally unknown exception was thrown!\n";
}

Pokud zadáme tři tečky místo typu výjimky, catch blok se provede pro všechny druhy vyjímek, které nebyly obslouženy žádným předchozím catch blokem. Všimněte si, že výjimku přijímáme konstantní referencí (konec konců, ze std::exception se dědí).

  • Zachyťte výjimku v funkci main.

Teď bychom měli při spuštění programu vidět následující výstup:

An exception was thrown:
I don't like that number!

Je s výhodou, že náš program nespadl, na druhou stranu teď vůbec nevíme, kde přesně došlo k chybě. Způsobilo výjimku číslo 123, nebo až 456? Není jasné, který kus kódu máme spravit, aby byl konstruktor Number spokojený.

Vlastní výjimky

Protože standardní knihovna obsahuje pouze omezené množství typů výjimek, budeme občas chtít definovat výjimky vlastní. Takto může uživatel našeho kódu lépe vymezit, jaké chyby chce ošetřit.

Vytvoříme si tedy pro Number novou výjimku, IDontLikeThatNumberException.

  • Vytvořte novou třídu IDontLikeThatNumberException, která dědí ze std::exception. V jejím konstruktoru získejte číslo, které chcete vypsat na výstup při volání metody what.
  • Také si v této třídě uložte celý řetězec, který chcete vypsat v případě volání metody what. Řetězec je nejlepší vytvořit a uložit hned v konstruktoru.
  • Nahraďte metodu what.

Teď bychom měli mít možnost chytat pouze výjimku, která vznikne uvnitř třídy Number:

catch (const IDontLikeThatNumberException& e) {
    std::cerr << "IDontLikeThatNumberException was thrown:\n";
    std::cerr << e.what() << '\n';
}

Řešení

Co je ještě dobré vědět o výjimkách

  • Pokud se nezdaří dynamická alokace paměti pomocí operátoru new, vyhodí se vyjímka std::bad_alloc.
  • Vytváření objektu typu std::string obvykle obnáší dynamickou alokaci paměti. Proto není vždy dobrý nápad používat ho ve výjimce – například tehdy, když výjimkou oznamujeme nedostatek paměti1).
  • Nikdy neházejte výjimky v destruktorech! Jak víme, letící výjimka volá destruktory. Co se má stát, když vyletí druhá výjimka? Standard určuje, že v tom případě se má program okamžitě ukončit.
1)
Z tohoto důvodu std::bad_alloc nemá konstruktor beroucí řetězec oznamující důvod chyby
courses/b6b36pcc/cviceni/cviceni-09.txt · Last modified: 2022/09/07 13:08 by nagyoing