7  Il modello dati dei linguaggi di programmazione

Un modello dati è una rappresentazione formale dei tipi di dati del linguaggio e delle operazioni che possono essere eseguite su di essi. Esso definisce le strutture fondamentali attraverso le quali i dati vengono organizzati, memorizzati, manipolati e interagiscono all’interno del programma.

Le componenti il modello dati sono:

  1. Tipi di dati:

    • Tipi primitivi: Questi sono i tipi di dati fondamentali che il linguaggio supporta nativamente, come numeri interi, numeri in virgola mobile, caratteri e booleani.

    • Tipi compositi: Questi sono tipi di dati costruiti combinando tipi primitivi. Esempi comuni includono array, liste, tuple, set e dizionari.

    • Tipi di dati definiti dall’utente: Questi sono tipi di dati che possono essere definiti dagli utenti del linguaggio, come le struct in C oppure le classi in Python o C++, che permettono di creare tipi di dati personalizzati.

  2. Operazioni:

    • Operazioni aritmetiche: Operazioni che possono essere eseguite sui tipi di dati, come addizione, sottrazione, moltiplicazione e divisione per i numeri.

    • Operazioni logiche: Operazioni che coinvolgono valori booleani, come AND, OR e NOT.

    • Operazioni di sequenza: Operazioni che si possono eseguire su sequenze di dati, come l’indicizzazione, la slicing e l’iterazione.

    • Altre operazioni ad hoc per il tipo di dato.

  3. Regole di comportamento:

    • Mutabilità: Determina se un oggetto può essere modificato dopo la sua creazione. Oggetti mutabili, come liste e dizionari in Python, possono essere cambiati. Oggetti immutabili, come tuple e stringhe, non possono essere modificati dopo la loro creazione.

    • Copia e clonazione: Regole che determinano come i dati vengono copiati. Per esempio, in Python, la copia di una lista crea una nuova lista con gli stessi elementi, mentre la copia di un intero crea solo un riferimento allo stesso valore.

7.1 Linguaggi procedurali

Nei linguaggi di programmazione procedurali, il modello dati è incentrato su tipi di dati semplici e compositi che supportano lo stile di programmazione orientato alle funzioni e procedure. Alcune caratteristiche tipiche includono:

  • Tipi primitivi: Numeri interi, numeri a virgola mobile, caratteri e booleani.

  • Strutture composite: Array, strutture (struct) e unioni (union). Gli array permettono di gestire collezioni di elementi dello stesso tipo, mentre le strutture permettono di combinare vari tipi di dati sotto un unico nome. Le unioni consentono di memorizzare diversi tipi di dati nello stesso spazio di memoria, ma solo uno di essi può essere attivo alla volta.

  • Operazioni basate su funzioni: Le operazioni sui dati vengono eseguite attraverso funzioni che manipolano i valori passati come argomenti.

Esempio in C:

#include <stdio.h>
#include <string.h>

#define MAX_DATI 100

1union Valore {
  int intero;
  float decimale;
  char carattere;
};

2struct Dato {
  char tipo;  
  // 'i' per int, 'f' per float, 'c' per char
  union Valore valore;
};

3void stampa_dato(struct Dato d) {
  switch (d.tipo) {
    case 'i':
      printf("Intero: %d\n", d.valore.intero);
      break;

    case 'f':
      printf("Float: %f\n", d.valore.decimale);
      break;

    case 'c':
      printf("Carattere: %c\n", d.valore.carattere);
      break;

    default:
      printf("Tipo sconosciuto\n");
      break;
  }
}

int confronta_dato(struct Dato d1, struct Dato d2) {
  if (d1.tipo != d2.tipo) return 0;

  switch (d1.tipo) {
    case 'i': return d1.valore.intero == d2.valore.intero;

    case 'f': return d1.valore.decimale == d2.valore.decimale;

    case 'c': return d1.valore.carattere == d2.valore.carattere;

    default: return 0;
  }
}

4void inserisci_dato(struct Dato dati[], int *count, struct Dato nuovo_dato) {
  if (*count < MAX_DATI) {
    dati[*count] = nuovo_dato;

    (*count)++;

  } else {
    printf("Array pieno, impossibile inserire nuovo dato.\n");
  }
}

5void cancella_dato(struct Dato dati[], int *count, struct Dato dato_da_cancellare) {
  for (int i = 0; i < *count; i++) {
    if (confronta_dato(dati[i], dato_da_cancellare)) {
      for (int j = i; j < *count - 1; j++) {
        dati[j] = dati[j + 1];
      }

      (*count)--;

      i--; 
    }
  }
}

int main() {
6  struct Dato dati[MAX_DATI];
  int count = 0;

  struct Dato dato1 = {'i', .valore.intero = 42};
  struct Dato dato2 = {'f', .valore.decimale = 3.14};
  struct Dato dato3 = {'c', .valore.carattere = 'A'};
  
7  inserisci_dato(dati, &count, dato1);
  inserisci_dato(dati, &count, dato2);
  inserisci_dato(dati, &count, dato3);

8  for (int i = 0; i < count; i++) {
    stampa_dato(dati[i]);
  }

9  cancella_dato(dati, &count, dato1);

  printf("Dopo cancellazione:\n");

  for (int i = 0; i < count; i++) {
    stampa_dato(dati[i]);
  }

  return 0;
}
1
Definizione di una union.
2
Definizione di una struct che include la union.
3
Funzione per stampare i valori in base al tipo.
4
Funzione per inserire un nuovo dato alla fine dell’array.
5
Funzione per cancellare tutte le occorrenze di un dato dall’array.
6
Definizione di un array di struct Dato.
7
Inserimento di dati nell’array.
8
Stampa dei dati nell’array.
9
Cancellazione di un dato specifico e ristampa dell’array.

L’esempio mostra come nel modello dati del linguaggio C possono essere definiti dei tipi compositi (Dato, Valore) e delle operazioni su quelli (stampa_dato, confronta_dato, inserisci_dato, cancella_dato). Il codice, pur realizzante una semplice libreria, appare slegato, cioè con funzioni che si applicano a tipi di dati specifici solo dall’interpretazione degli identificatori della funzione stessa e dei suoi parametri, cioè senza un legame esplicito e non ambiguo, tra tipo e funzione.

7.2 Linguaggi orientati agli oggetti

La programmazione orientata agli oggetti è un paradigma che utilizza oggetti per rappresentare concetti ed entità del mondo reale o astratto. Questo approccio si basa su un processo mentale fondamentale per risolvere problemi complessi: la decomposizione. Un problema complesso è più facilmente risolvibile se diviso in parti più piccole, ciascuna delle quali possiede uno stato e la possibilità di interagire con le altre parti. Questa divisione può essere effettuata per gradi, come se si osservasse sempre più da vicino il problema, effettivamente continunandone la specificazione, fino a raggiungere un livello sufficientemente di dettaglio da poter essere realizzato come istruzioni, codificate in costrutti permessi dalla sintassi del linguaggio, dell’oggetto.

7.2.1 Oggetti

Lo stato di un oggetto è definito dai suoi attributi, i cui valori possono essere altri oggetti già disponibili, sia definiti dall’utente che dal linguaggio. L’interazione tra diversi oggetti avviene attraverso i metodi, che sono funzioni associate agli oggetti che possono modificare lo stato dell’oggetto o invocare metodi su altri oggetti.

I membri di un oggetto (attributi e metodi) possono avere diverse limitazioni di accesso, definite dal concetto di visibilità:

  • Pubblica: Gli attributi e i metodi pubblici sono accessibili da qualsiasi parte del programma. Questa visibilità permette a qualsiasi altro oggetto o funzione di interagire con questi membri.

  • Privata: Gli attributi e i metodi privati sono accessibili solo da altri membri dell’oggetto e rispondono alla esigenza di separare il codice di interfaccia da quello utile al funzionamento interno.

  • Protetta: Gli attributi e i metodi protetti sono accessibili da tutti i membri del medesimo oggetto ma, a differenza dei privati, anche da quelli degli oggetti derivati. Questo fornisce un livello intermedio di accesso, utile per la gestione dell’ereditarietà.

L’incapsulamento è il principio su cui si basa la gestione della visibilità e guida la separazione del codice realizzante le specificità di un oggetto, da come è fruito dagli altri oggetti. Questo protegge l’integrità del suo stato e ne facilita la manutenzione del codice stesso, permettendo modifiche di implementazione, senza impatti sul codice esterno fintantoché non si cambiano i membri pubblici. Inoltre, se ben sfruttata nella progettazione, rende il codice più comprensibile e riduce la superficie d’attacco.

7.2.2 Classi

Un oggetto può essere generato da una struttura statica che ne definisce tutte le caratteristiche, la classe, oppure può essere creato a partire da un altro oggetto esistente, noto come prototipo.

Nella programmazione ad oggetti basata su classi, ogni oggetto è un’istanza vivente di una classe predefinita, che ne rappresenta il progetto o l’archetipo. La classe definisce i membri e la visibilità, quindi, in definitiva tutte le proprietà comuni agli oggetti dello stesso tipo o matrice. Gli oggetti vengono creati chiamando un metodo speciale della classe, noto come costruttore e, all’atto della loro vita, un secondo metodo, il distruttore, che si occupa di effettuare le azioni di terminazione.

La classe può inoltre definire metodi e attributi particolari, che possono essere ereditati da altre classi, cioè possono essere utilizzati da quest’ultime al pari dei propri membri. In tal modo, il linguaggio permette la costruzioni di gerarchie di classi che modellano relazioni di specializzazione, dalla più generale alla più particolare.

Ciò, oltre ad essere uno strumento di progettazione utile di per sé, facilita il riuso del codice per mezzo dell’estensione, al posto della modifica, di funzionalità. La classe che eredita da un’altra classe si definisce derivata dalla classe che, a sua volta, è detta base.

7.2.3 Prototipi

Alternativamente, alcuni linguaggi usano il concetto di prototipo, in cui gli oggetti sono le entità principali e non esiste una matrice separata come la classe. In questo paradigma, ogni oggetto può servire da prototipo per altri e ciò significa che, invece di creare nuove istanze di una classe, si creano nuovi oggetti clonando o estendendo quelli esistenti. È possibile aggiungere o modificare proprietà e metodi di un oggetto prototipo e, in tal caso, queste modifiche si propagheranno in tutti gli oggetti che derivano da esso.

Il paradigma basato su prototipi offre maggiore flessibilità e dinamismo rispetto a quello basato su classi, poiché la struttura degli oggetti può essere modificata in modo dinamico. D’altronde, questo approccio può anche introdurre complessità e rendere più difficile la gestione delle gerarchie di oggetti e la comprensione del codice, poiché non esistono strutture fisse come le classi.

7.2.4 Esempi di gerarchie di classi e prototipi

Vediamo le differenze tra classi e prototipi, riprendendo l’esempio in Java nella versione semplificata (senza astrazione):

class Animale { 
    String nome;

    Animale(String nome) {
        this.nome = nome;
    }

    void faiVerso() {
        System.out.println("L'animale fa un verso");
    }
}

class Cane extends Animale { 

    Cane(String nome) {
        super(nome);
    }

    @Override
    void faiVerso() {
        System.out.println("Il cane abbaia");
    }
}

public class Main {
    public static void main(String[] args) {
        Animale mioCane = new Cane("Fido");

        mioCane.faiVerso(); 
    }
}

Implementiamo il medesimo programma in Javascript1, linguaggio che usa il concetto di prototipo:

1 In JavaScript, le classi come sintassi sono state introdotte in ECMAScript 6 (ES6), per semplificare la creazione di oggetti e la gestione dell’ereditarietà prototipale. Tuttavia, è importante capire che sotto il cofano, JavaScript non utilizza classi nel senso tradizionale come in linguaggi come Java o C++ e non esiste un meccanismo nativo per creare classi astratte, anche se è possibile simulare il comportamento delle classi astratte utilizzando varie tecniche. Una comune è quella di lanciare un’eccezione se un metodo funzionalmente astratto non viene sovrascritto nella classe derivata.

1let Animale = {
    nome: "Generic",

    init: function(nome) {
        this.nome = nome;
    },

    faiVerso: function() {
        console.log("L'animale fa un verso");
    }
};

2let Cane = Object.create(Animale);

3Cane.faiVerso = function() {
    console.log("Il cane abbaia");
};

4let mioCane = Object.create(Cane);
5mioCane.init("Fido");

6mioCane.faiVerso();
1
Definizione dell’oggetto prototipo Animale.
2
Creazione di un nuovo oggetto basato sul prototipo Animale.
3
Viene creato un nuovo oggetto Cane basato sul prototipo Animale, usando Object.create(Animale). Questo permette a Cane di ereditare proprietà e metodi da Animale. Il metodo faiVerso viene sovrascritto nell’oggetto Cane per specificare il comportamento da cane.
4
Un nuovo oggetto mioCane viene creato basandosi sul prototipo Cane usando Object.create(Cane).
5
Il metodo init viene chiamato per inizializzare il nome dell’oggetto mioCane.
6
Quando viene chiamato mioCane.faiVerso(), il metodo sovrascritto nell’oggetto Cane viene eseguito, mostrando Il cane abbaia.

7.2.5 Ereditarietà

Come abbiamo visto, l’ereditarietà è un meccanismo che permette a una classe di ereditare membri da un’altra classe. Essa si può presentare singola o multipla, ove la prima consente a una classe derivata di estendere solo una classe base. Questo è il modello di ereditarietà più comune e supportato da molti linguaggi di programmazione orientati agli oggetti, come Java e C#.

L’ereditarietà multipla è tale da permettere a una classe di ricevere attributi e metodi contemporaneamente da più classi base. Questo meccanismo risponde all’esigenza di specializzare più concetti allo stesso tempo. Va sottolineato che è uno strumento potente prono, però, ad abusi, perché può introdurre complessità nella gestione delle gerarchie di classi e causare conflitti quando lo stesso metodo è ereditato da più classi, situazione nota come problema del diamante. Pertanto, alcuni linguaggi ne limitano l’applicazione, come Java che consente solo l’ereditarietà multipla di interfacce, ma non di classi. Altri, come Go, non supportano l’ereditarietà per scelta di progettazione. Go enfatizza la composizione rispetto all’ereditarietà per promuovere uno stile di programmazione più essenziale e flessibile. La composizione consente di costruire comportamenti complessi aggregando oggetti più semplici, evitando le complicazioni delle gerarchie di classi multilivello. Il C++, invece, supporta completamente l’ereditarietà multipla.

7.2.6 Interfacce e classi astratte

Le interfacce e le classi astratte sono due concetti fondamentali nella programmazione orientata agli oggetti, che consentono di definire contratti che le classi concrete devono rispettare.

Un’interfaccia è un contratto che specifica un insieme di metodi che una classe deve implementare, senza fornire l’implementazione effettiva di questi metodi. Sono utilizzate per definire comportamenti comuni che possono essere condivisi da classi diverse, indipendentemente dalla loro posizione nella gerarchia delle classi. Le classi che implementano un’interfaccia devono fornire una definizione concreta per tutti i metodi dichiarati nell’interfaccia. In Java, ad esempio, le interfacce sono definite con la parola chiave interface.

Una classe astratta è una classe che non può essere istanziata direttamente. Può contenere sia metodi astratti (senza codice al loro interno, che devono essere implementati dalle classi derivate) sia metodi concreti (con codice allinterno, che possono essere utilizzati dalle classi derivate). Le classi astratte sono utilizzate per fornire una base comune con alcune implementazioni di default e lasciare ad altre classi il compito di completare l’implementazione. In Java, le classi astratte sono definite con la parola chiave abstract.

Esempio di interfaccia, classa astratta e ereditarietà multipla in Java:

1interface Domesticazione {
2  void assegnaAddomesticato(boolean addomesticato);
3  boolean ottieniAddomesticato();
}

4abstract class Animale {
  String nome;

  Animale(String nome) {
    this.nome = nome;
  }

5  abstract String faiVerso();

6  String descrizione() {
    return "L'animale si chiama " + nome;
  }
}

7class Cane extends Animale implements Domesticazione {
8  private boolean addomesticato;

  Cane(String nome) {
    super(nome);
  }

  @Override
9  String faiVerso() {
    return "Il cane abbaia";
  }

  @Override
10  public void assegnaAddomesticato(boolean addomesticato) {
    this.addomesticato = addomesticato; 
  }

  @Override
11  public boolean ottieniAddomesticato() {
    return addomesticato; 
  }
}

12class Coccodrillo extends Animale {

  Coccodrillo(String nome) {
    super(nome);
  }

  @Override
  String faiVerso() {
    return "";  
  }
}

public class Main {
  public static void main(String[] args) {
13    Cane mioCane = new Cane("Fido");

    System.out.println(mioCane.descrizione()); 
    System.out.println(mioCane.faiVerso());    

    mioCane.assegnaAddomesticato(true); 
    System.out.println("Cane addomesticato: " + 
                       mioCane.ottieniAddomesticato());

14    Coccodrillo mioCoccodrillo = new Coccodrillo("Crocky");

    System.out.println(mioCoccodrillo.descrizione()); 
    System.out.println(mioCoccodrillo.faiVerso());   
  }
}
1
Interfaccia che definisce una proprietà che gli animali possono possedere, la domesticazione. Da notare che la domesticazione è una proprietà complementare alle altre caratterizzanti l’animale, addirittura non aprioristica.
2
Metodo per impostare lo stato di addomesticamento dell’animale.
3
Metodo per verificare se è addomesticato.
4
Classe astratta che ha l’implementazione di una caratteristica condivisa dalle classi derivate, descrizione(), e un metodo astratto per una seconda, faiVerso(), che, deve essere sempre presente negli oggetti di tipo base animale, ma non ne è comume l’implementazione.
5
Metodo astratto.
6
Metodo concreto.
7
Il cane è un animale che può essere addomesticato, quindi la classe Cane deriva Animale (cioè deve implementare necessariamente faiVerso()) e implementa Domesticazione (cioè deve implementare assegnaAddomesticato() e ottieniAddomesticato()). descrizione() viene ereditato colla implementazione di Animale.
8
Variabile utile a registrare se il cane è stato addomesticato.
9
Cane implementa faiVerso() di Animale.
10
Cane implementa assegnaAddomesticato() di Domesticazione.
11
Cane implementa ottieniAddomesticato() di Domesticazione.
12
Il coccodrillo non è addomesticabile, quindi, Coccodrillo non implementa l’interfaccia Domesticazione, ma è comunque un animale quindi deriva Animale e ne implementa l’unico metodo astratto faiVerso(). Non essendo addomesticabile, non ha neanche l’attributo addomesticato.
13
Creazione dell’oggetto Cane.
14
Creazione dell’oggetto Coccodrillo.

Le interfacce e le classi astratte sono strumenti potenti per promuovere la riusabilità del codice e l’estensibilità dei sistemi software, poiché permettono di definire contratti chiari e di implementare diverse versioni di una funzionalità senza modificare il codice preesistente.

7.2.7 Polimorfismo

Il polimorfismo è un concetto chiave della programmazione orientata agli oggetti che permette a oggetti di classi diverse di essere trattati come oggetti di una classe comune. È uno strumento complementare all’ereditarietà, nelle mani del programmatore, utile a modellare comportamenti comuni per oggetti di tipi diversi, permettendo al codice di interagire con questi oggetti senza conoscere esattamente il loro tipo specifico. In termini pratici, il polimorfismo permette di chiamare metodi su oggetti di tipi diversi e ottenere comportamenti specifici a seconda del tipo di oggetto su cui viene chiamato il medesimo metodo.

Il concetto di polimorfismo è strettamente legato all’idea di contratto tra oggetti. Questo contratto è definito dalle interfacce o dalle classi base e specifica quali metodi devono essere implementati dalle classi derivate. Quando un oggetto di una classe derivata è trattato come un oggetto della classe base o di un’interfaccia, si garantisce che esso rispetti il contratto definito dalla classe base o dall’interfaccia.

Esistono due tipi principali di polimorfismo:

  • Polimorfismo statico: Conosciuto soprattutto come overloading, si verifica quando più metodi nella stessa classe hanno lo stesso nome ma firme diverse (diverso numero o tipo di parametri). Il compilatore decide quale metodo chiamare in base alla firma del metodo.

Esempio in Java che supporta l’overloading:

class Esempio {
  void stampa(int a) {
    System.out.println("Intero: " + a);
  }

  void stampa(String a) {
    System.out.println("Stringa: " + a);
  }
}

public class Main {
  public static void main(String[] args) {
    Esempio es = new Esempio();

1    es.stampa(5);
2    es.stampa("ciao");
  }
}
1
Chiama il metodo stampa(int a).
2
Chiama il metodo stampa(String a).
  • Polimorfismo dinamico: Noto come overriding, si verifica quando una classe derivata fornisce una specifica implementazione di un metodo già definito nella sua classe base. L’implementazione da chiamare è determinata a runtime, cioè a tempo di esecuzione e non compilazione, in base al tipo dell’oggetto.

Esempio in Java riprendendo l’esempio con gli animali:

class Animale {
  void faiVerso() {
    System.out.println("L'animale fa un verso");
  }
}

class Cane extends Animale {
  @Override
  void faiVerso() {
    System.out.println("Il cane abbaia");
  }
}

public class Main {
  public static void main(String[] args) {
1    Animale mioAnimale = new Cane();

2    mioAnimale.faiVerso();
  }
}
1
L’oggetto mioAnimale è dichiarato come tipo Animale ma istanziato come Cane. Questo è un esempio di polimorfismo.
2
Il metodo faiVerso() viene chiamato sull’oggetto mioAnimale, ma viene eseguita la versione del metodo faiVerso() definita nella classe Cane, grazie al polimorfismo.

Il polimorfismo è strettamente legato all’ereditarietà, poiché l’ereditarietà è spesso il meccanismo che permette al polimorfismo di funzionare. Quando una classe derivata estende una classe base e sovrascrive i suoi metodi, permette agli oggetti della classe derivata di essere trattati come oggetti della classe base ma di comportarsi in modo specifico alla classe derivata.

I linguaggi di programmazione hanno delle differenze in relazione al supporto del polimorfismo:

  • Java: Supporta sia l’overloading che l’overriding.

  • C++: Supporta sia l’overloading che l’overriding. Fornisce meccanismi per specificare il tipo di legame (statico o dinamico) usando parole chiave come virtual.

  • Python: Supporta l’overriding, ma non l’overloading nello stesso senso di Java o C++. Python permette la definizione di metodi con argomenti predefiniti o argomenti variabili per ottenere un effetto simile all’overloading.

Esempio in Java da confrontare con quello seguente in Python:

class Animale {
1  void faiVerso() {
    System.out.println("L'animale fa un verso"); 
  }
}

class Cane extends Animale {
  @Override
2  void faiVerso() {
    System.out.println("Il cane abbaia"); 
  }

3  void faiVerso(String suono) {
    System.out.println("Il cane fa: " + suono); 
  }
}

public class Main {
  public static void main(String[] args) {
4    Animale mioAnimale = new Cane();
5    mioAnimale.faiVerso();

    Cane mioCane = new Cane();
6    mioCane.faiVerso("bau");
  }
}
1
Metodo faiVerso() definito nella classe base Animale.
2
Overriding del metodo faiVerso() nella classe derivata Cane.
3
Overloading del metodo faiVerso() nella classe derivata Cane.
4
Dichiarazione di un oggetto di tipo Animale, ma istanziato come Cane.
5
Chiamata al metodo faiVerso(), che esegue la versione del metodo nella classe Cane grazie al polimorfismo.
6
Chiamata al metodo faiVerso(String suono), che dimostra l’overloading del metodo nella classe Cane.

E in Python diventa:

class Animale:
1  def fai_verso(self):
    print("L'animale fa un verso")  

class Cane(Animale):
2  def fai_verso(self):
    print("Il cane abbaia")  

3  def fai_verso_con_suono(self, suono):
    print(f"Il cane fa: {suono}")  

4mio_animale = Cane()
5mio_animale.fai_verso()

mio_cane = Cane()
6mio_cane.fai_verso_con_suono("bau")
1
Metodo fai_verso() definito nella classe base Animale.
2
Overriding del metodo fai_verso() nella classe derivata Cane.
3
Definizione di un metodo aggiuntivo fai_verso_con_suono nella classe derivata Cane (Python non supporta l’overloading nello stesso senso di Java).
4
Dichiarazione e istanziazione di un oggetto mio_animale come Cane.
5
Chiamata al metodo fai_verso(), che esegue la versione del metodo nella classe Cane grazie al polimorfismo.
6
Chiamata al metodo fai_verso_con_suono(suono), che dimostra una forma di polimorfismo simile all’overloading in Python.

L’overriding è possibile grazie al dynamic dispatch, un meccanismo che consente di selezionare a runtime il metodo corretto da invocare in base al tipo effettivo dell’oggetto. Lo static disptach, al contrario, avviene al tempo di compilazione.

Ma il dispatch, cioè l’individuazione del metodo da eseguire, può essere singolo (single dispatch), così come presente nella maggior parte dei linguaggi orientati agli oggetti come Java e C++, e dove la scelta del metodo dipende solo dal tipo dell’oggetto sul quale il metodo stesso viene chiamato. Questo tipo di dispatch è sufficiente per supportare il polimorfismo detto di sottotipo, dove le classi derivate possono sovrascrivere i metodi della classe base e il metodo corretto viene selezionato a runtime in base al tipo effettivo dell’oggetto.

Il multiple dispatch, invece, estende ulteriormente le capacità del polimorfismo permettendo la selezione del metodo da invocare basandosi sui tipi runtime di più di un argomento. Questo è particolarmente utile in scenari dove il comportamento dipende da combinazioni di tipi di oggetti, e non solo dal tipo dell’oggetto su cui il metodo è chiamato. Linguaggi come Julia e CLOS (Common Lisp Object System)supportano nativamente il multiple dispatch, mentre linguaggi come Java e C++ non lo supportano direttamente ma possono emularlo attraverso pattern come il visitor.

7.2.8 Altri concetti

Dopo aver compreso i concetti fondamentali della programmazione orientata agli oggetti (OOP), come oggetti, classi, prototipi, ereditarietà e polimorfismo, è importante esplorare altri aspetti avanzati che contribuiscono alla potenza e alla flessibilità di questo paradigma.

7.2.8.1 Mixin e trait

I mixin e i trait sono concetti che permettono di aggiungere funzionalità a una classe senza utilizzare l’ereditarietà classica.

I mixin sono classi che offrono metodi che possono essere utilizzati da altre classi senza essere una classe base di queste ultime. Permettono di combinare comportamenti comuni tra diverse classi.

Esempio:

class MixinA:
    def metodo_a(self):
        print("Metodo A")

class MixinB:
    def metodo_b(self):
        print("Metodo B")

class ClasseConMixin(MixinA, MixinB):
    pass

obj = ClasseConMixin()
obj.metodo_a()
obj.metodo_b()

I trait sono simili ai mixin e permettono di definire metodi che possono essere riutilizzati in diverse classi. Sono supportati nativamente in linguaggi come Scala e Rust.

trait TraitA {
    def metodoA(): Unit = println("Metodo A")
}

trait TraitB {
    def metodoB(): Unit = println("Metodo B")
}

class ClasseConTrait extends TraitA with TraitB

val obj = new ClasseConTrait()
obj.metodoA()
obj.metodoB()

7.2.8.2 Duck Typing

Il duck typing è un concetto che si applica principalmente nei linguaggi dinamici, dove l’importanza è data al comportamento degli oggetti piuttosto che alla loro appartenenza a una specifica classe. Se un oggetto implementa i metodi richiesti da una certa operazione, allora può essere utilizzato per quella operazione, indipendentemente dal suo tipo.

Esempio:

class Anatra:
    def quack(self):
        print("Quack!")

class Persona:
    def quack(self):
        print("Sono una persona che imita un'anatra")

def fai_quack(oggetto):
    oggetto.quack()

anatra = Anatra()
persona = Persona()

fai_quack(anatra)
fai_quack(persona)