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.

1. Nyers nyelvi eszközökkel

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;
}

2. Az alakzatok létrehozása és másolása

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.

3. Okos pointer az alakzatoknak

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);
        }
        /* ... */
};

4. Az add() függvény paramétere

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.

5. Alakzat érték szerint

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.