Typescript e la programmazione orientata agli oggetti | Aulab

GUIDE PER ASPIRANTI PROGRAMMATORI

Typescript e la programmazione orientata agli oggetti

La programmazione orientata agli oggetti (in inglese object-oriented programming, abbreviato OOP), nella sua accezione più classica, punta ad utilizzare le tecniche e i paradigmi della programmazione procedurale in contesti isolati divisi per aree di competenza e comunicanti attraverso l’invocazione di metodi.  Le classi nella programmazione ad oggetti L’OOP implementa questo tipo di organizzazione utilizzando il…

Lezione 29 / 30
Enza Neri
Immagine di copertina

Vuoi avviare una nuova carriera o fare un upgrade?

Trova il corso Digital & Tech più adatto a te nel nostro catalogo!

La programmazione orientata agli oggetti (in inglese object-oriented programming, abbreviato OOP), nella sua accezione più classica, punta ad utilizzare le tecniche e i paradigmi della programmazione procedurale in contesti isolati divisi per aree di competenza e comunicanti attraverso l’invocazione di metodi

Le classi nella programmazione ad oggetti

L’OOP implementa questo tipo di organizzazione utilizzando il concetto di classe; una classe è un prototipo per creare oggetti con una struttura predefinita e una serie di funzioni (chiamate metodi della classe) che hanno accesso alle proprietà di tali oggetti. 

Quando TypeScript era alle sue prime versioni JavaScript non disponeva di una sintassi dedicata per definire classi, ma ne permetteva comunque la creazione con costrutti alternativi; similarmente a come abbiamo visto per gli enum, TypeScript, ai tempi, compilava la dichiarazione di una classe in un equivalente JavaScript a runtime. Oggi, questo non è più necessario, e il motivo per cui per tutta la guida abbiamo parlato di tipi ma non di classi è che oggi JavaScript le supporta nativamente con la stessa sintassi adottata inizialmente da TypeScript. 

Ecco, dunque, come appare la dichiarazione di una classe in JavaScript:

// definizione della classe
class User {
  // costruttore della classe
  constructor(name, age) {
    // i parametri del costruttore vengono assegnati
    // alle proprietà della classe
    this.name = name;
    this.age = age;
  }

  // i metodi operano sulle proprietà della classe
  toString() {
    return `User { name: '${this.name}'; age: ${this.age} }`
  }
}

Ovviamente, anche nella dichiarazione di classi, TypeScript ci viene in aiuto in molti modi; ne vediamo di seguito alcuni prima di proseguire:

interface ToString {
  toString(): string;
}

// implementazione di un'interfaccia
class User implements ToString {
  // modificatore d'accesso 'private'
  // inizializzazione di default
  private _age: number = 0;

  // proprietà 'name' dichiarata direttamente nel parametro del costruttore
  // modificatore readonly sulla proprietà 'name'
  // annotazioni di tipo
  constructor(readonly name: string, age: number) {
    this.age = age;
  }
  
  // getter della proprietà
  get age(): number { return this._age; }
  
  // setter della proprietà
  set age(value: number) {
    if (value < 0) throw new Error('invalid age');
    this._age = value;
  }

  // metodo che implementa l'interfaccia
  toString(): string {
    return `User { name: ${this.name}; age: ${this._age}; }`
  }
}

In questo esempio, abbiamo introdotto altre sintassi JavaScript native associate alle classi, come l’inizializzazione di default di una proprietà oppure i getter e setter di una proprietà. Oltre a queste aggiunte, abbiamo potuto applicare tutti i concetti già visti sui tipi anche alle classi e, in più, TypeScript ci ha fornito alcune sintassi di cortesia per fare OOP più agevolmente. Da questo esempio notiamo anche che grazie all’approccio OOP, possiamo proteggere la proprietà age garantendo che nessuna istanza di User abbia mai (né in creazione, né successivamente) un’età negativa. 

Anche il linguaggio JavaScript, da qualche anno, ha introdotto una nozione di proprietà privata; questa non va definita con il marcatore private, ma semplicemente prefiggendo il nome della proprietà con #. In ogni caso, l’uso dei marcatori private, public, readonly rimane utile in TypeScript per dichiarare le proprietà direttamente come parametri al costruttore, se vogliamo che queste siano passate al momento dell’istanziamento. 

Senza la pretesa di voler insegnare tutti i principi di OOP in pochi paragrafi, riprendiamo l’esempio del contatore e vediamo come la OOP ci propone di organizzare il nostro codice:

export class Counter {
  private _count = 0;

  get count(): number { return this._count; }
  increment(): void { this._count++; }
  reset(): void { this._count = 0; }
}

Il codice che consuma questa classe diventa: 

import { Counter } from "./counter";

const incrementBtn = document.querySelector("#increment") as HTMLButtonElement;
const resetBtn = document.querySelector("#reset") as HTMLButtonElement;
const counterSpan = document.querySelector("#counter") as HTMLSpanElement;

const counter = new Counter();

incrementBtn.addEventListener("click", () => {
  counter.increment();
  update();
});


resetBtn.addEventListener("click", () => {
  counter.reset();
  update();
});

function update(): void {
  counterSpan.innerText = String(counter.count);
}

Le differenze con il modulo sono, per il momento, pochissime ma importanti: 

  • Counter non viene inizializzata automaticamente all’importazione, ma dev’essere inizializzata usando new;
  • si possono creare infiniti Counter indipendenti. 

Data la natura dinamica delle classi, queste si rivelano più adatte dei moduli a implementare logiche che agiscono su uno stato non necessariamente globale, dove lo stato può anche essere costituito da altre classi; in questo caso si parla di composizione in OOP.

Typescript e il polimorfismo

Un altro concetto molto utilizzato in OOP è il polimorfismo, cioè la possibilità di un oggetto di rappresentare diversi tipi di dato attraverso un’interfaccia comune sfruttando le gerarchie di tipi. Il polimorfismo può essere in parte associato concettualmente al funzionamento degli union types

// union type
type A = ...;
type B = ...;
type AB = A | B;

// gerarchia di tipi
interface AB {}
interface A extends AB { ... }
interface B extends AB { ... }

La principale differenza tra i due approcci sta nel come li si utilizza: in genere, uno union type viene concretizzato attraverso una forma di restringimento e, poi, gestito in base al suo tipo concreto, mentre in OOP il polimorfismo viene usato con lo scopo esattamente opposto, vale a dire individuare ciò che più tipi hanno in comune e ignorare tutte le loro proprietà specifiche

Il principio per cui si esprime l’interfaccia che si intende consumare su un parametro che potrebbe implementare diverse altre interfacce è chiamato interface segregation principle, ed è uno dei pilastri della OOP. In pratica, si qualifica un oggetto esclusivamente rispetto alla porzione di metodi o proprietà con i quali si intende interagire, in modo tale da lasciare da parte tutto ciò che non serve. 

Data la natura delle classi, il modo più facile per discriminare la logica da applicare in base al tipo concreto è la classe stessa. Infatti, la natura stateful (dotata di stato) di una classe e in particolare dei suoi metodi ci permette di invocare tali metodi senza avere alcuna informazione sullo stato della classe né sulle logiche che tali metodi applicheranno. Per questo motivo, in OOP si preferisce implementare la stessa interfaccia su diverse classi e poi chiamare in astratto il metodo dell’interfaccia, anziché individuare un tipo che rappresenti più classi per poi applicare una determinata logica in base al tipo concreto che si sta processando

Chiariamo questo concetto riprendendo un esempio precedentemente proposto in questa guida: 

// stato rappresentato come union type
type Status = 
  | "idle" 
  | "loading" 
  | "error" 
  | "success";

// funzione che discrimina lo stato e applica la logica appropriata
function logStatusMessage(status: Status): void {
  switch (status) {
    case "idle":
      console.log("nothing happened :|");
      return;
    case "loading":
      console.log("please wait...");
      return;
    case "error":
      console.log("ops, something went wrong :(");
      return;
    case "success":
      console.log("we did it! :D");
      return;
    default:
      // questo caso è teoricamente impossibile
      throw new Error("Unreachable code");
  }
}

Abbiamo detto che in OOP si tende a impacchettare informazioni e logica e a usare il polimorfismo per discriminare le casistiche; quindi, il nostro esempio diventa: 

// interfaccia che esprime la funzione applicabile a diversi stati
interface Status { log(): void; }

// implementazioni dell'interfaccia stato per stato

class IdleStatus implements Status {
  log(): void { console.log("nothing happened :|"); }
}

class LoadingStatus implements Status {
  log(): void { console.log("please wait..."); }
}

class ErrorStatus implements Status {
  log(): void { console.log("ops, something went wrong :("); }
}

class SuccessStatus implements Status {
  log(): void { console.log("we did it! :D"); }
}

// il chiamante chiama il metodo dell'interfaccia
// ignorandone la casistica concreta
function logStatus(status: Status): void { status.log(); }

Qui, la funzione logStatus esiste solo per paragone con l’esempio precedente, perché, a tutti gli effetti, non aggiunge assolutamente nulla all’invocazione diretta del metodo log definito dall’interfaccia Status e implementato in tutte le sue concretizzazioni.

Prima di proseguire, fermiamoci a riflettere su pro e contro di questo approccio

  • pro: aggiungere o rimuovere un tipo di State in questa gerarchia è un’operazione esclusivamente additiva o sottrattiva, cioè non richiede di modificare le altre implementazioni, e questo rende l’intera gerarchia estremamente flessibile
  • contro: interfacce e classi sono decisamente più complesse da serializzare di una stringa, quindi, ipotizzando che lo stato provenga da una chiamata HTTP, avremo comunque bisogno di convertire la stringa iniziale in una delle classi-stato, prima di poterne invocare il metodo log
  • pro: in OOP ogni tipo si prende cura di sé stesso, quindi è pressoché impossibile “rompere” una classe dall’esterno se questa è scritta bene; 
  • contro: se si volessero implementare nuove logiche sui diversi tipi di stato, bisognerebbe aggiungere un metodo sull’interfaccia e, poi, implementarlo in ognuna delle classi, quindi bisognerebbe modificare il codice in molti punti diversi. 

L’ultimo punto è probabilmente il più doloroso e discende proprio dal principio di base dell’OOP, cioè l’idea di legare strutture di dati (cioè tipi) e le funzioni ad esse associate (cioè moduli) in modo che le seconde abbiano accesso esclusivo ai primi; questa architettura, per quanto efficace nel blindare le nostre logiche, rende molto difficile estendere correttamente gerarchie di classi fornite da codice esterno, come ad esempio librerie.

Per questo motivo, prima di proseguire, vediamo un modo per estendere il comportamento delle classi in base al loro tipo, usando dunque l’interfaccia comune tra più classi esattamente come se fosse uno union type. Possiamo farlo grazie all’operatore instanceof che riporta a runtime il tipo della classe. 

Immaginiamo di voler aggiungere al tipo Status un ulteriore metodo alert che apra un alert del browser in alcuni casi; in questo caso però, la gerarchia dei vari Status concreti proviene da una libreria esterna e, quindi, non possiamo aggiungere un metodo alert direttamente sull’interfaccia e poi implementarlo, ma dobbiamo invece creare una funzione esterna: 

function alertStatus(status: Status): void {
  if (status instanceof ErrorStatus) alert("ops!");
  else if (status instanceof SuccessStatus) alert("done!");
}

La nostra funzione alertStatus è ancora in grado di implementare diversi comportamenti per diversi sottotipi di Status ma, in questo caso, deve discriminare manualmente il tipo. In questo caso, non tutti i sottotipi hanno un comportamento implementato, nonostante dal parametro della funzione appaia che alertStatus operi su qualsiasi tipo di Status; infatti, sempre in questo caso, è come se per tutti gli stati diversi da errore e successo, il comportamento implementato sia { }, cioè “non fare nulla”.

Il motivo per cui instanceof funziona a runtime è che le classi non sono un costrutto TypeScript ma JavaScript! Ad esempio, grazie a instanceof, possiamo identificare in JavaScript i tipi Array e Date in esecuzione in qualsiasi momento. 

Sei indeciso sul percorso? 💭

Parliamone! Scrivici su Whatsapp e risponderemo a tutte le tue domande per capire quale dei nostri corsi è il più adatto alle tue esigenze.

Oppure chiamaci al 800 128 626