Continuiamo il discorso sulla programmazione a oggetti in Javascript. Come al solito i sorgenti degli esempi che accompagnano l'articolo sono disponibili sul sito (Javascript-Oggetti).
Nell'articolo precedente (Javascript: Esempi di Programmazione a Oggetti) abbiamo utilizzato una funzione costruttore per creare oggetti auto. Supponiamo ora di voler far gestire alla nostra ipotetica applicazione Javascript un oggetto aereo. Ci accorgiamo che la funzione costruttore Veicolo va quasi bene nel senso che attributi quali passeggeri e velocità e un metodo carica() sono appropriati anche per un aereo. Ma cosa succederebbe se volessimo gestire un attributo altitudine ed un metodo decolla()?
Si potrebbe pensare semplicemente di aggiungerli al costruttore
function Veicolo(passeggeri) {
this.velocita = 0;
this.passeggeri = 0;
this.altitudine = 0;
this.decolla = function() {
this.velocita = 100;
this.altitudine = 10;
}
/* ... */
}
Non è una buona idea. Una volta modificato il costruttore in questo modo diventa possibile scrivere
var auto = new Veicolo(2); auto.decolla();
Che chiaramente non ha senso, tranne forse per l'auto di 007.
L'attributo e il metodo aggiunti hanno reso il costruttore Veicolo troppo specifico. In questo modo può essere utilizzato per veicoli volanti, ma non più per veicoli terrestri.
D'altra parte se abbandonassimo l'oggetto veicolo per creare due oggetti nuovi (auto e aereo) con relative funzioni costruttore distinte e separate, dovremmo necessariamente duplicare tutto il codice necessario a gestire gli attributi e metodi comuni (passeggeri, velocità e carica()).
Abbiamo due oggetti con un certo numero di attributi e metodi in comune ed altri individuali e specifici.
Chi è pratico di altri linguaggi di programmmazione potrà riconoscere in questa situazione un caso tipico che può essere risolto sfruttando l'ereditarietà tra classi: creando cioè una classe base, dove vengono definiti gli attributi e metodi comuni ai due oggetti, e classi derivate che, oltre a definire attributi e metodi specifici, ereditano gli attributi e metodi della classe base.
L'oggetto prototype
Tornando a Javascript si è già visto che in questo linguaggio non esistono classi (vedi articolo precedente sopra citato) e gli oggetti sono creati a partire da funzioni costruttore. Per cui come possiamo risolvere la situazione se non è possibile definire classi base e classi derivate?
Ogni funzione in Javascript possiede un attributo prototype che si riferisce ad un oggetto prototype. All'oggetto prototype possiamo aggiungere attributi e metodi come potremmo fare con qualsiasi altro oggetto. Solo che l'oggetto prototype non è come tutti gli altri in quanto la sua funzione è fare da modello (prototipo appunto) per la creazione di altri oggetti.
La cosa in estrema sintesi funziona così. Ogni attributo e metodo aggiunto ad un oggetto prototype viene reso disponibile a tutti gli oggetti creati dalla funzione costruttore a cui l'oggetto prototype è attribuito.
Anche gli oggetti di sistema (come gli array) sono creati da funzioni costruttore che espongono un attributo prototype. Eccone un esempio di utilizzo.
Aggiungeremo all'oggetto Array un metodo sum() che ritorni la somma di tutti gli elementi dell'array ignorando eventuali elementi non numerici (esempio 1 codice dimostrativo).
var a = new Array(3, 2, 6);
Array.prototype.sum = function() {
var r = 0;
for(var i = 0; i < this.length; i++) {
if(typeof(this[i]) == 'number') {
r += this[i];
}
}
return r;
}
document.write(a.sum()); //Risultato 11
Come si vede anche gli oggetti già creati acquisiscono il nuovo metodo.
Nell'esempio appena visto abbiamo aggiunto un singolo metodo ad un oggetto prototype, ma niente ci vieta di rimpiazzare completamente un oggetto prototype con un nuovo oggetto appositamente creato.
Questo è il risultato. Data una funzione F ( function F(){...} ) ed un oggetto p, se assegnamo p come nuovo prototipo di F (F.prototype=p), gli oggetti che saranno (o sono stati già) creati utilizzando la funzione F come costruttore avranno disponibili, e potranno utilizzare come propri, tutti gli attributi e metodi di p.
Ereditarietà tramite prototype
Ecco quindi come fare a costruire l'oggetto aereo senza riscrivere gli attributi e metodi già definiti in veicolo (esempio 2 codice dimostrativo).
Il costruttore Veicolo già visto nell'articolo precedente (con qualche piccola modifica per eliminare un paio di righe di codice duplicato) è questo
function Veicolo(passeggeri) {
this.velocita = 0;
this.passeggeri = 0;
this.carica = function(passeggeri) {
if(passeggeri > 0) {
this.passeggeri += passeggeri;
}
}
this.carica(passeggeri);
}
Creiamo una funzione costruttore con gli attributi e metodi specifici dell'oggetto aereo.
function Aereo(passeggeri) {
this.carica(passeggeri);
this.altitudine = 0;
this.decolla = function() {
this.velocita = 100;
this.altitudine = 10;
}
}
sostituiamo l'oggetto prototype di Aereo con un nuovo oggetto creato con la funzione Veicolo
Aereo.prototype = new Veicolo();
Fatto questo possiamo scrivere
var jumbo = new Aereo(250); jumbo.decolla(); document.write(jumbo.velocita); // 100 document.write(jumbo.altitudine); // 10 document.write(jumbo.passeggeri); // 250
Si possono notare due cose
- il metodo decolla() definito nella funzione costruttore Aereo può accedere e modificare l'attributo velocita definito nella funzione costruttore Veicolo.
- il metodo carica() può essere invocato all'interno della funzione Aereo per inizializzare l'attributo passeggeri.
Quindi ogni oggetto creato da Aereo eredita tramite prototype attributi e metodi definiti nella funzione costruttore Veicolo. Che è quello che ci serviva.
Ripristino constructor nell'oggetto derivato
Ogni oggetto in Javascript possiede un attributo constructor che si riferisce alla funzione costruttore utilizzata per creare l'oggetto. Un effetto collaterale della sostituzione dell'oggetto prototype in una funzione è che in tutti gli oggetti creati utilizzando quella funzione come costruttore, l'attributo constructor si riferirà alla funzione utilizzata per creare l'oggetto che è divenuto il nuovo prototype della funzione.
Ecco perché dopo
Aereo.prototype = new Veicolo();
dobbiamo aggiungere
Aereo.prototype.constructor = Aereo;
altrimenti tutti gli oggetti creati da Aereo avranno l'attributo constructor che si riferisce a Veicolo. Vedi anche esempio 3 del codice dimostrativo.
Ricordatevi di fare questa cosa senza farvi (e soprattutto farmi!) troppe domande e vi troverete sempre bene. Scherzi a parte, mi limito a sottolineare che questa cosa accade, chi avesse la curiosità di sapere perché accade può trovare maggiori dettagli nei riferimenti al termine dell'articolo.
Variabili private
Anche se abbiamo predisposto un metodo carica() l'attributo passeggeri è accessibile pubblicamente per cui niente vieta di scrivere per esempio
jumbo.passeggeri = -7;
Per impedire che all'attributo siano assegnati valori incongrui possiamo modificare il costruttore Veicolo in questo modo (vedi esempio 4 codice dimostrativo).
function Veicolo(passeggeri) {
this.velocita = 0;
var p_passeggeri = 0;
this.carica = function(passeggeri) {
if(passeggeri > 0) {
p_passeggeri += passeggeri;
}
}
this.scarica = function(passeggeri) {
if(passeggeri < 0) { return; }
p_passeggeri -= passeggeri;
if(p_passeggeri < 0) {
p_passeggeri = 0;
}
}
this.numeroPasseggeri = function() {
return p_passeggeri;
}
this.carica(passeggeri);
}
L'attributo passeggeri è stato rimosso. Al suo posto si è dichiarata una variabile privata
var p_passeggeri = 0;
Solo i metodi all'interno di Veicolo, carica(), scarica() e numeroPasseggeri(), possono accedervi. L'unico controllo che viene fatto è che il contatore non diventi mai minore di zero. Potete divertirvi (a dire la verità mi vengono in mente diversi divertimenti più divertenti) a perfezionare i metodi in modo che sia impossibile scaricare o caricare frazioni di passeggero.
Miglioriamo l'applicazione di esempio
Arrivati a questo punto Veicolo contiene un contatore che indica il numero dei passeggeri caricati. Vogliamo rendere l'applicazione di esempio più sofisticata creando una nuova funzione costruttore Persona e modificando il metodo carica() in modo che accetti come argomento un oggetto persona. Il metodo scarica() servirà a far scendere un numero a piacere di passeggeri secondo la regola dell'ultimo a salire è il primo a scendere. Veicolo manterrà al suo interno non più un contatore, ma un array di oggetti persona (i passeggeri) ed avrà un nuovo metodo (listaPasseggeri()) con la funzione di stampare una lista di informazioni sui passeggeri.
Va da sè che il nuovo metodo, come tutti gli altri, sarà ereditato da tutti gli oggetti creati con funzioni derivate da Veicolo, nel nostro caso, per il momento, solo Aereo.
Per prima cosa creiamo la funzione costruttore Persona.
function Persona(nome) {
this.nome = nome;
}
Modifichiamo Veicolo in questo modo
function Veicolo(passeggero) {
this.velocita = 0;
var p_passeggeri = [];
this.carica = function(passeggero) {
if(typeof(passeggero) == 'object') {
p_passeggeri.push(passeggero);
}
}
this.scarica = function(n) {
if(n <= 0) { n = 1; }
if(n > p_passeggeri.length) {
n = p_passeggeri.length;
}
for(var i=0; i < n; i++) {
p_passeggeri.pop();
}
}
this.numeroPasseggeri = function() {
return p_passeggeri.length;
}
this.listaPasseggeri = function() {
for(var x=0; x < p_passeggeri.length; x++) {
document.write('#' + p_passeggeri[x].nome + '<br />');
}
}
this.carica(passeggero);
}
e Aereo così
function Aereo(passeggero) {
this.carica(passeggero);
/* il resto non cambia */
}
Aereo.prototype = new Veicolo();
Aereo.prototype.constructor = Aereo;
Sembrerebbe tutto a posto.
In effetti se ci limitiamo a caricare e scaricare passeggeri da un singolo oggetto aereo tutto funziona. Se però creiamo un secondo oggetto ci aspetta una sorpresa.
Consideriamo il codice seguente
var tizio = new Persona('Tizio');
var caio = new Persona('Caio');
var dc9 = new Aereo();
var jumbo = new Aereo();
jumbo.carica(tizio);
dc9.carica(caio);
document.write(jumbo.numeroPasseggeri()); // 2
jumbo.listaPasseggeri(); // #Tizio #Caio
document.write(dc9.numeroPasseggeri()); // 2
dc9.listaPasseggeri(); // #Tizio #Caio
Si capisce chiaramente che un passeggero caricato su uno degli oggetti aereo appare nella lista passeggeri anche dell'altro! I due oggetti hanno l'array (p_passeggeri[]) in comune (vedi anche esempio 5).
Questo dipende dal meccanismo utilizzato per gestire l'ereditarietà in Javascript. Quando scriviamo
Aereo.prototype = new Veicolo();
una singola istanza di un oggetto Veicolo sarà condivisa (tramite prototype) da tutti gli oggetti creati da Aereo.
Per risolvere il problema bisogna modificare il costruttore Aereo in questo modo
function Aereo(passeggeri) {
Veicolo.call(this,passeggeri);
this.carica(passeggeri);
/* il resto invariato */
}
call esegue la funzione costruttore Veicolo nello spazio di visibilità (scope) di this, cioè dell'oggetto aereo che stiamo creando. In questo modo p_passeggeri (e anche il resto) è ricreata nello spazio di visibilità di ogni singola istanza di Aereo (vedi anche esempio nella documentazione di call).
La vecchia chiamata this.carica(passeggeri) in Aereo non serve più perché a questo punto è l'omonimo metodo in Veicolo ad occuparsi dell'inizializzazione dell'array p_passeggeri come è giusto che sia visto che fin dall'inizio di questo lungo discorso l'attributo passeggeri è sempre stato di 'competenza' di Veicolo.
Il tutto può non essere tanto facile da capire, l'importante è essere consapevoli del problema e della soluzione. Confrontare il codice dell'esempio 5 (problema) ed esempio 6 (soluzione) potrà aiutare.
Nei riferimenti trovate anche approcci leggermente diversi al problema, io credo che l'uso di call sia tutto sommato la soluzione più lineare.
Esempio finale
L'esempio 7 del codice dimostrativo mostra un modo in parte diverso di creare gli oggetti derivati: secondo questa impostazione il codice all'interno delle funzioni costruttore viene ridotto al minimo mentre attributi e metodi sono aggiunti all'oggetto prototype del costruttore.
In questo modo, ad esempio, il costruttore Aereo viene scritto così
function Aereo(passeggeri) {
Veicolo.call(this, passeggeri);
}
Aereo.prototype = new Veicolo();
Aereo.prototype.constructor = Aereo;
Aereo.prototype.altitudine = 0;
Aereo.prototype.decolla = function() {
this.velocita = 100;
this.altitudine = 10;
}
Si capisce facilmente che definendo metodi e attributi una volta per tutte nel prototype del costruttore si velocizza e ottimizza il processo di creazione delle singole istanze.
Non è sempre possibile seguire questa strada: i metodi carica(), scarica(), numeroPasseggeri(), listaPasseggeri() rimangono definiti internamente a Veicolo in quanto devono poter accedere alla variabile privata p_passeggeri.
L'esempio mostra inoltre come aggiungere un nuovo attributo al prototype del costruttore e come questo divenga disponibile anche alle istanze degli oggetti già creati. Rimando ai commenti nel codice per i dettagli.
Riferimenti
Siamo alla fine di questa panoramica sulla programmazione a oggetti in Javascript. L'ereditarietà realizzabile nei modi che abbiamo visto è comunemente definita ereditarietà prototipale per distinguerla da quella basata sulle classi propria di altri linguaggi.
Ho lasciato da parte molti dettagli, ma d'altra parte l'intenzione non era quella di fare una trattazione esaustiva di un argomento abbastanza complesso, quanto piuttosto mostrare qualche esempio pratico. Concludo però con alcuni riferimenti utili per chi avessa voglia di approfondire.
- Object Hierarchy and Inheritance in JavaScript Mostra un esempio completo di gerarchia di oggetti. Il modo usato per invocare il costruttore di un oggetto genitore dal costruttore di un oggetto figlio è leggermente diverso da quello che trovate negli esempi di codice allegati al presente articolo.
- OOP in JS Da notare che l'esempio di codice presentato all'inizio della parte II contiene un errore per le ragioni spiegate in Correct OOP for Javascript. In pratica è una situaziona analoga a quella vista nei nostri esempi di codice 5 e 6.
- Object Oriented Programming in JavaScript
