Třídy, dědičnost a OOP v Javascriptu – II

JavaScript

V předchozím článku jsme si ukázali, jak se v Javascriptu řeší zapouzdření a objekty, ukázali si nejčastěji používané postupy a vysvětlili si, proč jsou špatné. V dnešním pokračování si ukážeme, jak se dědičnost v Javascriptu implementuje správně, pomocí prototypů.

Seriál: OOP v Javascriptu (3 díly)

  1. Třídy, dědičnost a OOP v Javascriptu – I 15.3.2010
  2. Třídy, dědičnost a OOP v Javascriptu – II 22.3.2010
  3. Třídy, dědičnost a OOP v Javascriptu – III 29.3.2010

předchozím díle jsme probrali základní pojmy OOP a dva špatné způsoby deklarace tříd: zneužití closure a Crockfordovy pokusy o privátní proměnné. Správný způsob si ukážeme nyní.

Jak už bylo řečeno, funkce v Javascriptu hraje dvě hlavní role. Buď je funkcí, nebo je konstrukční funkcí, krátce třídou. Klíčem k jedinému správnému vytváření tříd je vlastnost jménem prototype. Má ji každá funkce, a její výchozí hodnota je prázdný objekt.

var fn = function() {};
alert(typeof fn.prototype == 'object'); // true

Příklad: http://jsfiddle­.net/pNQr5/

Jak ukazuje následující příklad, prototype se použije vždy, když konstruktor zavoláme operátorem new. Určuje, jaké vlastnosti má vytvořená instance mít.

var Animal = function(name) {
    this.name = name;
};

Animal.prototype = {
    getName: function() {
        return this.name;
    }
};
var mici = new Animal('Mici');
var kevi = new Animal('Kevi');
alert(mici.getName()); // Mici
alert(kevi.getName()); // Kevi

Příklad: http://jsfiddle­.net/ZT4AW/

Takto vypadá přirozená deklarace tříd, tak jak plyne z návrhu jazyka. Všimněte si, že nevytváříme žádné privilegované metody, ani neukládáme stav do closure. Kdybychom to udělali, zavřela by se nám cesta k pozdějším modifikacím i dědičnosti. 

Jak funguje operátor new?

  1. vytvoří instanci podle prototype
  2. zavolá konstruktor  v jejím kontextu (uvnitř funkce se na instanci odkazujeme pomocí this)
  3. vrátí vytvořenou instanci

Jediná správná technika vytváření instancí je spojení operátoru new a vlastnosti prototype. Proč? Zapamatujme si, že instance vytvořená operátorem new je „odrazem v zrcadle“ objektu prototype. Kdykoliv změníme prototype, přidáme metodu nebo vlastnost, změna se projeví ve všech instancích, a to i v těch již vytvořených. Následující příklad to dokazuje:

var Animal = function(name) {
    this.name = name;
};
Animal.prototype = {
    getName: function() {
        return this.name;
    }
};

var mici = new Animal('Mici');
var kevi = new Animal('Kevi');
alert(mici.getName()); // Mici
alert(kevi.getName()); // Kevi

// chci přidat syntax sugar metodu
Animal.prototype.alertName = function() {
    alert(this.getName());
};

// hle!, metoda se přidala k již existující instanci
mici.alertName();

// chci změnit getName
Animal.prototype.getName = function() {
    return 'Mé jméno je: ' + this.name;
};

// ha!, metoda se změnila všem existujícím instancím
kevi.alertName();

// důkaz, že metody jsou sdílené
alert(mici.getName === kevi.getName); // true
alert(mici.getName === Animal.prototype.getName); // true

http://jsfiddle­.net/naqph/

Vlastnost prototype je všemi instancemi jedné třídy sdílená. Jak tedy nastavujeme vlastnosti konkrétní instanci? Jednoduše pomocí tečkového operátoru. Můžeme si to představit jako kreslení rtěnkou na zrcadlo.

rtěnka zrcadlo

Jak ukazuje následující příklad, tečkový operátor preferuje instanční vlastnosti. Když v předposledním řádku vlastnost name instanci smažeme, vrací se hodnota z prototype.

var Person = function(name) {
    this.name = name;
};
Person.prototype.name = 'noname';
var joe = new Person('Joe');
alert(joe.name == 'Joe'); // true
delete joe.name;
alert(joe.name == 'noname'); // true

Příklad: http://jsfiddle­.net/5ALHm/

To, že je prototype sdílen všemi instancemi, svádí některé programátory k tomu, že považují prototype za „kontejner pro statické položky třídy (constructor function)“, což je omyl. Prototype je vzor pro tvorbu instancí. Statické položky se správně přiřazují pouze ke konstruktoru.

// statické vlastnosti přiřazujeme pouze ke konstruktoru
Person.I_AM_CONSTANT = 42;
Person.staticLookupMethod = function() {};
Person.staticArray = [];

Z čeho tento omyl pramení, je zřejmé: Každého programátora znalého klasických tříd dříve nebo později napadne napsat jasně instanční vlastnost přímo do prototype.

// takhle ne, instanční objekty do prototype nepatří
var Person = function(skill) {
    this.skills.push(skill);
};
Person.prototype.skills = [];

var creativePerson = new Person('creativity');
var stupidPerson = new Person('stupidity');

// špatně, vypíše se 'creativity,stupidity'
alert(stupidPerson.skills);

// správně, instanční objekty až v konstruktoru
var Person2 = function(skill) {
    this.skills = [];
    this.skills.push(skill);
};

var creativePerson2 = new Person2('creativity');
var stupidPerson2 = new Person2('stupidity');
// správně, skills má být pouze stupidity
alert(stupidPerson2.skills);

Porozumět tomuto příkladu je klíčové, pokud chceme pochopit hlavní rozdíl mezi klasickou třídou a třídou, jak ji chápe Javascript. Operátor new vytváří mělkou kopii objektu prototype. Pole skills, definované v prototype, se tak stane de facto statickou vlastností, ale je zavádějící ji tak nazývat. Je zřejmé, že ke statickým vlastnostem nepřistupujeme přes instance. Shrňme si tedy, co kam patří:

  • statické vlastnosti a metody přiřazujeme konstruktoru
  • instanční metody definujeme v prototype
  • instanční objekty vytváříme v konstruktoru (nebo ostatních metodách)

Přesto se názvy instančních objektů do prototype občas zapisují, avšak neinicializované, a pouze kvůli dokumentaci.

// ideálně
var Person = function(skill) {
    this.skills = [];
    this.skills.push(skill);
};
/**
 * Person skills in array of strings
 * @type {array}
 */
Person.prototype.skills = null; // pozor, inicializovat až v konstruktoru

var creativePerson = new Person('creativity');
var stupidPerson = new Person('stupidity');
// správně, skills má být pouze stupidity
alert(stupidPerson.skills);

Objektová dědičnost

Malá odbočka: V Javascriptu se často hovoří o objektové dědičnosti. Následující technika, myslím, pěkně ilustruje, co to znamená, když objekt dědí z objektu. V praxi ji nemá smysl používat, ale bude se nám hodit při výkladu implementace dědičnosti.

// funkce beget vytvoří poděděný objekt
var beget = function(parent) {
    // F je pomocný dočasný konstruktor
    var F = function() {}; 
    F.prototype = parent;
    var child = new F;
    return child;
};

var parent = {}; // parent je objekt
var child = beget(parent); // a child také

// předkovi nastavím nějakou hodnotu
parent.property = 'value';

// a vida, potomek ji má též (opačně to samozřejmě nefunguje)
alert(child.property); // 'value'

Příklad: http://jsfiddle­.net/SgXxK/

Možnost měnit instance i poté, co byly vytvořeny, je silným dynamickým prvkem jazyka Javascript. Dejme tomu, že používáme externí knihovnu a chceme opravit bug v jedné metodě. Můžeme samozřejmě přímo opravit kód. Co když ale chceme opravu zveřejnit ve fóru, ba co hůř, distribuovat v samostatném souboru jako dočasný fix? Pokud je metoda definovaná přes prototype, je oprava snadná.

SomeLibrary.SomeClass.prototype.buggyMethod = function() {
    // nový kód
};

Co by se však stalo, kdyby konstruktor třídy SomeClass vypadal takto?

var SomeClass = function() {
    // takhle metody nikdy nedefinovat!
    this.buggyMethod = function() {};
};

Každá instance SomeClass by přepsala buggyMethod ze SomeClass.pro­totype. Oprava z vnějšku by byla nemožná. Museli bychom zasáhnout přímo do konstruktoru. Pokud bychom v konstruktoru definovali takto všechny metody (a takových příkladů je internet plný), uzavřeli bychom si cestu k pozdějším změnám. A hlavně bychom vytvářeli spoustu metod zbytečně, zas a znova, pro každou instanci zvlášť.

Daniel Steigerwald nabízí školení a konzultace JavaScriptu. Bližší informace zájemci naleznou na daniel.steiger­wald.cz

Na co si dát u prototype pozor

Prototype je mocný nástroj, a jako každý takový, může napáchat dost škody. Ukážeme si dva  příklady. 

Modifikace Object.prototype je zlo

Object je v hierarchii všech typů nejvýše, ale object se používá také jako datový typ, asociativní pole, kde klíčem je řetězec (string) a hodnotou cokoliv. Zde vidíme dva způsoby, jak objekt vytvořit:

var obj1 = {};
var obj2 = new Object();
alert(obj1.constructor === obj2.constructor); // true

Příklad: http://jsfiddle­.net/QDwdW/

Co se stane, když se rozhodneme, že úplně každý objekt by měl mít nějakou tu šikovnou metodu, třeba alert?

// tohle nikdy nedělejte
Object.prototype.alert = function() {
    alert(this);
};
(5).alert();
"Modifikace Object.prototype je zlo".alert();​

Příklad: http://jsfiddle­.net/kUTMv/

Jak vidíme, možnost přidat metodu všem objektům je fantastická. Má pouze dvě malé chyby. Za prvé tím rozbijeme všechny enumerace, a za druhé: rozbijeme tím všechny enumerace. Teoreticky jde o jednu a tu samou chybu, nicméně chyba je natolik závažná, že jsem ji považoval za nutné zmínit dvakrát.

// tohle nikdy nedělejte
Object.prototype.alert = function() {
    alert(this);
};
var styles = { color: 'red', height: 20 };
// vypíše true
alert(styles.alert != null)
for(var style in styles) {
    alert(style + ' ' + styles[style]);
}

Příklad: http://jsfiddle­.net/sA4Rs/

Cyklus for in při enumeraci prochází i klíče, které byly přidány do Object.prototype, takže pokud styles přiřadíme nějakému elementu, nastavíme mu krom barvy a šířky i alert, což asi nechceme. Že modifikovat Object.prototype je zlo, bylo napsáno na mnoha a mnoha místech. Bohužel, na mnoha a mnoha jiných místech, i v jinak dobrých článcích, tomu bylo naopak.

Kdy ještě může ještě být modifikace prototype nebezpečná?

Předchozí příklad naznačil, že pomocí prototype můžeme rozšiřovat existující třídy. Co kdybychom třeba poli přidali metodu each?

Array.prototype.each = function(fn, context) {
    for(var i = 0, l = this.length; i < l; i++) {
        fn.call(context || this, this[i], i, this);
    }
};
['a', 'b', 'c'].each(function(item) {
    alert(item);
});

Příklad: http://jsfiddle­.net/SdBhb/

Tento způsob je bezpečný pouze pokud zaručíme, že v aplikaci bude vždy jedině náš vlastní kód. Mohlo by se totiž stát, že i někoho jiného napadne přidat poli metodu each. V tom případě by byla naše vlastní implementace přepsána, případně my bychom přepsali each někomu jinému. Prohlížeč jako platforma pro vývoj webových aplikací je typicky heterogenní prostředí. Lidsky řečeno: widget napsaný nad knihovnou Mootools a jiný, napsaný pomocí knihovny PrototypeJS, spolu v jedné stránce fungovat nebudou. Existuje řada Javascriptových knihoven, ale jen tyto dvě (z těch, co stojí za řeč) modifikují prototype nativních typů.

Nativní typy

Použil jsem nový výraz, co znamená? Nativní typy jsou ty, které jsou v prohlížeči vestavěné. Vyjmenujme si ty, které se nachází ve všech prohlížečích: Array, Boolean,
Date, Error, Function, Number, Object, RegExp, String
. Typů je mnohem více, většinu z nich však podporuje až Internet Explorer 8. Názvy nejsou nic jiného než reference na konstruktory.

alert([].constructor === Array); // true​

Příklad: http://jsfiddle­.net/DjFus/

Pamatujme si, že jejich prototype bychom neměli modifikovat, pokud si nechceme zadělat na konflikty. Naopak zcela bezpečně lze modifikovat prototype tříd vlastních nebo tam, kde víme, že jiný než náš Javascript nepoběží (zpravidla Ja­vascript na serveru).

Implementace dědičnosti

Konečně se dostáváme k hlavnímu tématu, implementaci dědičnosti. Jak řekneme Javascriptu,  aby se třída Employee stala potomkem třídy Person? Jelikož nejsme ve Star Treku, nijak. Musíme mu to napsat. A předtím to někde vyčíst. V mnoha učebnicích i článcích je doporučován tento, sice funkční, ale jinak špatný, způsob:

// učebnicový nepraktický příklad
var Parent = function() {};
var Child = function() {};
Child.prototype = new Parent(); // voláme konstruktor, to nechceme

Důležitý je poslední řádek. Vzpomeňme, co jsme si řekli o operátoru new, totiž že každá změna prototype se ihned projeví na všech instancích. Přiřadíme-li tedy instanci Parent do prototype Child, máme prototypovou dědičnost. 

Proč jsem označil příklad jako nepraktický? Jak vidíme, při každém dědění se volá konstruktor Parent. To rozhodně nechceme, protože ten může třeba alokovat zdroje. Existuje lepší způsob – ale nejprve si obě třídy ukažme:

// deklarace třídy Person
var Person = function(name) {
    this.name = name;
};

Person.prototype.getName = function() {
    return this.name;
};

// deklarace třídy Employee

var Employee = function(name, salary) {
    // tady bych rád zavolal konstruktor Person, a předal mu name
    this.salary = salary;
};

Employee.prototype.getSalary = function() {
    return this.salary;
};

Employee.prototype.getName = function() {
    // tady bych rád zavolal přepsanou metodu Person getName
};

Jak se třída Employee stane potomkem třídy Person? Pamatujete co jsme si říkali o dědění objektů? Nic nám nebrání podědit prototype, třeba pomocí takovéto funkce:

var extends = function(child, parent) {
    // F je pomocný dočasný konstruktor
    var F = function() { };
    F.prototype = parent.prototype;
    child.prototype = new F();
}; 

// příklad volání
extends(Employee, Person);

Kompletní příklad

Nyní si ukážeme kompletní příklad Javascriptové dědičnosti, včetně volání přepsané metody. Mimochodem, použitá technika je navlas stejná jako ta, kterou Google používá ve své vlastní, nedávno zveřejněné, Javascriptové knihovně Google Closure.

// pomocná funkce pro dědění
var extends = function(child, parent) {
    // F je pomocný dočasný konstruktor
    var F = function() {};
    F.prototype = parent.prototype;
    child.prototype = new F();
    // konvence pro volání přepsaných metod
    child._superClass = parent.prototype;
    // dobrým zvykem je, aby instance odkazovala na svůj konstruktor
    child.prototype.constructor = child;
};

// třída Person
var Person = function(name) {
    this.name = name;
};

Person.prototype.getName = function() {
    return this.name;
};
// třída Employee
var Employee = function(name, salary) {
    // zavoláme bázový konstruktor
    Person.call(this, name);
    this.salary = salary;
};

// podědíme
extends(Employee, Person);

Employee.prototype.getSalary = function() {
    return this.salary;
};

// přepisujeme metodu z třídy Person
Employee.prototype.getName = function() {
    // voláme přepsanou metodu
    var name = Employee._superClass.getName.call(this);
    return name + ' (zaměstnanec)';
};

// vytvoříme instanci
var joe = new Employee('Joe', 1000);

// zkusmo přidáme metodu bázové třídě Person
Person.prototype.setName = function(name) {
    this.name = name;
};

// a teď tuto metodu vyzkoušíme na instanci Employee
joe.setName('Pepa');

// všechny tyto testy musí projít
alert([

    joe.getName() == 'Pepa (zaměstnanec)',
    joe.getSalary() == 1000,

    joe instanceof Person,
    joe instanceof Employee,

    typeof Employee == 'function',
    typeof joe == 'object',

    joe.constructor == Employee,
    Employee._superClass == Person.prototype

]);

Příklad: http://jsfiddle­.net/Eu8U8/

Takto se v Javascriptu správně implementuje dědičnost. Za zmínku stojí pomocná funkce extends. Javascriptu by sice pro vyjádření dědičnosti slušelo klíčové slovo, ale jak uvidíme později, obejdeme se pohodlně i bez něj. Funkce extends zakládá řetěz prototypové dědičnosti (prototype chain).

Volání přepsaných metod a konstruktorů

V konstruktoru Employee vidíme volání konstruktoru Person. V metodě getName pak volání přepsané metody. Je dobré si uvědomit, že volání přepsané metody:

var name = Employee._super­Class.getName­.call(this);

je pouze konvence. Klidně bychom mohli napsat:

var name = Person.prototy­pe.getName.ca­ll(this);

… nebo bychom mohli zavolat i úplně jinou metodu:

var name = Object.prototy­pe.toString.ca­ll(this);

Všimněme si volání metody call. Tím doslova říkáme: zavolej tuto metodu nad touto instancí. Tady bychom, stejně jako inženýři z Googlu, mohli skončit – a také pro dnešek skončíme. Téma by však nebylo kompletní, kdybychom si neřekli, jak lze zápis třídy i dědičnosti zjednodušit, jaké jsou další objektové techniky (agregace, mixování) a jak se k problému staví ostatní Javascriptové knihovny. Právě to bude námětem poslední části.

Nepřehlédněte!

Autor článku Daniel Steigerwald vystoupí s přednáškou na téma Třídy, dědičnost a OOP v Javascriptu na letošní konferenci Internet Developer Forum 2010. Přijďte si jej (a samosebou i další přednášející) poslechnout a zeptat se jich na to, co vás zajímá, ve středu 7. dubna do Národní technické knihovny (registrace nutná).

Independent software gardener, libertarian, web applications consultant and trainer. Google Developer Expert since 2012.

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 86

Přehled komentářů

jarda Hezký
Ondřej Žára Hezký článek
kvr Re: Hezký článek
kvr Re: Hezký článek
Daniel Steigerwald Re: Hezký článek
Daniel Steigerwald Re: Hezký článek
Ondřej Žára Re: Hezký článek
Daniel Steigerwald Re: Hezký článek
Ondřej Žára Re: Hezký článek
karf Bezva
Daniel Steigerwald Re: Bezva
EskiMag Chybička se vloudila
Daniel Steigerwald Re: Chybička se vloudila
fanoush mercedes s tmavymy skly a rtenka na zrcadlo :-)
Aleš Roubíček Re: mercedes s tmavymy skly a rtenka na zrcadlo :-)
Karel Re: mercedes s tmavymy skly a rtenka na zrcadlo :-)
fanoush Re: mercedes s tmavymy skly a rtenka na zrcadlo :-)
Bauglir Rozšiřování vestavěných typů
Bauglir Re: Rozšiřování vestavěných typů
Daniel Steigerwald Re: Rozšiřování vestavěných typů
v6ak Re: Rozšiřování vestavěných typů
Karel Výborný druhý (první) díl
MD Re: Výborný druhý (první) díl
cavo chyba
Daniel Steigerwald Re: chyba
Matěj Konečný Pěkný článek
Matěj Konečný Re: Pěkný článek
Michal Augustýn pěkné
junix Zklamani
David Grudl Re: Zklamani
kvr Re: Zklamani
v6ak Re: Zklamani
Daniel Steigerwald Re: Zklamani
Ondřej Žára Re: Zklamani
Daniel Steigerwald Re: Zklamani
Michal Augustýn Re: Zklamani
Daniel Steigerwald Re: Zklamani
Michal Augustýn Re: Zklamani
David Grudl Re: Zklamani
Michal Augustýn Re: Zklamani
junix Re: Zklamani
Daniel Steigerwald Re: Zklamani
junix Re: Zklamani
Ondřej Žára Re: Zklamani
kvr Re: Zklamani
junix Re: Zklamani
junix Re: Zklamani
kvr Re: Zklamani
junix Re: Zklamani
junix Re: Zklamani
Michal Augustýn Re: Zklamani
junix Re: Zklamani
David Grudl Re: Zklamani
MD Re: Zklamani
olin Re: Zklamani
Daniel Steigerwald Re: Zklamani
Michal Augustýn Re: Zklamani
junix Re: Zklamani
Michal Augustýn Re: Zklamani
keff Nádhera, díky! (a Universal Design Pattern)
David Grudl IE a DOM element jako prototyp
Daniel Steigerwald Re: IE a DOM element jako prototyp
David Grudl Re: IE a DOM element jako prototyp
junix Re: IE a DOM element jako prototyp
Daniel Steigerwald Re: IE a DOM element jako prototyp
Daniel Steigerwald Re: IE a DOM element jako prototyp
Aleš Roubíček Re: IE a DOM element jako prototyp
junix Obrazky pro ilustraci
tuft 3.možnost
Michal Augustýn Re: 3.možnost
Daniel Steigerwald Re: 3.možnost
kvr Re: 3.možnost
Ondřej Žára Re: 3.možnost
Michal Augustýn Re: 3.možnost
Michal Augustýn Re: 3.možnost
Daniel Steigerwald Re: 3.možnost
keff Re: 3.možnost
junix Re: 3.možnost
tuft Re: 3.možnost
tomFlidr Volání vlastních metod třídy v intervalu.
Daniel Steigerwald Re: Volání vlastních metod třídy v intervalu.
tomFlidr Re: Volání vlastních metod třídy v intervalu.
v6ak Re: Volání vlastních metod třídy v intervalu.
tomFlidr Re: Volání vlastních metod třídy v intervalu.
aichi Re: Volání vlastních metod třídy v intervalu.
nikdo extends je rezervované slovo
Zdroj: https://www.zdrojak.cz/?p=3192