IV. rész: Heterogén kollekció
Czirkos Zoltán · 2022.06.21.
Hogyan építhetünk heterogén kollekciót? Hogyan másoljuk meg azt, és miképpen oldható meg az objektumok memóriakezelése?
Heterogén kollekcióról az OOP-ről tanulva hamar hallunk. Bár OOP tervezés szempontjából ez nem feltétlenül az alapokhoz tartozik – hiszen egy tervezési mintáról van szó –, viszont könnyű megmutatni rajta keresztül, milyen helyzetben lehet hasznos a polimorfizmus. Ahogyan azt is, hogy ez milyen memóriakezelési problémákkal jár.
Lássuk, hogyan lehet ilyet építeni C++-ban! A konkrét modellezési feladat lényegtelen, maradjunk az egyszerűség kedvéért most az alakzatoknál.
A heterogén kollekció felépítéséhez két problémát kell megoldanunk; 1) hogy egy tárolót hozzunk létre (kollekció), és 2) hogy abban eltérő típusú objektumok lehessenek (heterogén). A tároló legyen egy dinamikus tömb! Mivel a tömbben nem lehetnek különféle méretű objektumok, ezért az egyes objektumokat külön is dinamikusan foglaljuk. Így az alábbi osztályhoz juthatunk:
class Drawing {
private:
Shape** shapes;
unsigned size;
public:
/* ... */
};
Mint tudjuk, a pointerek nagyon sok probléma megoldására alkalmasak. De ha a kód olvashatósága szempontjából vizsgáljuk őket, épp ez a baj velük: nem mindig egyértelmű a kódból elsőre, miért is volt az adott helyen szükség az indirekcióra. Jelen esetben az első csillag az alakzatok heterogenitása miatt kellett, a második pedig a dinamikus tömb miatt. Ahogy adott esetben mi sem tudjuk elsőre, úgy a fordító sem: ennek az osztálynak képtelen automatikusan helyes másoló konstruktort és destruktort generálni. Pedig kellene neki, a tömb és a dinamikusan foglalt alakzatok miatt is. A kézzel megírt destruktoron jól látszik:
Drawing::~Drawing() {
for (unsigned i = 0; i < size; ++i)
delete shapes[i];
delete[] shapes;
}
A fenti osztállyal egyszerre két problémát oldunk meg, a dinamikus tömböt és az alakzatok memóriakezelését is. Ez az OOP egyik fő elvét sérti (SRP – Single Responsibility Principle). A dinamikus tömböt szervezzük ki egyből egy külön osztályba! Van is ilyen beépítetten, a közismert vektor:
class Drawing {
private:
std::vector<Shape*> shapes;
public:
/* ... */
};
Drawing::~Drawing() {
for (Shape* s : shapes)
delete s;
}
Innentől kezdve csak azzal foglalkozunk, az egyes alakzatok memóriakezelését hogyan oldjuk meg. Az első dolog az alakzatok létrehozása. Ha a hívó példányosítja az objektumot, akkor ez így festhet:
void Drawing::add(Shape* s) {
try {
shapes.push_back(s);
} catch (...) {
delete s;
throw;
}
}
Drawing d;
d.add(new Circle(10));
Alapvetően csak annyi történik, hogy a tárolóba betesszük az új pointert. Viszont figyelni kell arra, hogy a
push_back()
függvény kivételt dobhat. Márpedig a tárolónak átadva az alakzatot, elvárjuk tőle, hogy fel is szabadítsa
majd azt; ennek teljesülnie kell akkor is, ha a művelet sikertelen. Ezért került a delete s
sor a catch
blokkba: ha nem kerül be az új alakzat címe a vektorba, akkor nem fogja felszabadítani sem a destruktor.
Mi a helyzet a tároló másolásával? A fordító által generált másoló konstruktor hiányos. Mert ugyan az hívja a vektor másoló
konstruktorát, ami ugyan a tömb másolását megoldja, de azzal csak a benne lévő pointerek másolódnak – az alakzatok pedig nem.
Úgyhogy a másolást nekünk kell megoldani. Itt viszont el kell érnünk azt, hogy az egyes objektumokat (háromszögeket, köröket,
téglalapokat...) típushelyesen másoljuk, mindegyiket a saját maga másoló konstruktorával. Az objektumok típusát viszont nem
ismerjük, csak az ősosztályukat: Shape
. A másolás problémáját legegyszerűbb intruzívan megoldani, elvárni az
alakzatoktól, hogy ők maguk végezzék el a másolást:
Drawing::Drawing(Drawing const& other) {
shapes.reserve(other.shapes.size());
for (Shape const * s : other.shapes)
shapes.push_back(s.clone()); // !
}
class Shape {
public:
virtual Shape* clone() const = 0;
/* ... */
};
class Rectangle : public Shape {
public:
virtual Shape* clone() const override {
return new Rectangle(*this);
}
/* ... */
};
Ez működő megoldás, de ezen a ponton már látszik is, hogy milyen probléma bontakozott itt ki. Az alakzatok dinamikus foglalása a legváltozatosabb helyen tűnik fel a kódban:
Drawing d;
d.add(new Circle(10)); // a használat helyén
Shape* Rectangle::clone() const {
return new Rectangle(*this); // az ÖSSZES alakzat osztályban
}
Drawing::~Drawing() {
for (Shape* s : shapes)
delete s; // a tárolóban
}
Ebből látszik az is, hogy ezen kéne elgondolkozni: hogyan tudjuk minél inkább egy helyre, egy osztályba „sűríteni” az ezzel kapcsolatos kódsorokat.
A destruktor delete s;
sora az okos pointereket juttathatja eszünkbe. Mivel minden alakzat csak egy tárolóban lehet
benne egyszerre (a destruktor feltétel nélkül törli az alakzatot is), ezért ez tulajdonképpen egy std::unique_ptr
.
Vigyük végig ezt a refaktort! Az osztályban:
class Drawing {
private:
std::vector<std::unique_ptr<Shape>> shapes;
Ez jó jel: mostanra mindkét csillagról kiderült, mi volt a célja.
A destruktorban:
Drawing::~Drawing() = default;
Egyáltalán nincs dolgunk. A vektor destruktora felel a tömbért, az egyes okos pointerek pedig az alakzatokért.
Az add()
függvényben:
void Drawing::add(std::unique_ptr<Shape> s) {
shapes.push_back(std::move(s));
}
Drawing d;
d.add(std::make_unique<Circle>(10));
Eltűnt a kézi kivételkezelés is. Ha a push_back
sikertelen, az s
destruktora fogja törölni az
alakzatot. A memóriakezelés az add()
hívásának helyén sajnos még mindig megjelenik: valójában a hívónak nem kellene
azzal foglalkoznia, nem is kellene tudnia róla, hogy belül okos pointer van; ennek ellenére az interfészen ez még mindig
megjelenik.
Ugyanez igaz a másoló konstruktorra, és az ezáltal elvárt függvényre is az alakzat osztályokban:
Drawing::Drawing(Drawing const& other) {
shapes.reserve(other.shapes.size());
for (std::unique_ptr<Shape> const & s : other.shapes)
shapes.push_back(s.clone());
}
class Shape {
public:
virtual std::unique_ptr<Shape> clone() const = 0;
/* ... */
};
class Rectangle : public Shape {
public:
virtual std::unique_ptr<Shape> clone() const override {
return std::make_unique<Rectangle>(*this);
}
/* ... */
};
Valójában a hozzáadás helyén még ismerjük az objektum pontos típusát. Tegyük fel egy pillanatra, hogy okos pointer használata
nélkül, magát az objektumot adjuk oda az add()
függvénynek:
Drawing d;
d.add(Circle(10));
Megoldható, hogy ez a hívás működjön, és a tároló hozza létre az okos pointert is? Valójában elég könnyen, hiszen az
add()
függvény lehet akár sablon is, és ezáltal ismerheti a létrehozott objektumot:
template <typename T>
void Drawing::add(T s) {
shapes.push_back(std::make_unique<T>(std::move(s)));
}
A fenti ötlet vezet ahhoz, hogy az egyes alakzatok memóriakezelését egy önálló osztályba tudjuk szervezni.
Az új segédosztályunk egyetlen egy alakzatot fog tárolni, de érték szerint. A konstruktora pedig átveheti azt az egyetlen egy
alakzatot. Lényegében egy std::any
-hez hasonló tároló osztályról lesz szó, aminek az interfészén meg is jeleníthetjük
az alakzatot magát:
class ShapeValue {
private:
struct Model {
virtual ~Model() = default;
virtual Model* clone() const = 0;
};
template <typename T>
struct ShapeModel : Model {
T shape;
ShapeModel(T shape) : shape(std::move(shape)) {}
virtual Model* clone() const override {
return new ShapeModel<T>(*this);
}
};
Model *model;
public:
template <typename T>
ShapeValue(T shape) : model(new ShapeModel<T>(std::move(shape))) {}
ShapeValue(ShapeValue const& other) {
model = other.model->clone();
}
~ShapeValue() {
delete model;
}
Shape& operator* () {
return *model;
}
};
Így a clone()
is ide költözhet, mert a ShapeModel
belső osztályoknál is ismerjük az alakzat pontos
típusát. Ezt felhasználva a heterogén kollekció:
class Drawing {
private:
std::vector<ShapeValue> shapes;
public:
Drawing() = default;
Drawing(Drawing const&) = default;
~Drawing() = default;
template <typename T>
void add(T shape) {
shapes.push_back(ShapeValue(std::move(shape)));
}
};
Valójában már nincs is rá szükség, egy std::vector
bármikor helyettesíthetné. (Hacsak nem akarunk további szolgáltatásokat hozzáadni az osztályhoz.)
A használat pedig:
Drawing d;
d.add(Circle(10));
Az alakzatot érték szerint tároló osztály egyébként hasznos lehet máshol is, nem csak a heterogén kollekcióban. Ennek a szerepe már független attól: arra való, hogy egy alakzatot – memóriakezelési problémákat félretéve – érték szerint tudjunk kezelni. Hogy emiatt heterogén kollekció építésére is alkalmas, az mellékes.