2  I paradigmi di programmazione

I linguaggi di programmazione possono essere classificati in diversi modi in base a scopo, struttura o altre caratterstiche di progettazione.

Una delle classificazioni più importanti è quella del paradigma di programmazione, che definisce il modello e gli stili di risoluzione dei problemi che un linguaggio supporta per mezzo della codificazione degli algoritmi.

Tuttavia, è importante notare che molti linguaggi moderni sfruttano efficacemente più di un paradigma di programmazione, rendendo difficile assegnare un linguaggio a una sola categoria.

Come ha affermato Bjarne Stroustrup, il creatore del C++:

Le funzionalità dei linguaggi esistono per fornire supporto agli stili di programmazione. Per favore, non considerate una singola funzionalità di linguaggio come una soluzione, ma come un mattoncino da un insieme variegato che può essere combinato per esprimere soluzioni.

I principi generali per il design e la programmazione possono essere espressi semplicemente:

  • Esprimere idee direttamente nel codice.

  • Esprimere idee indipendenti in modo indipendente nel codice.

  • Rappresentare le relazioni tra le idee direttamente nel codice.

  • Combinare idee espresse nel codice liberamente, solo dove le combinazioni hanno senso.

  • Esprimere idee semplici in modo semplice.

Questi sono ideali condivisi da molte persone, ma i linguaggi progettati per supportarli possono differire notevolmente. Una ragione fondamentale per questo è che un linguaggio incorpora una serie di compromessi ingegneristici che riflettono le diverse necessità, gusti e storie di vari individui e comunità. (Stroustrup (2013), p.10)

STROUSTRUP, Bjarne, 2013. The C++ Programming Language. 4th. https://dl.acm.org/doi/10.5555/2543987; Addison-Wesley Professional. ISBN 0321563840.
Il libro "The C++ Programming Language" di Bjarne Stroustrup, inventore del C++, nella sua quarta edizione, è una risorsa imprescindibile per padroneggiare il linguaggio C++ fino alla versione C++11. Stroustrup fornisce una guida dettagliata e autorevole su come utilizzare le funzionalità del linguaggio per migliorare le prestazioni, la leggibilità e l’affidabilità del codice.Punti chiave del libro includono:- Copertura approfondita di tutte le caratteristiche del linguaggio C++, dalle fondamenta alle novità del C++11.- Descrizione dettagliata della struttura e dell’interpretazione del linguaggio.- Tecniche avanzate come smart pointers e move semantics.- Utilizzo efficace delle librerie C++.- Uso dei template per codice generico.- Strumenti per la programmazione concorrente.

2.1 L’importanza dei paradigmi di programmazione

Comprendere i paradigmi di programmazione è fondamentale per diversi motivi:

  • Approccio alla risoluzione dei problemi: Ogni paradigma offre una visione diversa su come affrontare e risolvere problemi. Conoscere vari paradigmi permette ai programmatori di scegliere l’approccio più adatto in base al problema specifico. Ad esempio, per problemi che richiedono una manipolazione di stati, la programmazione imperativa può essere più intuitiva. Al contrario, per problemi che richiedono trasformazioni di dati senza effetti collaterali, la programmazione funzionale potrebbe essere più adatta.

  • Versatilità e adattabilità: I linguaggi moderni che supportano più paradigmi permettono ai programmatori di essere più versatili e adattabili. Possono utilizzare il paradigma più efficiente per diverse parti del progetto, migliorando sia la leggibilità che le prestazioni del codice.

  • Manutenzione del codice: La comprensione dei paradigmi aiuta nella scrittura di codice più chiaro e manutenibile. Ad esempio, il paradigma orientato agli oggetti può essere utile per organizzare grandi basi di codice in moduli e componenti riutilizzabili, migliorando la gestione del progetto.

  • Evoluzione professionale: La conoscenza dei vari paradigmi arricchisce le competenze di un programmatore, rendendolo più competitivo nel mercato del lavoro. Conoscere più paradigmi permette di comprendere e lavorare con una gamma più ampia di linguaggi di programmazione e tecnologie.

  • Ottimizzazione del codice: Alcuni paradigmi sono più efficienti in determinate situazioni. Ad esempio, la programmazione concorrente è essenziale per lo sviluppo di software che richiede alta prestazione e scalabilità, come nei sistemi distribuiti. Comprendere come implementare la concorrenza in vari paradigmi permette di scrivere codice più efficiente.

2.2 Il paradigma imperativo

La programmazione imperativa, a differenza della programmazione dichiarativa, è un paradigma di programmazione che descrive l’esecuzione di un programma come una serie di istruzioni che cambiano il suo stato. In modo simile al modo imperativo delle lingue naturali, che esprime comandi per compiere azioni, i programmi imperativi sono una sequenza di comandi che il computer deve eseguire in sequenza. Un caso particolare di programmazione imperativa è quella procedurale.

I linguaggi di programmazione imperativa si contrappongono ad altri tipi di linguaggi, come quelli funzionali e logici. I linguaggi di programmazione funzionale, come Haskell, non producono sequenze di istruzioni e non hanno uno stato globale come i linguaggi imperativi. I linguaggi di programmazione logica, come Prolog, sono caratterizzati dalla definizione di cosa deve essere calcolato, piuttosto che come deve avvenire il calcolo, a differenza di un linguaggio di programmazione imperativo.

L’implementazione hardware di quasi tutti i computer è imperativa perché è progettata per eseguire il codice macchina, che è scritto in stile imperativo. Da questa prospettiva a basso livello, lo stato del programma è definito dal contenuto della memoria e dalle istruzioni nel linguaggio macchina nativo del processore. Al contrario, i linguaggi imperativi di alto livello sono caratterizzati da un modello dati e istruzioni che risultano più facilmente usabili come strumenti di espressione di passi algoritmici.

2.2.1 Esempio in assembly

L’assembly è una categoria di linguaggi di basso livello, cioè strettamente legati all’hardware del computer, tanto che ogni processore ha il suo dialetto. I linguaggi assembly forniscono un modo per scrivere istruzioni direttamente eseguibili dalla CPU, permettendo un controllo fine delle operazioni a livello di singolo bit e registri. Il codice assembly è convertito in eseguibile per mezzo di un programma detto assembler.

Un esempio di un semplice programma scritto per l’architettura x86, utilizzando la sintassi dell’assembler NASM (Netwide Assembler), è il seguente. Questo codice esegue la somma di due numeri, converte il risultato in formato ASCII per la visualizzazione e infine stampa il risultato:

section .data
  num1 db 5            ; Definisce il primo numero (byte)
  num2 db 3            ; Definisce il secondo numero (byte)
  result db 0          ; Variabile per memorizzare il risultato (byte)
  msg db 'Result: ', 0 ; Messaggio di output (stringa terminata con NULL)

section .bss
  result_str resb 4   ; Buffer per la stringa del risultato (4 byte)

section .text
  global _start       ; Definisce l'etichetta _start come punto di ingresso globale

_start:
  ; Somma num1 e num2
  mov al, [num1]      ; Carica il primo numero in AL (registro 8-bit)
  add al, [num2]      ; Aggiunge il secondo numero a AL
  mov [result], al    ; Memorizza il risultato in result

  ; Converte il risultato in stringa ASCII
  movzx eax, byte [result] ; Carica il risultato in EAX (registro 32-bit), zero-extend da byte
  add eax, '0'             ; Converti il valore numerico in carattere ASCII (aggiungendo il valore di '0')
  mov [result_str], al     ; Memorizza il carattere ASCII in result_str

  ; Stampa il messaggio
  mov eax, 4          ; syscall numero per sys_write (4)
  mov ebx, 1          ; file descriptor 1 (stdout)
  mov ecx, msg        ; puntatore al messaggio da stampare
  mov edx, 8          ; lunghezza del messaggio (8 byte)
  int 0x80            ; chiamata di sistema (interruzione 0x80)

  ; Stampa il risultato
  mov eax, 4          ; syscall numero per sys_write (4)
  mov ebx, 1          ; file descriptor 1 (stdout)
  mov ecx, result_str ; puntatore alla stringa del risultato da stampare
  mov edx, 1          ; lunghezza della stringa del risultato (1 byte)
  int 0x80            ; chiamata di sistema (interruzione 0x80)

  ; Terminazione del programma
  mov eax, 1          ; codice di sistema per l'uscita (1)
  xor ebx, ebx        ; codice di ritorno 0 (pulire ebx impostandolo a 0)
  int 0x80            ; chiamata di sistema (interruzione 0x80)

Commento di ogni istruzione:

  • Sezione .data:

    • num1 db 5: Definisce una variabile num1 con valore iniziale 5 (un byte).
    • num2 db 3: Definisce una variabile num2 con valore iniziale 3 (un byte).
    • result db 0: Definisce una variabile result per memorizzare il risultato della somma (un byte).
    • msg db 'Result: ’, 0: Definisce una stringa di output terminata con un carattere NULL.
  • Sezione .bss:

    • result_str resb 4: Riserva 4 byte di memoria per result_str, che conterrà la stringa del risultato.
  • Sezione .text:

    • global _start: Definisce _start come l’etichetta del punto di ingresso globale del programma.
  • Somma di num1 e num2:

    • mov al, [num1]: Carica il valore di num1 pari a 00000101 nel registro AL (registro a 8 bit).
    • add al, [num2]: Aggiunge il valore di num2 pari a 00000011 a AL che, quindi, diviene 00001000.
    • mov [result], al: Memorizza il risultato della somma nella variabile result.
  • Conversione del risultato in stringa ASCII perché sia visualizzabile:1

    • movzx eax, byte [result]: Carica il valore di result nel registro EAX (registro a 32 bit) usando l’estensione con zeri[].
    • add eax, '0': Converte il valore numerico in un carattere ASCII (aggiungendo il valore di '0', che è 48 in decimale), quindi, 00001000 (8 in decimale) + 00110000 (48 in decimale), risultando in 00111000 (56 in decimale), che è il codice ASCII per il carattere '8'.
    • mov [result_str], al: Memorizza il carattere ASCII nel buffer result_str.
  • Stampa del messaggio:

    • mov eax, 4: Imposta eax al numero della chiamata di sistema2 per sys_write (4).
    • mov ebx, 1: Imposta ebx al file descriptor per stdout (1).
    • mov ecx, msg: Imposta ecx al puntatore al messaggio da stampare.
    • mov edx, 8: Imposta edx alla lunghezza del messaggio (8 byte).
    • int 0x80: Effettua una chiamata di sistema (interruzione 0x80) per eseguire sys_write.
  • Stampa del risultato:

    • mov eax, 4: Imposta eax al numero della chiamata di sistema per sys_write (4).
    • mov ebx, 1: Imposta ebx al file descriptor3 per stdout (1).
    • mov ecx, result_str: Imposta ecx al puntatore alla stringa del risultato da stampare.
    • mov edx, 1 Imposta edx alla lunghezza della stringa del risultato (1 byte).
    • int 0x80: Effettua una chiamata di sistema (interruzione 0x80) per eseguire sys_write.
  • Terminazione del programma:

    • mov eax, 1: Imposta eax al numero della chiamata di sistema per sys_exit (1).
    • xor ebx, ebx: Imposta ebx a 0 (codice di ritorno 0) utilizzando l’operazione xor bit a bit, perché applicando 0 xor 0 è 0 e 1 xor 1 è 1.
    • int 0x80: Effettua una chiamata di sistema (interruzione 0x80) per terminare il programma.

1 L’estensione con zeri (inglese: zero-extension) è un’operazione in assembly che espande un valore a un numero maggiore di bit, riempiendo i bit aggiuntivi con zeri. Questo è spesso necessario quando si passa da un registro più piccolo a uno più grande per assicurarsi che il valore sia correttamente rappresentato senza modificare il suo significato.

2 Una chiamata di sistema (inglese: system call o syscall) è un’interfaccia che permette ai programmi di richiedere servizi dal kernel del sistema operativo. Le chiamata di sistema forniscono un modo controllato e sicuro per i programmi di interagire con le risorse hardware e software del sistema, come file, memoria, dispositivi di input/output, e processi.

3 Un file descriptor è un identificatore univoco (tipicamente un numero intero) che un processo utilizza per accedere a un file o a una risorsa di input/output aperta, come un file, una socket di rete o un dispositivo hardware. Nei sistemi operativi Unix e Unix-like, i file descriptor sono utilizzati per gestire tutte le operazioni di input/output in modo uniforme.

Appare evidente che scrivere programmi complessi, ad esempio una rete neurale profonda o un application server, in Assembly è un compito che si potrebbe definire generalmente improbo, mentre altri ambiti lo richiedono specificatamente:

  • Sistemi operativi, ad esempio della componente di kernel, che ha il controllo del sistema e i driver, cioè i programmi utili alla comunicazione coll’hardware.

  • Applicazioni embedded: Microcontrollori di dispositivi medici, sistemi di controllo di veicoli, dispositivi IoT, ecc., cioè)dove è necessaria un’ottimizzazione estrema delle risorse computazionali.

  • Applicazioni HPC (di calcolo ad alte prestazioni, inglese: high performance computing): Il focus qui è eseguire calcoli intensivi e complessi in tempi relativamente brevi. Queste applicazioni richiedono un numero di operazioni per unità di tempo elevato e sono ottimizzate per sfruttare al massimo le risorse hardware disponibili, come CPU, GPU e memoria.

2.2.2 Esempio in Python

All’altro estremo della immediatezza di comprensione del testo del codice per un essere umano, troviamo Python, un linguaggio per tale ragione definito di alto livello, noto per la particolare leggibilità ed eleganza.

Infatti, ecco il medesimo esempio, visibilmente più conciso e certamente intuibile anche avendo basi limitate di programmazione e una basilare conoscenza dell’inglese:

1num1 = 5
num2 = 3

2result = num1 + num2

3print("Il risultato è: ", result)
1
Definizione delle variabili che identificano gli addendi.
2
Somma dei due numeri.
3
Stampa del risultato della somma.

2.2.3 Analisi comparativa

Assembly:

  • Basso livello di astrazione: L’assembly lavora direttamente con i registri della CPU e la memoria, quindi non astrae granché della complessità dell’hardware.

  • Scarsa versatilità: Il linguaggio è progettato per una ben definita architettura e, quindi, ha una scarsa applicabilità ad altre, anche se alcuni dialetti di assembly presentano delle similitudini.

  • Elevata precisione: Il programmatore ha un controllo dettagliato su ogni singola operazione compiuta dal processore, perché c’è una corrispondenza col codice macchina.

  • Complessità: Ogni operazione deve essere definita esplicitamente e in sequenza, il che rende il codice più lungo e difficile da leggere.

Python:

  • Alto livello di astrazione: Python fornisce un’astrazione più elevata sia dei dati che delle istruzioni, permettendo di ignorare i dettagli dei diversi hardware.

  • Elevata semplicità: Il codice è più breve e leggibile, facilitando la comprensione e la manutenzione.

  • Elevata versatilità: Il linguaggio è applicabile senza modifiche a un elevato numero di architetture hardware-software.

  • Produttività: I programmatori possono concentrarsi sulla complessità intrinseca del problema, senza preoccuparsi di molti dettagli implementativi del processo di esecuzione.

2.3 Il paradigma procedurale

La programmazione procedurale è un paradigma di programmazione, derivato da quella imperativa, che organizza il codice in unità chiamate procedure o funzioni. Ogni procedura o funzione è un blocco di codice che può essere richiamato da altre parti del programma, promuovendo la riutilizzabilità e la modularità del codice.

La programmazione procedurale è una naturale evoluzione della imperativa e uno dei paradigmi più antichi e ampiamente utilizzati. Ha avuto origine negli anni ’60 e ’70 con linguaggi come Fortan, COBOL e C, tutt’oggi rilevanti. Questi linguaggi hanno introdotto concetti fondamentali come funzioni, sottoprogrammi e la separazione tra codice e dati. Il C, in particolare, ha avuto un impatto duraturo sulla programmazione procedurale, diventando uno standard de facto per lo sviluppo di sistemi operativi e software di sistema.

I vantaggi principali sono:

  • Modularità: La programmazione procedurale incoraggia la suddivisione del codice in funzioni o procedure più piccole e gestibili. Questo facilita la comprensione, la manutenzione e il riutilizzo del codice.

  • Riutilizzabilità: Le funzioni possono essere riutilizzate in diverse parti del programma o in progetti diversi, riducendo la duplicazione del codice e migliorando l’efficienza dello sviluppo.

  • Struttura e organizzazione: Il codice procedurale è generalmente più strutturato e organizzato, facilitando la lettura e la gestione del progetto software.

  • Facilità di debug e testing: La suddivisione del programma in funzioni isolate rende più facile individuare e correggere errori, oltre a testare parti specifiche del codice.

D’altro canto, presenta anche degli svantaggi che hanno spinto i ricercatori a continuare l’innovazione:

  • Scalabilità limitata: Nei progetti molto grandi, la programmazione procedurale può diventare difficile da gestire. La mancanza di meccanismi di astrazione avanzati, come quelli offerti dalla programmazione orientata agli oggetti, può complicare la gestione della complessità.

  • Gestione dello stato: La programmazione procedurale si basa spesso su variabili globali per condividere stato tra le funzioni, il che può portare a bug difficili da individuare e risolvere.

  • Difficoltà nell’aggiornamento: Le modifiche a una funzione possono richiedere aggiornamenti in tutte le parti del programma che la utilizzano, aumentando il rischio di introdurre nuovi errori.

  • Meno Adatta per Applicazioni Moderne: Per applicazioni complesse e moderne che richiedono la gestione di eventi, interfacce utente complesse e modellazione del dominio, la programmazione procedurale può essere meno efficace rispetto ad altri paradigmi come quello orientato agli oggetti.

2.3.1 Funzioni e procedure

Nella programmazione procedurale, il codice è suddiviso in unità elementari chiamate funzioni e procedure. La differenza principale tra le due è la seguente:

  • Funzione: Una funzione è un blocco di codice che esegue un compito specifico e restituisce un valore. Le funzioni sono utilizzate per calcoli o operazioni che producono un risultato. Ad esempio, una funzione che calcola la somma di due numeri in linguaggio C:

    int somma(int a, int b) {
      return a + b;
    }
  • Procedura: Una procedura è simile a una funzione, ma non restituisce un valore. È utilizzata per eseguire azioni o operazioni che non necessitano di un risultato. Ad esempio, una procedura che stampa un messaggio in Pascal:

    procedure stampaMessaggio;
    begin
      writeln('Ciao, Mondo!');
    end;

2.3.2 Creazione di librerie

Un altro aspetto importante della programmazione procedurale è la possibilità di creare librerie, che sono collezioni di funzioni e procedure riutilizzabili. Le librerie permettono di organizzare e condividere codice comune tra diversi progetti, aumentando la produttività e riducendo la duplicazione del codice, nonché abilitando un modello commerciale che mette a disposizione del software prodotto da aziende o comunità specializzate.

Esempio di una semplice libreria ipotetica di somme in C:

  • File header (mialibreria.h):

    #ifndef MIALIBRERIA_H
    #define MIALIBRERIA_H
    
    int somma_interi(int a, int b);
    
    char* somma_stringhe(const char* a, const char* b);
    
    int somma_array(int arr[], int n);
    
    void stampa_messaggio(const char* messaggio, 
                          void* risultato, 
                          char tipo);
    
    #endif
  • File di implementazione (mialibreria.c):

    #include "mialibreria.h"
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    
    int somma_interi(int a, int b) {
      return a + b;
    }
    
    char* somma_stringhe(const char* a, const char* b) {
    1  char* risultato = malloc(strlen(a) + strlen(b) + 1);
    
    2  if (risultato) {
    3    strcpy(risultato, a);
    4    strcat(risultato, b);
      }
    
      return risultato;
    }
    
    int somma_array_interi(int arr[], int n) {
      int somma = 0;
    
      for (int i = 0; i < n; i++) {
        somma += arr[i];
      }
    
      return somma;
    }
    
    void stampa_messaggio(const char* messaggio, 
                          void* risultato, 
                          char tipo) {
      printf("%s", messaggio);
    
      if (tipo == 'i') {
    5    printf("%d\n", *(int*)risultato);
      } else if (tipo == 's') {
    6    printf("%s\n", (char*)risultato);
      }
    }
    1
    Allocazione della memoria per la somma delle due stringhe e +1 per il carattere di terminazione \0.
    2
    Controllo se la funzione malloc ha avuto successo nell’allocare la memoria richiesta. Se risultato è NULL, significa che malloc ha fallito e il blocco di codice all’interno dell’if viene saltato, evitando così di tentare di accedere a memoria non valida.
    3
    Se l’allocazione ha avuto successo, copia la prima stringa nel risultato.
    4
    Concatenazione della seconda stringa nel risultato.
    5
    Stampa del risultato se il tipo è intero.
    6
    Stampa del risultato se il tipo è una stringa.
  • File principale (main.c):

    #include "mialibreria.h"
    #include <stdio.h>
    #include <stdlib.h>
    
    int main() {
    1  int risultato = somma_interi(5, 3);
      stampa_messaggio("Il risultato della somma di interi è: ", 
                       &risultato, 'i');
    
    2  char* risultato_stringhe = somma_stringhe("Ciao, ", "mondo!");
      stampa_messaggio("Il risultato della somma di stringhe è: ", 
                       risultato_stringhe, 's');
    3  free(risultato_stringhe);
    
      int array[] = {1, 2, 3, 4, 5}; 
    4  int risultato_array = somma_array_interi(array, 5);
      stampa_messaggio("Il risultato della somma dell'array di interi è: ", 
                       &risultato_array, 'i');
    
      return 0;
    }
    1
    Chiamata della funzione per la somma di due interi.
    2
    Chiamata della funzione per la somma di due stringhe (implementata come una concatenazione).
    3
    Liberazione della memoria allocata per la stringa risultante.
    4
    Chiamata della funzione per la somma di un array di interi.

E il medesimo, ma in Python:

def somma_interi(a, b):
  return a + b

1def somma_stringhe(a, b):
  return a + b

2def somma_array(arr):
3  return sum(arr)

risultato_interi = somma_interi(3, 5)
print(f"Il risultato della somma di interi è: {risultato_interi}")

risultato_stringhe = somma_stringhe("Ciao, ", "mondo!")
print(f"Il risultato della somma di stringhe è: {risultato_stringhe}")

array_interi = [1, 2, 3, 4, 5]
risultato_array = somma_array(array_interi) 
print(f"Il risultato della somma dell'array è: {risultato_array}")
1
Il codice di somma_interi e somma_stringhe è identico e questo ci suggerisce che una delle due è ridondante.
2
La funzione ora prende in input solo l’array e non c’è bisogno di inserire anche la sua dimensione.
3
In Python, per evitare errori quando si usa la funzione sum, l’array (o lista) deve contenere elementi che supportano l’operazione di addizione tra di loro. Tipicamente, si usano numeri (interi o a virgola mobile), ma è possibile anche sommare altri tipi di elementi se l’operazione di addizione è definita per quel tipo di dato.

Anche qui Python appare più semplice e immediato, sicuramente vincente sul piano della comprensione del codice e della immediatezza di utilizzo. In realtà, Python e C hanno sia una forte complementarietà sulle applicazioni, sia una dipendenza perché molte librerie e l’interprete stesso di Python sono in C.

2.4 Il paradigma di orientamento agli oggetti

La programmazione orientata agli oggetti (in inglese object-oriented programming, OOP) è un paradigma di programmazione che organizza il software in termini di oggetti, ciascuno dei quali rappresenta un’istanza viva di una matrice astratta detta classe. Una classe definisce un tipo di dato che include attributi (dati) e metodi (funzionalità). Gli oggetti interagiscono tra loro attraverso messaggi, permettendo una struttura modulare e intuitiva.

L’OOP è emersa negli anni ’60 e ’70 con il linguaggio Simula, il primo linguaggio di programmazione a supportare questo paradigma. Tuttavia, è stato con Smalltalk, sviluppato negli anni ’70 da Alan Kay e altri presso lo Xerox PARC, che l’OOP ha guadagnato popolarità. Il paradigma è stato ulteriormente consolidato con il linguaggio C++ negli anni ’80 e con Java negli anni ’90, rendendolo uno dei più utilizzati per lo sviluppo software moderno. Oggi numerosi sono i linguaggi primariamente ad oggetti, ad esempio Python, C#, Ruby, Java, Swift, Javascript, ecc. ed altri lo supportano come PHP (dalla versione 5) e financo il Fortran nella versione 2003.

Rispetto ai paradigmi precedenti, l’OOP introduce diversi concetti chiave che ineriscono al disegno architetturale di software:

  • Classe e oggetto: La classe è un modello o schema per creare oggetti. Contiene definizioni di stato (attributi) e di modalità di manipolazione del proprio stato o di quello di altri oggetti (metodi). L’oggetto è un’istanza di una classe e rappresenta un’entità concreta nel programma con stato e comportamento mutevoli.

  • Incapsulamento: Nasconde i dettagli interni di un oggetto e mostra solo le interfacce necessarie agli altri oggetti. Migliora la modularità e protegge l’integrità dei dati.

  • Ereditarietà (relazione is-a): Permette a una classe di estenderne un’altra, ereditandone attributi e metodi. Favorisce il riuso del codice e facilita l’estensione delle funzionalità. Si usa quando una classe può essere considerata una specializzazione di un’altra. Ad esempio, un Gatto è un Animale, quindi la classe Gatto eredita dalla classe Animale.

  • Polimorfismo: Consente a oggetti di classi diverse di essere trattati come oggetti di una classe comune. Facilita l’uso di un’interfaccia uniforme per operazioni diverse. Il polimorfismo è strettamente legato all’ereditarietà e permette di usare un metodo in modi diversi a seconda dell’oggetto che lo invoca. Ad esempio, un metodo muovi() può comportarsi diversamente se invocato su un oggetto di classe Gatto rispetto a un oggetto di classe Uccello, ma entrambi sono trattati come Animale.

  • Astrazione: Permette di definire interfacce di alto livello per oggetti, senza esporre i dettagli implementativi. Facilita la comprensione e la gestione della complessità del sistema, perché, assieme a ereditarietà e polimorfismo, permette di pensare in modo più naturale, basando la decomposizione del problema anche su relazioni di tipo gerarchico e concettuale. Attraverso l’astrazione, si definiscono classi e interfacce che rappresentano concetti generici, come Forma o Veicolo, senza specificare i dettagli concreti delle implementazioni.

  • Composizione (relazione has-a): Permette a una classe di contenere altre classi come parte dei suoi attributi. È una forma di relazione che indica che un oggetto è composto da uno o più oggetti di altre classi. Si usa quando una classe ha bisogno di utilizzare funzionalità di altre classi ma non rappresenta una specializzazione di quelle classi. Ad esempio, una classe Auto può avere un oggetto Motore come attributo, indicando che un’Auto ha un Motore.

I vantaggi principali dell’OOP sono:

  • Modularità: Le classi e gli oggetti favoriscono la suddivisione del codice in moduli indipendenti, in una forma più granulare rispetto al paradigma procedurale. Non solo le istruzioni sono raggruppate per soddisfare una specifica operazione, ma possono essere viste come più operazioni su uno stato associato. La modularità è rafforzata dalle relazioni has-a e is-a, che aiutano a organizzare il codice in componenti logicamente separati e interconnessi.

  • Riutilizzabilità: L’uso di classi e l’ereditarietà (relazione is-a) consentono di riutilizzare il codice in nuovi progetti senza riscriverlo, limitando gli effetti collaterali sul codice con cui interagiscono. Le classi base possono essere estese per creare nuove classi con funzionalità aggiuntive, mantenendo al contempo la compatibilità con il codice esistente.

  • Facilità di manutenzione: L’incapsulamento e l’astrazione riducono la complessità perché permettono una migliore assegnazione logica dei principi usati nella progettazione dell’applicazione alle singole classi. Ciò facilita la manutenzione del codice, poiché nella modifica si possono individuare rapidamente le istruzioni impattate. La relazione has-a contribuisce ulteriormente alla manutenzione isolando le responsabilità all’interno delle classi.

  • Estendibilità: Le classi possono essere estese (relazione is-a) per aggiungere nuove funzionalità senza modificare il codice già preesistente, riducendo così gli impatti per il codice che ne dipende. Questo approccio facilita l’integrazione di nuove caratteristiche e miglioramenti, mantenendo la stabilità del sistema.

Anche se sussistono dei caveat:

  • Complessità iniziale: L’OOP può essere complesso da apprendere e implementare correttamente per i nuovi programmatori.

  • Overhead di prestazioni: L’uso intensivo di oggetti può introdurre un overhead di memoria e prestazioni rispetto alla programmazione procedurale.

  • Abuso di ereditarietà: L’uso improprio dell’ereditarietà può portare a gerarchie di classi troppo complesse e difficili da gestire, quindi, producendo un effetto opposto ad una delle ragioni di esistenza del concetto, cioè la semplicità di comunicazione della progettazione del software.

2.4.1 Esempio in Java

In questo esempio, la classe Animale rappresenta una tipo di dato generico con un attributo nome e un metodo faiVerso. La classe Cane specializza Animale, usando l’attributo nome e sovrascrivendo il metodo faiVerso, per fornire un’implementazione coerente colle sue caratteristiche. La classe Main crea un’istanza di Cane e chiama il suo metodo faiVerso (annotato con @Override4), dimostrando il polimorfismo e l’ereditarietà:

4 In Java, l’annotazione @Override è opzionale, ma altamente consigliata. Non omettere l’annotazione @Override non causerà un errore di compilazione o di runtime. Tuttavia, l’uso di @Override offre dei vantaggi importanti perché, innanzitutto, il compilatore può verificare che il metodo stia effettivamente sovrascrivendo uno nella classe base e segnalare un errore in caso contrario. Inoltre, l’annotazione migliora la leggibilità del codice perché indica chiaramente al lettore come il metodo è inteso rispetto all’ereditarietà.

1class Animale {
  String nome;

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

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

2class Cane extends Animale {

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

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

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

4    mioCane.faiVerso();
  }
}
1
Definizione della classe Animale che ha il doppio compito di provvedere all’implementazione per una caratteristica comune (nome e descrizione()) e una particolare (faiVerso()).
2
Definizione della classe derivata Cane.
3
@Override indica in esplicito che il faiVerso() del Cane sovrascrive (non eredita) il faiVerso() di Animale.
4
Output: Il cane abbaia.

In realtà, se gli oggetti devono rappresentare animali reali vorrà dire che non deve essere possibile crearne dalla matrice Animale. Vediamo, quindi, come implementare il medesimo esempio con una classe astratta, cioè una classe che non può essere usata per generare direttamente oggetti, sempre in Java.

Nel caso pratico, ogni animale ha il suo verso, quindi dobbiamo costringere il programmatore che vuole implementare classi corrispondenti ad animali reali, ad aggiungere tassativamente il metodo faiVerso() per comunicarne la caratteristica distintiva. Una modalità è marchiare Animale e il suo metodo da caratterizzare (faiVerso()), con costrutti ad hoc perché siano, rispettivamente, identificata come classe astratta (per mezzo della parola riservata abstract) e metodo da implementare. Al contempo, Cane non subisce specifiche modifiche sintattiche, ma deve rispettare il vincolo (implementare faiVerso()) perché, ereditando le caratteristiche di Animale, possa essere una classe concreta, cioè da cui si possono creare oggetti.

Il codice risultate è:

1abstract class Animale {
  String nome;

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

2  abstract String faiVerso();

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

4class Cane extends Animale {

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

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

5class Coccodrillo extends Animale {

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

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

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

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

    Animale mioCoccodrillo = new Coccodrillo("Crocky");

9    System.out.println(mioCoccodrillo.descrizione());
10    System.out.println(mioCoccodrillo.faiVerso());
  }
}
1
Definizione della classe astratta che ha il doppio compito di fornire una implementazione di default per una caratteristica comune (nome) e un vincolo di implementazione nelle classe derivate per una seconda caratteristica comune non implementabile nello stesso modo per tutte (faiVerso()).
2
Metodo astratto faiVerso() che le classi corrispondenti ad animali reali dovranno implementare e che dovrà resituire una stringa.
3
Metodo concreto faiVerso() che restituisce una stringa.
4
Definizione della classe derivata Cane.
5
Definizione della classe derivata Coccodrillo.
6
Il coccodrillo non emette versi!
7
Stampa: L'animale si chiama Fido.
8
Stampa: Il cane abbaia.
9
Stampa: L'animale si chiama Crocky.
10
Non stampa nulla perché il coccodrillo non emette versi!

2.4.2 Template

I template, o generics, non sono specifici dell’OOP, anche se sono spesso associati a esso. I template permettono di scrivere funzioni, classi, e altri costrutti di codice in modo generico, cioè indipendente dal tipo dei dati che manipolano. Questo concetto è particolarmente utile per creare librerie e moduli riutilizzabili e flessibili.

Ad esempio, definiamo la classe Box nel modo seguente:

1template <typename T>
2class Box {
3  T value;

public:
4  void setValue(T val) { value = val; }

5  T getValue() { return value; }
};
1
La keyword template definisce un template di classe che può lavorare con qualsiasi tipo T specificato al momento dell’uso.
2
Dichiarazione della classe Box che utilizza il template di tipo T.
3
Dichiarazione del membro dati value di tipo T, che rappresenta il valore contenuto nella scatola.
4
Metodo pubblico setValue che imposta il valore del membro dati value con il parametro val di tipo T.
5
Metodo pubblico getValue che restituisce il valore del membro dati value di tipo T.

Box può contenere un valore di qualsiasi tipo specificato al momento della creazione dell’istanza per mezzo del template T:

1Box<int> intBox;

2intBox.setValue(123);
3int x = intBox.getValue();

4Box<std::string> stringBox;

5stringBox.setValue("Hello, World!");
6std::string str = stringBox.getValue();
1
Creazione di un’istanza di Box con tipo int, chiamata intBox.
2
Chiamata del metodo setValue per impostare il valore di intBox a 123.
3
Chiamata del metodo getValue per ottenere il valore di intBox e assegnarlo alla variabile x di tipo int.
4
Creazione di un’istanza di Box con tipo std::string, chiamata stringBox.
5
Chiamata del metodo setValue per impostare il valore di stringBox a "Hello, World!".
6
Chiamata del metodo getValue per ottenere il valore di stringBox e assegnarlo alla variabile str di tipo std::string.

Anche nei linguaggi non orientati agli oggetti, i template trovano applicazione. Ad esempio, in Rust, un linguaggio di programmazione sistemistica non puramente OOP, il codice seguente restituisce il valore più grande di una lista:

1fn largest<T: PartialOrd>(list: &[T]) -> &T {
2    let mut largest = &list[0];

3    for item in list {
4        if item > largest {
5            largest = item;
        }
    }
6    largest
}

7fn main() {
8    let numbers = vec![34, 50, 25, 100, 65];
9    let max = largest(&numbers);

10    println!("The largest number is {}", max);
}
1
Definizione della funzione generica largest che accetta una lista di riferimenti a un tipo T che implementa il tratto PartialOrd e restituisce un riferimento a un valore di tipo T.
2
Inizializzazione della variabile largest con il primo elemento della lista.
3
Iterazione attraverso ogni elemento della lista.
4
Controllo se l’elemento corrente item è maggiore di largest.
5
Se item è maggiore, aggiornamento della variabile largest con item.
6
Restituzione di largest, che è il riferimento al più grande elemento trovato nella lista.
7
Definizione della funzione main, punto di ingresso del programma.
8
Creazione di un vettore di numeri interi numbers.
9
Chiamata della funzione largest con un riferimento a numbers e assegnazione del risultato a max.
10
Stampa del valore più grande trovato nella lista usando la macro println!.

2.4.3 Metaprogrammazione

La metaprogrammazione è un paradigma che consente al programma di trattare il codice come dati, permettendo al codice di generare, manipolare o eseguire altro codice. Anche questo concetto non è esclusivo dell’OOP. In C++, la metaprogrammazione è strettamente legata ai template. Un esempio classico è la template metaprogramming (TMP), che permette di eseguire calcoli a tempo di compilazione.

Un esempio è il codice seguente di calcolo del fattoriale:

template<int N>
struct Factorial {
1    static const int value = N * Factorial<N - 1>::value;
};

template<>
struct Factorial<0> {
2    static const int value = 1;
};
1
Questa riga definisce un membro statico value della struttura Factorial. Per un dato N, il valore viene calcolato come N moltiplicato per il valore del fattoriale di N - 1. Questo è un esempio di ricorsione a livello di metaprogrammazione template.
2
Questa riga è una specializzazione del template Factorial per il caso base quando N è 0. In questo caso, value è definito come 1, terminando la ricorsione template.

La metaprogrammazione è presente anche in linguaggi non OOP come Lisp, che utilizza le macro per trasformare e generare codice. Un esempio è il codice proposto di seguito dove è definita la macro when, che prende due parametri in input, cioè test e body, ove test è un’espressione condizionale e body un insieme di istruzioni da eeseguire se la condizione è vera:

(defmacro when (test &rest body)  
  `(if ,test                      
       (progn ,@body)))           

Commento riga per riga:

  1. Definizione di una macro chiamata when, che accetta un test e un numero variabile di espressioni (body).
  2. La macro espande in un’espressione if che valuta test. Se test è vero, esegue le espressioni contenute in body.
  3. progn è utilizzato per racchiudere ed eseguire tutte le espressioni in body in sequenza. L’operatore ,@ è usato per spalmare gli elementi di body nell’espressione progn.

Vediamo un esempio pratico di come si utilizza la macro when. Il test è valutare se x è maggiore di 10 e, nel caso, stampare "x is greater than 10" e poi assegnare x a 0. Chiamiamo la macro con i due parametri:

(when (> x 10)       
  (print "x is greater than 10") 
  (setf x 0))                        

Commento riga per riga:

  1. Invocazione della macro when con la condizione > x 10.
  2. Se la condizione è vera, viene eseguita l’istruzione (print "x is greater than 10"), che stampa il messaggio.
  3. Successivamente, viene eseguita l’istruzione (setf x 0), che assegna il valore 0 a x.

Questo viene espanso in:

(if (> x 10)                        
  (progn                        
    (print "x is greater than 10") 
    (setf x 0)))                   

Commento riga per riga:

  1. L’istruzione if valuta la condizione > x 10.
  2. Se la condizione è vera, viene eseguito il blocco progn.
  3. All’interno del blocco progn, viene eseguita l’istruzione (print "x is greater than 10").
  4. Infine, viene eseguita l’istruzione (setf x 0) all’interno del blocco progn.

2.5 Il paradigma dichiarativo

La programmazione dichiarativa è un paradigma di programmazione che si focalizza sul cosa deve essere calcolato piuttosto che sul come calcolarlo. In altre parole, i programmi dichiarativi descrivono il risultato desiderato senza specificare esplicitamente i passaggi per ottenerlo. Questo è in netto contrasto con la programmazione imperativa, dove si fornisce una sequenza dettagliata di istruzioni per modificare lo stato del programma.

La programmazione dichiarativa ha radici nella logica e nella matematica, ed è emersa come un importante paradigma negli anni ’70 e ’80 con l’avvento di linguaggi come Prolog (per la programmazione logica) e SQL (per la gestione dei database). La programmazione funzionale, con linguaggi come Haskell, è anch’essa una forma di programmazione dichiarativa.

I concetti principali associati alla programazione dichiarativa sono:

  • Descrizione del risultato: I programmi dichiarativi descrivono le proprietà del risultato desiderato senza specificare l’algoritmo per ottenerlo. Esempio: In SQL, per ottenere tutti i record di una tabella con un certo valore, si scrive una query che descrive la condizione, non un algoritmo che scorre i record uno per uno.

  • Assenza di stato esplicito: La programmazione dichiarativa evita l’uso esplicito di variabili di stato e di aggiornamenti di stato. Ciò riduce i rischi di effetti collaterali e rende il codice più facile da comprendere e verificare.

  • Idempotenza: Le espressioni dichiarative sono spesso idempotenti, cioè possono essere eseguite più volte senza cambiare il risultato. Questo è particolarmente utile per la concorrenza e la parallelizzazione.

Il vantaggio principale è relativo alla sua chiarezza perché ci si concentra sul risultato desiderato piuttosto che sui dettagli di implementazione.

La programmazione imperativa specifica come ottenere un risultato mediante una sequenza di istruzioni, modificando lo stato del programma. La programmazione dichiarativa, al contrario, specifica cosa deve essere ottenuto senza descrivere i dettagli di implementazione. In termini di livello di astrazione, la programmazione dichiarativa si trova a un livello superiore rispetto a quella imperativa.

2.5.1 Linguaggi

Ecco una lista di alcuni linguaggi di programmazione dichiarativi:

  1. SQL (Structured Query Language): Utilizzato per la gestione e l’interrogazione di database relazionali.

  2. Prolog: Un linguaggio di programmazione logica usato principalmente per applicazioni di intelligenza artificiale e linguistica computazionale.

  3. HTML (HyperText Markup Language): Utilizzato per creare e strutturare pagine web.

  4. CSS (Cascading Style Sheets): Utilizzato per descrivere la presentazione delle pagine web scritte in HTML o XML.

  5. XSLT (Extensible Stylesheet Language Transformations): Un linguaggio per trasformare documenti XML in altri formati.

  6. Haskell: Un linguaggio funzionale che è anche dichiarativo, noto per la sua pura implementazione della programmazione funzionale.

  7. Erlang: Un linguaggio utilizzato per sistemi concorrenti e distribuiti, con caratteristiche dichiarative.

  8. VHDL (VHSIC Hardware Description Language): Utilizzato per descrivere il comportamento e la struttura di sistemi digitali.

  9. Verilog: Un altro linguaggio di descrizione hardware usato per la modellazione di sistemi elettronici.

  10. XQuery: Un linguaggio di query per interrogare documenti XML.

Questi linguaggi rappresentano diversi ambiti di applicazione, dai database alla descrizione hardware, e sono accomunati dall’approccio dichiarativo nel quale si specifica cosa ottenere piuttosto che come ottenerlo.

Nota

SQL è uno degli esempi più diffusi di linguaggio di programmazione dichiarativo. Le query SQL descrivono i risultati desiderati piuttosto che le procedure operative.

Una stored procedure in PL/SQL (Procedural Language/SQL) combina SQL con elementi di linguaggi di programmazione procedurali come blocchi di codice, condizioni e cicli. PL/SQL è quindi un linguaggio procedurale, poiché consente di specificare come ottenere i risultati attraverso un flusso di controllo esplicito, rendendolo non puramente dichiarativo. PL/SQL è utilizzato principalmente con il database Oracle.

Un’alternativa a PL/SQL è T-SQL (Transact-SQL), utilizzato con Microsoft SQL Server e Sybase ASE. Anche T-SQL estende SQL con funzionalità procedurali simili, consentendo la scrittura di istruzioni condizionali, cicli e la gestione delle transazioni. Come PL/SQL, T-SQL è un linguaggio procedurale e non puramente dichiarativo.

Esistono anche estensioni ad oggetti come il PL/pgSQL (Procedural Language/PostgreSQL) per il database PostgreSQL.

2.5.2 Esempio in SQL

Esempio di una query SQL che estrae tutti i nomi degli utenti con età maggiore di 30:

Certamente! Ecco il codice SQL con i commenti identificati da un ID progressivo e l’elenco esplicativo:

1SELECT nome
2FROM utenti
3WHERE età > 30;
1
Seleziona la colonna nome.
2
Dalla tabella utenti.
3
Per le righe dove la colonna età è maggiore di 30.

2.5.3 Esempio in Prolog

In Prolog, si definiscono fatti e regole che descrivono relazioni logiche. Il motore di inferenza di Prolog utilizza queste definizioni per risolvere query, senza richiedere un algoritmo dettagliato. Di seguito, sono definiti due fatti (le prime due righe) e due regole (la terza e la quarta) e quindi si effettua una query che dà come risultato true:

genitore(padre, figlio). 
genitore(madre, figlio).  
antenato(X, Y) :- genitore(X, Y).  
antenato(X, Y) :- genitore(X, Z), antenato(Z, Y).  

?- antenato(padre, figlio). 

Commento riga per riga:

  1. Questa regola dichiara che padre è genitore di figlio.
  2. Questa regola dichiara che madre è genitore di figlio.
  3. Questa regola stabilisce che X è antenato di Y se X è genitore di Y.
  4. Questa regola stabilisce che X è antenato di Y se X è genitore di Z e Z è antenato di Y.
  5. Riga vuota.
  6. Questa è una query che chiede se padre è un antenato di figlio.

2.6 Il paradigma funzionale

La programmazione funzionale è un paradigma di programmazione che tratta il calcolo come la valutazione di funzioni matematiche ed evita lo stato mutabile e i dati modificabili. I programmi funzionali sono costruiti applicando e componendo funzioni. Questo paradigma è stato ispirato dal calcolo lambda, una formalizzazione matematica del concetto di funzione. La programmazione funzionale è un paradigma alternativo alla programmazione imperativa, che descrive la computazione come una sequenza di istruzioni che modificano lo stato del programma.

La programmazione funzionale ha radici storiche che risalgono agli anni ’30, con il lavoro di Alonzo Church sul calcolo lambda. I linguaggi di programmazione funzionale hanno iniziato a svilupparsi negli anni ’50 e ’60 con Lisp, ma è stato negli anni ’70 e ’80 che linguaggi come ML e Haskell hanno consolidato questo paradigma. Haskell, in particolare, è stato progettato per esplorare nuove idee in programmazione funzionale e ha avuto un impatto significativo sulla ricerca e sulla pratica del software.

La programmazione funzionale è una forma di programmazione dichiarativa che si basa su funzioni pure e immutabilità. Entrambi i paradigmi evitano stati mutabili e si concentrano sul risultato finale, ma la programmazione funzionale utilizza funzioni matematiche come unità fondamentali di calcolo.

Concetti fondamentali:

  • Immutabilità: I dati sono immutabili, il che significa che una volta creati non possono essere modificati. Questo riduce il rischio di effetti collaterali e rende il codice più prevedibile.

  • Funzioni di prima classe e funzioni di ordine superiore: Le funzioni possono essere passate come argomenti a altre funzioni, ritornate da funzioni, e assegnate a variabili. Le funzioni di ordine superiore accettano altre funzioni come argomenti o restituiscono funzioni.

  • Purezza: Le funzioni pure sono funzioni che, dato lo stesso input, restituiscono sempre lo stesso output e non causano effetti collaterali. Questo rende il comportamento del programma più facile da comprendere e prevedere.

  • Trasparenza referenziale: Un’espressione è trasparentemente referenziale se può essere sostituita dal suo valore senza cambiare il comportamento del programma. Questo facilita l’ottimizzazione e il reasonig sul codice.

  • Ricorsione: È spesso utilizzata al posto di loop iterativi per eseguire ripetizioni, poiché si adatta meglio alla natura immutabile dei dati e alla definizione di funzioni.

  • Composizione di funzioni: Consente di costruire funzioni complesse combinando funzioni più semplici. Questo favorisce la modularità e la riusabilità del codice.

Il paradigma funzionale ha diversi vantaggi:

  • Prevedibilità e facilità di test: Le funzioni pure e l’immutabilità rendono il codice più prevedibile e più facile da testare, poiché non ci sono stati mutabili o effetti collaterali nascosti.

  • Concorrenza: La programmazione funzionale è ben adatta alla programmazione concorrente e parallela, poiché l’assenza di stato mutabile riduce i problemi di sincronizzazione e competizione per le risorse.

  • Modularità e riutilizzabilità: La composizione di funzioni e la trasparenza referenziale facilitano la creazione di codice modulare e riutilizzabile.

E qualche svantaggio:

  • Curva di apprendimento: La programmazione funzionale può essere difficile da apprendere per chi proviene da paradigmi imperativi o orientati agli oggetti, a causa dei concetti matematici sottostanti e della diversa mentalità necessaria.

  • Prestazioni: In alcuni casi, l’uso intensivo di funzioni ricorsive può portare a problemi di prestazioni, come il consumo di memoria per le chiamate ricorsive. Tuttavia, molte implementazioni moderne offrono ottimizzazioni come la ricorsione di coda (in inglese, tail recursion).

  • Disponibilità di librerie e strumenti: Alcuni linguaggi funzionali potrebbero non avere la stessa ampiezza di librerie e strumenti disponibili rispetto ai linguaggi imperativi più diffusi.

2.6.1 Linguaggi

Oltre a Haskell, ci sono molti altri linguaggi funzionali, tra cui:

  • Erlang: Utilizzato per sistemi concorrenti e distribuiti.

  • Elixir: Costruito a partire da Erlang, è utilizzato per applicazioni web scalabili.

  • F#: Parte della piattaforma .NET, combina la programmazione funzionale con lo OOP.

  • Scala: Anch’esso combina programmazione funzionale e orientata agli oggetti ed è interoperabile con Java.

  • OCaml: Conosciuto per le sue prestazioni e sintassi espressiva.

  • Lisp: Uno dei linguaggi più antichi, multi-paradigma con forti influenze funzionali.

  • Clojure: Dialetto di Lisp per la JVM, adatto alla concorrenza.

  • Scheme: Dialetto di Lisp spesso usato nell’educazione.

  • ML: Linguaggio influente che ha portato allo sviluppo di OCaml e F#.

  • Racket: Derivato da Scheme, usato nella ricerca accademica.

2.6.2 Esempio in Haskell

Di seguito due funzioni, la prima sumToN è pura e somma i primi n numeri. (*2) è una funzione che prende un argomento e lo moltiplica per 2 e ciò rende la seconda funzione applyFunction una vera funzione di ordine superiore, poiché accetta (*2) come argomento oltre ad una lista, producendo come risultato il raddoppio di tutti i suoi elementi:

sumToN :: Integer -> Integer
1sumToN n = sum [1..n]

applyFunction :: (a -> b) -> [a] -> [b]
2applyFunction f lst = map f lst

main = do
3  print (sumToN 10)
4  print (applyFunction (*2) [1, 2, 3, 4])
1
Definizione di una funzione pura che calcola la somma dei numeri da 1 a n.
2
Funzione di ordine superiore che accetta una funzione e una lista.
3
Nel main, stampa il risultato di sumToN 10, che è 55.
4
Nel main, stampa il risultato di applyFunction (*2) [1, 2, 3, 4], che è [2, 4, 6, 8].