I. rész: Memóriakezelés

Czirkos Zoltán · 2022.06.21.

Az előadáson bemutatott kifejzésfa program refaktorálásának első része: a memóriakezelés.

Az előadáson bemutatott kifejezésfás program egy igazi állatorvosi ló. A probléma viszonylag egyszerű, ugyanakkor matematikailag jól körülhatárolható, ezért a modellezése is egyszerű, és jól illeszkedik az OOP-s elvekhez. Nem véletlen, hogy több könyvben is példának választják, a tárgy előadásanyagába is ezért került bele.

Ebben az írássorozatban az előadáson elkészült kód több lépésből álló refaktorálását mutatom be. Az egyes részek:

Az írások elkészítését Arne Mertz Simplify C++ című blogja inspirálta – bár jópár helyen egész más tervezési döntések születtek nála, mint ebben a verzióban.

1. A kiindulási alap: az eredeti kód

Az eredeti feladatunk így szólt: egy fát kell felépítenünk, amely négy alapműveletből felépített kifejezéseket tárol. Például a 4*(5+x) műveletsort az ábrán látható fa írja le. Lényegében ez egy szintaxisfa; a fa hierarchiája adja meg, mely műveletekhez mely operandusok tartoznak, így nyelvtani szabályokra, az operátorok közötti precedenciák és asszociativitások meghatározására itt nincsen szükség.

A fán különféle műveleteket kellett végeznünk, úgymint:

  • Kiírás infixes alakban, például az itt látható fánál ez a 4*(5+x), vagy az egyszerűség kedvéért a (4*(5+x)) sztring megépítését jelenti.
  • Kiértékelés egy adott x helyen, pl. f(3) = 4*(5+3) = 32.
  • Szimbolikus deriválás az x változó szerint, új kifejezésfa előállítása.
  • Egyszerűsítés.

A megoldást egy osztályhierarchia felépítése adta. (Letölthető innen: expression_eloadas.cpp.) A kifejezés ősosztály leszármazottai a konstans, változó, összeg és szorzat. Az egyes műveletek pedig virtuális függvényekként implementálhatóak, mivel azok az egyes típusokra jellemző módokon végezhetőek: egy konstans kiírása egy valós szám kiírását jelenti, az összeg deriválása pedig egy másik összeg objektum előállítását, amibe a bal- és a jobb oldali részfa deriváltja került. Az absztrakt ősosztály lényegi része:

class Expression {
  public:
    virtual double evaluate(double x) const = 0;
    virtual void print(std::ostream &os) const = 0;
    virtual Expression * derivative() const = 0;
    virtual Expression * simplify() const = 0;

    virtual ~Expression() {}
};

Az eltérő típusú objektumok kezelése ránk kényszerített egy indirekciót. A kompozit műveletek (összeg, szorzat: kifejezések összege és szorzata) számukra ismeretlen típusú objektumokkal dolgoztak, amelyet kénytelenek voltak indirekten, pointer segítségével tárolni. Ugyan valószínűleg a fa egyes csomópontjait enélkül is dinamikusan foglaltuk volna, hiszen a csomópontok száma a program bemenetétől függ, ez mégis programozói többletmunkát jelentett. A kompozit objektumokhoz destruktort kellett írnunk, amely a tartalmazott objektumokat felszabadítja:

class TwoOperand : public Expression {
  public:
    TwoOperand(Expression *lhs, Expression *rhs) : lhs_(lhs), rhs_(rhs) {}

    ~TwoOperand() {
        delete lhs_;
        delete rhs_;
    }
    
  protected:
    Expression *lhs_, *rhs_;
};

Ezzel egyben előírtuk azt is, hogy ezek építéséhez dinamikusan foglalt objektumokat kell felhasználni (a konstruktornak paraméterként adott két Expression kötelezően a new operátorral létrehozott kell legyen), továbbá az ősosztályban kért, új kifejezésfákat előállító műveletek (deriválás, egyszerűsítés) hasonlóképp kell viselkedjenek. (Viszont felhasználtuk a virtuális destruktort.) A dinamikus memóriakezelés további bonyodalmakat okozott több helyen is. A szorzat deriválásánál, amely az (a*b)' = a'b*ab' szabály alapján egy új fa építését jelenti, figyelnünk kellett, hogy ne csak a pointert másoljuk, mivel akkor kétszer hívódna a delete ugyanazokra az objektumokra:

HIBÁS
kód
virtual Expression * Product::derivative() const override {
    return new Sum(
       new Product(lhs_->derivative(), rhs_),  // a fa nem másolódik
       new Product(lhs_, rhs_->derivative())
    );
}

Hanem ilyenkor a részfákat is másoljuk, amihez pedig egy újabb virtuális függvényre, a clone()-ra volt szükségünk:

virtual Expression * Product::derivative() const override {
    return new Sum(
       new Product(lhs_->derivative(), rhs_->clone()),  // így már másolódik
       new Product(lhs_->clone(), rhs_->derivative())
    );
}

A nyers pointerek használata, new-olás, delete-elés önmagában is code smell, de a szintaxisfa használói számára is kényelmetlenséget, hibalehetőséget jelent. Például minden fa létrehozási művelet esetén, nem csak akkor, amikor ő maga írt new-t, hanem például egy deriválás esetén is, kézzel kell felszabadítania az objektumot egy delete kifejezéssel. Emiatt a következő sor memóriaszivárgást eredményez:

Expression * e = ...;

e->derivative()->print(std::cout);

És a kiíró (inserter) operátorral írt forma is:

std::cout << *(e->derivative());

Mivel a derivative() által visszaadott pointer nincs eltárolva, és a print()-en kívül a delete-nek is odaadva. De a fenti sorok írója számára nem túl természetes eltárolni a pointert, hiszen csak egyszer akarta használni az értékét, a derivált kifejezést.

Lássuk, hogy fejleszthető ez a program!

2. Triviális destruktor

Első lépésként az ősosztály erőforráskezelését érdemes kicsit megvizsgálni. A vonatkozó kódrészlet:

class Expression {
  public:
    Expression() = default;
    Expression(Expression &) = default;

    virtual ~Expression() {}
};

Tudjuk, hogy polimorf osztálynak virtuális destruktor kell, ezért deklaráltunk egyet. A deklaráció hatására a fordító már nem írta azt meg, ezért definiálnunk is kellett azt, még akkor is, ha csak egy üres függvénytörzsről van szó.

A destruktor deklarálásának azonban van egy kellemetlen mellékhatása. Tudjuk, hogy az erőforrásokat kezelő osztályoknak általában a destruktora mellett másoló konstruktora és értékadó operátorra is van szüksége. Ez olyan általános érvényű szabály, ami miatt a C++11-ben a fordító már nem generál másoló konstruktort, ha a programozó által deklarált destruktort lát, mert valószínűleg nem lenne helyes. Ezért kellett szólni neki a másoló konstruktor melletti = default jelöléssel, hogy mi viszont tudjuk, hogy helyes, ezért engedélyt adunk a fordítónak a megírására. A másoló konstruktor deklarálása viszont azt jelenti, konstruktort deklarálunk – így eltűnt az alapértelmezett konstruktor, amit szintén „vissza kell hoznunk”.

Tudjuk azt is, hogy az értékadó operátor (assignment operator) hasonlóan működik. Az ősosztályban úgysincs még adattag, azt hasonlóképp definiáltathatnánk a fordítóval. Ugyanez áll a jobbértékes konstruktorra és értékadásra is (move constructor és move assignment operator). Igazából mindegyik lehetne default, de nem törölhetjük ki őket, mert a destruktornak virtuálisnak kell lennie:

class Expression {
  public:
    Expression() = default;

    Expression(Expression const &) = default;
    Expression(Expression &&) = default;
    Expression & operator=(Expression const &) = default;
    Expression & operator=(Expression &&) = default;

    virtual ~Expression() = default;
};

Talán legjobb mindig mind az öt függvény deklarációját kiírni, még akkor is, ha mindegyiket default-oljuk. De ha egyet leírtunk az öt közül, akkor biztos mindről nyilatkoznunk kell.

3. Automatikus memóriakezelés

Nagyobb falat a memóriakezelés refaktorálása. De tudjuk, hogy okos pointereket használva egyszerűsödne a kód, az osztályok használói számára pedig jelentősen biztonságosabbá válna. Ha a derivative() függvény nem nyers pointerrel:

class Expression {
    virtual Expression * derivative() const = 0;
};

Hanem okos pointerrel térne vissza:

class Expression {
    virtual SomeSmartPtr<Expression> derivative() const = 0;
};

Akkor a memóriakezelés miatt neki sem kellene izgulnia: a visszaadott objektum destruktora tudná, mi a teendő a kifejezés objektummal.

Okos pointert valószínű nem kell majd írnunk, használhatjuk a C++11 beépített osztályait is. A szabványos referenciaszámlált pointer (std::shared_ptr), és a tulajdonjoggal rendelkező pointer (std::unique_ptr) is szóba jöhet, csak át kell gondolni, melyiket válasszuk.

std::unique_ptr

Az std::unique_ptr természetes választás, mert eddig is tulajdonjoggal rendelkező volt az összes pointerünk.

std::shared_ptr

A referenciaszámlálás használatának egy komolyabb hozománya is lehetne: kigyomlálhatnánk a clone() függvényeket. Ugyanis akkor nem lenne gond, hogy a kifejezésfáknak közös részfái (a memóriában közös részfái!) vannak, és nem kellene például a deriválásnál kifejezéseket másolgatni.

Szerencsére a körkörös hivatkozásokkal biztosan nem lenne bajunk, mert kifejezésfákról van szó – fákban meg nem lehet kör.

Az erőforrásmegosztásnak viszont ára lenne: konstansként kellene kezeljük a fákat. Mert az nem lenne menő, ha egy fát megváltoztatunk, és közben egy számunkra ismeretlen fát is módosítottunk. De eddig sem akartuk a fákat módosítani, minden műveletünk új fát hozott létre. Ez vállalhatónak tűnik.

Az érdekessége miatt az std::shared_ptr-es megoldást választottam.

A refaktort az ősosztálynál érdemes elkezdeni. Törölhető a clone(). Az Expression * visszatérési értékű függvények std::shared_ptr<Expression> értékűre cserélhetőek. A függvények szerepét magyarázó kommentek is frissíthetőek, nem kell már beleírni, hogy dinamikusan foglaltak a kifejezések:

class Expression {
  public:
    /* returns value of expression at x */
    virtual double evaluate(double x) const = 0;

    /* outputs expression as string to stream os */
    virtual void print(std::ostream &os) const = 0;

    /* return derivative expression */
    virtual std::shared_ptr<Expression> derivative() const = 0;

    /* return simplified version. */
    virtual std::shared_ptr<Expression> simplify() const = 0;

    /* ... */
    virtual ~Expression() {}
};

Ez a két apró változtatás nagyon sok mindent ki fog kényszeríteni. Az összes clone() függvény törlését (mivel azok mind override-dal vannak megadva), és a hívásait is. A visszatérési értékek típusának megváltoztatása miatt pedig az összes derivative()-re és simplify()-ra rá kell majd néznünk, és akkor már átdolgozzuk a kódjaikat is. Például egy viszonylag egyszerű függvény, a változó deriválása így fog kinézni, természetesen std::make_shared<>() használatával:

class Variable final : public Expression {
  public:
    /* ... */
    virtual std::shared_ptr<Expression> derivative() const override {
        return std::make_shared<Constant>(1);                       // !
    }
};

De ne feledkezzünk meg az adattagok konstanssá tételéről sem! Ez fontos. A kifejezésfáknak a megosztott részfák miatt konstansoknak kell lenniük, és ezt így ellenőriztetni tudjuk a fordítóval:

class Constant final : public Expression {
  private:
    double const c_;    //!

  public:
    Constant(double c): c_(c) {}
    /* ... */
};

A TwoOperand osztályból az összes erőforráskezelő függvény eltűnik. De ne feledjük, ennek az osztálynak van destruktora, amelyik a shared_ptr-ek destruktorát hívja, úgyhogy az ősben a virtuális destruktorra még mindig szükségünk van – ahogy mindig. Az egészből a két shared_ptr marad meg, de a bal és a jobb operandust amúgy is tárolnunk kellene:

class TwoOperand : public Expression {
  public:
    TwoOperand(std::shared_ptr<Expression> lhs, std::shared_ptr<Expression> rhs)
        : lhs_(lhs), rhs_(rhs) {}
    
    /* ... */

  protected:
    std::shared_ptr<Expression> const lhs_, rhs_;   // !
};

A szorzat deriválásából is eltűnhet a clone(), és itt tényleg erőforrásmegosztásunk van:

előtte
Expression* Product::derivative() const {
    return new Sum(
        new Product(lhs_->derivative(), rhs_->clone()),
        new Product(lhs_->clone(), rhs_->derivative())
    );
}
utána
std::shared_ptr<Expression> Product::derivative() const {
    return std::make_shared<Sum>(
        std::make_shared<Product>(lhs_->derivative(), rhs_),    // !
        std::make_shared<Product>(lhs_, rhs_->derivative())
    );
}

Végül pedig, a simplify()-okat kell rendbe tenni, de szerencsére azok is sokat egyszerűsödnek. Az eredeti kódok a Constant típusú objektumok kereséséhez dynamic_cast<>-ot használtak. Az nyelvi elem, csak nyers pointeren működik, viszont a <memory> header okos pointerekhez is biztosít hasonló függvényt, az std::dynamic_pointer_cast<>()-ot. Ezzel a minimalista egyszerűsítő algoritmusok hasonlóan megírható, ahogy eddig, csak az okos pointerek miatt már sokkal tisztábban. Eltűnik a sok delete:

std::shared_ptr<Expression> Sum::simplify() const {
    auto lhs_simpl = lhs_->simplify();
    auto rhs_simpl = rhs_->simplify();
    auto lhs_cons = std::dynamic_pointer_cast<Constant const>(lhs_simpl);
    auto rhs_cons = std::dynamic_pointer_cast<Constant const>(rhs_simpl);
    
    if (lhs_cons != nullptr && lhs_cons->get_value() == 0.0)    /* 0 + a = a */
        return rhs_simpl;
    if (rhs_cons != nullptr && rhs_cons->get_value() == 0.0)    /* a + 0 = a */
        return lhs_simpl;
    if (lhs_cons != nullptr && rhs_cons != nullptr)             /* c + c = c */
        return std::make_shared<Constant>(lhs_cons->get_value()
                                          + rhs_cons->get_value());
    
    return std::make_shared<Sum>(lhs_simpl, rhs_simpl);
}

De a sok delete eltűnése nem csak azért fontos, mert látszólag rövidebb lett tőle a kód, hanem mert sokkal könnyebb megírni. Nem kell gondolkozni azon, hogy melyik kifejezést kell megtartani, melyiket nem – amelyiket nem használjuk, felszabadul magától.

A főprogramunk ilyesmi lehet:

int main() {
    std::shared_ptr<Expression> c = std::make_shared<Product>(
                        std::make_shared<Constant>(5),
                        std::make_shared<Sum>(
                            std::make_shared<Constant>(3),
                            std::make_shared<Variable>()
                        )
                    );
    std::cout << "f(x) = " << *c << std::endl;
    std::cout << "f(3) = " << c->evaluate(3) << std::endl;
    
    std::cout << "f'(x) = " << *(c->derivative()->simplify()) << std::endl;

    std::cout << "f''(x) = " << *(c->derivative()->derivative()->simplify()) << std::endl;
}

A műveletek bátran láncolhatóak, mert a köztes lépésekben előállított fákat a shared_ptr-ek fel fogják szabadítani. Így már egy ->derivative()->derivative()->simplify() sem okoz memóriaszivárgást.

Az elkészült kód letölthető innen: expression_shared.cpp.

4. Irodalom