Nella narrazione comune, di questi tempi si è soliti contrapporre programmazione orientata agli oggetti (OOP) e programmazione funzionale (FP), come se fossero due approcci alla programmazione diametralmente opposti.
Nella realtà dei fatti, le classi non sono altro che moduli, cioè contesti di più funzioni che costruiscono un'API utile per lavorare con una certa struttura di dati (tipo).
Ma allora in che cosa differiscono esattamente OOP e FP?
E in che senso le classi sono moduli?
Addentriamoci nell'argomento e cerchiamo di smascherare (e se possibile abbattere) i pregiudizi da tifoserie che ammorbano l'umanità in questa simpatica epoca.
Differenze principali tra OOP e FP
A vederle da fuori, le stesse due API scritte in stile funzionale o ad oggetti differiscono principalmente rispetto al posizionamento sintattico del primo argomento nelle chiamate.
// unary operator
operator(data) // fp
data.operator() // oop
// binary operator
operator(data1, data2) // fp
data1.operator(data2) // oop
Un'altra differenza importante è che generalmente parlando di funzioni si pensa a componenti con un input ed un output distinti, mentre l'idea generale è che i metodi di una classe ne modifichino lo stato.
// fp – immutable approach
const result = operator(data) // data is the same as before; result gets the output
// oop – mutable approach
data.operator() // internal state of data is mutated
Ma è davvero così?
Dipende, ovviamente!
Non è una questione sintattica
Nell'esempio di sopra abbiamo implicitamente preso delle decisioni di implementazione che hanno condizionato il comportamento dei due approcci. Nulla ci vieterebbe di operare così:
// procedural mutable approach
operator(data) // "internal" state of data is mutated
// oop – immutable approach
const result = data.operator() // data is the same as before; result gets the output
Trova le differenze
Eccone una: non posso più definire "funzionale" l'approccio adottato adesso, perché una funzione propria fornisce il risultato dei suoi calcoli come valore di ritorno. La funzione operator(), chiamata nell'esempio, di fatto è una procedura, cioè un insieme di istruzioni che non restituiscono un valore, ma che producono effetti (in questo caso sul parametro data).
È dunque vero che i linguaggi funzionali puri generalmente promuovono l'utilizzo di valori immutabili e l'uso di funzioni pure; è anche vero che non tutte le funzioni sono pure, e che anche nella programmazione ad oggetti possiamo preferire l'immutabilità.
Dobbiamo quindi porci un'altra domanda:
Cosa cambia davvero tra programmazione funzionale e programmazione procedurale?
Qui la differenza non è sintattica, ma di comportamento:
- se un modulo è organizzato in funzioni che producono effetti, parliamo di programmazione procedurale;
- se invece è organizzato in funzioni che dato un input producono un output, parliamo di programmazione funzionale.
Nel mondo della programmazione a oggetti non sembra esistere una simile distinzione.
Infatti in OOP un metodo può comportarsi come una procedura o una funzione, ma si parlerà sempre di OOP.
Da un punto di vista del comportamento, non sembra che la OOP abbia un problema con la mutabilità, ma semplicemente il concetto di programmazione a oggetti non fissa a priori una decisione sul comportamento dei metodi di una determinata classe, se debbano agire come procedure sullo stato dell'istanza o produrre una nuova istanza con lo stato risultante.
Immutabilità e funzioni
Mettiamo che ci piaccia l'idea che ogni funzione costituisca un calcolo e non una modifica, e che ogni calcolo produca un risultato a sé stante.
Prendiamo quindi ad esempio un "tipo", cioè una struttura dati prefissata. Useremo un banalissimo punto bidimensionale, fatto di due coordinate (x, y).
// factory
function create(x, y) {
return { x, y };
}
// operators
function up(point, distance) {
return create(point.x, point.y + distance);
}
function down(point, distance) {
return create(point.x, point.y – distance);
}
function right(point, distance) {
return create(point.x + distance, point.y);
}
function left(point, distance) {
return create(point.x – distance, point.y);
}
function sum(point1, point2) {
return create(point1.x + point2.x, point1.y + point2.y);
}
function toString(point) {
return `Point { x: ${point.x}, y: ${point.y} }`;
}
const sourcePoint = create(5, 1);
const upperPoint = up(sourcePoint, 4.5);
const sumPoint = sum(sourcePoint, upperPoint);
console.log(toString(sourcePoint)); // Point { x: 5, y: 1 }
console.log(toString(upperPoint)); // Point { x: 5, y: 5.5 }
console.log(toString(sumPoint)); // Point { x: 10, y: 6.5 }
Nell'esempio abbiamo:
- Una fabbrica, cioè una funzione che norma il processo di creazione di un punto, prefissando input e output; in un esempio reale, create dovrebbe anche occuparsi di gestire il caso in cui le coordinate in input non siano adatte, cioè non siano dei numeri.
- Un elenco di operatori che sono in grado di produrre nuovi punti a partire da un punto fissato e con determinati parametri aggiuntivi, che potrebbero a loro volta essere punti.
Questa struttura tipo + operatori ci ricorda molto gli insiemi numerici, ed è proprio alla matematica che il buon programmatore funzionale guarda con gli occhi pieni di speranza, chiedendosi di fronte ad ogni scelta di implementazione: Cosa farebbe Haskell Curry?
Notare che il nostro "tipo" è una struttura totalmente passiva. Non porta con sé funzioni, non nasconde il proprio contenuto. Potremmo estendere il nostro modulo semplicemente aggiungendo funzioni che accettano la sua struttura (x, y) e restituiscono un qualche risultato.
Immutabilità e classi
L'idea di classe ci dà un pochino di controllo e consistenza in più quando si tratta di definire e circoscrivere un modulo.
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
up(distance) {
return new Point(this.x, this.y + distance);
}
down(distance) {
return new Point(this.x, this.y – distance);
}
right(distance) {
return new Point(this.x + distance, this.y);
}
left(distance) {
return new Point(this.x – distance, this.y);
}
sum(point) {
return new Point(this.x + point.x, this.y + point.y);
}
toString() {
return `Point { x: ${this.x}, y: ${this.y} }`;
}
}
const sourcePoint = new Point(5, 1);
const upperPoint = sourcePoint.up(4.5);
const sumPoint = sourcePoint.sum(upperPoint);
console.log(sourcePoint.toString()); // Point { x: 5, y: 1 }
console.log(upperPoint.toString()); // Point { x: 5, y: 5.5 }
console.log(sumPoint.toString()); // Point { x: 10, y: 6.5 }
Qui bisogna osservare principalmente una cosa: al di là della sintassi del costruttore e della parola this, non è cambiato assolutamente niente.
A tutti gli effetti, la parola this sostituisce il primo parametro degli operatori definiti nel caso funzionale. JavaScript la assegna per noi al momento della chiamata, attribuendole come valore l'istanza di Point da cui stiamo invocando il metodo.
Un'altra differenza è che ora tutte le funzioni del "modulo" Point se ne vanno in giro allegate sintatticamente ad ogni singola istanza di Point. Questo ci consente di sapere sempre quali funzioni possiamo chiamare su un oggetto, perché è lui stesso a fornirle. In aggiunta, se i nostri metodi ritornano nuove istanze di Point, possiamo costruire sintassi concatenate, come questa:
const point = new Point(15, 4.2)
.down(2)
.left(1)
.sum(new Point(5, 4));
console.log(point.toString()); // Point { x: 19, y: 6.2 }
Per ottenere lo stesso effetto nel primo caso, dovremmo scrivere:
const point = sum(left(down(create(15, 4.2), 2), 1), create(5, 4));
console.log(toString(point));
…che è decisamente meno leggibile. Il proliferare di parentesi non è mai un buon segno. Linguaggi puramente funzionali come Haskell consentono di collocare le funzioni binarie "in mezzo" tra i loro due parametri (infix sintax) alterandone la notazione ma senza modificarne da definizione, creando così una forma di concatenabilità:
— equivalent binary function calls
operator (operator a b) c — haskell for operator(operator(a, b), c)
a `operator` b `operator` c — no need for a different definition: this is like a.operator(b).operator(c)
Anche in JavaScript è possibile ottenere una composizione di funzioni leggibile con l'ausilio di funzioni ad hoc:
function compose(point, …fs) {
return fs.reduceRight((result, func) => func(result), point);
}
function pipe(point, …fs) {
return fs.reduce((result, func) => func(result), point);
}
const pointWithCompose = compose(
create(15, 6),
point => sum(point, create(5, 4)),
point => left(point, 1),
point => down(point, 2)
);
const pointWithPipe = pipe(
create(15, 6),
point => down(point, 2),
point => left(point, 1),
point => sum(point, create(5, 4))
);
console.log(toString(pointWithCompose)) // Point { x: 19, y: 8 }
console.log(toString(pointWithPipe)) // Point { x: 19, y: 8 }
In questo caso, compose e pipe sono due nuove funzioni del nostro modulo che consentono di concatenare diverse funzioni che manipolano un punto e di applicarle in ordine, o da destra verso sinistra (composizione) o da sinistra verso destra (pipa).
Nota bene: in questo specifico caso le operazioni sono tutte commutative, quindi l'ordine in cui vengono eseguite è totalmente irrilevante!
Riusciamo ad immaginare la stessa cosa nella versione a oggetti?
In realtà, possiamo ottenere un ottimo ibrido.
Unire classi e funzioni statiche in una pipeable API
Abbiamo detto che l'approccio OOP ci dà maggior controllo quando si tratta di circoscrivere il comportamento di un modulo. E se invece volessimo renderlo estensibile, utilizzando funzioni come quelle che abbiamo definito nell'approccio FP?
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
compose(…fs) {
return fs.reduceRight((result, func) => func(result), this);
}
pipe(…fs) {
return fs.reduce((result, func) => func(result), this);
}
toString() {
return `Point { x: ${this.x}, y: ${this.y} }`;
}
}
function up(point, distance) {
return new Point(point.x, point.y + distance);
}
function down(point, distance) {
return new Point(point.x, point.y – distance);
}
function right(point, distance) {
return new Point(point.x + distance, point.y);
}
function left(point, distance) {
return new Point(point.x – distance, point.y);
}
function sum(point1, point2) {
return new Point(point1.x + point2.x, point1.y + point2.y);
}
const pointWithCompose = new Point(15, 6).compose(
point => sum(point, new Point(5, 4)),
point => left(point, 1),
point => down(point, 2)
);
const pointWithPipe = new Point(15, 6).pipe(
point => down(point, 2),
point => left(point, 1),
point => sum(point, new Point(5, 4))
);
function combinedMove(point) {
return point.compose(
point => sum(point, new Point(5, 4)),
point => left(point, 1),
point => down(point, 2)
);
}
const pointWithCombinedMove = new Point(15, 6).pipe(combinedMove);
console.log(pointWithCompose.toString()); // Point { x: 19, y: 8 }
console.log(pointWithPipe.toString()); // Point { x: 19, y: 8 }
console.log(pointWithCombinedMove.toString()); // Point { x: 19, y: 8 }
Quella creata in questo esempio è definita una pipeable API:
- Viene usata una classe per conservare uno stato immutabile, in questo caso la coppia (x, y).
- Vengono definite operatori delle funzioni pure che accettano un parametro Point e restituiscono a loro volta un Point.
- La classe espone metodi che consentono di concatenare operazioni, in questo caso compose e pipe.
- I metodi compose e pipe sono a loro volta concatenabili.
- Gli operatori sono a loro volta componibili per creare nuovi operatori combinati.
Un esempio di questo tipo di API si può trovare in RxJS, una libreria che consente la composizione di flussi di eventi mescolando alla perfezione programmazione funzionale e ad oggetti.
A questo punto le vostre teste dovrebbero essere esplose.
Se così non fosse, con ogni probabilità avrete capito che le classi, come elemento di design, non sono affatto da contrapporre alle funzioni. A tutti gli effetti le classi hanno più a che vedere con i moduli, cioè con la segregazione e la categorizzazione di funzioni che possono agire su determinate strutture di dati.
Ciò che le classi fanno per noi, in particolare in JavaScript, è:
- Dare un nome (anche a runtime) ai tipi di dato, identificando in blocco le funzioni che sono adatte per gestire quel dato.
- Fornire una sintassi semplificata e concatenabile "fissando il primo parametro" nella parola chiave this.
- In linguaggi più fortemente tipizzati, definire in modo molto specifico il livello di accessibilità dello stato interno di un'istanza.
Quello che l'OOP non fa per noi è scegliere se usare i metodi come funzioni proprie (generando nuove istanze) o come procedure (mutando lo stato).
Quella è una scelta che spetta a noi; ma l'esito di quella scelta non sarà sempre così scontato.