Funzionari e categorie

Software di composizione

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!
>

Un tipo di dati di funzione è qualcosa su cui puoi mappare. È un contenitore che ha un'interfaccia che può essere utilizzata per applicare una funzione ai valori al suo interno. Quando vedi un funzione, dovresti pensare "mappabile". I tipi di Functor sono generalmente rappresentati come un oggetto con un metodo .map () che mappa da input a output preservando la struttura. In pratica, "preservare la struttura" significa che il valore restituito è lo stesso tipo di funzione (sebbene i valori all'interno del contenitore possano essere di tipo diverso).

Un funzione fornisce una scatola con zero o più oggetti all'interno e un'interfaccia di mappatura. Un array è un buon esempio di funzione, ma è possibile mappare anche molti altri tipi di oggetti, inclusi promesse, flussi, alberi, oggetti, ecc. L'array incorporato di JavaScript e gli oggetti promessa agiscono come funzioni. Per le raccolte (matrici, flussi, ecc.), .Map () in genere scorre sulla raccolta e applica la funzione data a ciascun valore nella raccolta, ma non tutti i funzioni ripetono. I Functor riguardano davvero l'applicazione di una funzione in un contesto specifico.

Le promesse usano il nome .then () invece di .map (). Di solito puoi pensare a .then () come un metodo asincrono .map (), tranne quando hai una promessa nidificata, nel qual caso scarterà automaticamente la promessa esterna. Ancora una volta, per valori che non sono promesse, .then () si comporta come un .map asincrono (). Per i valori che sono promesse stesse, .then () agisce come il metodo .flatMap () dalle monadi (a volte chiamato anche .chain ()). Quindi, le promesse non sono abbastanza funzionali, e non abbastanza monadi, ma in pratica, di solito puoi trattarle come entrambe. Non preoccuparti ancora di cosa siano le monadi. Le monadi sono una specie di funzione, quindi prima devi imparare i funzioni.

Esistono molte biblioteche che trasformeranno anche una varietà di altre cose in funzioni.

In Haskell, il tipo di funzione è definito come:

fmap :: (a -> b) -> f a -> f b

Data una funzione che accetta a e restituisce ae una funzione con zero o più come al suo interno: fmap restituisce una casella con zero o più b al suo interno. I bit f a e f b possono essere letti come “un funzione di a” e “un funzione di b”, nel senso che f a ha come all'interno della casella e f b ha bs all'interno della casella.

L'uso di un funzione è semplice: basta chiamare map ():

const f = [1, 2, 3];
f.map (doppia); // [2, 4, 6]

Leggi di Functor

Le categorie hanno due proprietà importanti:

  1. Identità
  2. Composizione

Poiché un funzione è una mappatura tra le categorie, le funzioni devono rispettare identità e composizione. Insieme, sono conosciute come le leggi dei funzione.

Identità

Se si passa la funzione di identità (x => x) in f.map (), dove f è un qualsiasi funzione, il risultato dovrebbe essere equivalente a (avere lo stesso significato di) f:

const f = [1, 2, 3];
f.map (x => x); // [1, 2, 3]

Composizione

I portatori devono obbedire alla legge sulla composizione: F.map (x => f (g (x))) è equivalente a F.map (g) .map (f).

La composizione della funzione è l'applicazione di una funzione al risultato di un'altra, ad esempio, data una x e le funzioni, f e g, la composizione (f ∘ g) (x) (di solito abbreviata in f ∘ g - la (x) è implicito) significa f (g (x)).

Molti termini di programmazione funzionale derivano dalla teoria delle categorie e l'essenza della teoria delle categorie è la composizione. La teoria delle categorie all'inizio fa paura, ma è facile. Come saltare da un trampolino o andare sulle montagne russe. Ecco le basi della teoria delle categorie in alcuni punti elenco:

  • Una categoria è una raccolta di oggetti e frecce tra oggetti (dove "oggetto" può significare letteralmente qualsiasi cosa).
  • Le frecce sono conosciute come morfismi. I morfismi possono essere pensati e rappresentati nel codice come funzioni.
  • Per qualsiasi gruppo di oggetti collegati, a -> b -> c, deve esserci una composizione che va direttamente da a -> c.
  • Tutte le frecce possono essere rappresentate come composizioni (anche se è solo una composizione con la freccia di identità dell'oggetto). Tutti gli oggetti in una categoria hanno frecce di identità.

Supponi di avere una funzione g che prende a e restituisce a b, e un'altra funzione f che prende a e restituisce a c; deve esserci anche una funzione h che rappresenta la composizione di f e g. Quindi, la composizione da a -> c, è la composizione f ∘ g (f dopo g). Quindi, h (x) = f (g (x)). La composizione delle funzioni funziona da destra a sinistra, non da sinistra a destra, motivo per cui f ∘ g viene spesso chiamato f dopo g.

La composizione è associativa. Fondamentalmente ciò significa che quando componi più funzioni (morfismi se ti senti di fantasia), non hai bisogno di parentesi:

h∘ (g∘f) = (h∘g) ∘f = h∘g∘f

Diamo un'altra occhiata alla legge sulla composizione in JavaScript:

Dato un funzione, F:

const F = [1, 2, 3];

Sono equivalenti:

F.map (x => f (g (x)));
// è equivalente a...
F.map (g) .map (f);

Endofunctors

Un endofunctor è un funzione che esegue il mapping da una categoria alla stessa categoria.

Un funzione può mappare da una categoria all'altra: X -> Y

Un endofunctor esegue il mapping da una categoria alla stessa categoria: X -> X

Una monade è un endofunctor. Ricorda:

“Una monade è solo un monoide nella categoria degli endofunctor. Qual è il problema?"

Spero che quella citazione stia iniziando a dare un po 'più senso. Arriveremo a monoidi e monadi più tardi.

Costruisci il tuo Functor

Ecco un semplice esempio di funzione:

const Identity = value => ({
  mappa: fn => Identità (fn (valore))
});

Come puoi vedere, soddisfa le leggi di funzione:

// trace () è un'utilità che consente di ispezionare facilmente
// i contenuti.
const trace = x => {
  console.log (x);
  ritorna x;
};
const u = Identità (2);
// Legge sull'identità
u.map (trace); // 2
u.map (x => x) .map (traccia); // 2
const f = n => n + 1;
const g = n => n * 2;
// Legge sulla composizione
const r1 = u.map (x => f (g (x)));
const r2 = u.map (g) .map (f);
r1.map (trace); // 5
r2.map (trace); // 5

Ora puoi mappare su qualsiasi tipo di dati, proprio come puoi mappare su un array. Bello!

È semplice quanto un funzione può ottenere in JavaScript, ma mancano alcune funzionalità che ci aspettiamo dai tipi di dati in JavaScript. Aggiungiamoli. Non sarebbe bello se l'operatore + potesse lavorare per valori di numeri e stringhe?

Per farlo funzionare, tutto ciò che dobbiamo fare è implementare .valueOf () - che sembra anche un modo conveniente per scartare il valore dal functor:

const Identity = value => ({
  mappa: fn => Identità (fn (valore)),
  valueOf: () => value,
});
const ints = (Identity (2) + Identity (4));
tracciare (int); // 6
const hi = (Identity ('h') + Identity ('i'));
traccia (hi); // "Ciao"

Bello. E se volessimo ispezionare un'istanza di Identity nella console? Sarebbe bello se dicesse "Identità (valore)", giusto. Aggiungiamo un metodo .toString ():

toString: () => `Identity ($ {value})`,

Freddo. Probabilmente dovremmo anche abilitare il protocollo di iterazione JS standard. Possiamo farlo aggiungendo un iteratore personalizzato:

[Symbol.iterator]: function * () {
  valore di snervamento;
}

Ora funzionerà:

// [Symbol.iterator] abilita le iterazioni JS standard:
const arr = [6, 7, ... Identity (8)];
trace (arr); // [6, 7, 8]

Cosa succede se si desidera prendere un'identità (n) e restituire una matrice di identità contenente n + 1, n + 2 e così via? Facile vero?

const fRange = (
  inizio,
  fine
) => Array.from (
  {lunghezza: fine - inizio + 1},
  (x, i) => Identità (i + inizio)
);

Ah, ma cosa succede se vuoi che funzioni con qualsiasi funzione? E se avessimo una specifica che dicesse che ogni istanza di un tipo di dati deve avere un riferimento al suo costruttore? Quindi potresti farlo:

const fRange = (
  inizio,
  fine
) => Array.from (
  {lunghezza: fine - inizio + 1},
  
  // cambia `Identity` in` start.constructor`
  (x, i) => start.constructor (i + start)
);
const range = fRange (Identity (2), 4);
range.map (x => x.map (trace)); // 2, 3, 4

Cosa succede se si desidera verificare se un valore è un functor? Potremmo aggiungere un metodo statico su Identity da verificare. Dovremmo inserire un .toString () statico mentre ci siamo:

Object.assign (Identity, {
  toString: () => 'Identità',
  è: x => typeof x.map === 'funzione'
});

Mettiamo tutto insieme:

const Identity = value => ({
  mappa: fn => Identità (fn (valore)),
  valueOf: () => value,
  toString: () => `Identity ($ {value})`,
  [Symbol.iterator]: function * () {
    valore di snervamento;
  },
  costruttore: identità
});
Object.assign (Identity, {
  toString: () => 'Identità',
  è: x => typeof x.map === 'funzione'
});

Nota che non hai bisogno di tutte queste cose extra per qualificarti come un funzione o un endofunctor. È strettamente per comodità. Tutto ciò di cui hai bisogno per un funzione è un'interfaccia .map () che soddisfi le leggi del funzione.

Perché Functors?

I portatori sono eccezionali per molte ragioni. Ancora più importante, sono un'astrazione che puoi utilizzare per implementare molte cose utili in un modo che funziona con qualsiasi tipo di dati. Ad esempio, cosa succede se si desidera dare il via a una catena di operazioni, ma solo se il valore all'interno del functor non è indefinito o nullo?

// Crea il predicato
const esiste = x => (x.valueOf ()! == undefined && x.valueOf ()! == null);
const ifExists = x => ({
  mappa: fn => esiste (x)? x.map (fn): x
});
const add1 = n => n + 1;
const double = n => n * 2;
// Non accade nulla...
IfExists (Identity (undefined)) map (trace).;
// Ancora niente...
. IfExists (Identity (null)) map (trace);
// 42
IfExists (Identity (20))
  .map (ADD1)
  .map (doppio)
  .map (trace)
;

Ovviamente, la programmazione funzionale consiste nel comporre minuscole funzioni per creare astrazioni di livello superiore. Cosa succede se si desidera una mappa generica che funzioni con qualsiasi funzione? In questo modo è possibile applicare parzialmente argomenti per creare nuove funzioni.

Facile. Scegli il tuo curry preferito o usa questo incantesimo di prima:

const curry = (
  f, arr = []
) => (... args) => (
  a => a.length === f.length?
    fa) :
    curry (f, a)
) ([... arr, ... args]);

Ora possiamo personalizzare la mappa:

const map = curry ((fn, F) => F.map (fn));
const double = n => n * 2;
const mdouble = map (double);
. MDouble (Identity (4)) map (trace); // 8

Conclusione

I portatori sono cose su cui possiamo mappare. Più specificamente, un functor è una mappatura da categoria a categoria. Un funzione può persino mappare da una categoria alla stessa categoria (cioè un endofunctor).

Una categoria è una raccolta di oggetti, con frecce tra gli oggetti. Le frecce rappresentano i morfismi (aka funzioni, aka composizioni). Ogni oggetto in una categoria ha un morfismo identitario (x => x). Per ogni catena di oggetti A -> B -> C deve esistere una composizione A -> C.

I Functor sono ottime astrazioni di ordine superiore che ti consentono di creare una varietà di funzioni generiche che funzioneranno per qualsiasi tipo di dati.

Avanti: Mixin funzionali>

Aumenta le tue abilità con il tutoraggio live 1: 1

DevAnywhere è il modo più veloce per salire di livello con competenze JavaScript avanzate:

  • Lezioni dal vivo
  • Orari flessibili
  • Tutoraggio 1: 1
  • Crea app di produzione reali
https://devanywhere.io/

Eric Elliott è autore di "Programmazione di applicazioni JavaScript" (O’Reilly) e cofondatore di DevAnywhere.io. 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.

Lavora dove vuole con la donna più bella del mondo.