A JavaScript nyelv szó szerint objektum-orientált nyelv, osztályok nem lévén benne. Ez a fejezet azt mutatja be, hogy a JavaScript nyelv dinamikus objektumaival és prototípusos mivoltával hogyan valósítható meg a kódújrahasznosítás, valamint hogyan lehet klasszikus objektum-orientált mintákat megvalósítani.
A prototípus egy olyan objektum, amely alapján egy másikat modellezni szeretnénk. Kicsit hasonlít az osztályokhoz abban a tekintetben, hogy hasonló objektumokat hozhatunk vele létre, de különbözik abban, hogy a prototípus maga is objektum.
A JavaScript prototípusos nyelv. Ez azt jelenti, hogy lehetőség van egy objektumot egy másik mintájára elkészíteni. Erre a nyelv többféle lehetőséget ad. Egyrészt kihasználhatjuk az objektumok dinamikus jellegét, és egy objektumban már meglévő adatot és/vagy funkcionalitást átmásolhatunk egy másik objektumba. Ilyetén a kiindulási objektum mintául szolgál az új objektum létrehozásához.
A másik lehetőség a prototípus-objektumok adta működés kihasználásában rejtőzik. Ekkor a prototípusosság koncepciója úgy valósul meg a nyelvben, hogy a készítendő objektum tartalmaz egy hivatkozást a modellül szolgáló objektumra. Ekkor ugyanis ha az adott tulajdonság az adott objektumban nincsen meg, akkor a prototípus objektum szolgáltathatja azt. Az adott objektum interfészében tehát megjelennek a prototípus-objektumok adatai és metódusai is, vagy másképpen, az adott objektumon keresztül elérhetőek a prototípus-objektumok el nem rejtett tulajdonságai is.
Ilyen formán a prototípusosság a kód-újrahasznosítás eszközévé is válik, hiszen egy már megírt funkcionalitás újraírás nélkül megjelenik egy másik objektumban. A következő részekben az újrahasznosításnak a fent említett két módszerét nézzük meg, először az objektumok dinamikusságát, majd a prototípusláncból fakadó előnyöket kihasználva.
Az alábbiakban olyan technikákat nézünk meg, amelyek segítenek egy már megírt kódrészletet újrahasznosítani. JavaScriptben a kód-újrahasznosítás szorosan összefügg a nyelv prototípusos jellegével, ahol prototípus alatt modellobjektumot értünk. Az újrahasznosítás során ugyanis nem kell újra megírni a kódot, hanem a modell mint prototípus mintájára kell alkalmazni azt. Ebben a fejezetben a JavaScript objektumok dinamikus jellegét használjuk ehhez.
Fentebb láthattuk, hogy a JavaScript objektumok futás időben tetszőleges tulajdonságokkal bővíthetők. Ezt kihasználva megtehetjük azt, hogy az újrahasznosítandó kódrészletet egyszerűen bemásoljuk a célobjektumba. Ezt szokták bővítésnek vagy mixinnek nevezni, jelezve, hogy meglévő tulajdonságokat vegyítenek egy másik objektumba.
//Kiindulási objektumok var o1 = { a: 1, b: 2 }; var o2 = { b: 42, c: 3 }; //Mixin o2.a = o1.a; o2.b = o1.b; //Teszt ok( o2.a === 1, 'Az a tulajdonság átmásolódott'); ok( o2.b === 2, 'A b tulajdonságot felülírta a másolandó objektum b tulajdonsága'); ok( o2.c === 3, 'A c tulajdonság érintetlen maradt.');
A fenti példában látható, hogy tulajdonság nevének egyezésekor a másolt objektum felülírja a célobjektumbeli tulajdonság értékét.
Annak érdekében, hogy ne tulajdonságról tulajdonságra kelljen a másolást megtenni, érdemes egy segédfüggvényt bevezetni, mely automatizálja ezt a feladatot. A legtöbb keretrendszer eltérő néven ugyan, de tartalmaz ilyen függvényt. Szokták extend(), augment(), mixin(), copy() vagy shallowCopy() néven is illetni. Az alábbiakban extendShallow() néven hivatkozunk rá, jelezvén a másolás tulajdonságát.
var extendShallow = function extendShallow(objTo, objFrom) { for (var prop in objFrom) { if (objFrom.hasOwnProperty(prop)) { objTo[prop] = objFrom[prop]; } } return objTo; };
Néha a cikluson belüli elágazást elhagyják, és így a prototípusláncon keresztül elérhető összes tulajdonság átmásolásra kerül. A fenti extendShallow() függvény csak egy objektumot másol a célobjektumba. Az alábbi változtatással tetszőleges számú objektumot megadhatunk, amelyek egymás után érvényesülnek a célobjektumon (ld. underscore.js).
var extendShallow = function extendShallow (obj) { [].slice.call(arguments, 1).forEach(function(source) { for (var prop in source) { if (source.hasOwnProperty(prop)) { obj[prop] = source[prop]; } } }); return obj; };
Használata a következő:
//Kiindulási objektumok var o1 = { a: 1, b: 2 }; var o2 = { b: 42, c: 3 }; extendShallow(o2, o1); //Tesztnek ld. az előző teszteket
A másolás során látható, hogy az o2 objektum megváltozik. Ha úgy szeretnénk egy objektumot előállítani, hogy az az o2 és o1 objektum vegyítése legyen, akkor egy üres objektumot kell kibővítenünk.
var o3 = extendShallow(, o2, o1); //Teszt ok( o3.a === 1, 'Az a tulajdonság átmásolódott'); ok( o3.b === 2, 'A b tulajdonság átmásolódott'); ok( o3.c === 3, 'A c tulajdonság átmásolódott');
Gyakran nincsen szükségünk az összes tulajdonság átmásolására, hanem csak néhány kijelölt tulajdonságot szeretnénk a fogadóobjektumba injektálni. Ekkor az extendShallow() segédfüggvény csak két objektumot tud paraméterként fogadni, a másolandó tulajdonságok szöveges nevei a harmadik paramétertől kezdve vannak felsorolva.
//Meghatározott tulajdonságok másolása var extendShallow = function extendShallow(objTo, objFrom) { for (var i = 2; i < arguments.length; i++) { var propName = arguments[i]; objTo[propName] = objFrom[propName]; } return objTo; }; //Használata a fenti o1 és o2 objektumokon extendShallow(o2, o1, 'a'); ok( o2.a === 1, 'Az a tulajdonság átmásolásra került'); ok( o2.b === 42, 'A b tulajdonság nem másolódott át');
A kétféle megközelítést, a több objektum vegyítését és a szelektív másolást, ötvözhetjük egyetlen segédfüggvényben, aminek a paraméterezése dönti el, melyik módot használjuk.
var extendShallow = function extendShallow(objTo, objFrom) { if (arguments[2] && typeof arguments[2] === 'string') { for (var i = 2; i < arguments.length; i++) { var propName = arguments[i]; objTo[propName] = objFrom[propName]; } } else { [].slice.call(arguments, 1).forEach(function(source) { for (var prop in source) { if (source.hasOwnProperty(prop)) { objTo[prop] = source[prop]; } } }); } return objTo; }
A fenti példák olyan szempontból egyszerűek voltak, hogy elemi értékek kerültek átmásolásra. Mi a helyzet azonban az összetett típusokkal, úgymint az objektumokkal, tömbökkel és függvényekkel? Az alábbi példában az oFrom objektum szolgál mintául az o1 és o2 objektumoknak.
//A kiindulási objektumok var oFrom = { arr: [], obj: { a: 1 }, put: function (elem) { this.arr.push(elem); } }; //Célobjektumok var o1 = extendShallow(, oFrom); var o2 = extendShallow(, oFrom); //Manipulálás o1.put(42); o1.obj.a = 100; //Teszt ok( o1.put === o2.put, 'Mindkét objektum ugyanazt a függvényt éri el'); ok( o1.arr === o2.arr, 'A tömb is ugyanaz'); ok( o1.obj === o2.obj, 'A belső objektum is ugyanaz'); ok( o2.arr[0] === 42, 'Ugyanazt a tömböt érik el'); ok( o2.obj.a === 100, 'Ugyanazt az objektumot érik el'); //Teljes objektumcsere o1.obj = { b: 2 }; ok( o2.obj.a === 100, 'Az o2 obj tulajdonsága nem változik'); ok( o1.obj.a === undefined, 'Az o1-ben nincs obj.a');
Az extendShallow() függvény másolatot készít a forrásobjektum tulajdonságaiból a célobjektumba. Ez elemi objektumoknál egy új érték létrejöttét jelenti, hiszen ezek érték szerint másolódnak, összetett objektumoknál azonban a másolás csak referenciaszinten valósul meg, a belső objektum tulajdonságai közösek. Ha viszont az egész objektumot lecseréljük, akkor megszűnik az egyezés.
Sok esetben probléma, ha a tömbökről és objektumokról nem teljes másolat készül, hanem közösek. Ekkor érdemes az extendDeep() segédfüggvényt használni, amely a tömbök és objektumok esetén is teljes másolatot készít rekurzívan bejárva ezeket a struktúrákat. Ez a függvény is képes több modellobjektumot felhasználni a bővítésre, valamint két objektum között csak bizonyos tulajdonságokat átmásolni. Az extendDeep() lelke a copy függvény, mely tömbök és objektumok esetén új példányt hoz létre, és végzi el a rekurzív hívást.
var extendDeep = function extendDeep(objTo, objFrom) { var copy = function copy(objTo, objFrom, prop) { if (typeof objFrom[prop] === "object") { objTo[prop] = (Object.prototype.toString.call(objFrom[prop]) === '[object Array]') ? [] : ; extendDeep(objTo[prop], objFrom[prop]); } else { objTo[prop] = objFrom[prop]; } } if (arguments[2] && typeof arguments[2] === 'string') { for (var i = 2; i < arguments.length; i++) { var prop = arguments[i]; copy(objTo, objFrom, prop); } } else { [].slice.call(arguments, 1).forEach(function(source) { for (var prop in source) { if (source.hasOwnProperty(prop)) { copy(objTo, source, prop); } } }); } return objTo; }; //Használata a legutóbbi oFrom, o1 és o2 objektumokon var o1 = extendDeep(, oFrom); var o2 = extendDeep(, oFrom); //Manipulálás o1.put(42); o1.obj.a = 100; //Teszt ok( o1.put === o2.put, 'Mindkét objektum ugyanazt a függvényt éri el'); ok( o1.arr !== o2.arr, 'A tömbök különböznek'); ok( o1.obj !== o2.obj, 'A belső objektumok különböznek'); ok( o2.arr[0] === undefined, 'Más tömbbel dolgoznak'); ok( o2.obj.a === 1, 'Más objektummal dolgoznak');
Az extendDeep() a függvényekről nem készít külön másolatokat, azok továbbra is a modellobjektumbeli metódusokra hivatkoznak.
A függvények referencia szerinti másolásával a függvényen belüli this mindig arra az objektumra mutat, amelyen meghívták. Mi van azonban akkor, ha azt szeretnénk, hogy a bővítés után is az eredeti objektumbeli adatokkal dolgozzon a másolt függvény? Ez akkor fordulhat elő, ha olyan funkcionalitást másolunk, amelynek működési nyilvántartása a modellobjektumban történik. Az extendBind() segédfüggvény oly módon másolja át egy modellobjektum összes metódusát, hogy a this objektum kontextusa meghatározható. Ennek hiányában az objFrom objektumhoz lesznek a metódusok kötve.
var extendBind = function extendBind(objTo, objFrom, context) { context = context || objFrom; for (var prop in objFrom) { if (objFrom.hasOwnProperty(prop) && typeof objFrom[prop] === 'function') { objTo[prop] = objFrom[prop].bind(context); } } return objTo; } //Használata var o1 = { name: 'o1' }; var o2 = { name: 'o2', a: function() { return this.name; } }; var o3 = { name: 'o3' }; extendBind(o1, o2, o3); ok( o1.a() === 'o3', 'bind sikerült');
Az előző megoldásokban a másolandó tulajdonságokat objektumok tartalmazták. Tulajdonságokat bemásolni azonban másképp is lehet. Ha a tulajdonságokat egy függvényben a this objektumhoz kapcsoljuk, majd a függvénynek a célobjektumot mint this-t adjuk át, akkor a célobjektumban létrejönnek a tulajdonságok. Ezek viszont most már nem másolatok, hanem valódi klónok lesznek. Mivel a bemásolás ebben az esetben függvény segítségével valósult meg, ezért ezt a változatot funkcionális bővítésnek nevezzük.
var funcFrom = function funcFrom() { this.arr = []; this.obj = { a: 1 }; this.put = function (elem) { this.arr.push(elem); }; return this; }; var o1 = funcFrom.call(); var o2 = funcFrom.call(); //Teszt ok( o1.put !== o2.put, 'Két különböző put függvény'); ok( o1.arr !== o2.arr, 'Különböző belső tömbök'); ok( o1.obj !== o2.obj, 'Különböző belső objektumok');
A fenti folyamatot is általánosíthatjuk egy segédfüggvényben. A másolandó funkcionalitást tehát függvényekbe helyezzük, és a this objektumot bővítjük ki vele. A extendFunc() függvény pedig végigveszi a második paramétertől kezdve a függvényeket és sorban mindegyikkel bővíti az első paraméterben érkező objektumot. A lenti példában a this objektum bővítését az extendShallow() függvénnyel végezzük el.
//A clone segédfüggvény var extendFunc = function extendFunc(obj) { [].slice.call(arguments, 1).forEach(function(source) { if (typeof source === 'function') { source.call(obj); } }); return obj; } //Másolandó funkcionalitások var func1 = function func1() { extendShallow(this, { random: function (n) { return Math.floor(Math.random() * n); } }); }; var func2 = function func2() { extendShallow(this, { add: function (a, b) { return a + b; } }); }; //Célobjektumok létrehozása var o1 = extendFunc(, func1, func2); var o2 = extendFunc(, func1, func2); //Teszt ok( typeof o1.random === 'function', 'A random függvény elérhető'); ok( typeof o2.add === 'function', 'Az add függvény elérhető'); ok( o1.random !== o2.random, 'A klónozott függvények különböznek');
Az objektumok dinamikus jellegét kihasználva lehetőség van egy objektum – mint prototípus – tulajdonságait egy másik objektumba helyezni. Ezt többféleképpen megtehetjük.
Ebben a fejezetben a kód újrahasznosítását a prototípuslánc segítségével oldjuk meg. Az alapötlet az, hogy az újrahasznosítandó kódot tartalmazó modellobjektumot most nem bemásoljuk a célobjektumba, hanem azt prototípus-objektumként vesszük fel, így annak tulajdonságait a célobjektum a prototípusláncon keresztül elérheti. A célobjektum pedig egyedi sajátosságait saját tulajdonságain keresztül érvényesítheti, elrejtve a prototípus tulajdonságait.
//Prototípus var oFrom = { a: 1, obj: { l: true }, hello: function () { return 'hello'; } }; //Célobjektumok var o1 = Object.create(oFrom); var o2 = Object.create(oFrom); ok( o1.a === 1, 'Prototípus adattagja elérhető'); ok( o1.hello() === 'hello', 'Prototípus metódusa elérhető'); //Írás a célobjektumokba o1.b = 2; o2.a = 11; ok( o1.b === 2, 'Saját adattag elérhető'); ok( o2.a === 11, 'Saját adattag eltakarja a prototípus azonos nevű adattagját'); ok( o1.hello === o2.hello, 'A prototípusban lévő látható tulajdonságok közösek'); //Összetett adatszerkezetek o1.obj.l = false; ok( o1.obj === o2.obj, 'A prototípusban lévő összetett adatszerkezetek közösek'); ok( o2.obj.l === false, 'A prototípusban lévő összetett adatszerkezetek elemein bekövetkező változások közösek'); o2.obj = { l: true }; ok( o1.obj !== o2.obj, 'A teljes objektumot felüldefiniálva elrejthető a prototípus objektuma'); ok( o2.obj.l !== o1.obj.l, 'Így már külön állapot látszik o2-ben');
A fenti példában látható, hogy a prototípus-objektumban lévő összetett adatszerkezetek (objektumok, tömbök) elemeinek változtatása a prototípus-objektumban érvényesül, így minden célobjektumban tükröződik. Ha úgy szeretnénk egy összetett adatszerkezet elemeit megváltoztatni, hogy az ne a prototípus-objektumban íródjon felül, akkor az összetett adatszerkezetet a célobjektumban kell felvenni.
Ezt a kis részt összefoglalva megállapítható, hogy a JavaScript nyelv prototípusossága egyszerűen adódik a prototípus-objektumok által. A prototípus-objektumok tárolják a minden rá hivatkozó objektum számára közös metódusokat és adattagokat. Általánosságban elmondható, hogy metódusok esetén ez hasznos dolog, az állapotot képviselő adattagok esetén azonban veszélyeket rejthet magában. Arról, hogy a különböző kód-újrahasznosítási mintákat hogyan lehet jól felhasználni, a következő fejezetek írják le.
Az előzőekben áttekintettük, hogy milyen tulajdonságai vannak a JavaScript objektumoknak, és milyen lehetőségek állnak rendelkezésre egy meglévő kód újrahasznosítására. Az alábbiakban ezeket felhasználva nézzük meg azt, hogy objektumok létrehozását hogyan is lehet elvégezni.
JavaScriptben egy objektum létrehozása legegyszerűbben az objektumliterállal vagy az Object.create() metódussal tehető meg.
var c = { name: 'Sári', dateOfBirth: { year: 2004, month: 11, day: 14 }, getName: function getName() { return this.name; }, setName: function setName(name) { this.name = name; } };
Ha több ugyanolyan funkcionalitású objektumot szeretnénk létrehozni, akkor egy olyan függvényt kell készítenünk, amely a megfelelő objektumot adja vissza. Ez nem más, mint a Gyár (Factory) minta.
var child = function child() { return { name: 'Anonymous', dateOfBirth: { year: 1970, month: 1, day: 1 }, getName: function getName() { return this.name; }, setName: function setName(name) { this.name = name; } }; }; var c1 = child(); var c2 = child(); ok( c1 !== c2, 'Különböző objektumok jönnek létre'); ok( c1.dateOfBirth !== c2.dateOfBirth, 'A belső objektumok is különböznek'); ok( c1.getName !== c2.getName, 'A metódusok is különböznek');
A child() függvényt semmi nem különbözteti meg a többi függvénytől. Azért, hogy jobban látszódjék az objektum létrehozásának szándéka, tegyük egyértelműbbé új objektum konstruálásának interfészét.
var child = { create: function create() { return { name: /*...*/, dateOfBirth: /*...*/, getName: /*...*/, setName: /*...*/ }; } }; var c1 = child.create(); var c2 = child.create();
Kényelmes dolog létrehozáskor megadni az újonnan létrejövő objektum tulajdonságainak értékét. Ehhez egy inicializáló objektumot adunk át a gyárfüggvénynek, amivel felülírjuk a modellobjektumból származó tulajdonságok értékét az extendDeep() függvénnyel.
var child = { create: function create(props) { return extendDeep({ name: /*...*/, dateOfBirth: /*...*/, getName: /*...*/, setName: /*...*/ }, props || ); } }; var c1 = child.create(); var c2 = child.create({ name: 'Zsófi', dateOfBirth: { year: 2006, month: 9, day: 1 } }); ok( c1.name === 'Anonymous', 'Inicialzáló objektum hiányában a modellobjektum értékei vannak'); ok( c2.name === 'Zsófi', 'Az inicializáló objektum értékei felülírják a modellobjektum értékeit');
JavaScriptben az objektumok adattagjai és metódusai mind publikusak, a nyelvben nincsen speciális szintaxis privát, védett vagy publikus tulajdonságok létrehozására. Closure segítségével azonban létrehozhatók privát adattagok és metódusok. Ekkor a gyárfüggvényen belül kell ezeket a privát tulajdonságokat definiálni, amik kívülről közvetlenül nem érhetők el, viszont megmaradnak a gyárfüggvény closure-jében, és a publikus metódusokon keresztül meghívhatók. Azokat a publikus függvényeket, amelyek privát adattagokat vagy metódusokat használnak, privilegizált metódusoknak hívjuk.
var child = { create: function create(props) { //Privát adattag var secretNickName = ''; return extendDeep({ //Publikus adattagok és metódusok name: 'Anonymous', dateOfBirth: { year: 1970, month: 1, day: 1 }, getName: function getName() { return this.name ; }, setName: function setName(name) { this.name = name; }, //Privilegizált metódusok setSecretNickName: function (name) { secretNickName = name; }, getSecretNickName: function () { return secretNickName; } }, props || ); } }; var c1 = child.create(); c1.setSecretNickName('Fairy'); ok( c1.secretNickName === undefined, 'Privát adattag kívülről nem érhető el'); ok( c1.getSecretNickName() === 'Fairy', 'Privilegizált metódusok hozzáférnek');
Az eddigi megoldásoknak az a hátránya, hogy minden adattag és metódus annyi példányban jött létre, ahány objektumot létrehoztunk. Ez az adattagoknál nem is jelent gondot, hiszen az egyes objektumoknak saját állapotterük van, a metódusoknál azonban felesleges, elég belőlük egy példány, amely az adott objektum állapotterével dolgozik. A hatékony tárolás a következő:
Ezt az elvárást többféleképpen is teljesíthetjük. Ha van egy modellobjektumunk, ami tartalmazza az adattagokat és a metódusokat is, akkor ebből mély bővítéssel olyan objektumokat hozhatunk létre, amelyekbe az adattagok átmásolódnak, de a metódusok a modellobjektumbeli metódusokra mutatnak. Ennek egyetlen hátránya, hogy az egyes példányobjektumokban a metódusok mint referenciák megjelennek.
var child = (function child() { var childProto = { name: /*...*/, dateOfBirth: /*...*/, getName: /*...*/, setName: /*...*/ }; return { create: function create(props) { return extendDeep(, childProto, props); } } })(); var c1 = child.create(); var c2 = child.create(); ok( c1.getName === c2.getName, 'A metódusok közösek'); ok( c1.dateOfBirth !== c2.dateOfBirth, 'Adattagok nem közösek');
Az alábbi megoldásnak az lehet az előnye, hogy szétválasztja a metódusokat, vagy általában a közös részeket, és az adattagokat. A metódusokat egyszintű bővítéssel, az adattagokat mély bővítéssel másolja. Ez akkor jó, ha a közös részben pl. objektumok vagy tömbök vannak, amelyek így minden objektum számára közösek.
var child = (function child() { var methodsProto = { getName: /*...*/, setName: /*...*/ }; var dataProto = { name: /*...*/, dateOfBirth: /*...*/ }; return { create: function create(props) { var obj = extendShallow(, methodsProto); return extendDeep(obj, dataProto, props); }, methodsProto: methodsProto, dataProto: dataProto } })(); var c1 = child.create(); var c2 = child.create(); ok( c1.getName === c2.getName, 'A metódusok közösek'); ok( c1.dateOfBirth !== c2.dateOfBirth, 'Adattagok nem közösek');
A másik lehetőség, hogy a metódusokat egy objektumba helyezzük, majd ezt adjuk meg a létrejövő objektumok prototípusának. A létrejövő objektumokba pedig csupán az adattagokat másoljuk. A metódusok objektumát is elérhetővé tesszük a methods tulajdonságon későbbi felhasználás céljából (ld. öröklés lejjebb).
var child = (function() { var methods = { getName: /*...*/, setName: /*...*/ }; var data = { name: /*...*/, dateOfBirth: /*...*/ }; return { create: function(props) { return extendDeep( Object.create(methods), data, props || ); }, methods: methods }; })();
Sajnos JavaScriptben nem lehet megtenni azt, hogy a privilegizált metódusokat közös helyre tegyük. Ennek az egyszerű oka az, hogy privát adattagok csak closure-ben létezhetnek. A closure viszont függvényhez tartozik. Egy függvény, egy closure. Ha tehát objektumonként szeretnénk privát adatokat tárolni, akkor annyi closure-t és ennek megfelelően annyi függvényt is kell létrehozni. És csak ezen függvényen belüli függvények érik el a privát adattagokat. A closure-t kívülről elérni vagy paraméterként átadni nem lehet.
A privát adattagokat és privilegizált metódusokat tehát objektumonként kell felvenni továbbra is a gyárfüggvényen belül.
var child = (function() { var publicMethods = { getName: /*...*/, setName: /*...*/ }; var publicData = { name: /*...*/, dateOfBirth: /*...*/ }; return { create: function(props) { //Privát adattag var secretNickName = /*...*/; //Privilegizált metódusok var privilegedMethods = { setSecretNickName: /*...*/, getSecretNickName: /*...*/ }; return extendDeep( Object.create(publicMethods), privilegedMethods publicData, props || ); }, methods: publicMethods }; })();
A klasszikus OOP-ben a kód újrafelhasználásának szinte egyetlen eszköze az osztályok közötti öröklés. Ezzel érik el azt, hogy a gyerekosztályok használhatják a szülőosztályok megfelelően beállított adattagjait és metódusait.
JavaScriptbeli megvalósításkor induljunk megint ki az elvárásokból. Fentebb már láthattuk, hogy célszerű szétválasztani egy adott viselkedés állapotterét és metódusait. Öröklés esetén két szereplő van: az egyik az, akinek az állapotterét és metódusait szeretnénk felhasználni, az ős; a másik az, aki felhasználja és kiegészíti ezeket a saját állapotterével és metódusaival. A kiegészítést úgy kell megtennie, hogy még véletlenül se írja felül a szülőobjektum állapotterét és metódusát, hiszen más objektumok ezt használják vagy ez alapján jönnek létre.
A metódusok öröklése elérhető a megfelelő prototípuslánc felépítésével. A származtatott modell metódusai a szülő metódusmodelljeit állítja be prototípusnak. A példányobjektumok pedig a gyerek metódusmodelljeit állítják be prototípusuknak.
Az alábbi példában a fenti ábra megvalósításán túl a gyerekmetódusok objektuma egy referenciát is tartalmaz a szülőmetódusokra (_super), hogy azok akkor is elérhetőek legyenek, ha a gyerek elrejti azokat a szülő elől a prototípusláncban (ld. pl. getName()).
var preschool = (function(_super) { var methods = { getSign: function getSign() { return this.sign; }, setSign: function setSign(sign) { this.sign = sign; }, getName: function getName() { var name = this._super.getName.call(this); return name + ' (preschool)'; } }; var publicMethods = extendShallow( Object.create(_super.methods), methods, { _super: _super.methods } ); var publicData = { sign: 'default sign' }; return { create: function(props) { return extendDeep( Object.create(publicMethods), _super.create(), publicData, props || ); }, methods: publicMethods }; })(child); var p1 = preschool.create(); var p2 = preschool.create(); p1.setName('Dávid'); ok( p1.name !== undefined, 'Ős tulajdonságai elérhetők'); ok( p1.sign !== undefined, 'Saját tulajdonságok is megvannak'); ok( p1.dateOfBirth !== p2.dateOfBirth, 'Objektumonkénti állapot'); ok( p1.setName === p2.setName, 'Ős metódusai közösek'); ok( p1.setSign === p2.setSign, 'Saját metódusok közösek'); ok( p1.getName() === 'Dávid (preschool)', 'Ősmetódus felülírva és meghívva');
Több modellobjektum metódusainak és adattagjainak használata elérhető úgy is, hogy a modellobjektumok tulajdonságaival egyszerűen kibővítjük a célobjektumot. Így a célobjektumban a bővítés sorrendjétől függően megjelennek az adattagok mély másolással, a metódusok pedig a modellobjektumok metódusaira hivatkoznak. Ezzel a módszerrel jól szimulálható a többszörös öröklés.
var childProto = { name: /*...*/, dateOfBirth: /*...*/, getName: /*...*/, setName: /*...*/ }; var preschoolProto = { sign: /*...*/, getSign: /*...*/, setSign: /*...*/ }; var preschool = { create: function(props) { return extendDeep(, childProto, preschoolProto, props); } }; //Teszteket ld. az előző fejezetben //Kivéve, hogy most nem definiáltuk felül a getName() metódust
Ennek a változatnak továbbra is hátránya az, hogy minden példányobjektumban megjelenik az összes metódus referenciaszinten. Ennek egyik változata az, amikor a bővítést vegyítik a prototípuslánccal (ld. a funkcionális bővítés alapcikkét). Ekkor a metódusokat egy prototípus-objektumba kompozitálják, az adattagokat pedig mély másolással egy erre hivatkozó példányobjektumba. Ehhez természetesen valamilyen módon szükséges a metódus-prototípusok és adatprototípusok szétválasztása.
Fentebb láthattuk, hogy a kód-újrahasznosítást alapvetően vagy prototípuslánccal vagy kompozitálással oldhatjuk meg. A fenti műveletek elvégzéséhez készíthetünk segédfüggvényeket, melyek a fenti megoldások felesleges ismétlésétől kímélnek meg.
Egy gyárfüggvény készítéséhez a következő adatokat szükséges megadnunk:
Az alábbi példákban a következő modellobjektumokat fogjuk használni:
var childProto = { methods: { getName: /*...*/, setName: /*...*/ }, data: { name: /*...*/, dateOfBirth: /*...*/ }, init: function () { //Privát adattag var secretNickName = /*...*/; //Privilegizált metódusok var privilegedMethods = { setSecretNickName: /*...*/, getSecretNickName: /*...*/ }; return extendShallow(this, privilegedMethods); } }; var preschoolProto = { methods: { getSign: /*...*/, setSign: /*...*/, getName: /*...*/ }, data: { sign: 'car' }, super: child };
A fentebb tárgyalt prototípusos öröklést a következőképpen általánosíthatjuk:
var createFactoryWithPrototype = function createFactoryWithPrototype(opts) { //Paraméterek kiolvasása var methods = opts.methods || ; var publicData = opts.data || ; var _super = opts.super; var init = opts.init || function () ; var publicMethods = extendShallow( Object.create(_super ? _super.methods : Object.prototype), methods, _super ? { _super: _super.methods } : ); return { create: function(props) { var obj = extendDeep( Object.create(publicMethods), _super ? _super.create() : , publicData, props || ); return extendFunc(obj, init); }, methods: publicMethods }; }; //child gyárfüggvény előállítása var child = createFactoryWithPrototype(childProto); var c1 = child.create(); var c2 = child.create(); c1.setSecretNickName('secret1'); c2.setSecretNickName('secret2'); ok( c1.getName === c2.getName, 'A metódusok közösek'); ok( c1.dateOfBirth !== c2.dateOfBirth, 'Adattagok nem közösek'); ok( c1.getSecretNickName() === 'secret1', 'Privát adattag elérhető'); ok( c1.getSecretNickName() !== c2.getSecretNickName(), 'Privát adattagok nem közösek'); //preschool gyárfüggvény előállítása var preschool = createFactoryWithPrototype(preschoolProto); var p1 = preschool.create(); var p2 = preschool.create(); p1.setName('Dávid'); ok( p1.name !== undefined, 'Ős tulajdonságai elérhetők'); ok( p1.sign !== undefined, 'Saját tulajdonságok is megvannak'); ok( p1.dateOfBirth !== p2.dateOfBirth, 'Objektumonkénti állapot'); ok( p1.setName === p2.setName, 'Ős metódusai közösek'); ok( p1.setSign === p2.setSign, 'Saját metódusok közösek'); ok( p1.getName() === 'Dávid (preschool)', 'Ősmetódus felülírva és meghívva');
Kompozíció elkészítéséhez majdnem ugyanezekre a paraméterekre van szükség. Annyi a különbség, hogy itt nincsen szülőobjektum, hiszen a paraméterül megkapott objektumokat vegyíti egybe. Az, hogy hogyan adjuk meg a metódusokat és adattagokat, változhat, mi most az előzőhöz hasonlóan külön adjuk meg őket. Annak érdekében, hogy áttekinthető legyen a paraméterezés, a modellobjektumokat külön definiáljuk (childProto, stb). Az alábbiakban azt a változatot adjuk meg, amikor a metódusok egy külön prototípus-objektumba másolódnak.
var createFactoryWithComposition = function createFactoryWithComposition() { var args = arguments; var methods = ; for (var i = 0; i < arguments.length; i++) { extendDeep(methods, args[i].methods || ); }; return { create: function(props) { var obj = Object.create(methods); for (var i = 0; i < args.length; i++) { extendDeep(obj, args[i].data || ); extendFunc(obj, args[i].init || function () ); }; return extendDeep(obj, props); } }; }; //child gyárfüggvény előállítása var child = createFactoryWithComposition(childProto); //ld. a teszteket a prototípusláncnál //preschool gyárfüggvény előállítása var preschool = createFactoryWithComposition(childProto, preschoolProto); //ld. a teszteket a prototípusláncnál, kivéve a getName felüldefiniálását
JavaScriptben a fent említett módokon túl lehetőség van a klasszikus objektum-orientált programozáshoz hasonló módon a new operátor segítségével objektumokat létrehozni.
var c = new Child();
A szintaxisbeli hasonlóság ellenére azonban a JavaScriptbeli működés jelentősen eltér. Mivel JavaScriptben nincsenek osztályok, ezért a new operátor operandusául egy függvényt kell megadni, amely a new operátor hatására speciálisan viselkedik, és egy objektumot hoz létre. A new operátorral használandó függvényeket konstruktorfüggvényeknek hívjuk, és speciális mivoltukat konvencionálisan nagy kezdőbetűvel jelezzük. A konstruktorfüggvények new operátorral történő meghívását konstruktorhívási mintának is nevezzük.
//A konstruktorfüggvény var Child = function Child() { this.name = 'Anonymous'; } //Konstruktorhívási minta var c = new Child(); ok( c.name === 'Anonymous', 'A létrejött objektum name tulajdonsága elérhető és helyes');
A konstruktorhívás hátterében zajló folyamatok megértéséhez először idézzük fel azokat az ismereteket, amelyeket a függvényekről mint objektumokról ismerünk. Ezek közül számunkra most különösen a függvény prototype tulajdonsága bír nagy jelentőséggel. Minden függvénynek ugyanis automatikusan van egy prototype tulajdonsága, ami egy olyan objektumra mutat, aminek constructor tulajdonsága az adott függvényre hivatkozik.
A függvény new-val történő hívásakor először egy olyan objektum jön létre a this kulcsszó alatt, amelynek prototípus-objektuma a konstruktorfüggvény prototype tulajdonsága által mutatott objektum. A this objektumhoz a konstruktorfüggvényben további adattagokat és metódusokat adhatunk, majd a függvény impliciten a this objektummal tér vissza. A háttérben zajló folyamatokat a következőképpen szemléltethetjük.
var Child = function Child() { //Új objektum létrehozása a this-ben var this = Object.create(Child.prototype); //További tulajdonságok hozzáadása this.name = 'Anonymous'; //Visszatérés a létrehozott objektummal return this; }
EcmaScript 5 előtt csak a függvények prototype tulajdonságán keresztül lehetett egy létrejövő objektum prototípus-objektumát beállítani. Az EcmaScript 5-ös Object.create() metódus azonban ezt feleslegessé teszi.
A konstruktorfüggvény további tulajdonsága az, hogy ha szerepel benne explicit return utasítás, akkor azzal konstruktorhívásuk azzal tér vissza, amit így megadtunk.
var Child = function Child() { this.name = 'Anonymous'; return { something: 'else' }; } var c = new Child(); ok( c.name === undefined, 'A name tulajdonság jött létre'); ok( c.something === 'else', 'Helyette az else tulajdonság érhető el');
A konstruktorfüggvények használatának további veszélye az, hogy ha elfelejtjük a new operátorral meghívni, akkor a this a globális objektumra mutat, és így könnyen teleszemetelhetjük a globális névteret. Ennek elkerülésére több minta is van:
var Child = function Child() { var that = Object.create(Child.prototype); that.name = 'Anonymous'; return that; } var c1 = new Child(); var c2 = Child(); ok( c1.name === 'Anonymous', 'Működik new-val'); ok( c2.name === 'Anonymous', 'Működik new nélkül'); ok( Object.getPrototypeOf(c2) === Child.prototype, 'A prototípus-objektum is jó');
var Child = function Child() { if (!(this instanceof Child)) { return new Child(); } this.name = 'Anonymous'; } //Ld. fenti tesztek
A klasszikus objektum-orientált szintaxis használatának további hátránya, hogy használata azt sugallja, hogy a JavaScript nyelvben valamilyen módon mégis osztályokat lehet létrehozni. Mindeközben alternatívát állít olyan megoldásoknak, amelyek a nyelv természetes adottságait használják ki.
Végül a klasszikus objektum-orientált fogalomkör számos olyan megoldást kényszerít a programozóra, amellyel a program elveszíti rugalmasságát, kiterjeszthetőségét. Az öröklés során az utódosztály közvetlen kapcsolatot épít ki a szülőosztállyal, megszüntetve ezzel a két funkcionalitás lazán kapcsolt mivoltát. Nem véletlen, hogy a klasszikus objektum-orientált programozásban használatos tervezési mintákról szóló alapvető szakirodalom is azt hangsúlyozza, hogy inkább a kompozíciót használjuk az öröklés helyett, és maga a könyv is azért jött létre, hogy klasszikus objektum-orientált megközelítés során tapasztalt akadályokra jól bevált megoldásokat dolgozzanak ki. Ezek a megoldások sokszor bonyolultak, JavaScriptben viszont nagyon egyszerűen áthidalhatók.
Az alábbiakban áttekintjük, hogy a fentebb, a JavaScript nyelv dinamikusságát és prototípusosságát kihasználó minták mellett a klasszikus objektum-orientált szemlélettel hogyan oldható meg objektumhierarchiák kialakítása. Tesszük ezt azért, mert a ma feltalálható korszerű függvénykönyvtárak nagy része is ezt a szemléletet követi. Ha használni nem is, de ismernünk tehát mindenféleképpen szükséges ezt a módszert is.
A konstruktorfüggvény objektumok létrehozására valók, és mint ilyenek gyárfüggvények is egyben. Korábbi példánk így néz ki konstruktorfüggvényekkel.
var Child = function () { this.name = 'Anonymous'; this.dateOfBirth = { year: 1970, month: 1, day: 1 }; this.getName = function getName() { return this.name; }; this.setName = function setName(name) { this.name = name; }; };
Paraméterek megadhatók egyesével, de átadható egy konfigurációs objektum is. Ekkor a this-t először az alapértelmezett értékekkel bővítjük, majd a konfigurációs objektummal.
var Child = function (props) { extendDeep(this, { name: /*...*/, dateOfBirth: /*...*/, getName: /*...*/, setName: /*...*/ }, props || ); };
Természetesen itt is van lehetőség privát adattagok létrehozására. Maga a konstruktorfüggvény biztosítja a closure-t ezeknek az elhelyezésére.
var Child = function (props) { //Privát adattag var secretNickName = ''; extendDeep(this, { name: /*...*/, dateOfBirth: /*...*/, getName: /*...*/, setName: /*...*/, //Privilegizált metódusok setSecretNickName: function (name) { secretNickName = name; }, getSecretNickName: function () { return secretNickName; } }, props || ); };
Metódusok hatékony tárolását tipikusan a prototípus-objektumban szokták elvégezni.
var Child = function (props) { extendDeep(this, { name: /*...*/, dateOfBirth: /*...*/ }, props || ); }; extendShallow(Child.prototype, { getName: /*...*/, setName: /*...*/ });
Természetesen itt sincs lehetőség a privát változókat elérő privilegizált metódusok prototípus-objektumban történő tárolására.
Gyakran látni olyan megoldást, amikor az előző példabeli létrehozást még egy további önkioldó függvénybe csomagolják. Ekkor a konstruktorfüggvényhez tartozó logika mind egy helyen van, és véletlenül sem szivárog a külső névterekbe. (Ezt a megoldást alkalmazza a TypeScript és a CoffeeScript fordító is.)
var Child = (function () { var Child = function (props) { extendDeep(this, { name: /*...*/, dateOfBirth: /*...*/ }, props || ); }; extendShallow(Child.prototype, { getName: /*...*/, setName: /*...*/ }); return Child; })();
A klasszikus objektum-orientált szintaxist utánozó megközelítésben is a korábban tárgyalt objektumhierarchia kialakítása a cél: a gyerekobjektumba kell másolni a gyerek és a szülő adattagjait, prototípusában a gyerek metódusai, annak prototípusában pedig a szülő metódusai kapnak helyet. Ennek kialakításában segít az inherit() segédfüggvény. (Ebben használhatnánk az Object.create() metódust is, de szándékosan azt a formát tartottuk meg, amely nagyobb függvénykönyvtárakban megtalálható, és ami működik EcmaScript 3-ban is.)
//Objektumhierarchiát kialakító függvény var inherit = function (C, P) { var F = function () ; F.prototype = P.prototype; C.prototype = new F(); C.prototype._super = P.prototype; C.prototype.constructor = C; }; //Gyerekkonstruktor készítése var Preschool = (function (_super) { var Preschool = function (props) { extendFunc(this, _super); //vagy paramétert is átadva: //_super.call(this, props) extendDeep(this, { sign: 'default sign' }, props || ); }; inherit(Preschool, _super); extendShallow(Preschool.prototype, { getSign: function getSign() { return this.sign; }, setSign: function setSign(sign) { this.sign = sign; }, getName: function getName() { var name = this._super.getName.call(this); return name + ' (preschool)'; } }); return Preschool; })(Child);
Fentebb láthattuk, hogy EcmaScript 5-ben több lehetőségünk is van objektumgenerátorokat létrehozni. A klasszikus objektum-orientált nyelvekben az adattagok és metódusok egységbe zárását az osztályok végzik el. JavaScriptben számos függvénykönyvtár próbálta valamilyen módon szimulálni a klasszikus osztály működését. A nagy gond ezekkel azonban az, hogy működésükhöz az adott függvénykönyvtár szükséges, és az egyes megvalósítások nem cserélhetőek fel egymással.
Látva ezt a problémát, az EcmaScript szabvány következő verziójában nyelvi szinten próbálják az adatok és metódusok egységbe zárását megoldani. Ehhez a klasszikus OOP nyelvek osztály megnevezését használják fel, utalva egyrészt arra, hogy ezek ilyen tulajdonságú objektumok létrehozására szolgálnak, másrészt továbbra is megkönnyítve a klasszikus OOP-n nevelkedett programozók számára a konstrukció megértését.
Fontos azonban tudnunk azt, hogy JavaScriptben továbbra sem lesznek a klasszikus értelemben vett osztályok. A class kulcsszóval létrehozott konstrukciók továbbra is objektumokká képződnek. A class kulcsszó igazából nem is nyelvi újdonság, hanem csak egy olyan szintaktikai kiegészítés, amellyel az objektumgenerátorok eddig körülményes készítése egyszerűbbé válik, de amely a háttérben az eddigi nyelvi megoldásokra képződik le (syntactic sugar). A leképzett szintaxis általában az előző fejezetben tárgyalt klasszikus OOP-re hasonlító megoldás, azaz függvényekre és azok prototype-jára vezetik vissza. Igazából az mindegy is, hogyan valósítják meg, a lényeg, hogy a metódusok a prototípus-objektumban szerepelnek, az adattagok pedig az új objektumban jönnek létre.
Íme példaképpen a Child objektumgenerálónk az EcmaScript 6 tervezete szerint (most a name adattaghoz gettert és settert hozunk létre):
class Child { constructor(name, dateOfBirth) { this._name = name; this.dateOfBirth = 100; } say(something) { return this.name + ' says: ' + something; } get name() { return this._name; } set name(value) { if (value === '') { throw new Error('Name cannot be empty.'); } this._name = value; } }
Természetesen lehetőség van öröklésre is. Erre az extends kulcsszó szolgál, és a metódusokon belül a super objektumon keresztül hivatkozhatunk a szülő objektum metódusaira.
class Preschool extends Child { constructor(name, dateOfBirth, sign) { super(name, dateOfBirth); this._sign = sign; } get sign() { return this._sign; } set sign(value) { this._sign = value; } get name() { return super.name + ' (preschool)'; } }
Az EcmaScript 6-os újdonságokat a legújabb böngészőkben, a Traceur, illetve a TypeScript fordító segítségével lehet kipróbálni.
A tananyag az ELTE - PPKE informatika tananyagfejlesztési projekt (TÁMOP-4.1.2.A/1-11/1-2011-0052) keretében valósult meg.
A tananyag elkészítéséhez az ELTESCORM keretrendszert használtuk.