Perché la composizione è più dura con le classi

Smoke Art Cubes to Smoke - MattysFlicks - (CC BY 2.0)
Nota: fa parte della serie "Composing Software" (ora un libro!) Sull'apprendimento da zero della programmazione funzionale e delle tecniche software compositive in JavaScript ES6 +. Rimanete sintonizzati. C'è molto di più a venire!

In precedenza, abbiamo esaminato le funzioni di fabbrica e abbiamo esaminato quanto sia facile utilizzarle per la composizione utilizzando mixin funzionali. Ora esamineremo le lezioni in modo più dettagliato ed esamineremo come la meccanica di classe si frappone alla composizione.

Daremo anche un'occhiata ai buoni casi d'uso per le lezioni e a come usarli in sicurezza.

ES6 include una sintassi di classe conveniente, quindi potresti chiederti perché dovremmo preoccuparci delle fabbriche. La differenza più evidente è che i costruttori e la classe richiedono la nuova parola chiave. Ma cosa fa realmente il nuovo?

  • Crea un nuovo oggetto e lo lega ad esso nella funzione di costruzione.
  • Restituisce implicitamente questo, a meno che tu non restituisca esplicitamente un altro oggetto.
  • Imposta l'istanza [[Prototype]] (un riferimento interno) su Constructor.prototype, in modo che Object.getPrototypeOf (istanza) === Constructor.prototype e istanza .__ proto__ === Constructor.prototype.
  • Imposta il file instance.constructor === Funzione di costruzione.

Tutto ciò implica che, a differenza delle funzioni di fabbrica, le classi non sono una buona soluzione per comporre mixin funzionali. Puoi comunque ottenere la composizione usando la classe, ma è un processo molto più complesso e, come vedrai, i costi aggiuntivi di solito non valgono lo sforzo extra.

Il prototipo delegato

Potrebbe eventualmente essere necessario il refactoring da una classe a una funzione di fabbrica e se si richiede ai chiamanti di utilizzare la nuova parola chiave, quel refactor potrebbe violare il codice client di cui non si è nemmeno a conoscenza in un paio di modi. Innanzitutto, a differenza di classi e costruttori, le funzioni di fabbrica non collegano automaticamente un collegamento prototipo delegato.

Il link [[Prototype]] viene utilizzato per la delega dei prototipi, che è un modo conveniente per conservare la memoria se si hanno milioni di oggetti o per ottenere un aumento delle micro-prestazioni dal programma se è necessario accedere a decine di migliaia di proprietà su un oggetto entro un ciclo di loop di rendering di 16 ms.

Se non è necessario microottimizzare la memoria o le prestazioni, il collegamento [[Prototype]] può causare più danni che benefici. La catena di prototipi alimenta l'operatore instanceof in JavaScript e, sfortunatamente, instanceof risiede per due motivi:

In ES5, il collegamento Constructor.prototype era dinamico e riconfigurabile, il che potrebbe essere una funzionalità utile se è necessario creare una fabbrica astratta, ma se si utilizza quella funzione, instanceof ti darà falsi negativi se Constructor.prototype non fa attualmente riferimento lo stesso oggetto in memoria a cui fa riferimento l'istanza [[Prototype]]:

utente di classe {
  costruttore ({userName, avatar}) {
    this.userName = userName;
    this.avatar = avatar;
  }
}
const currentUser = new User ({
  userName: 'Foo',
  avatar: 'foo.png'
});
User.prototype = {};
console.log (
  currentUser instanceof User, // <- false - Oops!
// Ma ha chiaramente la forma corretta:
  // {avatar: "foo.png", userName: "Foo"}
  utente corrente
);

Chrome risolve il problema rendendo configurabile la proprietà Constructor.prototype: false nel descrittore di proprietà. Tuttavia, Babel attualmente non rispecchia tale comportamento, quindi il codice compilato Babel si comporterà come costruttori ES5. La V8 fallisce silenziosamente se si tenta di riconfigurare la proprietà Constructor.prototype. Ad ogni modo, non otterrai i risultati che ti aspettavi. Peggio ancora: il comportamento è incoerente e il codice che funziona ora in Babel probabilmente si interromperà in futuro.

Non consiglio di riassegnare Constructor.prototype.

Un problema più comune è che JavaScript ha più contesti di esecuzione: sandbox di memoria in cui lo stesso codice accederà a posizioni di memoria fisica diverse. Se hai un costruttore in un frame principale, ad esempio, e lo stesso costruttore in un iframe, il modello Costruttore.prototipo del telaio principale non farà riferimento alla stessa posizione di memoria del costruttore.prototipo nell'iframe. I valori degli oggetti in JavaScript sono riferimenti di memoria sotto il cofano, e frame diversi puntano a posizioni diverse nella memoria, quindi i controlli === falliranno.

Un altro problema con instanceof è che si tratta di un controllo del tipo nominale piuttosto che di un controllo del tipo strutturale, il che significa che se si inizia con una classe e successivamente si passa a una fabbrica, tutto il codice chiamante che utilizza instanceof non capirà le nuove implementazioni anche se soddisfano lo stesso contratto di interfaccia. Ad esempio, supponi di avere il compito di creare un'interfaccia per il lettore musicale. Successivamente il team del prodotto ti dice di aggiungere supporto per i video. Più tardi, ti chiedono di aggiungere il supporto per i video 360. Forniscono tutti gli stessi controlli: riproduzione, arresto, riavvolgimento, avanzamento rapido.

Ma se stai usando istanze di controlli, i membri della tua classe di interfaccia video non soddisferanno l'istanza di controlli AudioInterface già nella base di codice.

Falliranno quando dovrebbero avere successo. Le interfacce condivisibili in altre lingue risolvono questo problema consentendo a una classe di dichiarare che implementa un'interfaccia specifica. Attualmente non è possibile in JavaScript.

Il modo migliore per gestire istanza di JavaScript è quello di interrompere il collegamento del prototipo delegato se non è necessario e lasciare che l'istanza di fallisca per ogni chiamata. In questo modo non otterrai un falso senso di affidabilità. Non ascoltare l'istanza di e non ti mentirà mai.

La proprietà .constructor

La proprietà .constructor è una funzione usata raramente in JavaScript, ma potrebbe essere molto utile ed è una buona idea includerla nelle istanze degli oggetti. È per lo più innocuo se non provi a usarlo per il controllo del tipo (che non è sicuro per gli stessi motivi per cui l'istanza di non è sicura).

In teoria, .constructor potrebbe essere utile per creare funzioni generiche che sono in grado di restituire una nuova istanza di qualunque oggetto passi.

In pratica, ci sono molti modi diversi per creare nuove istanze di cose in JavaScript - avere un riferimento al costruttore non è la stessa cosa che sapere come istanziare un nuovo oggetto con esso - anche per scopi apparentemente banali, come creare un vuoto istanza di un determinato oggetto:

// Restituisce un'istanza vuota di qualsiasi tipo di oggetto?
const empty = ({constructor} = {}) => costruttore?
  nuovo costruttore ():
  non definito
;
const foo = [10];
console.log (
  vuoto (foo) // []
);

Sembra funzionare con gli array. Proviamo con Promises:

// Restituisce un'istanza vuota di qualsiasi tipo?
const empty = ({constructor} = {}) => costruttore?
  nuovo costruttore ():
  non definito
;
const foo = Promise.resolve (10);
console.log (
  vuoto (pippo) // [TypeError: Promise resolver non definito è
             // non una funzione]
);

Nota la nuova parola chiave nel codice. Questo è il problema. Non è sicuro supporre che tu possa utilizzare la nuova parola chiave con qualsiasi funzione di fabbrica. A volte, ciò causerà errori.

Ciò di cui avremmo bisogno per fare questo lavoro è avere un modo standard per passare un valore in una nuova istanza usando una funzione di fabbrica standard che non richiede nuove. C'è una specifica per questo: un metodo statico su qualsiasi factory o costruttore chiamato .of (). Il metodo .of () è un factory che restituisce una nuova istanza del tipo di dati contenente qualunque cosa passi in .of ().

Potremmo usare .of () per creare una versione migliore della funzione generica empty ():

// Restituisce un'istanza vuota di qualsiasi tipo?
const empty = ({constructor} = {}) => constructor.of?
  constructor.of ():
  non definito
;
const foo = [23];
console.log (
  vuoto (foo) // []
);

Sfortunatamente, il metodo statico .of () sta appena iniziando a ottenere supporto in JavaScript. L'oggetto Promise ha un metodo statico che si comporta come .of (), ma si chiama invece .resolve (), quindi il nostro generico empty () non funziona con le promesse:

// Restituisce un'istanza vuota di qualsiasi tipo?
const empty = ({constructor} = {}) => constructor.of?
  constructor.of ():
  non definito
;
const foo = Promise.resolve (10);
console.log (
  vuoto (pippo) // non definito
);

Allo stesso modo, non esiste .of () per stringhe, numeri, oggetti, mappe, mappe deboli o set in JavaScript al momento della stesura di questo documento.

Se il supporto per il metodo .of () si attesta in altri tipi di dati JavaScript standard, la proprietà .constructor potrebbe alla fine diventare una funzionalità molto più utile del linguaggio. Potremmo usarlo per costruire una ricca libreria di funzioni di utilità in grado di agire su una varietà di funzioni, monadi e altri tipi di dati algebrici.

È facile aggiungere il supporto per .constructor e .of () a una fabbrica:

const createUser = ({
  userName = 'Anonimo',
  avatar = 'anon.png'
} = {}) => ({
  nome utente,
  avatar,
  costruttore: createUser
});
createUser.of = createUser;
// testing .of e .constructor:
const empty = ({constructor} = {}) => constructor.of?
  constructor.of ():
  non definito
;
const foo = createUser ({userName: 'Empty', avatar: 'me.png'});
console.log (
  vuoto (pippo), // {avatar: "anon.png", userName: "Anonimo"}
  foo.constructor === createUser.of, // true
  createUser.of === createUser // true
);

Puoi persino rendere .constructor non enumerabile aggiungendo al prototipo delegato:

const createUser = ({
  userName = 'Anonimo',
  avatar = 'anon.png'
} = {}) => ({
  __proto__: {
    costruttore: createUser
  },
  nome utente,
  avatar
});

Class to Factory è un cambiamento decisivo

Le fabbriche consentono una maggiore flessibilità nei seguenti modi:

  • Disaccoppiare i dettagli di istanza dal codice chiamante.
  • Consentire di restituire oggetti arbitrari, ad esempio per utilizzare un pool di oggetti per domare il Garbage Collector.
  • Non pretendere di fornire garanzie di alcun tipo, quindi i chiamanti sono meno tentati di utilizzare instanceof e altre misure di controllo del tipo inaffidabili, che potrebbero violare il codice in contesti di esecuzione o se si passa a una fabbrica astratta.
  • Poiché non pretendono di fornire garanzie di tipo, le fabbriche possono scambiare dinamicamente implementazioni con fabbriche astratte. ad esempio, un lettore multimediale che sostituisce il metodo .play () per diversi tipi di media.
  • Aggiungere funzionalità con la composizione è più semplice con le fabbriche.

Sebbene sia possibile raggiungere la maggior parte di questi obiettivi utilizzando le classi, è più facile farlo con le fabbriche. Ci sono meno potenziali insidie, meno complessità da destreggiarsi e molto meno codice.

Per questi motivi, è spesso desiderabile effettuare il refactoring da una classe a una fabbrica, ma può essere un processo complesso e soggetto a errori. Il refactoring dalle classi alle fabbriche è un'esigenza comune in ogni lingua OO. Puoi leggere di più al riguardo in "Refactoring: migliorare il design del codice esistente" di Martin Fowler, Kent Beck, John Brant, William Opdyke e Don Roberts.

A causa del fatto che i nuovi cambiano il comportamento di una funzione chiamata, il passaggio da una classe o un costruttore a una funzione di fabbrica è una modifica potenzialmente pericolosa. In altre parole, forzare i chiamanti a utilizzare nuovi potrebbe involontariamente bloccare i chiamanti nell'implementazione del costruttore, quindi nuove perdite potenzialmente spezzando i dettagli dell'implementazione nell'API chiamante.

Come abbiamo già visto, i seguenti comportamenti impliciti possono rendere l'interruttore una svolta:

  • L'assenza del collegamento [[Prototype]] dalle istanze di fabbrica interromperà i controlli delle istanze del chiamante.
  • L'assenza della proprietà .constructor dalle istanze di fabbrica potrebbe violare il codice che si basa su di essa.

Entrambi i problemi possono essere risolti agganciando manualmente quelle proprietà nelle fabbriche.

Internamente, dovrai anche tenere presente che questo può essere legato dinamicamente dai siti di chiamate di fabbrica, il che non accade quando i chiamanti usano nuovi. Ciò può complicare le cose se si desidera archiviare prototipi di fabbrica astratti alternativi come proprietà statiche nella fabbrica.

C'è anche un altro problema. Tutti i chiamanti della classe devono usare nuovi. Lasciarlo in ES6 genererà sempre:

classe Foo {};
// TypeError: il costruttore di classi Foo non può essere invocato senza 'new'
const Bar = Foo ();

In ES6 +, le funzioni freccia sono comunemente utilizzate per creare fabbriche, ma poiché le funzioni freccia non hanno il proprio legame in JavaScript, invocando una funzione freccia con nuovi lanci si verifica un errore:

const foo = () => ({});
// TypeError: foo non è un costruttore
const bar = new foo ();

Pertanto, se si tenta di eseguire il refactoring da una classe a una factory di funzioni freccia, non funzionerà in ambienti ES6 nativi, il che è OK. Fallire duramente è una buona cosa.

Ma se compili le funzioni freccia in funzioni standard, non riuscirà. È un male, perché dovrebbe essere un errore. "Funzionerà" durante la creazione dell'app, ma potenzialmente non riuscirà nella produzione in cui potrebbe influire sull'esperienza dell'utente o addirittura impedire all'app di funzionare.

Una modifica delle impostazioni predefinite del compilatore potrebbe interrompere l'app, anche se non hai modificato il tuo codice. Quel gotcha porta a ripetere:

Avviso: il refactoring da una classe a una factory di funzioni freccia potrebbe sembrare funzionare con un compilatore, ma se il codice compila la factory in una funzione freccia nativa, l'app si interromperà perché non puoi utilizzarne una nuova con le funzioni freccia.

Il codice che richiede nuovi violazioni del principio aperto / chiuso

Le nostre API dovrebbero essere aperte alle estensioni, ma chiuse alle ultime modifiche. Poiché un'estensione comune a una classe è quella di trasformarla in una factory più flessibile, ma quel refactor è una modifica di rottura, il codice che richiede la nuova parola chiave viene chiuso per estensione e aperto a modifiche di rottura. Questo è l'opposto di ciò che vogliamo.

L'impatto di questo è più grande di quanto sembri inizialmente. Se l'API di classe è pubblica o se lavori su un'app molto grande con un team molto grande, è probabile che il refactor rompa il codice di cui non sei nemmeno a conoscenza. È un'idea migliore per deprecare completamente la classe e sostituirla con una funzione di fabbrica per andare avanti.

Questo processo trasforma un piccolo problema tecnico che può essere risolto silenziosamente dal codice in un problema illimitato di persone che richiede consapevolezza, istruzione e buy-in - un cambiamento molto più costoso!

Ho visto il nuovo problema causare molte volte mal di testa molto costoso ed è banalmente facile da evitare:

Esporta una fabbrica anziché una classe.

La classe Parola chiave e si estende

La parola chiave class dovrebbe essere una sintassi migliore per i modelli di creazione di oggetti in JavaScript, ma è insufficiente in diversi modi:

Sintassi amichevole

Lo scopo principale della classe era fornire una sintassi amichevole per imitare la classe da altre lingue in JavaScript. La domanda che dovremmo porci è: JavaScript deve davvero imitare la classe da altre lingue?

Le funzioni di fabbrica di JavaScript forniscono una sintassi più intuitiva pronta all'uso, con molta meno complessità. Spesso un oggetto letterale è abbastanza buono. Se devi creare molte istanze, le fabbriche sono un buon passo successivo.

In Java e C ++, le fabbriche sono più complicate delle classi, ma spesso valgono comunque la pena costruirle perché offrono maggiore flessibilità. In JavaScript, le fabbriche sono meno complicate e più flessibili delle classi.

Confronta la classe:

utente di classe {
  costruttore ({userName, avatar}) {
    this.userName = userName;
    this.avatar = avatar;
  }
}
const currentUser = new User ({
  userName: 'Foo',
  avatar: 'foo.png'
});

Vs la fabbrica equivalente ...

const createUser = ({userName, avatar}) => ({
  nome utente,
  avatar
});
const currentUser = createUser ({
  userName: 'Foo',
  avatar: 'foo.png'
});

Con la familiarità delle funzioni JavaScript e freccia, le fabbriche sono chiaramente meno sintattiche e più facili da leggere. Forse preferisci vedere la nuova parola chiave, ma ci sono buoni motivi per evitarne di nuovi. Il pregiudizio di familiarità potrebbe trattenerti.

Quali altri argomenti ci sono?

Prestazioni e memoria

Buoni casi d'uso per prototipi delegati sono rari.

la sintassi della classe è un po 'più gradevole della sintassi equivalente per le funzioni del costruttore ES5, ma lo scopo principale è quello di collegare la catena di prototipi dei delegati e sono rari i casi d'uso per i prototipi dei delegati. Si riduce davvero alle prestazioni.

class offre due tipi di ottimizzazioni delle prestazioni: memoria condivisa per le proprietà archiviate nel prototipo del delegato e ottimizzazioni della ricerca delle proprietà.

L'ottimizzazione della memoria del prototipo delegato è disponibile sia per le fabbriche che per le classi. Una factory può impostare il prototipo impostando la proprietà __proto__ in un oggetto letterale o usando Object.create (proto).

Anche così, i dispositivi più moderni hanno la RAM misurata in gigabyte. Prima di utilizzare un prototipo delegato, è necessario creare un profilo e assicurarsi che sia davvero necessario.

Qualsiasi tipo di ambito di chiusura o accesso alle proprietà viene misurato in centinaia di migliaia o milioni di operazioni / secondo, quindi le differenze di prestazioni sono raramente misurabili nel contesto di un'applicazione, per non parlare dell'impatto.

Ci sono eccezioni, ovviamente. RxJS ha utilizzato le istanze di classe perché sono più veloci degli ambiti di chiusura, ma RxJS è una libreria di utilità per scopi generici che potrebbe essere utilizzata nel contesto di centinaia di migliaia di operazioni che devono essere compresse in un ciclo di rendering di 16 ms.

ThreeJS utilizza le classi, ma ThreeJS è una libreria di rendering 3d che potrebbe essere utilizzata per i motori di gioco che manipolano migliaia di oggetti ogni 16 ms.

Ha senso per le biblioteche come ThreeJS e RxJS fare di tutto per ottimizzare al massimo le loro possibilità.

Nel contesto delle applicazioni, dovremmo evitare l'ottimizzazione prematura e concentrare i nostri sforzi solo laddove avranno un grande impatto. Per la maggior parte delle applicazioni, ciò significa chiamate e payload della nostra rete, animazioni, strategie di caching delle risorse, ecc ...

Non ottimizzare al massimo le prestazioni se non hai notato un problema di prestazioni, profilato il codice dell'applicazione e individuato un vero collo di bottiglia.

Invece, è necessario ottimizzare il codice per manutenzione e flessibilità.

Tipo di controllo

Le classi in JavaScript sono dinamiche e i controlli di instanceof non funzionano in contesti di esecuzione, quindi il controllo del tipo basato sulla classe non è un inizio. È inaffidabile. È probabile che causi bug e renda inutilmente rigida la tua applicazione.

Ereditarietà delle classi con `extends`

L'ereditarietà delle classi causa diversi problemi noti che devono essere ripetuti:

  • Accoppiamento stretto: l'eredità di classe è la forma più stretta di accoppiamento disponibile nella progettazione orientata agli oggetti.
  • Gerarchie inflessibili: dato il tempo e gli utenti sufficienti, tutte le gerarchie di classi alla fine sono errate per i nuovi casi d'uso, ma l'accoppiamento stretto rende difficili i refactor.
  • Problema Gorilla / Banana: nessuna eredità selettiva. "Volevi una banana ma quello che hai ottenuto è stato un gorilla con in mano la banana e l'intera giungla". ~ Joe Armstrong in "Coders at Work"
  • Duplicazione per necessità: a causa di gerarchie inflessibili e del problema gorilla / banana, il riutilizzo del codice è spesso realizzato da copia / incolla, violando DRY (non ripetere te stesso) e sconfiggendo l'intero scopo dell'eredità in primo luogo.

L'unico scopo di extends è quello di creare tassonomie di classe per antenati. Un abile hacker leggerà questo e dirà: “Ah ah! Non così! Puoi fare una composizione di classe! "A cui risponderei," ah, ma ora stai usando la composizione di oggetti invece dell'eredità di classe, e ci sono modi più facili e sicuri per farlo in JavaScript senza estensione. "

Le lezioni vanno bene se stai attento

Con tutti gli avvisi fuori mano, emergono alcune linee guida chiare che possono aiutarti a usare le classi in sicurezza:

  • Evita instanceof: risiede nel fatto che JavaScript è dinamico e ha contesti di esecuzione multipli e, in entrambe le situazioni, instanceof ha esito negativo. Può anche causare problemi se si passa a una fabbrica astratta lungo la strada.
  • Evita estensioni: non estendere una singola gerarchia più di una volta. "Favorisci la composizione degli oggetti rispetto all'eredità di classe." ~ "Modelli di progettazione: elementi di software riutilizzabile orientato agli oggetti"
  • Evita di esportare la tua classe. Usa la classe internamente per migliorare le prestazioni, ma esporta una factory che crea istanze al fine di scoraggiare gli utenti dall'estendere la tua classe ed evitare di costringere i chiamanti a usarne di nuovi.
  • Evita il nuovo. Cerca di evitare di usarlo direttamente ogni volta che ha senso e non forzare i tuoi chiamanti a usarlo. (Esporta invece una fabbrica).

Va bene usare la classe se:

  • Stai creando componenti dell'interfaccia utente per un framework come React o Angular. Entrambi i framework racchiudono le classi di componenti in fabbriche e gestiscono l'istanza per te, quindi non è necessario utilizzare nuove nel proprio codice.
  • Non erediti mai dalle tue classi o componenti. Prova invece la composizione degli oggetti, la composizione delle funzioni, le funzioni di ordine superiore, i componenti di ordine superiore o i moduli: tutti sono modelli di riutilizzo del codice migliori rispetto all'ereditarietà delle classi.
  • Devi ottimizzare le prestazioni. Ricorda solo di esportare una fabbrica in modo che i chiamanti non debbano usare nuovi e non essere attirati nella trappola di estensione.

Nella maggior parte delle altre situazioni, le fabbriche ti serviranno meglio.

Le fabbriche sono più semplici delle classi o dei costruttori in JavaScript. Inizia sempre con la soluzione più semplice e passa a soluzioni più complesse solo quando necessario.

Avanti: Tipi di dati compostabili con funzioni>

Prossimi passi

Vuoi saperne di più sulla composizione degli oggetti con JavaScript?

Scopri JavaScript con Eric Elliott. Se non sei un membro, ti stai perdendo!

Eric Elliott è l'autore di "Programmazione di applicazioni JavaScript" (O’Reilly) e "Impara JavaScript con Eric Elliott". Ha contribuito alle esperienze software per Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC e i migliori artisti della registrazione tra cui Usher, Frank Ocean, Metallica e molti altri.

Trascorre la maggior parte del tempo nella Bay Area di San Francisco con la donna più bella del mondo.