In che modo le lingue con i tipi Forse anziché i null gestiscono le condizioni dei bordi?


53

Eric Lippert ha fatto un punto molto interessante nella sua discussione sul perché C # usa un tipo nullpiuttosto che un Maybe<T>tipo :

La coerenza del sistema di tipi è importante; possiamo sempre sapere che un riferimento non annullabile non è mai stato ritenuto non valido in nessuna circostanza? Che dire del costruttore di un oggetto con un campo non annullabile di tipo di riferimento? 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? Un sistema di tipo che ti mente sulle sue garanzie è pericoloso.

È stato un po 'di apertura degli occhi. I concetti in questione mi interessano, e ho fatto alcuni scherzi con compilatori e sistemi di tipi, ma non ho mai pensato a quello scenario. Come possono le lingue che hanno un tipo Maybe anziché un null gestire casi come l'inizializzazione e il recupero degli errori, in cui un riferimento non nullo apparentemente garantito non è, in effetti, in uno stato valido?


Suppongo che se forse fa parte del linguaggio potrebbe essere che sia implementato internamente tramite un puntatore null ed è solo zucchero sintattico. Ma non credo che nessuna lingua lo faccia in questo modo.
Panzi,

1
@panzi: Ceylon utilizza la digitazione sensibile al flusso per distinguere tra Type?(forse) e Type(non null)
Lukas Eder

1
@RobertHarvey Non c'è già un pulsante "bella domanda" in Stack Exchange?
user253751

2
@panzi Questa è un'ottimizzazione valida e valida, ma non aiuta con questo problema: quando qualcosa non è Maybe T, non deve essere Nonee quindi non è possibile inizializzare la sua memorizzazione sul puntatore null.

@immibis: l'ho già spinto. Riceviamo preziose poche buone domande qui; Ho pensato che questo meritasse un commento.
Robert Harvey,

Risposte:


45

Quella citazione indica un problema che si verifica se la dichiarazione e l'assegnazione degli identificatori (qui: membri dell'istanza) sono separati l' uno dall'altro. Come schizzo pseudocodice rapido:

class Broken {
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() {
        foo = new Foo()
        throw new Exception()
        // this code is never reached, so "bar" is not assigned
        bar = new Bar()
    }

    ~Broken() {
        foo.cleanup()
        bar.cleanup()
    }
}

Lo scenario è ora che durante la costruzione di un'istanza, verrà generato un errore, quindi la costruzione verrà interrotta prima che l'istanza sia stata completamente costruita. Questo linguaggio offre un metodo distruttore che verrà eseguito prima della deallocazione della memoria, ad esempio per liberare manualmente le risorse non di memoria. Deve anche essere eseguito su oggetti parzialmente costruiti, poiché le risorse gestite manualmente potrebbero essere già state allocate prima che la costruzione fosse interrotta.

Con null, il distruttore potrebbe verificare se una variabile è stata assegnata come if (foo != null) foo.cleanup(). Senza null, l'oggetto è ora in uno stato indefinito - qual è il valore di bar?

Tuttavia, questo problema esiste a causa della combinazione di tre aspetti:

  • L'assenza di valori predefiniti come nullo l'inizializzazione garantita per le variabili membro.
  • La differenza tra dichiarazione e cessione. Forzare l'assegnazione immediata delle variabili (ad es. Con letun'istruzione come nei linguaggi funzionali) è facile forzare l'inizializzazione garantita, ma limita la lingua in altri modi.
  • Il sapore specifico dei distruttori come metodo che viene chiamato dal runtime della lingua.

È facile scegliere un altro design che non presenti questi problemi, ad esempio combinando sempre la dichiarazione con l'assegnazione e facendo in modo che la lingua offra più blocchi di finalizzatore anziché un singolo metodo di finalizzazione:

// the body of the class *is* the constructor
class Working() {
    val foo: Foo = new Foo()
    FINALIZE { foo.cleanup() }  // block is registered to run when object is destroyed

    throw new Exception()

    // the below code is never reached, so
    //  1. the "bar" variable never enters the scope
    //  2. the second finalizer block is never registered.
    val bar: Bar = new Bar()
    FINALIZE { bar.cleanup() }  // block is registered to run when object is destroyed
}

Quindi non c'è un problema con l'assenza di null, ma con la combinazione un insieme di altre funzionalità con un'assenza di null.

La domanda interessante è ora perché C # ha scelto un design ma non l'altro. Qui, il contesto della citazione elenca molti altri argomenti per un null nel linguaggio C #, che può essere riassunto principalmente come "familiarità e compatibilità" - e questi sono buoni motivi.


C'è anche un altro motivo per cui il finalizzatore ha a che fare con nulls: l'ordine di finalizzazione non è garantito, a causa della possibilità di cicli di riferimento. Ma immagino che anche il tuo FINALIZEprogetto risolva questo: se fooè già stato finalizzato, la sua FINALIZEsezione semplicemente non funzionerà.
svick,

14

Lo stesso modo in cui garantisci che tutti gli altri dati siano in uno stato valido.

Si può strutturare la semantica e controllare il flusso in modo tale che non si possa avere una variabile / campo di qualche tipo senza creare completamente un valore per esso. Invece di creare un oggetto e lasciare che un costruttore assegni valori "iniziali" ai suoi campi, puoi creare un oggetto solo specificando i valori per tutti i suoi campi contemporaneamente. Invece di dichiarare una variabile e quindi assegnare un valore iniziale, è possibile introdurre una variabile solo con un'inizializzazione.

Ad esempio, in Rust si crea un oggetto di tipo struct tramite Point { x: 1, y: 2 }anziché scrivere un costruttore che lo fa self.x = 1; self.y = 2;. Naturalmente, questo può scontrarsi con lo stile del linguaggio che hai in mente.

Un altro approccio complementare è l'utilizzo dell'analisi della vivacità per impedire l'accesso allo storage prima della sua inizializzazione. Ciò consente di dichiarare una variabile senza inizializzarla immediatamente, a condizione che sia dimostrata prima della prima lettura. Può anche rilevare alcuni casi relativi a guasti come

Object o;
try {
    call_can_throw();
    o = new Object();
} catch {}
use(o);

Tecnicamente, potresti anche definire un'inizializzazione arbitraria di default per gli oggetti, ad es. Azzerare tutti i campi numerici, creare array vuoti per i campi di array, ecc. Ma questo è piuttosto arbitrario, meno efficiente di altre opzioni e può mascherare i bug.


7

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 Nothingvalore 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 Maybeche 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ì. Inte Maybe Intsono due tipi completamente separati. Trovare Nothingin una pianura Intsarebbe 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 Maybetipo 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 Nothingo a Just a". In particolare:

  • Maybeè il costruttore del tipo : può essere pensato (erroneamente) come una classe generica (dove si atrova la variabile di tipo). L'analogia C # è class Maybe<a>{}.
  • Justè un costruttore di valori : è una funzione che accetta un argomento di tipo ae restituisce un valore di tipo Maybe ache 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 Maybevalore restituito non ha altro che "Nothing". x = Nothingè analogo a int? x = null;(supponendo che abbiamo limitato il nostro ain Haskell Int, cosa che può essere fatta scrivendo x = Nothing :: Maybe Int).

Ora che le basi del Maybetipo 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 nullperché 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 donotazione 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-expressionpuò 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 Inta Double? Usa la fromIntegralfunzione). 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 } 

( fooe barsono funzioni di accesso ai campi anonimi qui invece dei campi effettivi, ma possiamo ignorare questo dettaglio).

Il NotSoBrokencostruttore del valore è incapace di compiere qualsiasi altra azione oltre a prendere a Fooe 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 Brokensempre fallisce. Non c'è modo di rompere il NotSoBrokencostruttore 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: makeNotSoBrokenaccetta a Fooe a Barcome argomenti e produce a Maybe NotSoBroken).

Il tipo restituito deve essere Maybe NotSoBrokene non semplicemente NotSoBrokenperché 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 useNotSoBrokenche prevede a NotSoBrokencome argomento:

useNotSoBroken :: NotSoBroken -> Whatever

( useNotSoBrokenaccetta a NotSoBrokencome 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: makeNotSoBrokenrestituisce a Maybe NotSoBroken, ma useNotSoBrokenprevede a NotSoBroken. Questi tipi non sono intercambiabili e la compilazione del codice non riesce.

Per ovviare a questo, possiamo usare caseun'istruzione per ramificare in base alla struttura del Maybevalore (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, makeNotSoBrokenviene valutato, che è garantito per produrre un valore di tipo Maybe NotSoBroken.
  • La casedichiarazione ispeziona la struttura di questo valore.
  • Se il valore è Nothing, viene valutato il codice "Gestisci situazione qui".
  • Se il valore corrisponde invece a un Justvalore, viene eseguito l'altro ramo. Si noti come la clausola di corrispondenza identifica contemporaneamente il valore come Justcostruzione e associa il suo NotSoBrokencampo interno a un nome (in questo caso x). xpuò quindi essere usato come il NotSoBrokenvalore 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.


TL; DR dovrebbe essere in cima :)
andrew.fox

@ andrew.fox Buon punto. Lo modificherò.
Approaching

0

Penso che la tua citazione sia un argomento da uomo di paglia.

I linguaggi moderni oggi (incluso C #), ti garantiscono che il costruttore sia completamente completo o no.

Se c'è un'eccezione nel costruttore e l'oggetto viene lasciato parzialmente non inizializzato, avere nullo Maybe::noneper stato non inizializzato non fa alcuna differenza reale nel codice distruttore.

Dovrai occupartene in entrambi i modi. Quando ci sono risorse esterne da gestire, è necessario gestirle in modo esplicito in qualsiasi modo. Le lingue e le biblioteche possono essere d'aiuto, ma dovrai pensarci.

Btw: In C #, il nullvalore è praticamente equivalente a Maybe::none. È possibile assegnare nullsolo a variabili e membri oggetto che a livello di tipo sono dichiarati nullable :

String? nullableString = getOptionalString();
Nullable<String> maybe = nullableString; // This is equivalent

Questo non è in alcun modo diverso dal seguente frammento:

Maybe<String> optionalString = getOptionalString();

Quindi, in conclusione, non vedo come la nullità sia in alcun modo opposta ai Maybetipi. Vorrei anche suggerire che C # si è intrufolato nel suo stesso Maybetipo e lo ha chiamato Nullable<T>.

Con i metodi di estensione, è anche facile ottenere la pulizia della Nullable per seguire il modello monadico:

Resource? resource = initializationThatMayFail();
...
resource.ifExists( Resource r -> r.cleanup() );

2
cosa vuol dire "il costruttore completa o no"? In Java, ad esempio, l'inizializzazione del campo (non finale) nel costruttore non è protetta dalla corsa dei dati - si qualifica come completamente completato o no?
moscerino

@gnat: cosa intendi con "In Java, ad esempio, l'inizializzazione del campo (non finale) nel costruttore non è protetta dalla corsa dei dati". A meno che tu non faccia qualcosa di straordinariamente complesso che coinvolge più thread, le possibilità di condizioni di gara all'interno di un costruttore sono (o dovrebbero essere) quasi impossibili. Non è possibile accedere a un campo di un oggetto non costruito se non all'interno del costruttore dell'oggetto. E se la costruzione fallisce, non hai un riferimento all'oggetto.
Roland Tepp,

La grande differenza tra nullcome membro implicito di ogni tipo ed Maybe<T>è che lo farà con Maybe<T>, puoi anche avere solo T, che non ha alcun valore predefinito.
svick,

Durante la creazione di matrici, spesso non sarà possibile determinare valori utili per tutti gli elementi senza doverne leggere alcuni, né sarà possibile verificare staticamente che nessun elemento letto senza un valore utile sia stato calcolato per esso. La cosa migliore da fare è inizializzare gli elementi dell'array in modo tale che possano essere riconosciuti come inutilizzabili.
supercat

@svick: In C # (che era la lingua in questione dall'OP), nullnon è un membro implicito di ogni tipo. Per nullessere un valore lebal, è necessario definire il tipo in modo che sia esplicitamente nullo, il che rende un T?(zucchero sintassi per Nullable<T>) essenzialmente equivalente Maybe<T>.
Roland Tepp,

-3

C ++ lo fa avendo accesso all'inizializzatore che si verifica prima del corpo del costruttore. C # esegue l'inizializzatore predefinito prima del corpo del costruttore, assegna approssimativamente 0 a tutto, floatsdiventa 0,0, boolsdiventa falso, i riferimenti diventano nulli, ecc. In C ++ puoi farlo eseguire un inizializzatore diverso per garantire che un tipo di riferimento non nullo non sia mai nullo .

class Foo { Foo(int i) { throw new Exception("Never finishes"); }
class Bar { Bar(string s) { } }

class Broken
{
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() :
        foo = new Foo(123),// roughly causes a "goto destroy_foo;"
        bar = new Bar("never executes") { }

    // This destructory-function never runs because the constructor never completed
    ~Broken() 
    // This is made-up syntax:
    // : 
    // destroy_bar:
    // bar.~Bar();
    // destroy_foo:
    // foo.~Foo();
    {
    }
}

2
la domanda riguardava le lingue con tipi Forse
moscerino

3
" Riferimenti diventano nulli " - l'intera premessa della domanda è che non abbiamo null, e l'unico modo per indicare l'assenza di un valore è utilizzare un Maybetipo (noto anche come Option), che AFAIK C ++ non ha nel libreria standard. L'assenza di null ci consente di garantire che un campo sarà sempre valido come proprietà del sistema di tipi . Questa è una garanzia più forte rispetto all'assicurarsi manualmente che non esista un percorso di codice in cui potrebbe essere ancora presente una variabile null.
amon,

Mentre c ++ non ha nativamente forse tipi espliciti di Forse, cose come std :: shared_ptr <T> sono abbastanza vicine da pensare che sia ancora rilevante che c ++ gestisca il caso in cui l'inizializzazione di variabili può avvenire "al di fuori dell'ambito" del costruttore, e è infatti richiesto per i tipi di riferimento (&), poiché non possono essere nulli.
FryGuy,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.