Classi Astratte

Edoardo Midali
Edoardo Midali

Le classi astratte in TypeScript rappresentano un meccanismo fondamentale per definire strutture base che non possono essere istanziate direttamente ma servono come modelli per classi derivate. Questo pattern è essenziale nell’architettura software per stabilire contratti comuni e implementazioni parziali che le sottoclassi devono completare.

Concetto Fondamentale

Una classe astratta si distingue dalle classi normali per l’impossibilità di essere istanziata direttamente. Il suo scopo principale è fornire una base comune per altre classi, definendo sia implementazioni concrete che metodi astratti che devono essere implementati dalle classi figlie. Questo approccio garantisce uniformità nell’interfaccia pubblica mantenendo flessibilità nell’implementazione specifica.

La keyword abstract applicata a una classe impedisce la sua istanziazione diretta e può essere applicata anche ai metodi per indicare che devono essere implementati nelle sottoclassi. Questo meccanismo offre un controllo compile-time che garantisce l’implementazione completa dei contratti definiti.

abstract class Animal {
  protected name: string;

  constructor(name: string) {
    this.name = name;
  }

  // Metodo concreto disponibile per tutte le sottoclassi
  getName(): string {
    return this.name;
  }

  // Metodo astratto che deve essere implementato
  abstract makeSound(): string;
  abstract move(): void;
}

class Dog extends Animal {
  makeSound(): string {
    return "Woof!";
  }

  move(): void {
    console.log(`${this.name} is running`);
  }
}

Metodi Astratti vs Concreti

Le classi astratte possono contenere sia metodi astratti che concreti. I metodi concreti forniscono implementazioni riutilizzabili che le sottoclassi ereditano automaticamente, mentre i metodi astratti definiscono contratti che ogni sottoclasse deve rispettare fornendo la propria implementazione.

I metodi astratti non possono avere un corpo di implementazione nella classe astratta e devono essere marcati con la keyword abstract. Questo approccio permette di definire l’interfaccia pubblica mantenendo la flessibilità implementativa nelle classi derivate.

La presenza di anche un solo metodo astratto rende obbligatorio dichiarare l’intera classe come astratta. Questo vincolo garantisce la coerenza del design e previene istanziazioni incomplete.

Proprietà e Costruttori

Le classi astratte possono definire proprietà e costruttori che vengono ereditati dalle sottoclassi. I costruttori delle classi astratte vengono invocati attraverso super() nelle classi derivate, permettendo l’inizializzazione delle proprietà base.

Le proprietà possono utilizzare tutti i modificatori di accesso (public, protected, private) con particolare utilità per protected che permette l’accesso alle sottoclassi mantenendo l’incapsulamento verso l’esterno.

abstract class Shape {
  protected readonly id: string;
  protected color: string;

  constructor(color: string) {
    this.id = Math.random().toString();
    this.color = color;
  }

  getInfo(): string {
    return `Shape ${this.id} with color ${this.color}`;
  }

  abstract calculateArea(): number;
  abstract getPerimeter(): number;
}

class Circle extends Shape {
  constructor(color: string, private radius: number) {
    super(color);
  }

  calculateArea(): number {
    return Math.PI * this.radius ** 2;
  }

  getPerimeter(): number {
    return 2 * Math.PI * this.radius;
  }
}

Vantaggi del Design Pattern

Le classi astratte offrono numerosi vantaggi architetturali. Primo, garantiscono l’uniformità dell’interfaccia across multiple implementazioni, assicurando che tutte le sottoclassi rispettino il contratto definito. Secondo, permettono la condivisione di codice comune attraverso metodi concreti, riducendo la duplicazione.

Terzo, forniscono un meccanismo di controllo compile-time che previene errori di implementazione incompleta. Il compilatore TypeScript verifica che tutte le classi derivate implementino i metodi astratti richiesti.

Quarto, facilitano l’estensibilità del codice permettendo l’aggiunta di nuove implementazioni senza modificare il codice esistente. Questo principio, noto come Open/Closed Principle, è fondamentale nella progettazione software maintainable.

Differenze con Interfacce

Mentre le interfacce definiscono solo contratti senza implementazione, le classi astratte possono contenere sia contratti (metodi astratti) che implementazioni concrete. Le interfacce supportano l’ereditarietà multipla attraverso implements, mentre le classi astratte seguono l’ereditarietà singola attraverso extends.

Le classi astratte sono particolarmente utili quando si ha un set di implementazioni condivise che si vuole riutilizzare, mentre le interfacce sono preferibili per definire contratti puri senza logica implementativa.

Una classe può implementare multiple interfacce ma può estendere solo una classe astratta. Tuttavia, può combinare entrambi gli approcci, estendendo una classe astratta e implementando una o più interfacce.

Tipizzazione e Polimorfismo

Le classi astratte supportano completamente il polimorfismo, permettendo di trattare istanze di classi derivate attraverso il tipo della classe astratta. Questo approccio facilita la scrittura di codice generico che opera su diversi tipi concreti attraverso un’interfaccia comune.

Il sistema di tipi TypeScript garantisce che le operazioni eseguite su riferimenti di tipo classe astratta siano valide per tutte le implementazioni concrete, fornendo type safety senza sacrificare la flessibilità.

function processShapes(shapes: Shape[]): void {
  shapes.forEach((shape) => {
    console.log(shape.getInfo());
    console.log(`Area: ${shape.calculateArea()}`);
    // TypeScript garantisce che questi metodi esistano
  });
}

const shapes: Shape[] = [new Circle("red", 5), new Rectangle("blue", 10, 20)];

processShapes(shapes); // Polimorfismo in azione

Best Practices

Quando si progettano classi astratte, è importante mantenere un equilibrio tra generalità e specificità. La classe astratta dovrebbe contenere solo funzionalità veramente comuni alle sottoclassi, evitando di forzare implementazioni non appropriate.

I metodi astratti dovrebbero definire contratti chiari e coerenti, con signature che hanno senso per tutte le possibili implementazioni. È preferibile avere interfacce più semplici e specifiche piuttosto che complesse e generiche.

L’uso dei modificatori di accesso dovrebbe essere attentamente considerato: protected per elementi che le sottoclassi devono poter accedere, private per dettagli implementativi della classe astratta stessa.

Le classi astratte rappresentano quindi uno strumento potente per la creazione di gerarchie di classi ben strutturate, fornendo un meccanismo elegante per bilanciare riutilizzo del codice, type safety e flessibilità implementativa.