Il pregiudizio di familiarità ti sta trattenendo: è tempo di abbracciare le funzioni delle frecce

“Anchor” - Actor212 - (CC BY-NC-ND 2.0)

Insegno JavaScript per vivere. Di recente ho spostato il mio curriculum per insegnare prima le funzioni delle frecce al curry - nelle prime lezioni. L'ho spostato prima nel curriculum perché è un'abilità estremamente preziosa e gli studenti raccolgono il curry con le frecce molto più velocemente di quanto pensassi.

Se riescono a capirlo e sfruttarlo prima, perché non insegnarlo prima?

Nota: i miei corsi non sono progettati per persone che non hanno mai toccato una riga di codice prima. La maggior parte degli studenti si iscrive dopo aver trascorso almeno qualche mese a scrivere codice - da soli, in un campo di addestramento o professionalmente. Tuttavia, ho visto molti sviluppatori junior con poca o nessuna esperienza raccogliere rapidamente questi argomenti.

Ho visto un gruppo di studenti acquisire familiarità con le funzioni delle frecce al curry nell'arco di una singola lezione di 1 ora. (Se sei un membro di "Impara JavaScript con Eric Elliott", puoi guardare subito la lezione di Curry & Composizione ES6 di 55 minuti).

Vedendo quanto velocemente gli studenti lo raccolgono e iniziano a esercitare i loro nuovi poteri al curry, sono sempre un po 'sorpreso quando inserisco le funzioni delle frecce al curry su Twitter e Twitterverse risponde con indignazione al pensiero di infliggere quel codice "illeggibile" su le persone che dovranno mantenerlo.

Prima di tutto, lascia che ti dia un esempio di ciò di cui stiamo parlando. La prima volta che ho notato il contraccolpo è stata la risposta di Twitter a questa funzione:

const secret = msg => () => msg;

Sono rimasto scioccato quando le persone su Twitter mi hanno accusato di aver cercato di confondere le persone. Ho scritto quella funzione per dimostrare quanto sia facile esprimere le funzioni al curry in ES6. È l'applicazione e l'espressione pratiche più semplici di una chiusura che mi viene in mente in JavaScript. (Correlato: “Che cos'è una chiusura?”).

È equivalente alla seguente espressione di funzione:

const secret = function (msg) {
  funzione return () {
    return msg;
  };
};

secret () è una funzione che accetta un messaggio e restituisce una nuova funzione che restituisce il messaggio. Sfrutta le chiusure per fissare il valore di msg a qualunque valore passi in secret ().

Ecco come lo usi:

const mySecret = secret ('hi');
il mio segreto(); // 'Ciao'

Si scopre che la "doppia freccia" è ciò che confonde le persone. Sono convinto che questo sia un dato di fatto:

Con familiarità, le funzioni freccia in linea sono il modo più leggibile per esprimere le funzioni curry in JavaScript.

Molte persone mi hanno sostenuto che la forma più lunga è più facile da leggere rispetto alla forma più corta. Sono in parte ragione, ma soprattutto sbagliato. È più dettagliato, e più esplicito, ma non più facile da leggere - almeno, non per qualcuno che abbia familiarità con le funzioni delle frecce.

Le obiezioni che ho visto su Twitter non erano semplicemente soddisfacenti per la fluida esperienza di apprendimento dei miei studenti. Nella mia esperienza, gli studenti prendono le funzioni delle frecce al curry come i pesci prendono all'acqua. Pochi giorni dopo averli imparati, sono tutt'uno con le frecce. Li usano senza sforzo per affrontare ogni tipo di sfida con il codice.

Non vedo alcun segno che le funzioni della freccia siano "difficili" per loro da imparare, leggere o capire - una volta che hanno fatto l'investimento iniziale di impararle nel corso di alcune lezioni e sessioni di studio di 1 ora.

Leggono facilmente le funzioni delle frecce al curry che non hanno mai visto prima e mi spiegano cosa sta succedendo. Naturalmente scrivono le loro quando le presenti una sfida.

In altre parole, non appena acquisiscono familiarità con le funzioni delle frecce al curry, non hanno problemi con esse. Li leggono facilmente mentre leggi questa frase e la loro comprensione si riflette in un codice molto più semplice con meno bug.

Perché alcune persone pensano che le espressioni delle funzioni legacy siano più facili da leggere

Il pregiudizio di familiarità è un pregiudizio cognitivo umano misurabile che ci porta a prendere decisioni autodistruttive nonostante sia consapevole di un'opzione migliore. Continuiamo a usare gli stessi vecchi schemi nonostante conosciamo schemi migliori per comodità e abitudine.

Puoi imparare molto di più sul pregiudizio alla familiarità (e su molti altri modi in cui ci inganniamo) dal libro eccellente "The Undoing Project: A Friendship Changed Our Minds". Questo libro dovrebbe essere richiesto per ogni sviluppatore di software, perché ti incoraggia a pensare in modo più critico e testare i tuoi presupposti per evitare di cadere in una varietà di trappole cognitive - e anche la storia di come sono state scoperte quelle trappole cognitive è davvero buona .

Le espressioni di funzioni legacy probabilmente causano errori nel tuo codice

Oggi stavo riscrivendo una funzione di freccia al curry da ES6 a ES5 in modo da poterla pubblicare come un modulo open-source che le persone potevano usare nei vecchi browser senza traspilare. La versione ES5 mi ha scioccato.

La versione ES6 era semplice, corta ed elegante - solo 4 linee.

Ho pensato di sicuro, questa era la funzione che avrebbe dimostrato a Twitter che le funzioni delle frecce sono superiori e che le persone dovrebbero abbandonare le loro funzioni ereditarie come le cattive abitudini che sono.

Quindi ho twittato:

Ecco il testo delle funzioni, nel caso in cui l'immagine non funzioni per te:

// al curry con le frecce
const composeMixins = (... mixins) => (
  istanza = {},
  mix = (... fns) => x => fns.reduce ((acc, fn) => fn (acc), x)
) => mix (... mixins) (istanza);
// vs ES5-style
var composeMixins = function () {
  var mixins = [] .slice.call (argomenti);
  funzione return (istanza, mix) {
    if (! istanza) istanza = {};
    if (! mix) {
      mix = function () {
        var fns = [] .slice.call (argomenti);
        funzione di ritorno (x) {
          return fns.reduce (function (acc, fn) {
            return fn (acc);
          }, X);
        };
      };
    }
    return mix.apply (null, mixins) (istanza);
  };
};

La funzione in questione è un semplice wrapper attorno a pipe (), un'utilità di programmazione funzionale standard comunemente usata per comporre le funzioni. Una funzione pipe () esiste in lodash come lodash / flow, in Ramda come R.pipe (), e ha persino il suo operatore in diversi linguaggi di programmazione funzionale.

Dovrebbe essere familiare a tutti coloro che hanno familiarità con la programmazione funzionale. Come dovrebbe la sua dipendenza primaria: ridurre.

In questo caso, viene utilizzato per comporre mixin funzionali, ma questo è un dettaglio irrilevante (e un intero altro post sul blog). Ecco i dettagli importanti:

La funzione accetta un numero qualsiasi di mixin funzionali e restituisce una funzione che li applica uno dopo l'altro in una pipeline, come una catena di montaggio. Ogni mixin funzionale prende l'istanza come input e inserisce alcune cose su di essa prima di passare alla funzione successiva nella pipeline.

Se si omette l'istanza, viene creato un nuovo oggetto per te.

A volte potremmo voler comporre i mixin in modo diverso. Ad esempio, potresti voler passare a compose () invece di pipe () per invertire l'ordine di precedenza.

Se non è necessario personalizzare il comportamento, è sufficiente lasciare da solo il valore predefinito e ottenere il comportamento pipe () standard.

Solo i fatti

Opinioni a parte sulla leggibilità, ecco i fatti oggettivi relativi a questo esempio:

  • Ho esperienza pluriennale con espressioni di funzione ES5 ed ES6, frecce o altro. Il pregiudizio di familiarità non è una variabile in questi dati.
  • Ho scritto la versione ES6 in pochi secondi. Contiene zero bug (di cui sono a conoscenza - supera tutti i test unitari).
  • Mi ci sono voluti diversi minuti per scrivere la versione ES5. Almeno un ordine di grandezza più tempo. Minuti contro secondi. Ho perso il mio posto nelle rientranze della funzione due volte. Ho scritto 3 bug, ognuno dei quali ho dovuto eseguire il debug e correggere. Due dei quali ho dovuto ricorrere a console.log () per capire cosa stesse succedendo.
  • La versione ES6 è composta da 4 righe di codice.
  • La versione ES5 è lunga 21 righe (17 in realtà contengono codice).
  • Nonostante la noiosa verbosità, la versione ES5 perde effettivamente parte della fedeltà delle informazioni disponibile nella versione ES6. È molto più lungo, ma comunica di meno, continua a leggere per i dettagli.
  • La versione ES6 contiene 2 spread per i parametri delle funzioni. La versione ES5 omette gli spread e utilizza invece l'oggetto argomenti impliciti, che danneggia la leggibilità della firma della funzione (downgrade fedeltà 1).
  • La versione ES6 definisce il valore predefinito per il mix nella firma della funzione in modo da poter vedere chiaramente che è un valore per un parametro. La versione ES5 nasconde quel dettaglio e invece lo nasconde in profondità all'interno del corpo della funzione. (downgrade fedeltà 2).
  • La versione ES6 ha solo 2 livelli di rientro, il che aiuta a chiarire la struttura di come dovrebbe essere letta. La versione ES5 ha 6 e i livelli di annidamento sono oscuri anziché aiutare la leggibilità della struttura della funzione (downgrade di fedeltà 3).

Nella versione ES5, pipe () occupa la maggior parte del corpo della funzione, al punto che è un po 'folle definirlo in linea. Deve davvero essere suddiviso in una funzione separata per rendere leggibile la versione ES5:

var pipe = function () {
  var fns = [] .slice.call (argomenti);
  funzione di ritorno (x) {
    return fns.reduce (function (acc, fn) {
      return fn (acc);
    }, X);
  };
};
var composeMixins = function () {
  var mixins = [] .slice.call (argomenti);
  funzione return (istanza, mix) {
    if (! istanza) istanza = {};
    if (! mix) mix = pipe;
    return mix.apply (null, mixins) (istanza);
  };
};

Questo mi sembra chiaramente più leggibile e comprensibile per me.

Vediamo cosa succede quando applichiamo la stessa "ottimizzazione" della leggibilità alla versione ES6:

const pipe = (... fns) => x => fns.reduce ((acc, fn) => fn (acc), x);
const composeMixins = (... mixins) => (
  istanza = {},
  mix = pipe
) => mix (... mixins) (istanza);

Come l'ottimizzazione ES5, questa versione è più dettagliata (aggiunge una nuova variabile che prima non c'era). A differenza della versione ES5, questa versione non è significativamente più leggibile dopo aver estratto la definizione di pipe. Dopotutto, aveva già un nome di variabile chiaramente assegnato ad esso nella firma della funzione: mix.

La definizione di mix era già contenuta nella sua stessa linea, il che rende improbabile che i lettori si confondano su dove finisce e il resto della funzione continua.

Ora abbiamo 2 variabili che rappresentano la stessa cosa invece di 1. Abbiamo guadagnato molto? Non ovviamente no.

Quindi perché la versione ES5 è ovviamente migliore con la stessa funzione sottratta?

Perché la versione ES5 è ovviamente più complessa. La fonte di tale complessità è il nocciolo di questa questione. Affermo che la fonte della complessità si riduce al rumore di sintassi e che il rumore di sintassi sta oscurando il significato della funzione, non aiutando.

Cambiamo marcia ed eliminiamo alcune altre variabili. Usiamo ES6 per entrambi gli esempi e confrontiamo solo le funzioni freccia e le espressioni di funzioni legacy:

var composeMixins = function (... mixins) {
  funzione di ritorno (
    istanza = {},
    mix = function (... fns) {
      funzione di ritorno (x) {
        return fns.reduce (function (acc, fn) {
          return fn (acc);
        }, X);
      };
    }
  ) {
    return mix (... mixins) (istanza);
  };
};

Mi sembra molto più leggibile. Tutto ciò che abbiamo cambiato è che stiamo sfruttando la sintassi dei parametri di riposo e di default. Ovviamente, dovrai avere familiarità con il resto e la sintassi predefinita affinché questa versione sia più leggibile, ma anche se non lo sei, penso che sia ovvio che questa versione sia ancora meno ingombra.

Ciò ha aiutato molto, ma mi è ancora chiaro che questa versione è ancora abbastanza ingombra che l'astrazione di pipe () nella sua stessa funzione ovviamente aiuterebbe:

const pipe = function (... fns) {
  funzione di ritorno (x) {
    return fns.reduce (function (acc, fn) {
      return fn (acc);
    }, X);
  };
};
// Espressioni di funzioni legacy
const composeMixins = function (... mixins) {
  funzione di ritorno (
    istanza = {},
    mix = pipe
  ) {
    return mix (... mixins) (istanza);
  };
};

Va meglio, vero? Ora che l'assegnazione del mix occupa solo una riga, la struttura della funzione è molto più chiara, ma c'è ancora troppo rumore di sintassi per i miei gusti. In composeMixins (), non mi è chiaro a colpo d'occhio dove finisce una funzione e ne inizia un'altra.

Piuttosto che chiamare corpi funzione, quella parola chiave funzione sembra fondersi visivamente con gli identificatori che la circondano. Ci sono funzioni nascoste nella mia funzione! Dove finiscono la firma del parametro e inizia il corpo della funzione? Posso capire se guardo da vicino, ma non è visivamente ovvio per me.

E se potessimo sbarazzarci della parola chiave funzione e richiamare i valori di ritorno indicandoli visivamente con una grande freccia grossa => invece di scrivere una parola chiave di ritorno che si fonde con gli identificatori circostanti?

A quanto pare, possiamo, ed ecco come appare:

const composeMixins = (... mixins) => (
  istanza = {},
  mix = pipe
) => mix (... mixins) (istanza);

Ora dovrebbe essere chiaro cosa sta succedendo. composeMixins () è una funzione che accetta un numero qualsiasi di mixin e restituisce una funzione che accetta due parametri opzionali, istanza e mix. Restituisce il risultato dell'istanza di piping attraverso i mixin composti.

Solo un'altra cosa ... se applichiamo la stessa ottimizzazione a pipe (), si trasforma magicamente in una riga:

const pipe = (... fns) => x => fns.reduce ((acc, fn) => fn (acc), x);

Con quella definizione su una riga, il vantaggio di sottrarla alla sua stessa funzione è meno chiaro. Ricorda, questa funzione esiste come utility in Lodash, Ramda e un gruppo di altre librerie, ma vale davvero la pena sovraccaricare l'importazione di un'altra libreria?

Vale anche la pena estrarlo nella sua stessa linea? Probabilmente. Sono in realtà due funzioni diverse e separarle le rende più chiare.

D'altra parte, averlo in linea chiarisce il tipo e le aspettative di utilizzo quando si guarda la firma del parametro. Ecco cosa succede quando lo creiamo in linea:

const composeMixins = (... mixins) => (
  istanza = {},
  mix = (... fns) => x => fns.reduce ((acc, fn) => fn (acc), x)
) => mix (... mixins) (istanza);

Ora torniamo alla funzione originale. Lungo la strada, non abbiamo scartato alcun significato. In effetti, dichiarando in linea i nostri parametri e valori predefiniti, abbiamo aggiunto informazioni su come viene utilizzata la funzione e su come potrebbero apparire i valori dei parametri.

Tutto quel codice extra nella versione ES5 era solo rumore. Rumore di sintassi. Non serviva a nessuno scopo utile se non quello di acclimatare persone che non hanno familiarità con le funzioni delle frecce al curry.

Una volta acquisita sufficiente familiarità con le funzioni delle frecce al curry, dovrebbe essere chiaro che la versione originale è più leggibile perché c'è molto meno sintassi in cui perdersi.

È anche meno soggetto a errori, perché c'è molto meno superficie in cui i bug possono nascondersi.

Sospetto che ci siano molti bug nascosti nelle funzioni legacy che verrebbero trovati ed eliminati se si aggiornasse alle funzioni freccia.

Suppongo anche che il tuo team diventerebbe significativamente più produttivo se imparassi ad abbracciare e favorire una maggiore sintassi disponibile in ES6.

Mentre è vero che a volte le cose sono più facili da capire se rese esplicite, è anche vero che come regola generale, meno codice è meglio.

Se meno codice può realizzare la stessa cosa e comunicare di più, senza sacrificare alcun significato, è oggettivamente migliore.

La chiave per conoscere la differenza è il significato. Se più codice non riesce ad aggiungere più significato, quel codice non dovrebbe esistere. Questo concetto è così basilare, è una linea guida di stile ben nota per il linguaggio naturale.

La stessa linea guida di stile si applica al codice sorgente. Abbraccialo e il tuo codice sarà migliore.

Alla fine della giornata, una luce nell'oscurità. In risposta a un altro tweet che dice la versione ES6 meno leggibile:

È ora di familiarizzare con ES6, curry e composizione delle funzioni.

Prossimi passi

I membri di "Learn JavaScript with Eric Elliott" possono guardare la lezione di Curry & Composition ES6 di 55 minuti in questo momento.

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 a esperienze software per Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC e i migliori artisti discografici 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.