Objektový JavaScript pohledem Javisty, dědičnost

Následující příspěvek navazuje na část zabývající se objekty v JavaScriptu. Opět je založen především z popisu na stránkách Mozzily. V JS světě existuje více přístupů k řešení dědičnosti(!), ale od toho v Javě se vždy něčím liší a není vhodné snažit se s JS pracovat s objekty vždy stejně jako v Javě.

Prototyp a dědičnost

JS objekty jsou založen na prototypu, kdy se oproti Javě tolik nerozlišuje rozdíl mezi třídou a objektem (instancí třídy). Objekty jsou vytvářeny pomocí prototypu jakožto jeho šablony. Narozdíl od Javy můžeme s vlastnostmi vytvořeného objektu dále manipulovat a jakýkoliv objekt může mít své vlastnosti, kterými se liší od ostatních včetně těch, které byly vytvořeny stejným konstruktorem. Sdílení vlastností mezi objekty (podobné tomu, jak jej známe u tříd v Javě) a především dědičnost jsou založena na prototypu, který je “jen” další vlastností asociovanou s objektem.

Prototyp (Prototype)

V JS je každý objekt asociován s objektem prototype, který nese vlastnosti třídy (říká, jak má vypadat objekt). Vlastnosti dané třídy jsou zapouzdřeny v prototypu, proto aby mohly být děděny.

Pozn. funkce je také objekt, a tak i funkce má prototype.

Pokud přidáme novou vlastnost přímo objektu, jiný objekt stejné třídy ji mít nebude, ale vlastnosti celé třídy lze modifikovat, a to přes prototyp.

Př. přidání vlastnosti objektu vs. přidání třídě:

function Person() {
    this.gender = 'male';
}

                            
var boy1 = new Person();
boy1.age = 15; // novou vlastnost bude mít jen tento objekt
alert(boy1.age); // 15
var boy2 = new Person();
alert(boy2.age); // undefined
vs. 
Person.prototype.age = 0;
var boyA = new Person();
alert(boyA.age); // 0
var boyB = new Person();
alert(boyB.age); // 0

Prototyp lze zpřístupnit metodou Object.getPrototypeOf(obj). Metoda je ale až od ECMAScript 2015, dříve standardizovaný způsob nebyl (ačkoliv ve většině prohlížečů funguje přístup přes vlastnost __proto__)

Každý objekt má prototyp a na prototypu je založena dědičnost v JS - potomek pomocí něj dědí vlastnosti předka. Prototype se řetězí (tzv. prototype chain) a nabízí vlastnosti svých předků. Na konci je Object.prototype (resp. nad ním je už pouze null).

Object.getPrototypeOf((boy1)) === Person.prototype;
Object.getPrototypeOf((boy1)) !== Object.prototype;
Object.getPrototypeOf(Object.getPrototypeOf(boy1)) === Object.prototype;

                            
Object.getPrototypeOf(new Object) === Object.prototype;
Object.getPrototypeOf(Object.getPrototypeOf((new Object))) === null;

Pokud je vlastnost jak na objektu, tak na prototypu, pak se při volání tečkovou notací upřednostní vlastnost z objektu, poté se prohledává prototype této třídy a dále postupně prototype předků. Pokud není nalezena ani v řetězci prototypů, pak je undefined.

function Animal() {
  this.a = 'A';
}
Animal.prototype.a = 'A-prototype';
Animal.prototype.b = 'B-prototype';

                            
var animal = new Animal();
alert(animal.a); // A
alert(animal.b); // B-prototype

Volání new na pozadí vytvoří objekt typu Object a následně do jeho interní proměnné [[prototype]] zkopíruje vlastnosti dané konstruktorem.

Prototyp také umožňuje velkou dynamičnost JS a mj. snadnou úpravu knihovny třetí strany (vč. oprav chyb). Neměli bychom však měnit prototypy nativního JavaScriptu, především Object.prototype, protože tím například způsobíme problém s enumeracemi.

Prototypová dědičnost

Pomocí prototypu se definují právě ty vlastnosti, které mají být viditelné pro potomky.

Prototypová dědičnost ale není totéž co objektová dědičnost. Oproti Javě se tak viditelnost vlastností neshoduje s těmi, které má viditelné předek. Tzn. na objektu potomka nemusí být možné zavolat metodu, kterou má předek, pokud tato není součástí jeho prototypu. Dokonce vestavěná třída Object přes prototype zveřejňuje jen některé metody pro své potomky (např. valueOf) a jiné metody (např. key, is) jsou pouze pro instance Object a pro potomky nikoliv. Nejde tedy o klasickou dědičnost jako ji známe z Javy, kdy lze instanci potomka považovat také za instanci předka. To nám na druhou stranu tolik nevadí, protože JS nemá typovou kontrolu.

K dosažení prototypové dědičnosti potřebujeme:

  1. Děděné metody předka definujeme přes prototype.
  2. Předáme potomkovi konstruktor předka pomocí funkce call.
  3. Nastavíme prototyp předka a odkaz na správný konstruktor

ADD 1: Děděné metody předka definujeme přes prototype

Metody, které mají být děděny, definujeme přes prototype na konstruktoru (místo přes this uvnitř konstruktoru).

function Person(first, last, age) {
  this.name = {
     first : first,
     last : last
  };
  this.age = age;
}
Person.prototype.greeting = function() {
  return "Hi " + this.name.first;
};

Definovat metody mimo konstruktor může také vést k lepší přehlednosti. Naopak proměnné je potřeba definovat uvnitř konstruktoru, protože vně funkcí this odkazuje na globální kontext a nikoliv tento objekt, proto nebude například fungovat Person.prototype.fullName = this.name.first + ' ' + this.name.last; a vrátí undefined undefined. Tento přístup patří k jednomu z návrhových vzorů, jak v JS objektový kód psát.

ADD 2: Předáme potomkovi konstruktor předka pomocí funkce call

Vlastnosti třídy jsou v JS definovány uvnitř konstruktoru (prototype.constructor), a proto právě konstruktor předka chceme předat potomkovi. Uděláme to pomocí metody call. Tuto metodu umožňuje volat požadovanou metodu v kontextu předaného this a s předanými argumenty. V tomto případě použijeme metodu call na konstruktoru předka, kterému kromě this předáme z potomka argumenty k naplnění vlastností předka (pokud konstruktor není bezparametrický, pak by měla metoda call pouze parametr this).

function Teacher(first, last, age, subject) {
  Person.call(this, first, last, age);
  this.subject = subject;
}
var t = new Teacher('Jan','Komenský', 30, 'Biologie'); 
alert(t.name.last); // Komenský

Získáme tím přístup ke konstruktoru a proměnným předka, ale nikoliv k metodám.

ADD 3: Nastavíme prototyp předka a odkaz na správný konstruktor

Zatím nám v prototypu chybí metody z prototypu předka. Ty zpřístupníme následovně:

Teacher.prototype = Object.create(Person.prototype);

to ale způsobí, že bude chybně odkazován konstruktor. Ten je totiž proměnnou prototypu (prototype.constructor). To napravíme následovně

Object.defineProperty(Teacher.prototype, 'constructor', { 
    value: Teacher, 
    enumerable: false, // so that it does not appear in 'for in' loop
    writable: true });

Někdy bývá místo nastavení prototypu pomocí Object.create použito volán konstruktor předka (např. Teacher.prototype = new Person;). Tomuto přístup je lépe vyhnout, protože s sebou nese nedostatky spojené s tím, že se konstruktor vždy zavolá, a to dokonce jen tím, že je v potomkovi definován, což může vést k nechtěnému chování.

Kompletní příklad

Třída Person a její potomek Teacher:

function Person(first, last, age) {
  this.name = {
     first : first,
     last : last
  };
  this.age = age;
}
function Teacher(first, last, age, subject) {
  Person.call(this, first, last, age);
  this.subject = subject;
}
Teacher.prototype = Object.create(Person.prototype);
Teacher.prototype.constructor = Teacher;

Překrytí metody

Potomek může překrývat metody předka - tím, že se přednostně prohledává prototyp potomka.

Teacher.prototype.greeting = function() {
 alert('Dear ' + this.name.first);
};

Jednonásobná dědičnost

V prototypové dědičnosti JS není podobně jako v Javě možná vícenásobná dědičnost, protože každý objekt má je jeden prototype a ten má zase jen jeden prototype (tj. jednoho předka). Výjimkou je Object.prototype, který už prototyp nemá žádný.

Objektová dědičnost

Dědičnost jako ji známe z Javy, kdy lze instanci potomka považovat také za instanci předka, lze implementovat “ručně” nebo použít rozšíření JS jakým je například TypeScript.

Definice třídy pomocí Class

JS umožňuje definovat třídu (pomocí konstruktů jako class, constructor, static, extends a super) podobně jako Java, ale až od verze ECMAScript 2015 a podpora je pouze v nových prohlížečích (není například v žádném IE, ale až v Edge), kdežto předešlý postup funguje i pro starší prohlížeče.

Na pozadí je stále prototypová dědičnost a jde jen o “syntaktický cukr”.