Hlavní navigace

Knihovna ClanLib (5)

7. 6. 2004
Doba čtení: 10 minut

Sdílet

Dnes se podíváme na sadu chytrých (smart) pointerů, které nám ClanLib nabízí k použití. Článek snad pomůže všem programátorům v C++, kterým se už nechce trávit dlouhé hodiny hledáním úniků paměti a přemýšlením "Kam jen ten delete napsat?" a nejspíš i podstatně zrychlí a zefektivní jimi psaný kód. Na konci článku předvedeme využití chytrého ukazatele na rozsáhlejším příkladu, kterým rozšíříme naši hru.

Dnes se podíváme na sadu chytrých (smart) pointerů, které nám ClanLib nabízí k použití. Smart pointery jsou obrovskými pomocníky při správě paměti obecně a v C++ se s nimi můžeme setkat velice často (tedy nejen v ClanLibu). Článek snad pomůže všem programátorům v C++, kterým se už nechce trávit dlouhé hodiny hledáním úniků paměti a přemýšlením „Kam jen ten delete napsat?“ a nejspíš i podstatně zrychlí a zefektivní jimi psaný kód. Na konci článku předvedeme využití chytrého ukazatele na rozsáhlejším příkladu, kterým rozšíříme naši hru.

Úvod do Smart Pointerů

Klasický ukazatel v C++ má velké množství výhod a umožňuje spoustu triků. Příkladem takových hezkých vlastností je například fungování polymorfismu, možnost sdílení dat a podobně. Velkou nevýhodou však je, že se musíme sami starat o správu paměti. Když pointeru přiřadíme dynamicky alokovaná data (třeba výsledek operátoru new), musíme se zároveň dobře rozmyslet, kdy a kde tato data uvolníme operátorem delete.

Podobné rozhodnutí bývá často nesmírně složité a je jedním z nejčastějších zdrojů chyb. Buď se snažíme přistupovat k již neexistujícímu objektu (běhová chyba a pád programu), nebo naopak objekt neuvolníme vůbec (časem nejspíš vyčerpáme všechnu paměť). Kdo z vás nezažil podobnou situaci?

Velmi složité bývá například rozhodnout, kdy odalokovávat, pokud na jedna data míří několik ukazatelů z různých objektů. Často v takové situaci není jisté, v jakém pořadí budou tyto objekty zanikat a který z nich bude poslední.

V takové situaci bývá v C++ typickým řešením napsat tzv. obslužnou třídu, tj. třídu, která se bude starat o problematicou správu paměti. Taková třída bude znát nějaký algoritmus, který bude daný problém řešit. Algoritmy se mohou lišit třídu od třídy podle jejich specifického chování.

Jedním takovým velmi (ale bohužel ne zcela) univerzálním algoritmem je počítání referencí. Myšlenka je velice jednoduchá. Na náš objekt budeme ukazovat speciálními ukazateli schopnými sdílet informaci o tom, kolik jich je. To se dá naimplementovat třeba tak, že je chytrý ukazatel třídou s proměnnou ref_pocet (typicky klasický ukazatel na int). Pokaždé, když vytvoříme nový chytrý ukazatel (na nový ještě neodkazovaný objekt), bude tato jeho proměnná rovna jedné. Použijeme-li pak jeho operátor přiřazení „=“, inkrementujeme ref_pocet, protože to znamená, že už máme o jeden víc ukazatelů na stejný objekt. V destruktoru chytrého ukazatele zase ref_pocet snižujeme, a pokud klesne na nulu, uvolníme i odkazovaný objekt.

Popravdě řečeno zas tak úplně samo to naimplementovat nejde a jistě se najde spousta lidí, kteří ocení již hotové řešení, například to, co nám nabízí ClanLib. Pokud by však někoho přeci jen zajímaly podrobnosti imlementace, doporučil bych mu knihu Accelerated C++ … (vyšla i v češtině pod názvem Rozumíme C++), kde je tato problematika velmi pěkně popsána, vysvětlena a naprogramována.

Počítání referencí funguje dobře všude tam, kde nevytváříme cyklické odkazy (A ukazuje na B a B ukazuje na A), což je drtivá většina případů. Jelikož se jedná o poměrně efektivní algoritmus, je na něm možné vybudovat obecnou obslužnou třídu. Příkladem takových obecných obslužných tříd jsou SP v ClanLibu. Pojďme si je tedy popsat jeden po druhém. (Následujících několik příkladů je inspirováno jedním z overview dodávaných s ClanLibem.)

Sharing Pointer

Sharing Pointer (sdílející ukazatel) je nejjednodušším z nabízených SP. Dělá vlastně přesně to, co jsme si popsali, sdílí jedna data mezi několika svými instancemi. Jedná se samozřejmě o šablonovou třídu (CL_SharedPtr<…>), abychom mohli pomocí něj ukazovat na libovolný typ. Jeho použití může vypadat následovně:

// ukazatel na NasTyp pojmenovany A
CL_SharedPtr<NasTyp> A = new NasTyp();

// A i B nyni budou ukazovat stale na jednu
// jedinou instanci naseho typu NasTyp
CL_SharedPtr<NasTyp> B = A;

// A, B, C stale ukazuji na tu samou instanci
CL_SharedPtr<Thing> C = B;

// muzeme pristupovat k metodam NasTyp
// pomoci ->, jak jsme zvykli
A->Metoda();

// muzeme dereferencovat pomoci *,
// jak jsme zvykli
funkce(*a);

// nyni je A nulovy ukazatel, jako
// kdyz bychom jej proste zkonstruovali
A = 0;

// dosud B a C stale ukazuji na stejna data

b = 0;

// nyni uz nebude na data ukazovat zadny SP
// a budou tedy bezpecne zrusena
c = 0;

S CL_SharedPtr se tedy pracuje úplně stejně jako s klasickým ukazatelem, pouze se nemusíme starat o uvolňování paměti, jelikož je automatické. CL_SharedPtr využijeme na konci článku při imlementaci třídy T_ModHry reprezentující jednotlivé módy Onion Bombermana.

Příprava tříd na CL_LazyCopyPtr a CL_OwningPtr

Aby bylo možné odkazovat se na naši třídu pomocí dalších dvou SP, bude nutné z implementačních důvodů tyto třídy dědit od třídy CL_Clonable, jak ukazuje další příklad:

class NasTyp : public CL_Clonable {

  // je nutne vytvorit nasledujici metodu
  CL_Clonable* clone() const {return new NasTyp(*this);}

  // nasleduje klasicka deklarace tridy, jak jsme na to zvykli

}; 

Jak je vidět, nejedná se o nic tak složitého. Naši třídu odvodíme od CL_Clonable a napíšeme metodu clone(), která, jak je vidět, vrací ukazatel na kopii daného objektu. Tuto metodu potřebují níže popisované SP ke svému správnému fungování (proč tomu tak je, se můžete dozvědět také ve výše uvedené knize).

Nesmíme zapomenout definovat metodu clone() v každém potomkovi, aby nedocházelo k ořezávání při kopírování:

class OdvozenyTyp : public NasTyp {

  // je nutne vytvorit nasledujici metodu pro OdvozenyTyp!!!
  CL_Clonable* clone() const {return new OdvozenyTyp(*this);}

  // nasleduje klasicka deklarace tridy, jak jsme na to zvykli

}; 

Je třeba si na tento detail dávat dobrý pozor a mít ho na seznamu věcí, které mohou způsobit podivné chování vašeho programu, jelikož pokud zapomenete definovat clone() v potomkovi, je to z hlediska C++ v pořádku a kompilátor vás varovat nebude.

Possesive Pointer

Possesive pointer (vlastnící ukazatel) je lakomec, který s nikým nehodlá sdílet svá data. Když musí, vytvoří raději jejich kopii. V ClanLibu je reprezentován třídou CL_OwningPtr. Podívejme se na příklad jeho použití:

// ukazatel na NasTyp pojmenovany A
CL_SharedPtr<NasTyp> A = new NasTyp();

// A a B nyni budou ukazovat na ruzne instance
// typu NasTyp. B vznikne volanim kopirovaciho
// konstruktoru objektu A
CL_SharedPtr<NasTyp> B = A;

Samozřejmě, že rušení přidružených dat je opět automatické. Například další přiřazení do A uvolní původně přidružená data.

Copy-on-write Pointer

Nakonec jsem si nechal asi nejužitečnější SP reprezentovaný třídou CL_LazyCopyPtr, který se sémanticky chová úplně stejně jako předchozí Possesive Pointer. Implementace je však jiná, jelikož tento ukazatel ve skutečnosti sdílí data do té doby, než jsou měněna. Tím vytváří dokonalou iluzi toho, že vždy kopírujeme, ovšem ve skutečnosti kopírujeme, jen když je to nutné. Použití tohoto ukazatele nám tedy jednak usnadňuje správu paměti, jednak významně zefektivňuje náš program v případech, kde často kopírujeme a obecně nemůžeme sdílet data, protože jsou občas v kopii modifikovaná, a to se na originálu nesmí projevit. Takováto obecná obslužná třída je v takovém případě k nezaplacení.

Z hlediska čistoty kódu by někdo mohl chtít psát jako parametr šablony CL_OvningPtr a CL_LazyCopyPtr přímo základní třídu CL_Clonable, což by ho však v důsledku nutilo použít přetypování všude tam, kde chce použít potomka, a to není ani čisté, ani přehledné, ani pohodlné. Pro tento případ ClanLib nabízí řešení v podobě druhého šablonového parametru, kterým je typ potomka, tj. píšeme:

CL_OwningPtr<CL_Clonable, TypPotomka>

Přetypování pak jsou prováděna automaticky.

Ukázka využití SP

Navážeme nyní na vývoj hry Onion Bomberman, kterou jsme začali psát minule. Uvědomili jsem si, že se hra bude skládat z několika módů, jejichž seznam je ve zdroje.xml. Zde je také specifikováno, který mód bude nastaven jako aktivní první. Zatím jsme se dohodli, že módy budou Menu a Bitva. Předpokládáme však, že časem asi nějaké další přibudou. Řekli jsem si také, že budou mít nejspíš všechny společné to, že v každém kroku hry budou aktualizovat svou logiku a následně se vykreslí.

Bitva je mod hry, stejně tak Menu je mod hry. Nabízí se tedy možnost napsat třídu T_ModHryZaklad a od ní odvozovat konkrétní módy (Bitvu, Menu, případně další). Tato základní třída by měla obsahovat to, co je pro módy společné, tedy operace AktualizujSe() a VykresliSe(), a je dost možné, že časem objevíme i další společné chování módů (nebo taky ne). Víme však, že ze smyčky v metodě Run() tříy T_Hra budeme chtít pro AktivniMod volat právě výše zmíněné metody.

V podobných situacích bývá vhodné zakrýt konkrétní implementaci módů hry (včetně použití dědičnosti apod.) a zpřístupnit pouze příslušné dvě metody, které budou uživatelé používat. K tomu nám poslouží třída rozhraní T_ModHry, která bude pouze zpřístupňovat tyto operace skrz ukazatel na T_ModHryZaklad, a to bude právě náš SP. Podívejme se tedy na její hlavičku:

#include <string>
#include "hlavicky.h"
#include "t_modhryzaklad.h"

class T_ModHry {

// konstrukce a destrukce:
public:

  // konstruktor - vytvori prazdny mod
  T_ModHry();

  // konstruktor - parametrem nazev konkretniho modu
  T_ModHry(std::string JmenoModu);

// verejne metody
public:

  void AktualizujSe();
  void VykresliSe();

// implementace:
private:

  // ukazatel na implementacni tridu
  CL_SharedPtr<T_ModHryZaklad> Impl;

};

Zde není myslím nic zvláštního ani neobvyklého. Za zmínku stojí pouze:

// ukazatel na implementacni tridu
CL_SharedPtr<T_ModHryZaklad> Impl;

Impl je ukazatel na T_ModHryZaklad, který se sám postará o správu paměti. Využijeme ho v implementačním souboru, který je také velice jednoduchý:

#include "t_modhry.h"
#include "t_menu.h"
#include "t_bitva.h"
#include "t_neplatnymodexception.h"

//-----------------------------------------------
// konstruktor
//-----------------------------------------------
T_ModHry::T_ModHry()
: Impl(0) {

} // konstruktor --------------------------------

//-----------------------------------------------
// konstruktor
//-----------------------------------------------
T_ModHry::T_ModHry(std::string JmenoModu)
{
    // na zaklade jmena se rozhodneme, jaky
    // konkretni mod vytvorime

    if (JmenoModu == "Menu") {
      Impl = new T_Menu();
      return;
    }
    if (JmenoModu == "Bitva") {
      Impl = new T_Bitva();
      return;
    }

    // pokud dojdeme az sem, znamena to, ze Jmeno
    // nezname, vyvolame proto vyjimku
    throw T_NeplatnyModException();
} // konstruktor --------------------------------

//-----------------------------------------------
// AktualizujSe()
//-----------------------------------------------
void T_ModHry::AktualizujSe() {
  if (Impl) {
    Impl->AktualizujSe();
  }
  else {
    throw T_NeplatnyModException();
  }
} // AktualizujSe() -----------------------------

//-----------------------------------------------
// VykresliSe()
//-----------------------------------------------
void T_ModHry::VykresliSe() {
  if (Impl) {
    Impl->VykresliSe();
  }
  else {
    throw T_NeplatnyModException();
  }
} // VykresliSe() -------------------------------

V konstruktoru s parametrem inicializujeme Impl konkrétním módem na základě předaného jména. Metody AktualizujeSe() a VykresliSe() už pouze volají Impl->AktualizujSe() a Impl->VykresliSe().

Třída T_ModHryZaklad už je velice jednoduchá. Jedinou zajímavostí snad je, že má soukromý konstruktor, čímž zamezíme uživatelům tuto třídu používat samostatně. Vynutíme si tak přístup přes T_ModHry, která je přítelem, a tedy může používat soukromý konstruktor. Podívejme se tedy na hlavičkový soubor:

#include <string>
#include "hlavicky.h"

class T_ModHryZaklad {

  friend class T_Menu;
  friend class T_Bitva;
  friend class T_ModHry;

// destrukce
public:

  virtual ~T_ModHryZaklad()  {};

// konstrukce
private:

  // konstruktor
    T_ModHryZaklad() {};

// rozhrani:
private:

  virtual void AktualizujSe() = 0;
  virtual void VykresliSe() = 0;

};

Jedná se o abstraktní třídu, která zatím ani nepotřebuje implementační soubor.

Nakonec napíšeme třídu T_Menu, ktrerá bude zatím v metodě VykresliSe() dělat to, co dosud dělala metoda Run() třídy T_Hra, tj. vykreslování pozadí. T_Bitva bude zatím úplně stejná, jen její metody nebudou nic dělat, proto ji tu už nebudu opisovat. Hlavička T_Menu:

#include "t_modhryzaklad.h"

class T_Menu : public T_ModHryZaklad {

  friend class T_ModHryZaklad;
  friend class T_ModHry;

// konstrukce a destrukce:
private:

  // Soukromy konstruktor i destruktor,
  // aby tato trida nesla sama o sobe
  // vytvorit.
  // Chceme ji totiz pouzivat pres tridu
  // T_ModHry, a skryt tak implementaci.

  // konstruktor
    T_Menu();

  // destruktor
    ~T_Menu();

// metody:
private:

  virtual void AktualizujSe();
  virtual void VykresliSe();

// promenne:
private:

   // pozadi menu
   CL_Surface Pozadi;

};

    Implementace třídy T_Menu:

#include "t_menu.h"
#include <iostream>

//-----------------------------------------------
// konstruktor
//-----------------------------------------------
T_Menu::T_Menu()
// konstrukce pozadi
: Pozadi("Obrazky/Pozadi", Konfigurace::SpravceZdroju) {

} // konstruktor --------------------------------

//-----------------------------------------------
// destruktor
//-----------------------------------------------
T_Menu::~T_Menu(){

} // destruktor ---------------------------------

//-----------------------------------------------
// AktualizujSe()
//-----------------------------------------------
void T_Menu::AktualizujSe() {

} // AktualizujSe() -----------------------------

//-----------------------------------------------
// VykresliSe()
//-----------------------------------------------
void T_Menu::VykresliSe() {

  // vykreslime pozadi
  Pozadi.draw();

} // VykresliSe() -------------------------------

Nakonec ještě modifikovaná metoda Run() třídy T_Hra:

CS24_early

//-----------------------------------------------
// Run()
//-----------------------------------------------
void T_Hra::Run() {

  // pockame si na stisteni klavesy escape
  while (! CL_Keyboard::get_keycode(CL_KEY_ESCAPE)) {

    // invariant: Dosud nebyl stisten escape
    // pri get_keycode()

    // aktualizujeme logiku aktivniho modu
    AktivniMod.AktualizujSe();

    // vykreslime aktivni mod
    AktivniMod.VykresliSe();

    // zobrazime nakreslene zmeny (prehozeni
    // predniho a zadniho bufferu)
    CL_Display::flip();

    // uspime aplikaci na deset milisekund,
    // abychom zbytecne neblokovali procesor
    const int DobaObnoveni = 10;
    CL_System::sleep(DobaObnoveni);

    // probudime aplikaci resp. knihovnu,
    // aby byla aktualni
    CL_System::keep_alive();

  } // while

} // Run() --------------------------------------

To je pro dnešek vše. Na všechny dosud napsané kódy Onion Bombermana s obarvenou syntaxi v pdf se můžete podívat zde a KDevelopí projekt si můžete stáhnout zde.

Příště se podíváme na třídu CL_Sprite, která nám umožňuje velmi pohodlně vytvářet animace z dvojrozměrných obrázků a s její pomocí naprogramujeme panáčka, nebo něco podobného pro naši hru.

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

Autor článku