Un linguaggio di programmazione serve sia per istruire una macchina ad eseguire dei conti, che per organizzare le nostre idee su come quei conti devono essere eseguiti. Per questo, nella scelta di un linguaggio di programmazione, dobbiamo tener presente quali sono gli strumenti che vengono offerti dal linguaggio per formare idee più complesse partendo da poche idee semplici.
Ogni linguaggio di programmazione dovrebbe avere almeno tre caratteristiche per ottenere questo obiettivo:
Delle espressioni primitive che rappresentano le entità più semplici del linguaggio
Dei metodi per combinare gli elementi primitivi in elementi composti
Dei metodi per astrarre concetti primitivi in modo che gli elementi composti possano essere utilizzati a loro volta come elementi primitivi di entità ancora più complesse
In programmazione abbiamo due tipi di elementi: le PROCEDURE e i DATI.
In modo informale possiamo definire i dati come gli oggetti che vorremmo manipolare, e le procedure come la descrizione delle regole per manipolare i dati. Quindi, per quanto spiegato sopra, un linguaggio dovrebbe avere dei dati primitivi e delle procedure primitive, e dovrebbe avere dei metodi per combinare e astrarre i dati e le procedure.
Iniziamo a vedere qualche semplice iterazione con l'interprete di Python: se digitiamo sulla tastiera una espressione, l'interprete risponde valutando tale espressione. Per esempio se digitiamo il numero 345 e poi premiamo la combinazione di tasti shift + enter
, l'interprete valuta l'espressione che abbiamo appena scritto:
In [ ]:
345
Semplici espressioni numeriche possono essere combinate usando delle procedure primitive che rappresentano l'applicazione di procedure a quei numeri. Per esempio:
In [ ]:
339 + 6
In [ ]:
345 - 6
In [ ]:
2.7 / 12.1
In [ ]:
345 - 12/6
Si noti come in questo caso, per queste semplici procedure numeriche che corrispondono agli operatori aritmetici, viene implicitamente usata una notazione chiamata postfix. Importando la libreria operator
è possibile esprimere le stesse espressioni in notazione prefix:
In [ ]:
# Importa tutte le procedure (funzioni) definite nel modulo "operator"
from operator import *
Si consiglia di leggere la documentazione della libreria operator
sul sito di python. Le funzioni principali che useremo in questo notebook sono:
add(a,b)
corrisponde ad a+b
sub(a,b)
corrisponde ad a-b
mul(a,b)
corrisponde ad a*b
truediv(a,b)
corrisponde ad a/b
Per esempio:
In [ ]:
add(339, 6)
Uno dei vantaggi della notazione prefix è che rende sempre chiaro qual è l'operatore/procedura che deve essere svolta, applicandola a quali dati: add
è il nome dell'operatore, mentre tra parentesi sono definiti i due dati numerici a cui deve essere applicata l'operazione.
In [ ]:
sub(345, truediv(12, 6))
In [ ]:
mul(add(2,3), (sub(add(2,2), add(3,2))))
Si noti come l'espressione precedente sarebbe più chiara se scritta come:
mul(
add(2, 3),
sub(
add(2, 2),
add(3, 2)
)
)
In questo caso l'interprete lavora in un ciclo chiamato "leggi-valuta-stampa": legge le espressioni composte e le espressioni primitive, le valuta nell'ordine in cui le trova, e stampa alla fine il risultato finale.
In [ ]:
a = 13
In questo caso abbiamo una variabile, che abbiamo chiamato a
, e il cui valore è il numero 13. A questo punto possiamo usare la variabile a
come un oggetto di tipo numerico:
In [ ]:
3*a
In [ ]:
add(a, add(a,a))
In [ ]:
pi = 3.14159
In [ ]:
raggio = 5
In [ ]:
circonferenza = 2*pi*raggio
In [ ]:
circonferenza
In [ ]:
raggio = 10
In [ ]:
circonferenza
In questo caso, l'interprete del linguaggio, ha prima valutato l'espressione 2*pi*raggio
, e dopo ha assegnato il valore ottenuto dalla valutazione dell'espressione alla variabile di nome circonferenza
.
In questo caso, l'operatore di assegnamento =
rappresenta il più semplice meccanismo di astrazione, perché permette di dare un nome al risultato di operazioni più complesse.
In pratica, qualsiasi programma viene costruito partendo dalla costruzione, passo passo, di oggetti computazionali via via più complessi.
L'uso di un interprete, che in modo incrementale valute le espressioni che li vengono passate, favorisce la definizione di tante piccole procedure, innestate l'uno nell'altra.
Dovrebbe essere chiaro a questo punto, che l'interprete deve mantenere una sorta di MEMORIA che tiene traccia di tutti gli assegnamenti di nomi a oggetti, chiamato il global environment
. Per vedere quali sono i nomi memorizzati in memoria si usa il comando who
:
In [ ]:
who
Uno degli obiettivo di questo corso è di insegnare a pensare in maniera "algoritmica". Proviamo ad analizzare come l'interprete del linguaggio valuta operazioni composte come quelle viste prima. In pratica, la valutazione di operazioni composte avviene attraverso la procedura seguente:
1.1 Prima, valuta le sottoespressioni della espressioni composta
1.2 Applica la procedura indicata dalla sottoespressioni più a sinistra (l'operatore), agli argomenti che sono i valori della sottoespressione (gli operandi).
Si noti come questa procedura per valutare un'operazione composta, per prima cosa deve eseguire il processo di valutazione a ogni elemento dell'espressione composta. Quindi la regola di valutazione di un'espressione è intrinsecamente RICORSIVA, ovvero include come uno dei suoi passi la chiamata a se stessa.
NOTA: mostrare alla lavagna un recursion tree con l'espressione precedente.
Abbiamo identificato alcuni elementi che devono appartenere ad un linguaggio di programmazione:
Abbiamo quindi bisogno di un modo per poter definire nuove procedure, in modo che una nuova operazione possa essere definita in termini di composizione di operazioni più semplici.
Consideriamo per esempio una procedura di elevamento al quadrato.
In [ ]:
quadrato = mul(3, 3)
In [ ]:
quadrato
In [ ]:
power = mul(x,x)
print(power)
In [ ]:
x = 7
Per ottenere un livello di astrazione più alto abbiamo bisogno di un meccanismo (una sintassi del linguaggio) per definire nuove procedure (funzioni). La sintassi è la seguente:
def <Nome>(<parametri formali>):
<corpo della procedura>
Si noti come sia apparsa la prima parole chiave riservata del linguaggio: def. Inoltre, <Nome>
è il nome che noi vogliamo associare alla procedura (funzione) che stiamo definendo, e i <parametri formali>
(chiamati argomenti della procedura) sono le variabili che non appartengono direttamente al working eniviroment (ovvero alla MEMORIA dell'interprete), ma sono "visibili" solo internamente alla procedura in cui sono definite.
Se torniamo all'esempio delle definizione di una procedura per l'elevamento al quadrato, possiamo scrivere:
In [ ]:
def Quadrato(numero):
return mul(numero, numero)
In [ ]:
Quadrato
In [ ]:
who
In [ ]:
Quadrato(532)
In [ ]:
quadrato(mul(3,2))
A questo punto possiamo anche definire nuove procedure in termini della procedura appena definita, definendo per esempio una nuova procedura chiamata SommaDiQuadrati
:
In [ ]:
def SommaQuadrati(x, y):
return add(Quadrato(x), Quadrato(y))
In [ ]:
SommaQuadrati(4,3)
In [ ]:
x
In [ ]:
del x
ESEMPIO: considera la seguente espressione composta:
In [ ]:
def F(a):
return SommaQuadrati(add(a, 1), mul(a, 2))
In [ ]:
F(5)
DOMANDA: come è stata valutata questa procedura?
DOMANDA: con gli elementi del linguaggio sin qui introdotti, possiamo definire una funzione ValoreAssoluto(x)
, ovvero
$|x| = x$ se $x \geq 0$, e $|x|=-x$ se $x < 0$?
RISPOSTA: No, il linguaggio non è abbastanza espressivo.