06 Il Libro di Ridgun
Tipi di Dati Algebrici (ADT), Polimorfismo e perché qui non vogliamo null né Any
Nel 1965, Tony Hoare inventò il riferimento nullo (null / nil). Quarant'anni dopo chiese pubblicamente scusa e lo definì "il mio errore da un miliardo di dollari".
Perché? Perché nei linguaggi con null, una funzione ti promette nella sua firma che ti restituirà un Utente... e quando meno te lo aspetti ti restituisce null e ti butta giù il server alle tre del mattino.
E altrettanto succede con l'ereditarietà degli oggetti e il tipo Any (o Object): metti un numero e un testo in una lista [1, "due"], il linguaggio alza le spalle e dice "va bene, allora adesso questa è una List[Any], buona fortuna a indovinare cosa c'è dentro quando la percorrerai".
In Ridgun tagliamo entrambi i problemi alla radice con i Tipi di Dati Algebrici (ADT) e il Polimorfismo Parametrico. Suona come il nome di un esame tosto, ma in cinque minuti vedrai che è il modo più naturale del mondo per modellare i dati.
6.1. Tipi Prodotto (Record): I tuoi soliti struct, ma con i superpoteri
Quando vuoi raggruppare diversi dati che appartengono a una stessa entità (ad esempio, un server con il suo nome, il suo IP e la sua porta), definisci un type con i suoi campi:
type Server = host: String, porta: Int, tls_attivo: Bool
Scrivendo solo quella riga, Ridgun ti regala quattro cose automaticamente:
1. Il costruttore Server
fn :: mio nodo => Server
mio nodo = Server "api.esempio.it" 443 True
2. Una funzione getter pura per ogni campo (host, porta, tls_attivo)
Non devi scrivere s.getHost() né nulla di simile; ogni nome di campo è direttamente una funzione che estrae quel dato:
fn :: url connessione Server => String
url connessione srv = "Connettere a " + (host srv) + " sulla porta {porta srv}"
3. Aggiornamento funzionale dei record (with campo: valore)
Immagina un record di configurazione con 12 o 15 campi (come le opzioni di un comando ls o di un server web) e vuoi cambiare solo uno o due campi senza toccare il resto. Invece di ricostruire il record passando i 15 campi a mano, usi with e i due punti : (esattamente come nella definizione del type):
fn :: attiva modalita dev Server => Server
attiva modalita dev srv = srv with porta: 8080, tls_attivo: False
Il meglio dei due mondi:
- Nel tuo codice è 100% immutabile e sicuro (il compilatore controlla che
portaetls_attivoesistano inServere abbiano il tipo corretto). - Sotto il cofano, se
srvnon è condiviso con nessun altro, l'eseguibile nativo riutilizza la sua memoria in-place (Arc::try_unwrap) senza copiare il resto dei campi.
4. Pattern matching completo se vuoi aprire il record da cima a fondo
fn :: e porta sicura Server => Bool
e porta sicura (Server _ 443 True) = True
e porta sicura _ = False
6.2. Tipi Somma (Varianti con |): Come mandare in pensione null, le eccezioni e l'ereditarietà
Ora viene il bello.
Un record normale ha host E porta E tls_attivo.
Ma nel mondo reale molte volte hai bisogno di dire: "Un metodo di pagamento è O* una carta con il suo numero, O un bonifico con il suo IBAN, O contanti (che non portano alcun dato extra)"*.
Nella programmazione orientata agli oggetti dovresti creare un'interfaccia MetodoPagamento, tre sottoclassi in tre file diversi, costruttori, instanceof... In Ridgun è una riga:
type MetodoPagamento = Carta String Int | Bonifico String | Contanti
E guarda cosa succede quando elabori un MetodoPagamento in una funzione:
fn :: descrivi pagamento MetodoPagamento => String
descrivi pagamento (Carta num _) = "Pagamento con carta che termina con {num}"
descrivi pagamento (Bonifico iban) = "Bonifico dal conto {iban}"
descrivi pagamento Contanti = "Pagamento in contanti allo sportello"
Ogni variante (Carta, Bonifico, Contanti) porta attaccati unicamente i dati che hanno senso per lei. È impossibile costruire un pagamento in Contanti che abbia un iban appeso per errore.
6.3. "E come metto [1, "due"] in una lista senza usare Any?"
Torniamo alla domanda da un milione di dollari. Immagina di leggere un file CSV o JSON in cui una cella può essere un numero intero, un testo o essere vuota.
Se avessi un tipo Any, metteresti [1, "due"] e perderesti tutto l'aiuto del compilatore: da quel momento in poi qualsiasi operazione sulla lista sarebbe camminare bendati.
In Ridgun (proprio come in Haskell o Rust), crei in due secondi un tipo somma che spiega esattamente quali cose possono vivere dentro quella lista:
type ValoreCella = Intero Int | Testo String | Vuoto
fn :: formatta cella ValoreCella => String
formatta cella (Intero n) = "Numero: {n}"
formatta cella (Testo s) = "Testo: {s}"
formatta cella Vuoto = "(vuoto)"
fn :: mia riga => [ValoreCella]
mia riga = [Intero 1, Testo "due", Vuoto]
Vedi la differenza?
- Con
Any, il compilatore non sa se nella lista ci sono interi, stringhe o una caffettiera collegata via USB. - Con
[ValoreCella], puoi mescolare numeri e testi nella stessa lista, ma il compilatore sa al 100% che esistono solo quelle tre possibilità e ti obbliga a spacchettare ogni caso (Intero n,Testo s,Vuoto) prima di usarlo. Sicurezza totale senza perdere nemmeno un briciolo di flessibilità.
6.4. Option e Result: Quando qualcosa può non esserci o può fallire
Poiché in Ridgun non esiste null né esistono eccezioni che saltano per aria (throw new Exception()), il linguaggio porta integrati due tipi somma universali:
Option: o haiSome valore, o haiNone(niente).Result: o l'operazione è andata bene (Ok valore), o è fallita per un motivo preciso (Err errore).
fn :: cerca divisione Int Int => Result
cerca divisione _ 0 = Err "Ehi! Non si può dividere per zero"
cerca divisione a b = Ok (a / b)
fn :: stampa ris Result => ()
stampa ris (Ok n) = echo "Il risultato è {n}"
stampa ris (Err msg) = echo "Errore controllato: {msg}"
Nessuno può ignorare un errore per distrazione perché lo stesso tipo Result ti obbliga a guardare se dentro c'è un Ok o un Err.
6.5. Polimorfismo Parametrico: Funzioni generiche con A, B, T...
Supponi di scrivere una funzione che inverte una coppia (x, y) o che estrae il primo elemento di una lista. Importa davvero se la lista è di Int, di String o di Server? Proprio per niente! L'algoritmo è identico.
In Ridgun, qualsiasi lettera maiuscola singola (A..Z) in una firma dei tipi funziona automaticamente come un tipo jolly polimorfico (variabile di tipo):
fn :: testa sicura [A] A => A
testa sicura [] predefinito = predefinito
testa sicura (x:_) _ = x
fn :: gira coppia (A B) => B A
gira coppia (x, y) = (y, x)
Nota la magia di testa sicura [A] A => A e gira coppia (A B) => B A:
- Non è un
Anyincontrollato: dice che il tipo della lista[A], il tipo del valorepredefinito(A) e il tipo restituito (A) devono essere lo stesso. - Se chiami
testa sicura [10, 20] 0, il compilatore tira le somme (A = Int) e ti restituisce unInt. - Se chiami
testa sicura ["a", "b"] "nessuno", tira le somme (A = String) e ti restituisce unoString. - Ma se provi a barare e chiami
testa sicura [10, 20] "nessuno", zac! Il compilatore ti ferma su due piedi perché il primo argomento dice cheAèInte il secondo dice cheAèString.
6.6. Crea i tuoi operatori (op ::): Dai vettori alle frasi su misura
Perché solo i numeri nativi dovrebbero avere il diritto di usare + o *? E perché accontentarsi dei soliti operatori quando puoi inventare la sintassi perfetta per il tuo problema?
Con op :: (o direttamente senza prefisso se usi pattern di operatore) puoi fare due prodezze:
1. Insegnare a +, -, *, /, % a operare con i tuoi tipi
type Complesso = reale: Int, immag: Int
op :: Complesso + Complesso => Complesso
a + b = Complesso ((reale a) + (reale b)) ((immag a) + (immag b))
2. Creare operatori Mixfix (che si leggono come frasi naturali, inclusi type e with)
In una firma di operatore, le parole con iniziale maiuscola (Int, String, A..Z) o tra parentesi quadre/tonde ([Int], (A, B)) sono gli slot per gli argomenti, e le parole in minuscolo fanno parte della frase dell'operatore (incluse parole comunissime come type o with, che in Ridgun sono contestuali e non ti bloccano mai):
op :: se Bool allora A altrimenti A => A
se True allora v altrimenti _ = v
se False allora _ altrimenti f = f
op :: format String type String with Int => String
format label type kind with w = "{label} ({kind}, width={w})"
main => ()
main =
messaggio = se (10 > 5) allora "Vince il dieci!" altrimenti "Impossibile"
echo messaggio
echo (format "panel" type "bordered" with 80)
Ti sei appena costruito il tuo operatore ternario polimorfico e un DSL di formattazione in 3 righe di Ridgun.
6.7. Costruttori condivisi, varianti di tipo diretto, expand type e spazi naturali in fn ::
Ridgun porta i tipi algebrici e le funzioni un passo oltre in espressività ed estensibilità:
- Costruttori condivisi tra diversi
type: Più tipi somma possono riutilizzare lo stesso nome di costruttore (Error,Var,Lit) con uguale o diversa arità; il verificatore dei tipi li distingue in base alla colonna della firma. - Varianti con nome di tipo diretto (
type Something = String | Int | MyStruct Float Int): Quando una variante è un tipo primitivo (String,Int,Float/Real,Bool,Char), non hai bisogno di ripetereInt IntnéString String. Puoi passare valori diretti ("ciao",42) o costruttori espliciti (String "ciao",Int 42), e fare pattern matching sia con letterali ("admin",0) sia con pattern tipizzati ((String s),(Int n)). - Estensione aperta dei tipi (
expand type) e fusione automatica delle clausolefn ::: Conexpand typepuoi aggiungere nuove varianti a un tipo somma o nuovi campi a un record. Ridichiarandofn ::per la stessa funzione, le nuove clausole vengono fuse automaticamente prima di qualsiasi clausola jolly_(senza bisogno di scrivereexpand fn). - Spazi naturali
" "invece di_infn ::, funzioni native e nomi di file: Proprio come inop ::, i nomi di funzione infn ::, le chiamate a funzioni native (assert equals,str len,read file) e i file.rg(test core language.rg) possono essere scritti con spazi naturali:
type Something = String | Int | MyStruct Float Int
fn :: descrivi qualcosa Something => String
descrivi qualcosa "admin" = "admin_speciale"
descrivi qualcosa 0 = "zero"
descrivi qualcosa (String s) = "testo:{s}"
descrivi qualcosa (Int n) = "intero:{n}"
descrivi qualcosa (MyStruct ratio cnt) = "struttura:{ratio}:{cnt}"
descrivi qualcosa _ = "altro"
expand type Something = Bool
fn :: descrivi qualcosa Something => String
descrivi qualcosa (Bool b) = "booleano:{b}"
5. Tipi generici personalizzati (type Result T E, type Page T) e namespace di modulo (import ... as, mod.Tipo)
Oltre alle funzioni polimorfiche, puoi dichiarare i tuoi tipi somma e record generici con parametri di tipo (A..Z), nonché organizzare tipi, costruttori, campi e funzioni con lo stesso nome in moduli diversi senza collisioni (module ::, import db as d, http.Config, d.Config). Ogni modulo ha visibilità stretta per importazione su 3 livelli: risolve i nomi senza prefisso cercando prima nelle (1) proprie dichiarazioni (inclusi più file che condividano module :: nome), poi nei (2) suoi import diretti e solo in ultimo luogo nei (3) suoi import transitivi, in modo che altri moduli fratelli o l'applicazione principale non contaminino mai una libreria importata:
type Risultato T E = Successo T | Fallimento E
type Pagina T = elementi: [T], totale: Int
fn :: spacchetta successo (Risultato A String) A => A
spacchetta successo (Successo v) _ = v
spacchetta successo (Fallimento _) d = d
Nel Capitolo 07 metteremo tutti i core del tuo processore a lavorare contemporaneamente senza versare una goccia di sudore: funzioni di ordine superiore (map, ||map, filter, ||filter, fold), chiusure lessicali ((x => expr)), grafi paralleli || con -> e canali concorrenti.