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)
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 variabilenum1
con valore iniziale 5 (un byte).num2 db 3
: Definisce una variabilenum2
con valore iniziale 3 (un byte).result db 0
: Definisce una variabileresult
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 perresult_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
enum2
:mov al, [num1]
: Carica il valore dinum1
pari a00000101
nel registroAL
(registro a 8 bit).add al, [num2]
: Aggiunge il valore dinum2
pari a00000011
aAL
che, quindi, diviene00001000
.mov [result], al
: Memorizza il risultato della somma nella variabileresult
.
Conversione del risultato in stringa ASCII perché sia visualizzabile:1
movzx eax, byte [result]
: Carica il valore diresult
nel registroEAX
(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 in00111000
(56 in decimale), che è il codice ASCII per il carattere'8'
.mov [result_str], al
: Memorizza il carattere ASCII nel bufferresult_str
.
Stampa del messaggio:
mov eax, 4
: Impostaeax
al numero della chiamata di sistema2 persys_write
(4).mov ebx, 1
: Impostaebx
al file descriptor perstdout
(1).mov ecx, msg
: Impostaecx
al puntatore al messaggio da stampare.mov edx, 8
: Impostaedx
alla lunghezza del messaggio (8 byte).int 0x80
: Effettua una chiamata di sistema (interruzione0x80
) per eseguiresys_write
.
Stampa del risultato:
mov eax, 4
: Impostaeax
al numero della chiamata di sistema persys_write
(4).mov ebx, 1
: Impostaebx
al file descriptor3 perstdout
(1).mov ecx, result_str
: Impostaecx
al puntatore alla stringa del risultato da stampare.mov edx, 1
Impostaedx
alla lunghezza della stringa del risultato (1 byte).int 0x80
: Effettua una chiamata di sistema (interruzione0x80
) per eseguiresys_write
.
Terminazione del programma:
mov eax, 1
: Impostaeax
al numero della chiamata di sistema persys_exit
(1).xor ebx, ebx
: Impostaebx
a 0 (codice di ritorno 0) utilizzando l’operazionexor
bit a bit, perché applicando0 xor 0
è0
e1 xor 1
è1
.int 0x80
: Effettua una chiamata di sistema (interruzione0x80
) 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:
- 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:
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:
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
):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. Serisultato
èNULL
, significa chemalloc
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
esomma_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
è unAnimale
, quindi la classeGatto
eredita dalla classeAnimale
.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 classeGatto
rispetto a un oggetto di classeUccello
, ma entrambi sono trattati comeAnimale
.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
oVeicolo
, 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 oggettoMotore
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 @Override
4), 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
edescrizione()
) e una particolare (faiVerso()
). - 2
-
Definizione della classe derivata
Cane
. - 3
-
@Override
indica in esplicito che ilfaiVerso()
delCane
sovrascrive (non eredita) ilfaiVerso()
diAnimale
. - 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 tipoT
specificato al momento dell’uso. - 2
-
Dichiarazione della classe
Box
che utilizza il template di tipoT
. - 3
-
Dichiarazione del membro dati
value
di tipoT
, che rappresenta il valore contenuto nella scatola. - 4
-
Metodo pubblico
setValue
che imposta il valore del membro dativalue
con il parametroval
di tipoT
. - 5
-
Metodo pubblico
getValue
che restituisce il valore del membro dativalue
di tipoT
.
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 tipoint
, chiamataintBox
. - 2
-
Chiamata del metodo
setValue
per impostare il valore diintBox
a123
. - 3
-
Chiamata del metodo
getValue
per ottenere il valore diintBox
e assegnarlo alla variabilex
di tipoint
. - 4
-
Creazione di un’istanza di
Box
con tipostd::string
, chiamatastringBox
. - 5
-
Chiamata del metodo
setValue
per impostare il valore distringBox
a"Hello, World!"
. - 6
-
Chiamata del metodo
getValue
per ottenere il valore distringBox
e assegnarlo alla variabilestr
di tipostd::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 tipoT
che implementa il trattoPartialOrd
e restituisce un riferimento a un valore di tipoT
. - 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 dilargest
. - 5
-
Se
item
è maggiore, aggiornamento della variabilelargest
conitem
. - 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 anumbers
e assegnazione del risultato amax
. - 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 strutturaFactorial
. Per un datoN
, il valore viene calcolato comeN
moltiplicato per il valore del fattoriale diN - 1
. Questo è un esempio di ricorsione a livello di metaprogrammazione template. - 2
-
Questa riga è una specializzazione del template
Factorial
per il caso base quandoN
è 0. In questo caso,value
è definito come1
, 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:
Commento riga per riga:
- Definizione di una macro chiamata
when
, che accetta untest
e un numero variabile di espressioni (body
). - La macro espande in un’espressione
if
che valutatest
. Setest
è vero, esegue le espressioni contenute inbody
. progn
è utilizzato per racchiudere ed eseguire tutte le espressioni inbody
in sequenza. L’operatore,@
è usato per spalmare gli elementi dibody
nell’espressioneprogn
.
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:
Commento riga per riga:
- Invocazione della macro
when
con la condizione> x 10
. - Se la condizione è vera, viene eseguita l’istruzione
(print "x is greater than 10")
, che stampa il messaggio. - Successivamente, viene eseguita l’istruzione
(setf x 0)
, che assegna il valore0
ax
.
Questo viene espanso in:
Commento riga per riga:
- L’istruzione
if
valuta la condizione> x 10
. - Se la condizione è vera, viene eseguito il blocco
progn
. - All’interno del blocco
progn
, viene eseguita l’istruzione(print "x is greater than 10")
. - Infine, viene eseguita l’istruzione
(setf x 0)
all’interno del bloccoprogn
.
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:
SQL (Structured Query Language): Utilizzato per la gestione e l’interrogazione di database relazionali.
Prolog: Un linguaggio di programmazione logica usato principalmente per applicazioni di intelligenza artificiale e linguistica computazionale.
HTML (HyperText Markup Language): Utilizzato per creare e strutturare pagine web.
CSS (Cascading Style Sheets): Utilizzato per descrivere la presentazione delle pagine web scritte in HTML o XML.
XSLT (Extensible Stylesheet Language Transformations): Un linguaggio per trasformare documenti XML in altri formati.
Haskell: Un linguaggio funzionale che è anche dichiarativo, noto per la sua pura implementazione della programmazione funzionale.
Erlang: Un linguaggio utilizzato per sistemi concorrenti e distribuiti, con caratteristiche dichiarative.
VHDL (VHSIC Hardware Description Language): Utilizzato per descrivere il comportamento e la struttura di sistemi digitali.
Verilog: Un altro linguaggio di descrizione hardware usato per la modellazione di sistemi elettronici.
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.
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:
- 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:
- Questa regola dichiara che
padre
è genitore difiglio
. - Questa regola dichiara che
madre
è genitore difiglio
. - Questa regola stabilisce che
X
è antenato diY
seX
è genitore diY
. - Questa regola stabilisce che
X
è antenato diY
seX
è genitore diZ
eZ
è antenato diY
. - Riga vuota.
- Questa è una query che chiede se
padre
è un antenato difiglio
.
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 disumToN 10
, che è 55. - 4
-
Nel
main
, stampa il risultato diapplyFunction (*2) [1, 2, 3, 4]
, che è[2, 4, 6, 8]
.