I/b. rész: Gyártófüggvények

Czirkos Zoltán · 2022.06.21.

Mellékszál a kifejezésfa program refaktorolásában: gyártófüggvények és CRTP.

A sorozat előző részében a kifejezésfa programjába automatikus memóriakezelés került, az std::shared_ptr<> osztályt felhasználva. Ezzel robusztusabb lett a kód: könnyebb használni az osztályhierarchiát, gyakorlatilag nem tudunk memóriaszivárgást kiváltani. A megosztott részfák által pedig sok másolást megspóroltunk. Ez volt a kód végleges változata: expression_shared.cpp.

A kód azonban így megtelt std::shared_ptr<Expression>-ökkel és std::make_shared<>-ekkel. Például az 5*(3+x) kifejezésfa létrehozásánál:

std::shared_ptr<Expression> f =
    std::make_shared<Product>(
        std::make_shared<Constant>(5),
        std::make_shared<Sum>(
            std::make_shared<Constant>(3),
            std::make_shared<Variable>()
        )
    );

Ezeknek a neve még mindig a memóriakezelésre utal, ami némileg zavaró. Amikor létre akarunk hozni egy szorzat objektumot, akkor igazából ezt a két szót akarjuk leírni: létrehozás, szorzat. Amikor pedig egy kifejezést szeretnénk tárolni, akkor egy kifejezés típusú változóval szeretnénk azt megtenni.

Egy típusdefiníció és néhány gyártófüggvény bevezetése megoldja a problémánkat.

1. A típusdefiníció

A kifejezés típust könnyedén bevezethetjük. Egy using ExpressionPtr = std::shared_ptr<Expression> sor leírása után a programban az összes std::shared_ptr<Expression>-t akár automatikusan ExpressionPtr-re cserélhetjük. Így a név mögé van rejtve a memóriakezelés, de azért a név utal a közös részfákra (...Ptr). Az ősosztály definiálásakor kicsit figyelni kell: mivel már ez használni fogja az ExpressionPtr típust, ezért a típusdefiníciót az osztály definíciója elé kell írnunk, és ahhoz meg az osztályt előre deklarálnunk kell:

class Expression;   // elődeklaráció

using ExpressionPtr = std::shared_ptr<Expression>;

class Expression {
  public:
    virtual ExpressionPtr derivative() const = 0;

    virtual ExpressionPtr simplify() const = 0;
    
    /* ... */
};

Észrevehetjük, hogy a kiíró operátor << használatánál ezeket a pointereket mindig dereferáltuk. Mindig pointerekkel dolgozunk, ezért praktikusabb lenne az operátor jobb oldali operandusát is ilyen típusúra választani:

std::ostream & operator<<(std::ostream & os, ExpressionPtr e) { // Expression& helyett
    e->print(os);
    return os;
}

Így rengeteg * operátor eltűnik a programból. Ugyan a referencia kifejezte, hogy nem lehet null pointer, de amúgy sem használunk sehol null értékű ExpressionPtr-eket. Így kényelmesebb használni.

A kód jelenlegi állapota ez: expression_ptr.cpp. A főprogramon szembetűnő a változás.

2. A gyártófüggvények

A gyakori std::make_shared<...>(...) használat is egy mintázatra utal. Egy csomópont példányosítása mindig egy make_shared() hívást jelent. Ezek helyett az egyes osztályokba betehetünk egy create() nevű statikus gyártófüggvényt, amely egy csomópont létrehozásához használható majd: létrehozza a csomópontot, és becsomagolja az okos pointerbe. Például a konstans esetén:

class Constant {
  public:
    static ExpressionPtr create(double c) {
        return std::make_shared<Constant>(c);
    }
    /* ... */
};

auto c = Constant::create(3.14);

Talán megint a főprogramon látszik legjobban a változás:

előtte
ExpressionPtr c = std::make_shared<Product>(
                      std::make_shared<Constant>(5),
                      std::make_shared<Sum>(
                          std::make_shared<Constant>(3),
                          std::make_shared<Variable>()
                      )
                  );
utána
ExpressionPtr c = Product::create(
                      Constant::create(5),
                      Sum::create(
                          Constant::create(3),
                          Variable::create()
                      )
                  );

A jelenlegi változat: expression_factory_method.cpp.

Gyártófüggvények és privát konstruktorok

Gyakran gyártófüggvények használata esetén a konstruktorokat priváttá (vagy védetté) tesszük, mert az osztály tagfüggvényeként jelen lévő gyártófüggvény úgyis eléri azt. Ez az okos pointerek és a make_shared() használata esetén nem olyan egyszerű. Ugyanis a tényleges konstruktorhívás nem a gyártófüggvényben, hanem a make_shared()-ben történik meg, ami pedig nem része az osztálynak. No persze a friend kulcsszó segíthet.

3. A gyártóosztály

Észrevehetjük a refaktorálás közben, hogy a create() függvények eléggé egyformák lettek. Például a konstans és az összeg esetén:

ExpressionPtr Constant::create(double c) {
    return std::make_shared<Constant>(c);
}

ExpressionPtr Sum::create(ExpressionPtr lhs, ExpressionPtr rhs) {
    return std::make_shared<Sum>(lhs, rhs);
}

Ez annyira nem meglepő, hiszen lényegében csak a make_shared() hívásokat csomagoltuk be, hogy kényelmesebb legyen használni őket. De lényegében egy kaptafára megy az összes:

ExpressionPtr VALAMILYEN_KIFEJEZÉS::create(KONSTRUKTOR_PARAMÉTEREK) {
    return std::make_shared<VALAMILYEN_KIFEJEZÉS>(KONSTRUKTOR_PARAMÉTEREK);
}

A feladatra írhatnánk egy sablon függvényt, hasonlóképp, ahogy az std::make_shared() esetén is csinálták. Egy kis csavar a történetben, hogy most nem egy globális függvényt kell írnunk, hanem különféle osztályokat kell kiegészíteni statikus függvényekkel. De ez nem gond, mert a függvényt betesszük egy segédosztályba, és aztán mindenki abból a segédosztályból származik majd, megörökölve a statikus függvényt. A gyártó osztálynak sablonparamétere lesz a gyártott objektum típusa; a konstruktor paraméterei pedig értelemszerűen a gyártófüggvény paraméterei kell legyenek.

template <typename EXPRESSION_TYPE>
class ExpressionFactory {
  public:
    template <typename... ARGS>
    static ExpressionPtr create(ARGS && ... args) {
        return std::make_shared<EXPRESSION_TYPE>(std::forward<ARGS>(args)...);
    }
};

Például a Constant osztály definíciója, és gyártófüggvényének használata így néz ki ezután:

class Constant final : public Expression, public ExpressionFactory<Constant> {  // !
    /* ... */
};

auto c = Constant::create(3.14);

A Constant::create(3.14) hívásakor az ExpressionFactory<Constant>::create<double> függvény fog hívódni. A gyártóosztály sablonparaméterét az öröklésnél megadtuk (EXPRESSION_TYPE = Constant), a függvény sablonparaméterét pedig a fordító vezeti le (ARGS... = double).

CRTP

Érdekes módon, az osztályok így egy olyan osztályból örökölnek, amelynek sablonparaméterként saját magukat adják (ConstantExpressionFactory<Constant>). Ezt a sablon metaprogramozásban CRTP-nek (Curiously recurring template pattern) nevezik. Az általános séma ez:

template <typename T>
class Base {
};

template Derived : public Base<Derived> {
};

Trait-ek és mix-in-ek

Bár alapvelv a tervezésnél, hogy nem öröklünk kódot újrafelhasználási céllal, azért ez más helyzet. Az ExpressionFactory osztály csak egy statikus függvényt ad hozzá az osztályokhoz. Inkább csak egy mix-in, nem egy teljes értékű ősosztály. Egyes nyelvek meg is különböztetik az ilyen jellegű relációt; pl. a PHP az ilyen osztályokat trait-nek nevezi, és a use kulcsszóval tudjuk beemelni egy trait kódját egy másik osztályba.

A mix-in jellegű osztályok általában valamilyen nagyon általános feladatot oldanak meg, ezért akarhatjuk a kódban több, egymástól független osztályban felhasználni őket. Észre is vehetjük, hogy az ExpressionFactory osztályunkban igazából semmilyen Expression-specifikus dolog nincsen; ha a create() függvény visszatérési értékét egy std::shared_ptr-re cseréljük, egy teljesen általános kódot kapunk:

template <typename TYPE>
class Factory {
  public:
    template <typename... ARGS>
    static std::shared_ptr<TYPE> create(ARGS && ... args) {
        return std::make_shared<TYPE>(std::forward<ARGS>(args)...);
    }
};

A gyártó osztályt használó változat: expression_factory_class.cpp.

4. A gyártóosztály okosítása

A gyártófüggvény külön osztályba szervezésével egy OOP tervezésben megszokott tervezési mintát követtünk – persze most fordítási időben, nem futási időben adjuk meg a létrehozandó objektum típusát. Így most a kifejezések létrehozásának módját kicsit jobban kézbe tudjuk venni. Például tudjuk, hogy változó csak egyféle van, az x, és az bármilyen környezetben ugyanazt jelenti. És azt is tudjuk, hogy a referenciaszámlálás miatt megoszthatjuk a fák között az objektumokat.

Így megtehetjük, hogy egyetlen egy példányt hozunk létre a változóból, és azt tesszük mindenhova. Ehhez specializáljuk az ExpressionFactory sablont a Variable típusra. Legegyszerűbben így:

template <>
class ExpressionFactory<Variable> {
  public:
    static ExpressionPtr create();
};

ExpressionPtr ExpressionFactory<Variable>::create() {
    static ExpressionPtr x = std::make_shared<Variable>();
    return x;
};

Ezzel a módosítással a változó Singleton-ná (egykévé) vált. Vegyük észre, hogy ehhez nem a változó osztályt módosítottuk, hanem a gyártófüggvényt. Sokan úgy tartják, hogy a Singleton tervezési minta értelmetlen – jogosan, hiszen ha nem akarjuk, hogy egy osztályból több példány legyen, akkor ne hozzunk belőle többet létre, és kész. Ehhez nem az osztályt kell módosítani! Itt ezt az elvet követi a kód: a gyártófüggvény biztosítja, hogy ne jöjjön több példány létre az objektumból.

A letölthető kód a változó gyártójának specializációját tartalmazza: expression_factory_variable_singleton.cpp.

Mindezek a változások az eredeti kódba nem fognak belekerülni, hanem egy másik irányba megy tovább a fejlesztés. A gyártóosztályból való öröklés amúgy is csak kényelmi funkció, nem igazán C++-os. Ezt a publikus öröklés is mutatja, mert annak nem ezt kellene jelentenie. Leginkább a gyártóosztály megmaradhatna külön – specializálni, mint fentebb, már akkor is lehet.