INTRODUZIONE
Il Javascript, a differenza di altri linguaggi, non consente di ridefinire il comportamento degli operatori quando interagiscono con gli oggetti che definiamo nel nostro programma, ma ci permette di modificare il valore nel quale un oggetto viene trasformato.
Putroppo possiamo considerare questa opportunità solo un misero premio di consolazione, perché il controllo che possiamo ottenere è abbastanza limitato. Ad ogni modo potrebbe sempre tornarci utile conoscere quali possibilità il linguaggio ci offre, considerando che ES6 ha portato una soluzione dal valore non indifferente.
L’intero articolo si basa su un buon numero di concetti legati alla coercizione, perciò se non siete pratici di questo argomento vi consiglio di dare un’occhiata alla serie presente sul blog.
Quello che possiamo fare, infatti, è modificare il risultato della coercizione eseguita sui nostri oggetti.
personalizzazione PRIMA DI es6
Dato che l’intero ecosistema della coercizione si basa sui metodi toString e valueOf richiamabili di default su qualsiasi oggetto (e sarà compito delle varie Abstract Operations farlo), quello che potevamo fare prima dell’avvento di ES6 era semplicemente ridefinire questi due metodi nei nostri oggetti.
Perché ho utilizzato la parola ridefinire? Perché i due metodi sono richiamabili di default? Dovete sapere che in Javascript tutti gli oggetti sono in automatico collegati all’oggetto Object.prototype tramite quella che viene definita prototype chain. Questo oggetto definisce un discreto numero di metodi che sono perciò invocabili direttamente dagli altri oggetti ad esso connessi. Se volete saperne di più sull’argomento consiglio la lettura di questa risorsa.
Prima di ridefinire il loro comportamento vediamo qual è quello di default, messo a disposizione da Object.prototype:
var object = { prop: "value", }; object.valueOf(); // object object.toString(); // "[object Object]"
Ecco che il metodo valueOf si limita a restituire un riferimento all’oggetto stesso sul quale è stato invocato, mentre il metodo toString, leggendo il valore di una proprietà interna di nome [[Class]], costruisce la fin troppo comune stringa “[object Object]”.
Anche gli array sono oggetti, ma per essi il comportamento del metodo toString è stato ridefinito:
var array = [1, 2, 3]; array.valueOf(); // array array.toString(); // "1,2,3"
Quando un qualsiasi oggetto viene coerciso, a seconda delle condizioni iniziali, un metodo tra toString e valueOf avrà la precedenza. Solo nel caso in cui esso non restituisca un valore di tipo primitivo (come ad esempio il metodo valueOf di Object.prototype) l’altro metodo verrà invocato come ripiego. Se nemmeno esso restituisse un valore primitvo verrà sollevato un TypeError.
Attenzione! Potremmo decidere di restituire null oppure undefined quando ridefiniamo questi metodi perché sono valori primitivi validi. Comunque il Javascript non porta mai a questo risultato per gli oggetti che ci mette a disposizione e dovremmo seguire il suo esempio, restituendo come valore primitivo uno tra stringa, numero e booleano.
Operazioni numeriche come Number(obj);
, +obj; // + unario
oppure - (unario e binario), *, **, /, %
chiaramente daranno priorità al metodo valueOf, mentre un’operazione di coercizione esplicita come String(obj);
darà priorità al metodo toString senza alcun dubbio.
I dubbi sorgono quando si affronta il + binario e l’operatore == di loose equality i quali danno comunque priorità a valueOf, ma non posso fare altro che rimandarvi alla serie di articoli sulla coercizione per fugare ogni dubbio.
Vediamo, in pratica, come tutto questo può aiutarci a raggiungere il nostro scopo:
var myObj = { toString: function() { return "myObj"; }, valueOf: function() { return 10; } } Number(myObj); // 10 +myObj; // 10 myObj - 7; // 3 myObj % 3; // 1 myObj + 15; // 25 "this is " + myObj; // "this is 10" -> l'operatore + binario invoca valueOf String(myObj); // "myObj"
Abbiamo perciò ridefinito il comportamento del nostro oggetto per le più comuni operazioni aritmetiche.
Potremmo comunque non essere soddisfatti del risultato ottenuto nell’operazione "this is " + myObj;
, ovvero “this is 10”, preferendo “this is myObj” ad esso. Purtroppo, esclusa la corcizione esplicita tramite la funzione String(), l’unico modo per ottenere ciò, in ES5, è imporre anche al metodo valueOf la restituzione della stringa “myObj” sacrificando la possibilità di utilizzare l’oggetto nelle operazioni aritmetiche, pena un’infinita serie di NaN derivanti dalla coercizione della stringa “myObj” in un numero.
Questo perché quando la ToPrimitive Abstract Operation viene invocata e ci si aspetta che essa restituisca un valore di tipo numerico, se così non avviene il Javascript è costretto a trasformare il valore ottenuto in un numero.
Prendiamo il seguente esempio per afferrare bene questo concetto, ripercorrendo gli step seguiti dal linguaggio:
var obj = {}; obj - 10; // NaN
- viene invocata la ToNumber Abstract Operation sull’oggetto obj per poter eseguire l’operazione aritmetica
- la ToNumber Abstract Operation è costretta ad invocare la ToPrimitive Abstract Operation perché obj non è un valore di tipo primitivo
- la ToPrimitive Abstract Operation richiama il metodo valueOf dell’oggetto obj nella speranza che le fornisca un valore primitivo, ma questo non avviene dato che viene restituito obj stesso (colpa del metodo presente in Object.prototype)
- la ToPrimitive Abstract Operation ripiega sul metodo toString dell’oggetto obj nella speranza che le fornisca un valore primitivo, e questo avviene perché esso fornisce “[object Object]”
- la ToPrimitive Abstract Operation restituisce “[object Object]” alla ToNumber Abstract Operation
- la ToNumber Abstract Operation è obbligata a restituire un valore di tipo numerico, perciò coercide “[object Object]” nel valore numerico corrispondente, ovvero NaN
- ora è possibile eseguire l’operazione
NaN - 7
che ha come risultato NaN.
personalizzazione CON es6
Prima di addentrarci nell’ulteriore possibilità offerta da ES6 è bene prestare attenzione al concetto di hint. Questo perché ogni volta che la ToPrimitive Abstract Operation viene invocata, per decidere se dare priorità al metodo valueOf oppure al metodo toString, necessita di un “suggerimento”.
Suggerimento che può prendere uno dei seguenti valori: number, string, default.
Quando esso vale string verrà data precedenza al metodo toString, mentre quando vale number oppure default sarà il metodo valueOf ad avere priorità. L’unica eccezione è il tipo Date, per il quale il suggerimento default renderà prioritario il metodo toString.
Vediamo quali suggerimenti vengono inoltrati dalle principali operazioni viste fin’ora:
Suggerimento | Operazioni |
---|---|
string | String(), interpolazione [ES6] |
number | Number(), + unario, – unario e binario, *, **, /, % |
default | + binario, == |
Potrebbe sembrare il contrario, ma anche ES5 si basa sugli stessi concetti. La differenza risiede nella possibilità, offerta da ES6, di interagire direttamente con questo meccanismo.
È necessario fornire all’oggetto un metodo con un nome speciale, il quale riceverà una stringa con il valore del suggerimento. In base ad esso potremmo decidere il da farsi, come richiamare manualmente i metodi valueOf e toString se è nostro desiderio, oppure invocare altri metodi. La cosa importante è produrre come risultato finale un valore di tipo primitivo, per non incorrere nel TypeError.
Qual è il nome speciale di questo metodo? Symbol.toPrimitive! Questo articolo non parlerà dei simboli, anche perché non è necessario avere confidenza con essi per raggiungere il nostro obiettivo.
Vediamo subito una possibile implementazione del metodo, la quale ci permette di ottenere il medesimo comportamento definito da ES5:
let obj = { [Symbol.toPrimitive](hint) { // funzione che controlla se un valore è un oggetto const isObject = (value) => value !== null && typeof value === 'object' || typeof value === 'function'; switch(hint) { case "default": case "number": // con l'hint default/value la precedenza la ha il metodo valueOf const value = this.valueOf(); // se value è di tipo primitivo viene restituito if(!isObject(value)) return value; // altrimenti viene invocato toString() e ne viene restituito il risultato // qualsiasi esso sia, con la possibilità di causare un TypeError else return this.toString(); case "string": // con l'hint string la precedenza la ha il metodo toString const string = this.toString(); // se value è di tipo primitivo viene restituito if(!isObject(string)) return string; // altrimenti viene invocato valueOf() e ne viene restituito il risultato // qualsiasi esso sia, con la possibilità di causare un TypeError else return this.valueOf() } } }
Ecco che se volessi che il TypeError non venga sollevato se la ToPrimitive Abstract Operation non restituisse un valore di tipo primitivo – richiamando implementazioni dei metodi valueOf e toString non affidabili – potrei prendere spunto dal sopracitato codice, aggiungendo qualche piccola modifica:
let obj = { [Symbol.toPrimitive](hint) { // funzione che controlla se un valore è un oggetto const isObject = (value) => value !== null && typeof value === 'object' || typeof value === 'function'; switch(hint) { case "default": case "number": { // con l'hint default/value la precedenza la ha il metodo valueOf let res = this.valueOf(); // se il risultato è di tipo primitivo viene restituito if(!isObject(res)) return res; // altrimenti viene invocato toString() res = this.toString(); // e ne viene restituito il risultato se esso è di tipo primitivo if(!isObject(res)) return res; // altrimenti restituisco un valore consono alla situazione // per evitare quantomeno il TypeError return NaN; } case "string": { // con l'hint string la precedenza la ha il metodo toString let res = this.toString(); // se il risultato è di tipo primitivo viene restituito if(!isObject(res)) return res; // con l'hint string la precedenza la ha il metodo valueOf res = this.valueOf(); // se il risultato è di tipo primitivo viene restituito if(!isObject(res)) return res; // altrimenti restituisco un valore consono alla situazione // per evitare quantomeno il TypeError return ""; } } } }
Possiamo tranquillamente inserire altre modifiche più sostanziali al meccanismo, tenendo però presente la tabella esposta poco sopra. Come esempio pratico riprendo l’oggetto myObj definito in precedenza, dove desideravo che l’oggetto venisse trasformato in un valore di tipo stringa anziché di tipo numerico quando si interfacciava con l’operatore + binario. Come illustra la tabella, anche l’operatore == verrà influenzato dalla mia modifica.
let myObj = { toString: function() { return "myObj"; }, valueOf: function() { return 10; }, [Symbol.toPrimitive](hint) { switch(hint) { case "number": return this.valueOf(); case "default": case "string": return this.toString(); } } }
La cosa interessante è che potremmo utilizzare comunque il valore numerico di myObj in un’operazione + binaria o con l’operatore == se necessario, sfruttando l’operatore + unario.
Di nuovo, la tabella conferma questa affermazione: l’operatore + unario suggerisce “number”
"this is " + myObj; // "this is myObj" `greetings from ${myObj}`; // "greetings from myObj" "myObj" == myObj; // true 32 + +myObj; // 42 `greetings from ${+myObj}`; // "greetings from 10" 10 == +myObj; // true