26 dicembre 2021

Native prototypes

La proprietà "prototype" viene largamente utilizzata da JavaScript stesso. Tutti i costruttori integrati ne fanno uso.

Come prima cosa andremo ad analizzare questa proprietà nel dettaglio; in un secondo momento vedremo come utilizzarla per aggiungere nuove funzionalità agli oggetti integrati.

Object.prototype

Ipotizziamo di dover mostrare un oggetto vuoto:

let obj = {};
alert( obj ); // "[object Object]" ?

Da dove arriva il codice che genera la stringa "[object Object]"? E’ il metodo integrato toString, ma dove lo possiamo trovare? L’oggetto obj è vuoto!

…La notazione obj = {} equivale a obj = new Object(), dove Object è un costruttore integrato, in cui la proprietà prototype fa riferimento ad un oggetto con toString e altri metodi.

Questo è ciò che accade:

Quando viene invocato new Object() (o viene creato un literal object {...}), il suo [[Prototype]] viene impostato a Object.prototype, come abbiamo studiato nel capitolo precedente:

Quindi, quando obj.toString() viene invocato, il metodo viene cercato in Object.prototype.

Possiamo verificarlo in questo modo:

let obj = {};

alert(obj.__proto__ === Object.prototype); // true

alert(obj.toString === obj.__proto__.toString); //true
alert(obj.toString === Object.prototype.toString); //true

Da notare che non esistono ulteriori [[Prototype]] nella catena Object.prototype:

alert(Object.prototype.__proto__); // null

Altri prototypes integrati

Altri oggetti integrati, come Array, Date, Function ed altri, hanno i propri metodi in prototypes.

Ad esempio, quando creiamo un array [1, 2, 3], il costruttore di default new Array() viene invocato internamente. Quindi Array.prototype ne diventa il prototipo e fonisce i suoi metodi. Questo comportamento rende l’utilizzo della memoria molto efficiente.

Come definito nella specifica, tutti i prototype integrati hanno Object.prototype in cima. Questo è il motivo per cui alcune persone dicono che “tutto deriva dagli oggetti”.

Qui vediamo il quadro complessivo (di 3 oggetti integrati):

Proviamo a controllare il prototype manualmente:

let arr = [1, 2, 3];

// eredita da Array.prototype?
alert( arr.__proto__ === Array.prototype ); // true

// e successivamente da Object.prototype?
alert( arr.__proto__.__proto__ === Object.prototype ); // true

// e infine null
alert( arr.__proto__.__proto__.__proto__ ); // null

Alcuni metodi in prototype potrebbero essere stati sovrascritti, ad esempio, Array.prototype possiede una sua implementazione personalizzata di toString, che elenca gli elementi separandoli con una virgola:

let arr = [1, 2, 3]
alert(arr); // 1,2,3 <-- il risultato di Array.prototype.toString

Come abbiamo visto in precedenza, Object.prototype possiede una sua implementazione di toString, ma poiché Array.prototype è molto più vicina nella catena dei prototype, questa sarà la variante utilizzata.

Strumenti integrati nel browser, come la console che puoi trovare nei Chrome developer tools (strumenti per sviluppatori), mostrano l’ereditarietà (potreste utilizzare console.dir per gli oggetti integrati):

Gli altri ogetti integrati funzionano allo stesso modo. Anche le funzioni – poiché sono oggetti di un costruttore integrato Function, e i suoi metodi (call/apply e gli altri) sono presi da Function.prototype. Anche le funzioni possiedono una loro implementazione di toString.

function f() {}

alert(f.__proto__ == Function.prototype); // true
alert(f.__proto__.__proto__ == Object.prototype); // true, eredità da objects

Primitivi

La situazione è molto più complessa con strings, numbers e booleans.

Come abbiamo già visto, questi non sono oggetti. Ma se proviamo ad accedere alle loro proprietà, viene creato un oggetto temporaneo utilizzando i rispettivi costruttori String, Number e Boolean. Essi forniscono metodi e poi spariscono.

Questi oggetti vengono creati di “nascosto” e in realtà molti motori ottimizzano il loro utilizzo, ma la specifica li descrive in questo modo. I metodi di questi oggetti sono memorizzati nella proprietà del loro prototype, e sono disponibili tramite String.prototype, Number.prototype e Boolean.prototype.

I valori null e undefined non possiedono degli oggetti che li contengono

I valori speciali null e undefined si comportano diversamente. Non possiedono degli oggetti contenitori, quindi non avremmo a disposizione proprietà e metodi. E non avremmo nemmeno il propotype corrispondente.

Modificare i native prototypes

I Native prototypes possono essere modificati. Ad esempio, se aggiungiamo il metodo String.prototype, questo diventa disponibile a tutte le string:

String.prototype.show = function() {
  alert(this);
};

"BOOM!".show(); // BOOM!

Durante lo sviluppo, potremmo avere bisogno di nuovi metodi integrati che ci piacerebbe avere, e potremmo quindi essere tentati di aggiungerli ai native prototype. Generalmente questa è una pessima idea.

Importante:

I prototype sono globali, quindi è molto facile generare conflitti. Se due librerie differenti aggiungono un metodo String.prototype.show, allora uno dei due sovrascriverà l’altro.

Quindi, generalmente, modificare i native prototype viene considerata una cattiva pratica.

Nella programmazione moderna, esiste solamente un caso in cui è accettabile sovrascrivere i native prototype. Per fare polyfilling.

Polyfilling è una pratica che prevede di sostituire un oggetto definito nella specifica JavaScript, che non è ancora stato implementato da un particolare engine.

Possiamo implementarlo noi manualmente e popolare i prototype integrati con la nostra implementazione.

Ad esempio:

if (!String.prototype.repeat) { // se questo metodo non esiste
  // lo aggiungiamo al prototype

  String.prototype.repeat = function(n) {
    // ripetiamo la stringa n volte

    // in realtà, il codice dovrebbe essere leggermente più complesso di cosi
    // (l'algoritmo completo è descritto nella specifica)
    // ma generalemente anche un polyfill imperfetto è considerato sufficiente
    return new Array(n + 1).join(this);
  };
}

alert( "La".repeat(3) ); // LaLaLa

Prendere in prestito dai prototypes

Nel capitolo *Decorators* e forwarding, call/apply abbiamo parlato di come “prendere in prestito” metodi.

Questo avviene quando prendiamo un metodo da un oggetto e lo copiamo in un altro.

Alcuni metodi dei native prototype sono presi in prestito.

Ad esempio, se stiamo costruendo un oggetto simil-array, potremmo voler copiare alcuni metodi degli Array.

Esempio.

let obj = {
  0: "Hello",
  1: "world!",
  length: 2,
};

obj.join = Array.prototype.join;

alert( obj.join(',') ); // Hello,world!

Funziona perché l’algoritmo integrato del metodo join necessita solamente degli indici corretti e della proprietà length. Non va a verificare se un oggetto sia effettivamente un array. Molti metodi integrati si comportano in questo modo.

Un’altra possibilità è quella di ereditare di default obj.__proto__ da Array.prototype, quindi tutti i metodi di Array diventeranno automaticamente disponibili in obj.

Ma questo è impossibile se obj eredita già da un altro oggetto. Ricorda, possiamo ereditare solamente da un oggetto per volta.

La pratica di “prendere in prestito” i metodi è flessibile, consente di ereditare funzionalità da oggetti differenti se necessario.

Riepilogo

  • Tutti gli oggetti integrati seguono lo stesso comportamento:
    • I metodi vengono memorizzati nel prototype (Array.prototype, Object.prototype, Date.prototype, etc.)
    • L’oggetto memorizza solamente i dati (gli elementdi dell’array, le proprietà dell’object, la data)
  • I tipi di dato primitivi memorizzano i metodi nel prototype, utilizzando degli oggetti “contenitori”: Number.prototype, String.prototype e Boolean.prototype. Fanno eccezione undefined e null che non possiedono alcun oggetto contenitore.
  • I prototype integrati possono essere modificati o popolati con nuovi metodi. Ma questa è una pratica sconsigliata. L’unico caso in cui è accettabile aggiungere nuovi metodi è per fornire l’implementazione di funzionalità definite nella specifica JavaScript agli engines che ancora non le supportano.

Esercizi

importanza: 5

Aggiungi al prototype di tutte le funzioni il metodo defer(ms), che si occupa di eseguire la funzione dopo ms millisecondi.

Una volta fatto, il seguente codice dovrebbe funzionare:

function f() {
  alert("Hello!");
}

f.defer(1000); // mostra "Hello!" dopo 1 secondo
Function.prototype.defer = function(ms) {
  setTimeout(this, ms);
};

function f() {
  alert("Hello!");
}

f.defer(1000); // mostra "Hello!" dopo 1 secondo
importanza: 4

Aggiungi al prototype di tutte le funzioni il metodo defer(ms), il quale ritorna un wrapper (contenitore), che si occupa di invocare la funzione dopo ms millisecondi.

Qui vediamo un esempio di come dovrebbe funzionare:

function f(a, b) {
  alert( a + b );
}

f.defer(1000)(1, 2); // mostra 3 dopo 1 secondo

Da notare che gli argomenti devono essere passati alla funzione originale.

Function.prototype.defer = function(ms) {
  let f = this;
  return function(...args) {
    setTimeout(() => f.apply(this, args), ms);
  }
};

// controlla
function f(a, b) {
  alert( a + b );
}

f.defer(1000)(1, 2); // mostra 3 dopo 1 secondo

Da notare: utilizziamo this in f.apply per far sì che il nostro decorator funzioni con i metodi degli oggetti.

Quindi se la nostra funzione viene invocata come metodo di un oggeto, allora this viene passato al metodo originale f.

Function.prototype.defer = function(ms) {
  let f = this;
  return function(...args) {
    setTimeout(() => f.apply(this, args), ms);
  }
};

let user = {
  name: "John",
  sayHi() {
    alert(this.name);
  }
}

user.sayHi = user.sayHi.defer(1000);

user.sayHi();
Mappa del tutorial