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.
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.
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:
ExpressionPtr c = std::make_shared<Product>(
std::make_shared<Constant>(5),
std::make_shared<Sum>(
std::make_shared<Constant>(3),
std::make_shared<Variable>()
)
);
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.
É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
(Constant
→ ExpressionFactory<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.
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.