III. rész: Az enum-alapú Visitor
Czirkos Zoltán · 2022.06.21.
A Visitor tervezési minta egy egyszerű enum típusú tagváltozó segítségével is megvalósítható. Ehhez típus szerinti switch()-re van szükség, de ez a switch() egyetlen egy helyen jelenik csak meg a kódban.
Az előző részben refaktorált kódban a Visitor tervezési mintát használva vált szét a kifejezések adatszerkezete a kifejezésfán végezhető műveletektől. A tervezési minta lényege az, hogy egy virtuális függvényhívás segítségével a kifejezések típusai szerint különböző függvényeket tudunk hívni. A kifejezések típusai szerinti műveletek függvényeit pedig felsoroljuk a Visitor osztályokban.
Apropó felsorolás – azt szoktuk mondani, hogy a virtuális függvények arra valók, hogy az egyes típusokban különféle
működéseket valósítsunk meg. Tehát lényegében arra, hogy a leszármazási hierarchiában a típus szerinti switch()
-elést
elkerüljük, mert az nem OOP szemléletű, és nehezen karbantartható kódot eredményez. No de igaz ez a Visitor mintát használva?
Nem egészen. Sőt igazából nem.
Nézzük át a mostani Visitor megvalósításunkat. A kifejezések interfészén előírunk egy függvényt, amelyet minden leszármazottnak meg kell valósítania:
class Expression {
public:
virtual void accept_visitor(ExpressionVisitor &v) = 0;
/* ... */
};
Ez az a függvény, amely a Visitor-okban a típus szerinti függvényeket visszahívja:
class ExpressionVisitor {
public:
virtual void visit_constant(Constant &) = 0;
virtual void visit_variable(Variable &) = 0;
virtual void visit_sum(Sum &) = 0;
virtual void visit_product(Product &) = 0;
/* ... */
};
A kifejezés accept_visitor()
meghívásának egyetlen célja, hogy ez az esetszétválasztás megtörténjen. Sőt
ha minden művelethez ennek az osztálynak a leszármazottait használjuk, akkor igazából ez az egyetlen egy hely a programban,
ahol ez az esetszétválasztás megtörténik: az accept_visitor()
függvény meghívásakor. Akárhány új műveletet
vettünk fel, ezt már nem kellett újra megcsinálnunk.
Töröljük ki ezért az accept_visitor()
függvényeket a kifejezésekből, és tegyünk be helyette egy felsorolt
típusú változót, amely a kifejezés típusát mutatja. Ezt a változót minden leszármazottban inicializálnunk kell majd
a megfelelő értékűre:
class Expression {
public:
enum Type {
CONSTANT,
VARIABLE,
SUM,
PRODUCT,
};
Type const type;
Expression(Type type) : type(type) {}
/* ... */
};
A type
változónak konstansnak kell lennie, nem csak a megosztott memóriakezelésünk miatt, hanem általában is,
hiszen egy objektum típusa soha nem változhat meg az élete során.
Vigyük végig ezt a változtatást az összes osztályon! A fordító jelezni fogja, ha valahol hibázunk, mert a törölt
accept_visitor()
függvények override
-osak voltak, az Expression
osztálynak pedig megszűnik
az alapértelmezett, paraméter nélküli konstruktora.
Ezek után pedig egészítsük ki az ExpressionVisitor
ősosztályt a típus szerinti switch()
-et tartalmazó
függvénnyel:
class ExpressionVisitor {
protected:
void visit(Expression & e) {
switch (e.type) {
case Expression::CONSTANT:
visit_constant(static_cast<Constant &>(e));
break;
case Expression::VARIABLE:
visit_variable(static_cast<Variable &>(e));
break;
case Expression::SUM:
visit_sum(static_cast<Sum &>(e));
break;
case Expression::PRODUCT:
visit_product(static_cast<Product &>(e));
break;
}
}
private:
virtual void visit_constant(Constant &) = 0;
virtual void visit_variable(Variable &) = 0;
virtual void visit_sum(Sum &) = 0;
virtual void visit_product(Product &) = 0;
/* ... */
};
Ez a függvény megkapja a kifejezést, és a típus vizsgálata után rögtön tudja, mi a teendője: a megfelelő visit_xxx()
függvényt meghívni. Egy static_cast
-ra most szükségünk van.
Vegyük észre, hogy ez az új visit()
függvény nem virtuális. A leszármazottaknak nem kell már ezt implementálniuk,
csak meghívniuk, amikor egy adott kifejezést fel akarnak dolgozni. Mivel ez a függvény lesz az egyetlen, amelyik a
visit_xxx()
típus szerinti függvényeket meghívja, azok akár lehetnek privátak is, az
NVI (non-virtual interface) C++ tervezési elvnek megfelelően.
Ha ezt meg tudjuk tenni, az tényleg azt jelenti, hogy csak itt végzünk típus szerinti esetszétválasztást!
(Egyébként az Expression
objektumok type
adattagja lehetne privát is, az ExpressionVisitor
pedig az osztály barátja, hogy csak ő lássa a típust jelző adattagot.)
Egy-két apró változtatás mellett még egy fontos dolgunk van: a Visitor leszármazottaiból az e.accept_visitor(*this)
hívásokat ki kell törölni, mert nincsen már rá szükség. Helyette az ExpressionVisitor
ősosztály visit()
függvényét hívjuk meg, mert most már az végzi az esetszétválasztást:
class ExpressionPrinter final: public ExpressionVisitor {
public:
void print(Expression &e) {
visit(e);
}
/* ... */
};
Az elkészült változat letölthető innen: expression_enum.cpp.
Kérdés, hogy ezzel a kód karbantarthatósága hogy változik. A típus szerinti switch()
-re azt szokták mondani, hogy
az ennek az ellensége, de most a tervezési mintának köszönhetően ez egyetlen egy helyre került.
Mi a teendőnk, ha új műveletet szeretnénk?
- Egyetlen dolgunk van: leszármazni az
ExpressionVisitor
osztályból, és megvalósítani a kifejezésekre jellemző műveleteket. Ez ugyanolyan egyszerű, mint eddig.
Mi a teendőnk, ha új típust szeretnénk?
- Először is, le kell származni az ősosztályból, és megvalósítani az új osztályt. Ezt eddig is meg kellett volna tennünk.
- Az előzőhöz szükségünk lesz az ősosztály módosítására, mivel a felsorolt típust ki kell egészítenünk egy új értékkel.
Ez többlet munka az eddigiekhez képest, cserébe viszont nem kell
accept_visitor()
függvényt írnunk a leszármazottban. - Az esetszétválasztást végző függvényben lesz egy új, de triviális sorunk. Elméletben ez elfelejthető, a gyakorlatban
viszont szinte minden fordító jelezni szokta azt, ha
enum
típusú értékreswitch()
-elés esetén a halmaz valamelyik elemét kifelejtettük. - Meg kell írnunk a Visitor leszármazottakban az új típust kezelő függvényeket – de ez a virtuális függvényeknél is így lett volna.
Tehát lényegében egy pont ugyanolyan jól karbantartható kódot kaptunk, mint amilyen a tisztán OOP-s megoldás is volt.
Bár a megoldásunk típus switch()
-et tartalmaz, de a Visitor mintának köszönhetően garantáltan csak egyetlen
egy helyen a kódban. Manapság elég gyakori, hogy így implementálják a Visitor mintát, és nem a klasszikus OOP-s módszerrel.
A program előző verziójából kispóroltam az egyszerűsítést, de érdemes egy kicsit elgondolkozni azon, hogy a típus ismerete
mennyire leegyszerűsíti a dolgunkat. Az a kód sosem volt szép, a benne lévő dynamic_cast
-ok miatt. Pedig azok
kellettek, mert sok helyen azt akartuk vizsgálni, hogy milyen típusú objektumról van szó. Például a konstans+konstans eset
vizsgálatához, a saját típuson kívül két további objektum típusát, azaz összesen már hármat. Erre nagyon nehéz lenne szép
OOP-s megoldást találni. (Visitor2...) Most ezt a kódot érthetőbben írhatjuk:
void ExpressionSimplifier::visit_sum(Sum & s) {
auto lhs = /* ... */;
auto rhs = /* ... */;
if (lhs->type == Expression::CONSTANT && rhs->type == Expression::CONSTANT) {
Constant & c1 = static_cast<Constant &>(*lhs);
Constant & c2 = static_cast<Constant &>(*rhs);
return std::make_shared<Constant>(c1.get_value() + c2.get_value());
}
/* ... */
}
Észrevehetjük, hogy az előző átalakítással a kifejezések tagfüggvényei teljesen eltűntek. Mindegyik osztály csak konstruktorokat és gettereket tartalmaz. A gettereknek nincs sok haszna, mert konstans adattagokat használtunk, de a tagfüggvények hiánya azt jelenti, hogy az osztályok most már igazából struktúrák. A kifejezés osztályhierarchia tisztán csak egy adatszerkezetet ad meg. Semmit nem csinál, mert minden műveletet a fát feldolgozó Visitor objektumok végeznek el.
Ezért elvégezhetünk egy utolsó változtatást a kódon: a kifejezéseket struktúrákká alakíthatjuk, a Visitor leszármazottakból
pedig a getter hívásokat törölhetjük. Egyetlen egy dologra kell figyelnünk: az Expression
ősosztály virtuális
destruktora meg kell maradjon! Erre szükségünk van, mint mindig: a kétoperandusú kifejezések okos pointereket tartalmaznak,
és ezek destruktorait meg kell hívni. Viszont ha már van virtuális destruktorunk, akkor végülis van egy függvényünk, amit
tisztán virtuálissá tehetünk, így az osztály absztrakt lehet. Csak ne felejtsük el definiálni is!
struct Expression {
/* ... */
virtual ~Expression() = 0;
};
inline Expression::~Expression() {}
Tisztán virtuális destruktor?
A destruktor, bár speciális szerepű, automatikusan hívódó függvény, igazából ugyanolyan tagfüggvénye az osztálynak, mint bármelyik más. Ugyanazzal a szintaxissal hívható, lehet virtuális is – sőt, még akár tisztán virtuális is.
Tudni kell azonban azt, hogy a fordító a leszármazott osztályok destruktorát automatikusan definiálni fogja majd, ha mi nem tettük meg. Vagy ha igen, akkor is beleérti majd azt, hogy az ősosztály destruktorát meg kell hívni. Emiatt, bár az ősben a destruktor tisztán virtuális, külön definiálnunk kell azt, hogy a leszármazottból legyen mit meghívni.
Vajon lehet ilyet? Igen, lehet – egy tisztán virtuális függvénynek létezhet definíciója is, amit explicit minősítéssel,
a virtuális függvényhívás mechanizmusának megkerülésével, meg is lehet hívni: obj.Osztály::fv()
. Amikor
a leszármazottban definiáljuk (vagy a fordító definiálja magától) a destruktort, akkor ez az explicit minősítés persze
implicitté válik, mert a konstruktorok és a destruktorok futásakor a virtuális függvények nem virtuálisak. Tehát
az egész végülis olyan, mintha a „virtual ~Expression() = 0 {}
” kódot írnánk, csak ilyet nem írhatunk,
mert ez szintaktikai hibás. Ezért külön, az osztályon kívül kell definiálni a függvényt, akár {}
, akár
= default
jelöléssel.
Olyankor szokás a destruktort tisztán virtuálisnak minősíteni, amikor absztrakt osztályt szeretnénk létrehozni, de nincs más
függvény, amit tisztán virtuálissá tehetnénk. Destruktora minden osztálynak van, az ősosztályoknak pedig virtuális destruktora
kell legyen – így az biztosan jó lesz erre. A kifejezésekkel épp így történt. A példányosítás megakadályozására
egyébként használhatnánk egy protected
konstruktort is.
A végleges változat kódja alább látható, vagy letölthető innen is: expression_public.cpp.
#include <iostream>
#include <memory>
/* ADATSZERKEZET */
struct Expression {
enum Type {
CONSTANT,
VARIABLE,
SUM,
PRODUCT,
};
Type const type;
Expression(Type type) : type(type) {}
Expression(Expression const &) = default;
Expression(Expression &&) = default;
Expression & operator=(Expression const &) = default;
Expression & operator=(Expression &&) = default;
virtual ~Expression() = 0;
};
inline Expression::~Expression() {}
struct Constant final : Expression {
double const c;
Constant(double c): Expression(CONSTANT), c(c) {}
};
struct Variable final : Expression {
Variable(): Expression(VARIABLE) {}
};
struct TwoOperand : Expression {
std::shared_ptr<Expression> const lhs, rhs;
protected:
TwoOperand(Type type, std::shared_ptr<Expression> lhs, std::shared_ptr<Expression> rhs)
: Expression(type), lhs(lhs), rhs(rhs) {}
};
struct Sum final : TwoOperand {
Sum(std::shared_ptr<Expression> lhs, std::shared_ptr<Expression> rhs)
: TwoOperand(SUM, lhs, rhs) {}
};
struct Product final : TwoOperand {
Product(std::shared_ptr<Expression> lhs, std::shared_ptr<Expression> rhs)
: TwoOperand(PRODUCT, lhs, rhs) {}
};
/* MŰVELETEK */
class ExpressionVisitor {
protected:
void visit(Expression & e) {
switch (e.type) {
case Expression::CONSTANT:
visit_constant(static_cast<Constant &>(e));
break;
case Expression::VARIABLE:
visit_variable(static_cast<Variable &>(e));
break;
case Expression::SUM:
visit_sum(static_cast<Sum &>(e));
break;
case Expression::PRODUCT:
visit_product(static_cast<Product &>(e));
break;
}
}
private:
virtual void visit_constant(Constant &) = 0;
virtual void visit_variable(Variable &) = 0;
virtual void visit_sum(Sum &) = 0;
virtual void visit_product(Product &) = 0;
public:
ExpressionVisitor() = default;
ExpressionVisitor(ExpressionVisitor const &) = default;
ExpressionVisitor(ExpressionVisitor &&) = default;
ExpressionVisitor & operator=(ExpressionVisitor const &) = default;
ExpressionVisitor & operator=(ExpressionVisitor &&) = default;
virtual ~ExpressionVisitor() {}
};
class ExpressionPrinter final: public ExpressionVisitor {
private:
std::ostream &os_;
void print_twooperand(TwoOperand &t, char op) {
os_ << '(';
print(*t.lhs);
os_ << op;
print(*t.rhs);
os_ << ')';
}
virtual void visit_constant(Constant &c) override {
os_ << c.c;
}
virtual void visit_variable(Variable &v) override {
os_ << 'x';
}
virtual void visit_sum(Sum &s) override {
print_twooperand(s, '+');
}
virtual void visit_product(Product &p) override {
print_twooperand(p, '*');
}
public:
explicit ExpressionPrinter(std::ostream &os) : os_(os) {}
void print(Expression &e) {
visit(e);
}
};
std::ostream & operator<<(std::ostream & os, Expression &e) {
ExpressionPrinter ep(os);
ep.print(e);
return os;
}
class ExpressionEvaluator final: public ExpressionVisitor {
private:
double x_;
double result_;
void evaluate_twooperand(TwoOperand &t, double (*do_op)(double, double)) {
double left = evaluate(*t.lhs);
double right = evaluate(*t.rhs);
result_ = do_op(left, right);
}
virtual void visit_constant(Constant &c) override {
result_ = c.c;
}
virtual void visit_variable(Variable &v) override {
result_ = x_;
}
virtual void visit_sum(Sum &s) override {
evaluate_twooperand(s, [](double a, double b) { return a+b; });
}
virtual void visit_product(Product &p) override {
evaluate_twooperand(p, [](double a, double b) { return a*b; });
}
public:
explicit ExpressionEvaluator(double x) : x_(x) {}
double evaluate(Expression &e) {
visit(e);
return result_;
}
};
/* KLIENS */
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;
ExpressionEvaluator ee(3);
std::cout << "f(3) = " << ee.evaluate(*c) << std::endl;
}