Ecco come lo fa Haskell: (non esattamente in contrasto con le dichiarazioni di Lippert poiché Haskell non è un linguaggio orientato agli oggetti).
ATTENZIONE: una risposta lunga e senza fiato da parte di un serio fanboy Haskell.
TL; DR
Questo esempio mostra esattamente quanto Haskell sia diverso da C #. Invece di delegare la logistica della costruzione della struttura a un costruttore, deve essere gestita nel codice circostante. Non è possibile che un Nothing
valore di valore null (o in Haskell) cresca laddove ci aspettiamo un valore non nullo poiché i valori null possono verificarsi solo all'interno di tipi di wrapper speciali chiamati Maybe
che non sono intercambiabili con / direttamente convertibili in normali, non- tipi nullable. Al fine di utilizzare un valore reso nulla annullandolo racchiudendolo in a Maybe
, dobbiamo prima estrarre il valore utilizzando la corrispondenza del modello, che ci costringe a deviare il flusso di controllo in un ramo in cui sappiamo per certo che abbiamo un valore non nullo.
Perciò:
possiamo sempre sapere che un riferimento non annullabile non è mai stato ritenuto non valido in nessuna circostanza?
Sì. Int
e Maybe Int
sono due tipi completamente separati. Trovare Nothing
in una pianura Int
sarebbe paragonabile a trovare la stringa "pesce" in un Int32
.
Che dire del costruttore di un oggetto con un campo non annullabile di tipo di riferimento?
Non è un problema: i costruttori di valori in Haskell non possono fare altro che prendere i valori che vengono dati e metterli insieme. Tutta la logica di inizializzazione ha luogo prima che venga chiamato il costruttore.
Che dire del finalizzatore di un tale oggetto, in cui l'oggetto è finalizzato perché il codice che avrebbe dovuto compilare il riferimento ha generato un'eccezione?
Non ci sono finalizzatori in Haskell, quindi non posso davvero affrontarlo. La mia prima risposta è comunque valida.
Risposta completa :
Haskell non ha null e utilizza il Maybe
tipo di dati per rappresentare nullable. Forse è un tipo di dati algabraico definito in questo modo:
data Maybe a = Just a | Nothing
Per quelli di voi che non hanno familiarità con Haskell, leggete questo come "A Maybe
è o a Nothing
o a Just a
". In particolare:
Maybe
è il costruttore del tipo : può essere pensato (erroneamente) come una classe generica (dove si a
trova la variabile di tipo). L'analogia C # è class Maybe<a>{}
.
Just
è un costruttore di valori : è una funzione che accetta un argomento di tipo a
e restituisce un valore di tipo Maybe a
che contiene il valore. Quindi il codice x = Just 17
è analogo a int? x = 17;
.
Nothing
è un altro costruttore di valori, ma non accetta argomenti e il Maybe
valore restituito non ha altro che "Nothing". x = Nothing
è analogo a int? x = null;
(supponendo che abbiamo limitato il nostro a
in Haskell Int
, cosa che può essere fatta scrivendo x = Nothing :: Maybe Int
).
Ora che le basi del Maybe
tipo sono fuori mano, come fa Haskell a evitare i problemi discussi nella domanda del PO?
Bene, Haskell è davvero diverso dalla maggior parte delle lingue discusse finora, quindi inizierò spiegando alcuni principi linguistici di base.
Prima di tutto, a Haskell, tutto è immutabile . Qualunque cosa. I nomi si riferiscono a valori, non a posizioni di memoria in cui i valori possono essere memorizzati (questo da solo è un'enorme fonte di eliminazione dei bug). A differenza di C #, dove dichiarazione delle variabili e assegnazione sono due operazioni distinte, in Haskell valori sono creati da definire il loro valore (ad esempio x = 15
, y = "quux"
, z = Nothing
), che non cambiano mai. Pertanto, codice come:
ReferenceType x;
Non è possibile in Haskell. Non ci sono problemi con l'inizializzazione dei valori null
perché tutto deve essere inizializzato esplicitamente su un valore affinché esista.
Secondariamente, Haskell non è un linguaggio orientato agli oggetti : è un linguaggio puramente funzionale , quindi non ci sono oggetti nel senso stretto della parola. Invece, ci sono semplicemente funzioni (costruttori di valore) che accettano i loro argomenti e restituiscono una struttura amalgamata.
Successivamente, non esiste assolutamente un codice di stile imperativo. Con questo intendo che la maggior parte delle lingue segue uno schema simile a questo:
do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
do thing 6
return thing 7
Il comportamento del programma è espresso come una serie di istruzioni. Nei linguaggi orientati agli oggetti, anche le dichiarazioni di classi e funzioni svolgono un ruolo importante nel flusso del programma, ma è essenziale, la "carne" dell'esecuzione di un programma assume la forma di una serie di istruzioni da eseguire.
In Haskell, questo non è possibile. Invece, il flusso del programma è dettato interamente dalle funzioni di concatenamento. Anche la do
notazione dall'aspetto imperativo è solo uno zucchero sintattico per trasmettere funzioni anonime >>=
all'operatore. Tutte le funzioni assumono la forma di:
<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression
Dove body-expression
può essere tutto ciò che valuta un valore. Ovviamente ci sono più funzioni di sintassi disponibili ma il punto principale è la completa assenza di sequenze di istruzioni.
Infine, e probabilmente soprattutto, il sistema di tipi di Haskell è incredibilmente rigoroso. Se dovessi riassumere la filosofia progettuale centrale del sistema di tipi di Haskell, direi: "Fai in modo che il maggior numero di cose possibili vada storto in fase di compilazione, in modo che il meno possibile vada storto in fase di esecuzione". Non ci sono conversioni implicite di sorta (vuoi promuovere un Int
a Double
? Usa la fromIntegral
funzione). L'unica possibilità che si verifichi un valore non valido in fase di esecuzione è l'uso Prelude.undefined
(che apparentemente deve essere lì ed è impossibile rimuoverlo ).
Con tutto questo in mente, diamo un'occhiata all'esempio "rotto" di Amon e proviamo a ri-esprimere questo codice in Haskell. Innanzitutto, la dichiarazione dei dati (utilizzando la sintassi dei record per i campi con nome):
data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar }
( foo
e bar
sono funzioni di accesso ai campi anonimi qui invece dei campi effettivi, ma possiamo ignorare questo dettaglio).
Il NotSoBroken
costruttore del valore è incapace di compiere qualsiasi altra azione oltre a prendere a Foo
e a Bar
(che non sono nullable) e farne una NotSoBroken
. Non c'è spazio per inserire il codice imperativo o addirittura assegnare manualmente i campi. Tutta la logica di inizializzazione deve avvenire altrove, molto probabilmente in una funzione di fabbrica dedicata.
Nell'esempio, la costruzione di Broken
sempre fallisce. Non c'è modo di rompere il NotSoBroken
costruttore di valori in modo simile (non c'è semplicemente nessun posto dove scrivere il codice), ma possiamo creare una funzione di fabbrica che è allo stesso modo difettosa.
makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing
(la prima riga è una dichiarazione di firma del tipo: makeNotSoBroken
accetta a Foo
e a Bar
come argomenti e produce a Maybe NotSoBroken
).
Il tipo restituito deve essere Maybe NotSoBroken
e non semplicemente NotSoBroken
perché gli abbiamo detto di valutare Nothing
, per cui è un costruttore di valori Maybe
. I tipi semplicemente non si allineano se scrivessimo qualcosa di diverso.
Oltre ad essere assolutamente inutile, questa funzione non soddisfa nemmeno il suo vero scopo, come vedremo quando proveremo a usarlo. Creiamo una funzione chiamata useNotSoBroken
che prevede a NotSoBroken
come argomento:
useNotSoBroken :: NotSoBroken -> Whatever
( useNotSoBroken
accetta a NotSoBroken
come argomento e produce a Whatever
).
E usalo così:
useNotSoBroken (makeNotSoBroken)
Nella maggior parte delle lingue, questo tipo di comportamento potrebbe causare un'eccezione puntatore null. In Haskell, i tipi non corrispondono: makeNotSoBroken
restituisce a Maybe NotSoBroken
, ma useNotSoBroken
prevede a NotSoBroken
. Questi tipi non sono intercambiabili e la compilazione del codice non riesce.
Per ovviare a questo, possiamo usare case
un'istruzione per ramificare in base alla struttura del Maybe
valore (usando una funzione chiamata pattern matching ):
case makeNotSoBroken of
Nothing -> --handle situation here
(Just x) -> useNotSoBroken x
Ovviamente questo frammento deve essere inserito in un contesto per essere effettivamente compilato, ma dimostra le basi di come Haskell gestisce nullable. Ecco una spiegazione dettagliata del codice sopra:
- In primo luogo,
makeNotSoBroken
viene valutato, che è garantito per produrre un valore di tipo Maybe NotSoBroken
.
- La
case
dichiarazione ispeziona la struttura di questo valore.
- Se il valore è
Nothing
, viene valutato il codice "Gestisci situazione qui".
- Se il valore corrisponde invece a un
Just
valore, viene eseguito l'altro ramo. Si noti come la clausola di corrispondenza identifica contemporaneamente il valore come Just
costruzione e associa il suo NotSoBroken
campo interno a un nome (in questo caso x
). x
può quindi essere usato come il NotSoBroken
valore normale che è.
Pertanto, la corrispondenza dei modelli offre una potente funzione per rafforzare la sicurezza dei tipi, poiché la struttura dell'oggetto è indissolubilmente legata alla ramificazione del controllo.
Spero che questa sia stata una spiegazione comprensibile. Se non ha senso, entra in Learn You A Haskell For Great Good! , uno dei migliori tutorial di lingua online che abbia mai letto. Spero che vedrai la stessa bellezza in questa lingua.