Ereditarietà

Edoardo Midali
Edoardo Midali

L’ereditarietà in TypeScript rappresenta uno dei pilastri fondamentali della programmazione orientata agli oggetti, permettendo di creare gerarchie di classi che condividono comportamenti comuni attraverso relazioni parent-child. Questo meccanismo facilita il riutilizzo del codice, l’estensibilità e la modellazione di relazioni “è-un” nel dominio applicativo.

Fondamenti e Sintassi

L’ereditarietà si realizza attraverso la keyword extends, che permette a una classe figlia di acquisire tutte le proprietà e i metodi pubblici e protetti della classe genitore. La classe derivata eredita l’intera interfaccia della classe base, potendo estenderla con nuove funzionalità o sovrascrivere comportamenti esistenti.

class Veicolo {
  constructor(protected marca: string, protected modello: string) {}

  descrivi(): string {
    return `${this.marca} ${this.modello}`;
  }

  avvia(): void {
    console.log("Veicolo avviato");
  }
}

class Automobile extends Veicolo {
  constructor(marca: string, modello: string, private numeroPosti: number) {
    super(marca, modello);
  }

  descrivi(): string {
    return `${super.descrivi()} con ${this.numeroPosti} posti`;
  }
}

Il Costruttore e super()

Le classi derivate devono chiamare super() nel costruttore prima di accedere a this, invocando il costruttore della classe genitore. Questo garantisce che l’inizializzazione della classe base avvenga correttamente prima che la classe derivata aggiunga la propria logica di inizializzazione.

La chiamata a super() può passare argomenti al costruttore del genitore, permettendo di configurare lo stato ereditato. L’ordine di esecuzione procede dalla classe base verso le classi derivate, garantendo che le dipendenze siano soddisfatte prima dell’uso.

Override dei Metodi

L’override permette alle classi derivate di fornire implementazioni specializzate di metodi definiti nella classe base. Il metodo sovrascritto può invocare l’implementazione originale attraverso super.nomeMetodo(), permettendo di estendere piuttosto che sostituire completamente il comportamento.

class Forma {
  constructor(protected colore: string) {}

  disegna(): void {
    console.log(`Disegno una forma ${this.colore}`);
  }

  area(): number {
    return 0;
  }
}

class Cerchio extends Forma {
  constructor(colore: string, private raggio: number) {
    super(colore);
  }

  disegna(): void {
    super.disegna();
    console.log(`È un cerchio di raggio ${this.raggio}`);
  }

  area(): number {
    return Math.PI * this.raggio ** 2;
  }
}

Modificatori di Accesso

I modificatori public, protected e private controllano la visibilità dei membri ereditati. I membri protected sono accessibili nelle classi derivate ma non dall’esterno, bilanciando incapsulamento ed estensibilità. I membri private rimangono inaccessibili anche alle sottoclassi.

Questa gerarchia di visibilità permette di definire API pubbliche stabili mentre si preserva flessibilità per l’estensione interna. Le classi derivate possono aumentare ma non diminuire la visibilità dei membri ereditati.

Catene di Ereditarietà

TypeScript supporta ereditarietà a più livelli, dove una classe può ereditare da un’altra che a sua volta eredita da una terza. Ogni classe nella catena può aggiungere funzionalità, creando specializzazioni progressive del comportamento base.

class Entita {
  constructor(public id: string) {}
}

class EntitaTemporale extends Entita {
  constructor(id: string, public creatoIl: Date = new Date()) {
    super(id);
  }
}

class EntitaUtente extends EntitaTemporale {
  constructor(id: string, public username: string) {
    super(id);
  }
}

Classi Astratte

Le classi astratte definiscono contratti parziali che le classi concrete devono completare. Marcate con abstract, non possono essere istanziate direttamente ma servono come base per gerarchie di classi che condividono struttura comune ma implementazioni diverse.

I metodi astratti dichiarano firma senza implementazione, obbligando le sottoclassi a fornire implementazioni concrete. Questo pattern garantisce coerenza di interfaccia mantenendo flessibilità implementativa.

abstract class Repository<T> {
  abstract trova(id: string): Promise<T | null>;
  abstract salva(entita: T): Promise<void>;
  abstract elimina(id: string): Promise<void>;

  async esisteId(id: string): Promise<boolean> {
    const entita = await this.trova(id);
    return entita !== null;
  }
}

class RepositoryUtenti extends Repository<Utente> {
  async trova(id: string): Promise<Utente | null> {
    // Implementazione specifica
    return database.utenti.findOne({ id });
  }

  async salva(utente: Utente): Promise<void> {
    await database.utenti.save(utente);
  }

  async elimina(id: string): Promise<void> {
    await database.utenti.delete({ id });
  }
}

Polimorfismo

L’ereditarietà abilita polimorfismo, dove riferimenti a classi base possono contenere istanze di classi derivate. Questo permette di scrivere codice generico che opera su interfacce astratte, indipendentemente dall’implementazione concreta.

abstract class Handler {
  abstract gestisci(richiesta: any): void;
}

class HandlerJSON extends Handler {
  gestisci(richiesta: any): void {
    console.log("Gestisco JSON");
  }
}

class HandlerXML extends Handler {
  gestisci(richiesta: any): void {
    console.log("Gestisco XML");
  }
}

function elabora(handlers: Handler[], richiesta: any) {
  handlers.forEach((h) => h.gestisci(richiesta));
}

Composizione vs Ereditarietà

Mentre l’ereditarietà modella relazioni “è-un”, la composizione rappresenta relazioni “ha-un”. TypeScript favorisce composizione per evitare gerarchie profonde e rigide. L’ereditarietà è appropriata per specializzazioni naturali del dominio, mentre la composizione offre maggiore flessibilità per combinare comportamenti.

Mixin e Ereditarietà Multipla

TypeScript non supporta ereditarietà multipla diretta ma offre i mixin per combinare comportamenti da multiple sorgenti. I mixin sono funzioni che estendono classi, permettendo composizione di funzionalità ortogonali.

type Constructor<T = {}> = new (...args: any[]) => T;

function Timestampable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    timestamp = new Date();
  };
}

function Loggable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    log(msg: string) {
      console.log(`[${this.constructor.name}] ${msg}`);
    }
  };
}

class Documento {
  constructor(public titolo: string) {}
}

const DocumentoAvanzato = Loggable(Timestampable(Documento));

Type Checking e instanceof

L’operatore instanceof verifica se un oggetto appartiene a una gerarchia di classi specifica, permettendo type narrowing e gestione tipizzata di oggetti polimorfici.

Pattern Template Method

L’ereditarietà facilita il pattern Template Method, dove la classe base definisce la struttura di un algoritmo delegando passi specifici a sottoclassi attraverso metodi astratti o overridabili.

Considerazioni di Design

L’ereditarietà crea accoppiamento forte tra classi. Gerarchie profonde possono diventare fragili e difficili da mantenere. Preferire ereditarietà per relazioni stabili e ben definite del dominio, usando composizione e interfacce per flessibilità maggiore.

Le classi base dovrebbero essere progettate per l’estensione, documentando quali metodi sono sicuri da sovrascrivere e quali invarianti devono essere preservati. Il principio di sostituzione di Liskov guida design corretto: le sottoclassi devono essere sostituibili alle loro classi base senza alterare la correttezza del programma.

Performance e Ottimizzazione

L’ereditarietà ha overhead minimo in TypeScript, compilando a prototypal inheritance JavaScript. La risoluzione di metodi attraverso la catena prototipale è efficiente, ma catene molto profonde possono impattare prestazioni in scenari ad alta frequenza.

L’ereditarietà in TypeScript fornisce quindi un meccanismo potente ma da usare giudiziosamente, bilanciando riutilizzo del codice con manutenibilità e flessibilità architetturale.