lunedì 28 gennaio 2008

Javascript: Oggetto prototype ed ereditarietà

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.

14 commenti:

stefano ha detto...

fantastico post,
veramente molto molto chiaro!

Anonimo ha detto...

Grazie,
i due articoli sono molto chiari ed utili.

Ciao

Domenico

Anonimo ha detto...

Ciao.

Ti meriti un "Grazie" per la documentazione chiara e ricca di esempi. Sei riuscito a seguire un approccio descrittivo didattico e (ritengo personalmente) corretto, dando prima di ogni spiegazione ulteriore gli elementi necessari a comprenderla.
Ottimo lavoro, complimenti !

Fabrizio.

Anonimo ha detto...

Ciao
Ringrazio anche io per la chiarezza e gli esempi.
Volevo solo fare una domanda perchè provando a scrivere il codice dell'esempio e ad eseguirlo, ho un valore undefined come ultimo nella listaPasseggeri.
Non capisco dove si genera questo undefined.
Se qualcuno può spiegarmelo mi farebbe veramente un grossissimo favore.
Grazie ancora
Ciao Elena

Massimo ha detto...

Io non vedo nessun undefined, ho provato per scrupolo su più browser. Non hai mica scritto a mano il codice o fatto il copia e incolla dall'articolo? Perché in questo caso è facile fare un errore. Prova a scaricare lo zip con gli esempi. Il link è all'inizio del post.

Se no posta esattamente il numero dell'esempio e in quale pacchetto si trova (sono 2: js-oggetti1.zip e js-oggetti2.zip)

Anonimo ha detto...

Ciao Massimo
Molto probabilmente ho sbagliato qualcosa io perchè ho scritto a mano il codice.
Dopo mi scarico lo zip con gli esempi e verifico.
Se non capisco ti chiedo ancora.
Grazie
Ciao Elena

Anonimo ha detto...

Ciao Massimo
Ho trovato il mio errore.
Invece di richiamare il metodo listaPasseggeri correttamente scrivevo:
document.write(jumbo.listaPasseggeri());
Anche se non capisco perchè stampava undefined dopo il nome del passeggero ...
Comunque grazie ancora
Ciao Elena

Anonimo ha detto...

Grandissima spiegazione,complimenti. Grazie mille

Antonio

Anonimo ha detto...

bravo davvero!

Anonimo ha detto...

sei un grande!!! l'ereditarietà prototipale del Javascript è una gatta da pelare anche per un programmatore smaliziato, in quanto si discosta dalla via "classica" di gestire gli oggetti (le classi di Java, per intenderci...), tuttavia l'hai spiegata benissimo! grande!

Anonimo ha detto...

Grande...ottima spiegazione

Anonimo ha detto...

E' difficile trovare trattazioni decenti sul js, la maggior parte sono porcherie obsolete senza un minimo di stile... non so perchè ma lo trattano tutti come se fosse un ammasso di funzioni da utilizzare come se niente fosse. Pochi capiscono la potenza di tutto ciò.

Però questo articolo è veramente un'eccezione.

Massimo ha detto...

Mi fa piacere che questo post scritto ormai più di tre anni fa riceva ancora commenti positivi. Grazie a tutti.

Anonimo ha detto...

molto molto interessante!

Posta un commento

Nota. Solo i membri di questo blog possono postare un commento.