III/b. rész: A Visitor minta más nyelvekben

Czirkos Zoltán · 2022.06.21.

Ez az írás megmutatja, hogy a Visitor tervezési mintát hogyan kell implementálni egy egész más felépítésű programozási nyelvben, a JavaScript-ben. Spoiler alert: sehogy, nincsen rá szükség.

A kifejezésfás programot az előző írásokban a Visitor OOP tervezési minta segítségével refaktoráltuk, szétválaszthatóvá vált az adatszerkezet és az azon dolgozó algoritmusok. A sorozat befejező része kicsit (nagyon) eltávolodik a C++-tól, megmutatja, hogy egy teljesen más felépítésű, működésű programozási nyelvben hogyan oldható meg ugyanez a feladat. A választott nyelv a JavaScript.

1. A Visitor tervezési minta és az osztály alapú OOP

Emlékeztetőként: a Visitor tervezési mintával szétválasztható egymástól a kódban egy adatszerkezet osztályhierarchiája az abban tárolt objektumokon végzett tevékenységektől. Az egyes tevékenységeket a minta szerint egy külön osztályhierarchia reprezentálja, így új tevékenység hozzáadásakor az adatszerkezet osztályait nem kell módosítani.

A minta a tevékenységeket elvégző objektumokat nevezik látogatónak, azaz Visitor-nak. Az adatszerkezet hierarchiájába ehhez egyetlen virtuális függvényt kell tenni: azt, amelyik fogadja a tevékenységet elvégző objektum függvényhívását. Ez az accept_visitor() függvény, amelynek egyetlen dolga, hogy a Visitor a típusra jellemző tevékenységét elvégző függvényét meghívja.

adatok
hierarchiája
class Element {
  public:
    virtual void accept_visitor(Visitor & v) = 0;
};

class ConcreteElement1 : public Element {
  public:
    virtual void accept_visitor(Visitor & v) override {   // *
        v.visit_concrete_element_1(*this);  // 1
    }
};

class ConcreteElement2 : public Element {
  public:
    virtual void accept_visitor(Visitor & v) override {   // *
        v.visit_concrete_element_2(*this);  // 2
    }
};

Erre függvényhívásra azt is szokták mondani, hogy az elem a látogatót visszahívja. Ez azért van így, mert az elem feldolgozását általában a látogató felől kezdeményezzük: ConcreteVisitor::process(). A programunk feladatától függően ennek a függvénynek nem feltétlenül kell virtuálisnak lennie.

tevékenységek
hierarchiája
class Visitor {
  public:
    virtual void visit_concrete_element_1(ConcreteElement1 & e) = 0;
    virtual void visit_concrete_element_2(ConcreteElement2 & e) = 0;
};

class ConcreteVisitor : public Visitor {
  public:
    void process(Element & e) {
        e.accept_visitor(*this);    // *
    }
    virtual void visit_concrete_element_1(ConcreteElement1 & e) override { // 1
        /* ... */
    }
    virtual void visit_concrete_element_2(ConcreteElement2 & e) override { // 2
        /* ... */
    }
};

A kérdés persze felmerül, hogy szükségünk van-e minderre a mágiára, mert azért azt érezni lehet, hogy valami nincs rendjén. Sejtjük, hogy valami fundamentális probléma van, és az egymást oda-vissza hívó függvényekkel csak megkerüljük, nem megoldjuk a problémát.

Az eredeti problémánk az volt, hogy egy kész, meglévő osztályhierarchiához szerettünk volna hozzáadni utólag új műveleteket, leginkább az osztályok módosítása nélkül. Az objektumorientált programozásban általában objektumokról beszélünk, közben a programunkban igazából osztályokat írunk. A fundamentális probléma az, hogy szétválasztottuk az objektumokat és az objektumok viselkedését egymástól. Tehát az, hogy megkülönböztetjük az objektumokat és az osztályokat.

2. A prototipikus öröklés

A klasszikus, osztály alapú OOP-ben az osztály és az objektum két különböző fogalom. Az osztályok ebben egyfajta tervrajzként jelennek meg. Például ha szükségünk van a programunkban székekre, akkor készítünk egy tervrajzot, az osztályt, amelyben leírjuk, hogyan néz ki egy szék. Utána pedig a szék objektumokat ez alapján példányosítjuk. Ebben a modellben egy karfás szék létrehozásához előbb készítenünk kell egy új tervrajzot a szék tervrajza alapján (ez a leszármazott osztály), hogy utána ténylegesen létre tudjuk hozni a karfás szék objektumokat.

A prototípus alapú OOP-ben nincsenek külön osztályok és objektumok. Ebben ha szükségünk van egy székre, akkor fogunk egy üres objektumot, és hozzáadunk négy lábat meg egy háttámlát. Ha szükségünk van további székekre, akkor az előző mintájára készítjük el azokat. Ha pedig szükségünk van egy karfás székre, egyszerűen gyártunk egy széket, és rászereljük a két karfát is.

Sejthető, hogy egy C++-ban nem menne egykönnyen – nem tehetjük meg, hogy üres objektumhoz adattagokat adunk hozzá. A prototípus alapú OOP a dinamikus természetű nyelvekre jellemző, az olyanokra, mint amilyen például a JavaScript is. Ezekben a nyelvekben a változók típusa, az objektumok adattagjainak száma stb. mind futási időben változhatnak. A következő példában a prototípusok használatát ezért JavaScript kódrészleteken keresztül mutatjuk be.

Induljunk ki az alakzatos példából! Hozzunk létre egy alakzatot (shape), és tároljuk annak pozícióját, továbbá legyen az alakzatnak egy olyan függvénye is, amelyik kiírja a konzolra a pozíciót! Ennek leírásához létrehozunk egy üres objektumot az Object.create() függvénnyel, amelybe betesszük a koordinátákat és a függvényt:

var shape = Object.create(null);
shape.x = 1;
shape.y = 2;
shape.print_pos = function() {
    console.log("Shape position: (" + this.x + "," + this.y + ")");
};

A létrehozott objektum máris működőképes, a tagfüggvénye meghívható:

shape.print_pos();          /* Shape position: (1,2) */

Ezt az alakzat objektumot önmagában nem fogjuk használni, csak arra, hogy más objektumok számára minta, prototípus legyen. Például egy téglalap (rectangle) létrehozása így nézhet ki:

var rectangle = Object.create(shape);   /* ! */
rectangle.w = 3;
rectangle.h = 4;
rectangle.get_area = function() {
    return this.w * this.h;
};

Ennek a téglalapnak meghívhatjuk a területet kiszámító függvényét:

console.log(rectangle.get_area());  /* 12 */

A lényeg azonban nem ez, hanem az Object.create(shape) függvényhívás. Ezzel azt mondjuk, hogy szeretnénk létrehozni egy objektumot, amelynek a prototípusa a shape objektum. Emiatt a téglalapunknak is van x attribútuma és print_pos() függvénye:

console.log(rectangle.x);      /* 1 */
rectangle.print_pos();         /* Shape position: (1,2) */

Adattagok olvasása

Az eddigi kódunkban tehát a rectangle objektum prototípusa a shape objektum, a shape objektum pedig nem rendelkezik ilyennel. A prototípusnak az attribútumok elérésekor van szerepe. Ugyanis egy objektum valamely adattagjának olvasásakor nem csak az adott objektumot, hanem annak prototípusát is figyelembe veszi a JavaScript motor. Például a rectangle.w kifejezés értéke 3, mert a w nevű attribútum megtalálható az objektumban. Ugyanakkor x nevű attribútum nincs benne. Ennek ellenére a rectangle.x kifejezésnek is van értéke, ami 1. Ennek kikereséséhez előbb szintén a rectangle objektumot kell megvizsgálni, de mivel nincs benne az x, a keresést a prototípusában kell folytatni, ahol meg is lesz. A rectangle.qqq kifejezést nem lehet kiértékelni, mivel qqq sem a téglalapban, sem az alakzatban nincs, az alakzatnak pedig már nincs prototípusa. Így ennek értéke undefined.

Hasonlóképp működik a függvények esetén is. A rectangle.get_area() meghívható, a rectangle.print_pos() is (amelyet a shape-ben lehet megtalálni). A shape.get_area() viszont nem kiértékelhető, mert abban az objektumban nincsen ilyen függvény.

Adattagok írása

Az adattagok írása egy kicsit másképp működik. Ha egy adattagot létrehozunk vagy módosítunk, akkor az a módosítás mindig a konkrét objektumot érinti, soha nem az ősét. Tehát egy rectangle.x = 100; sor hatására a rectangle objektum fog módosulni, nem pedig a shape:

rectangle.x = 100;

Ez egy fontos momentum. Azért kell ennek így lennie, mert a rectangle.x módosításával nyilvánvalóan a téglalap objektumot szerettük volna módosítani (áthelyezni), nem pedig annak prototípusát. Nem szeretnénk, hogy a művelet által a többi objektum is megváltozzon, amelynek a shape a prototípusa. A rectangle.x adattag olvasása viszont vissza fogja adni a beírt értéket, mert az adattag keresése a rectangle objektumtól indul, és már ott van x, nem is jut elé a shape-ig, mint az előbb.

Ami érdekes, hogy ezek után a rectangle.print_pos() (100,2)-t ír ki. Az attribútumok keresése ugyanis mindig attól az objektumtól indul, amelyre a this mutat. A rectangle.print_pos() kifejezésnél a this a rectangle referenciája lesz, ezért a print_pos() függvényben this.x értéke 100 a rectangle-ből, this.y értéke pedig 2 a shape-ből.

rectangle.print_pos();         /* Shape position: (100,2) */

Az rectangle.x = 100; sorral tehát nem az összes alakzatot módosítjuk, hanem csak a konkrét téglalapot. Ha az őst, azaz a prototípust szerettük volna módosítani, akkor shape.valami-nek adtunk volna értéket. Például a shape.color = 'black'; sor hatására az ősbe fog kerülni az új adattag, amit persze látni fogunk minden más objektumban is, amelynek a prototípusa a shape:

shape.color = 'black';
console.log(rectangle.color);   /* black */

Látszik, hogy a két objektum között referencia jellegű kapcsolat van. A shape módosításával a rectangle is módosult. A prototípus módosítása kihat az összes objektumra, amelynek a szóban forgó objektum prototípusa. Sőt egy objektum létrehozása után, utólag is módosíthatjuk annak prototípus objektumát, és ez hatással lesz az objektumra!

Mindezek miatt általában úgy szoktunk dolgozni, hogy külön kezeljük a prototípus objektumokat, és külön a programunk tényleges objektumait. A prototípus objektumokba tesszük a közös dolgokat (például a függvényeket), a programunk által manipulált adatok pedig a tényleges objektumok adattagjai lesznek. A prototípus objektumokat nagyrészt csak arra használjuk, hogy új objektumok létrehozásakor azok prototípusának jelöljük meg őket. Az előző példában az objektumhierarchiának igazából így kellett volna kinéznie:

A print_pos() közös minden alakzatra, a get_area() csak a téglalapokra nézve. Az x, y, w, h adattag pedig egyedi az r1 objektumban. (Klasszikus értelemben véve a shape és a rectangle osztályok, az r1 pedig objektum lenne.)

Így működik a prototípus alapú öröklés. Észre kell venni, hogy ez mennyi eddig megismert eszközt feleslegessé tesz. Nincsenek külön statikus tagváltozók: amit az objektumokba teszünk, azok egyediek, amit pedig a prototípusba, az közös. Nincsenek statikus függvények sem: egyszerűen csak olyan függvényeket írunk, amiben nem használjuk a this-t. A függvények automatikusan virtuálisak, hiszen a függvény is csak adat, aminek a keresése az objektumtól indul; a prototípus láncolatban legkésőbb definiált függvényt fogjuk megtalálni.

3. A kifejezéses példa JavaScript-ben

A prototipikus öröklés teljesen fölöslegessé teszi klasszikus OOP-ből ismert Visitor mintát, legalábbis eredeti célja szerint biztosan. Itt ha egy objektumhierarchiánál szükségünk van egy új műveletre, akkor egyszerűen betesszük az új műveletet a prototípus objektumokba, és már készen is vagyunk.

Először létrehozzuk a hierarchiát (kifejezések):

kifejezések
hierarchiája
var expression = Object.create(null);

var constant = Object.create(expression);
constant.create = function(c) {
    var obj = Object.create(constant);
    obj.c = c;
    return obj;
};

var variable = Object.create(expression);
variable.create = function() {
    return Object.create(variable);
};

var twooperand = Object.create(expression);

var sum = Object.create(twooperand);
sum.create = function(lhs, rhs) {
    var obj = Object.create(sum);
    obj.lhs = lhs;
    obj.rhs = rhs;
    return obj;
};

var product = Object.create(twooperand);
product.create = function(lhs, rhs) {
    var obj = Object.create(product);
    obj.lhs = lhs;
    obj.rhs = rhs;
    return obj;
}

A gyártófüggvények az osztályokba kerültek, mint C++-ban a static create()-ek.

Ezek után tetszőleges műveleteket adhatunk hozzá a prototípusokhoz:

eval()
constant.eval = function(x) { return this.c; };
variable.eval = function(x) { return x; };
sum.eval = function(x) { return this.lhs.eval(x) + this.rhs.eval(x); };
product.eval = function(x) { return this.lhs.eval(x) * this.rhs.eval(x); };

Ha kell, a szokásos technikával az ősökbe tehetünk közös segédfüggvényeket, mint itt a twooperand-ba:

print()
constant.print = function() { document.write(this.c); };
variable.print = function() { document.write('x'); };
twooperand.print_helper = function(op) {
    document.write('(');
    this.lhs.print();
    document.write(op);
    this.rhs.print();
    document.write(')');
};
sum.print = function() { this.print_helper('+'); };
product.print = function() { this.print_helper('*'); };

Végül pedig, már csak használnunk kell a megépült hierarchiát:

használat
var f = product.create(constant.create(5),
                       sum.create(constant.create(3),
                                  variable.create()));

document.write("f(x) = ");
f.print();
document.write("<br>");

document.write("f(10) = " + f.eval(10));
document.write("<br>");

A létrehozott objektumok:

A fenti kód letölthető innen: proto_teljes.html.

Írj be egy RPN kifejezést! Pl. 3 x + 5 *