it-swarm.com.de

Wie gehen Sprachen mit Vielleicht-Typen anstelle von Nullen mit Randbedingungen um?

Eric Lippert machte einen sehr interessanten Punkt in seine Diskussion darüber, warum C # einen null anstelle eines Maybe<T> Typs verwendet :

Die Konsistenz des Typsystems ist wichtig; Können wir immer wissen, dass eine nicht nullfähige Referenz unter keinen Umständen als ungültig angesehen wird? Was ist mit dem Konstruktor eines Objekts mit einem nicht nullbaren Feld vom Referenztyp? Was ist mit dem Finalizer eines solchen Objekts, bei dem das Objekt finalisiert wird, weil der Code, der die Referenz ausfüllen sollte, eine Ausnahme ausgelöst hat? Ein Typensystem, das Sie über seine Garantien belügt, ist gefährlich.

Das war ein bisschen ein Augenöffner. Die Konzepte interessieren mich und ich habe ein bisschen mit Compilern und Typsystemen herumgespielt, aber ich habe nie über dieses Szenario nachgedacht. Wie behandeln Sprachen, die einen Vielleicht-Typ anstelle eines Null-Typs haben, Edge-Fälle wie Initialisierung und Fehlerbehebung, in denen sich eine angeblich garantierte Nicht-Null-Referenz tatsächlich nicht in einem gültigen Zustand befindet?

53
Mason Wheeler

Dieses Zitat weist auf ein Problem hin, das auftritt, wenn die Deklaration und Zuweisung von Bezeichnern (hier: Instanzmitglieder) getrennt voneinander sind. Als kurze Pseudocode-Skizze:

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()
    }
}

Das Szenario besteht nun darin, dass während der Erstellung einer Instanz ein Fehler ausgelöst wird, sodass die Erstellung abgebrochen wird, bevor die Instanz vollständig erstellt wurde. Diese Sprache bietet eine Destruktormethode, die ausgeführt wird, bevor der Speicher freigegeben wird, z. Nicht-Speicherressourcen manuell freizugeben. Es muss auch für teilweise erstellte Objekte ausgeführt werden, da manuell verwaltete Ressourcen möglicherweise bereits zugewiesen wurden, bevor die Erstellung abgebrochen wurde.

Mit Nullen konnte der Destruktor testen, ob eine Variable wie if (foo != null) foo.cleanup() zugewiesen wurde. Ohne Nullen befindet sich das Objekt jetzt in einem undefinierten Zustand - was ist der Wert von bar?

Dieses Problem besteht jedoch aufgrund der Kombination von drei Aspekten:

  • Das Fehlen von Standardwerten wie null oder die garantierte Initialisierung für die Mitgliedsvariablen.
  • Der Unterschied zwischen Deklaration und Abtretung. Das Erzwingen der sofortigen Zuweisung von Variablen (z. B. mit einer let -Anweisung in funktionalen Sprachen) ist eine einfache Möglichkeit, eine garantierte Initialisierung zu erzwingen - schränkt die Sprache jedoch auf andere Weise ein.
  • Die spezifische Variante von Destruktoren als Methode, die von der Sprachlaufzeit aufgerufen wird.

Es ist einfach, ein anderes Design zu wählen, das diese Probleme nicht aufweist, indem beispielsweise die Deklaration immer mit der Zuweisung kombiniert wird und die Sprache mehrere Finalizer-Blöcke anstelle einer einzelnen Finalisierungsmethode bietet:

// 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
}

Es gibt also kein Problem mit dem Fehlen von Null, sondern mit der Kombination einer Reihe anderer Funktionen mit dem Fehlen von Null.

Die interessante Frage ist nun, warum C # ein Design gewählt hat, aber nicht das andere. Im Kontext des Zitats sind hier viele andere Argumente für eine Null in der C # -Sprache aufgeführt, die meist als „Vertrautheit und Kompatibilität“ zusammengefasst werden können - und das sind gute Gründe.

45
amon

Auf die gleiche Weise garantieren Sie, dass alle anderen Daten in einem gültigen Zustand sind.

Man kann die Semantik so strukturieren und den Ablauf so steuern, dass Sie nicht eine Variable/ein Feld eines Typs haben, ohne einen Wert dafür vollständig zu erstellen. Anstatt ein Objekt zu erstellen und einen Konstruktor seinen Feldern "Anfangswerte" zuweisen zu lassen, können Sie ein Objekt nur erstellen, indem Sie Werte für alle Felder gleichzeitig angeben. Anstatt eine Variable zu deklarieren und dann einen Anfangswert zuzuweisen, können Sie nur eine Variable mit einer Initialisierung einführen.

Zum Beispiel erstellen Sie in Rust ein Objekt vom Strukturtyp über Point { x: 1, y: 2 } anstatt einen Konstruktor zu schreiben, der self.x = 1; self.y = 2;. Dies kann natürlich mit dem Sprachstil, den Sie sich vorstellen, in Konflikt geraten.

Ein weiterer komplementärer Ansatz ist die Verwendung der Lebendigkeitsanalyse, um den Zugriff auf den Speicher vor seiner Initialisierung zu verhindern. Dies ermöglicht das Deklarieren einer Variablen, ohne sie sofort zu initialisieren, solange sie nachweislich vor dem ersten Lesen zugewiesen wurde. Es kann auch einige fehlerbedingte Fälle wie z

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

Technisch könnten Sie auch eine beliebige Standardinitialisierung für Objekte definieren, z. Setzen Sie alle numerischen Felder auf Null, erstellen Sie leere Arrays für Array-Felder usw. Dies ist jedoch eher willkürlich, weniger effizient als andere Optionen und kann Fehler maskieren.

14
user7043

So macht es Haskell: (nicht gerade ein Widerspruch zu Lipperts Aussagen, da Haskell keine objektorientierte Sprache ist).

WARNUNG: Langatmige Antwort von einem ernsthaften Haskell-Fan.

TL; DR

Dieses Beispiel zeigt genau, wie unterschiedlich Haskell von C # ist. Anstatt die Logistik der Strukturkonstruktion an einen Konstruktor zu delegieren, muss sie im umgebenden Code behandelt werden. Es gibt keine Möglichkeit, dass ein Nullwert (oder Nothing in Haskell) auftaucht, wenn wir einen Wert ungleich Null erwarten, da Nullwerte nur in speziellen Wrapper-Typen auftreten können, die als Maybe bezeichnet werden sind nicht austauschbar mit/direkt konvertierbar in reguläre, nicht nullfähige Typen. Um einen Wert zu verwenden, der durch das Umschließen in ein Maybe auf Null gesetzt werden kann, müssen wir zuerst den Wert mithilfe des Mustervergleichs extrahieren, wodurch wir gezwungen werden, den Kontrollfluss in einen Zweig umzuleiten, in dem wir mit Sicherheit wissen, dass wir ein Non haben -null Wert.

Deshalb:

können wir immer wissen, dass eine nicht nullfähige Referenz unter keinen Umständen als ungültig angesehen wird?

Ja. Int und Maybe Int Sind zwei völlig getrennte Typen. Das Finden von Nothing in einer Ebene Int wäre vergleichbar mit dem Finden der Zeichenfolge "fish" in einem Int32.

Was ist mit dem Konstruktor eines Objekts mit einem nicht nullbaren Feld vom Referenztyp?

Kein Problem: Wertekonstruktoren in Haskell können nichts anderes tun, als die ihnen gegebenen Werte zu nehmen und zusammenzusetzen. Die gesamte Initialisierungslogik findet statt, bevor der Konstruktor aufgerufen wird.

Was ist mit dem Finalizer eines solchen Objekts, bei dem das Objekt finalisiert wird, weil der Code, der die Referenz ausfüllen sollte, eine Ausnahme ausgelöst hat?

In Haskell gibt es keine Finalisierer, daher kann ich das nicht wirklich ansprechen. Meine erste Antwort steht jedoch noch.

Vollständige Antwort :

Haskell hat keine Null und verwendet den Datentyp Maybe, um Nullables darzustellen. Vielleicht ist ein algabraischer Datentyp wie folgt definiert:

data Maybe a = Just a | Nothing

Für diejenigen unter Ihnen, die mit Haskell nicht vertraut sind, lesen Sie dies als "A Maybe ist entweder ein Nothing oder ein Just a". Speziell:

  • Maybe ist der Konstruktor type: Er kann (fälschlicherweise) als generische Klasse betrachtet werden (wobei a die Typvariable ist). Die C # -Analogie lautet class Maybe<a>{}.
  • Just ist ein Wertekonstruktor: Es ist eine Funktion, die ein Argument vom Typ a verwendet und einen Wert vom Typ Maybe a Zurückgibt, der enthält der Wert. Der Code x = Just 17 Ist also analog zu int? x = 17;.
  • Nothing ist ein weiterer Wertekonstruktor, der jedoch keine Argumente akzeptiert und das zurückgegebene Maybe keinen anderen Wert als "Nothing" hat. x = Nothing Ist analog zu int? x = null; (Vorausgesetzt, wir haben unser a in Haskell auf Int beschränkt, was durch Schreiben von x = Nothing :: Maybe Int Durchgeführt werden kann). .

Wie vermeidet Haskell die in der OP-Frage behandelten Probleme, nachdem die Grundlagen des Typs Maybe nicht mehr im Weg sind?

Nun, Haskell unterscheidet sich wirklich Von den meisten bisher diskutierten Sprachen, daher erkläre ich zunächst einige grundlegende Sprachprinzipien.

Zunächst einmal ist in Haskell alles unveränderlich . Alles. Namen beziehen sich auf Werte, nicht auf Speicherorte, an denen Werte gespeichert werden können (dies allein ist eine enorme Quelle für die Beseitigung von Fehlern). Anders als in C #, wo Variablendeklaration und -zuweisung zwei separate Operationen sind, werden in Haskell Werte durch Definieren ihres Werts erstellt (z. B. x = 15, y = "quux", z = Nothing), Was niemals möglich ist Veränderung. Daher Code wie:

ReferenceType x;

Ist in Haskell nicht möglich. Es gibt keine Probleme beim Initialisieren von Werten auf null, da alles explizit auf einen Wert initialisiert werden muss, damit er existiert.

Zweitens ist Haskell keine objektorientierte Sprache : Es ist eine rein funktionale Sprache, daher gibt es keine Objekte im engeren Sinne des Wortes. Stattdessen gibt es einfach Funktionen (Wertekonstruktoren), die ihre Argumente verwenden und eine zusammengeführte Struktur zurückgeben.

Als nächstes gibt es absolut keinen zwingenden Stilcode. Damit meine ich, dass die meisten Sprachen einem Muster wie diesem folgen:

do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
    do thing 6
return thing 7

Das Programmverhalten wird als eine Reihe von Anweisungen ausgedrückt. In objektorientierten Sprachen spielen Klassen- und Funktionsdeklarationen ebenfalls eine große Rolle im Programmfluss, aber das "Fleisch" der Programmausführung besteht im Wesentlichen aus einer Reihe von auszuführenden Anweisungen.

In Haskell ist dies nicht möglich. Stattdessen wird der Programmablauf vollständig durch Verkettungsfunktionen bestimmt. Sogar die imperativ aussehende do - Notation ist nur syntaktischer Zucker, um anonyme Funktionen an den Operator >>= Zu übergeben. Alle Funktionen haben die Form:

<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression

Wobei body-expression Alles sein kann, was einen Wert ergibt. Natürlich stehen mehr Syntaxfunktionen zur Verfügung, aber der Hauptpunkt ist das völlige Fehlen von Anweisungssequenzen.

Schließlich und wahrscheinlich am wichtigsten ist Haskells Schriftsystem unglaublich streng. Wenn ich die zentrale Designphilosophie von Haskells Schriftsystem zusammenfassen müsste, würde ich sagen: "Machen Sie zur Kompilierungszeit so viele Dinge wie möglich schief, damit zur Laufzeit so wenig wie möglich schief geht." Es gibt keinerlei implizite Konvertierungen (möchten Sie eine Int zu einer Double heraufstufen? Verwenden Sie die Funktion fromIntegral). Der einzige, bei dem zur Laufzeit möglicherweise ein ungültiger Wert auftritt, ist die Verwendung von Prelude.undefined (Was anscheinend muss nur vorhanden sein und kann nicht entfernt werden ).

Schauen wir uns vor diesem Hintergrund amons "kaputtes" Beispiel an und versuchen, diesen Code in Haskell erneut auszudrücken. Zunächst die Datendeklaration (unter Verwendung der Datensatzsyntax für benannte Felder):

data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar } 

(foo und bar sind hier wirklich Zugriffsfunktionen auf anonyme Felder anstelle von tatsächlichen Feldern, aber wir können dieses Detail ignorieren).

Der Wertekonstruktor NotSoBroken kann keine andere Aktion ausführen als Foo und Bar (die nicht nullwertfähig sind) und daraus ein NotSoBroken zu machen Sie. Es gibt keinen Platz, um imperativen Code einzufügen oder die Felder manuell zuzuweisen. Die gesamte Initialisierungslogik muss an anderer Stelle stattfinden, höchstwahrscheinlich in einer dedizierten Factory-Funktion.

Im Beispiel schlägt die Konstruktion von Broken immer fehl. Es gibt keine Möglichkeit, den Wertekonstruktor NotSoBroken auf ähnliche Weise zu unterbrechen (es gibt einfach keinen Ort, an dem der Code geschrieben werden kann), aber wir können eine Factory-Funktion erstellen, die ähnlich fehlerhaft ist.

makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing

(Die erste Zeile ist eine Typensignaturdeklaration: makeNotSoBroken verwendet ein Foo und ein Bar als Argumente und erzeugt einen Maybe NotSoBroken).

Der Rückgabetyp muss Maybe NotSoBroken Und nicht einfach NotSoBroken sein, da wir ihn angewiesen haben, Nothing auszuwerten, was ein Wertekonstruktor für Maybe ist. Die Typen würden einfach nicht in einer Reihe stehen, wenn wir etwas anderes schreiben würden.

Abgesehen davon, dass diese Funktion absolut sinnlos ist, erfüllt sie nicht einmal ihren eigentlichen Zweck, wie wir sehen werden, wenn wir versuchen, sie zu verwenden. Erstellen wir eine Funktion namens useNotSoBroken, die ein NotSoBroken als Argument erwartet:

useNotSoBroken :: NotSoBroken -> Whatever

(useNotSoBroken akzeptiert ein NotSoBroken als Argument und erzeugt ein Whatever).

Und benutze es so:

useNotSoBroken (makeNotSoBroken)

In den meisten Sprachen kann diese Art von Verhalten eine Nullzeigerausnahme verursachen. In Haskell stimmen die Typen nicht überein: makeNotSoBroken gibt einen Maybe NotSoBroken Zurück, aber useNotSoBroken erwartet einen NotSoBroken. Diese Typen sind nicht austauschbar und der Code kann nicht kompiliert werden.

Um dies zu umgehen, können wir eine case - Anweisung verwenden, um basierend auf der Struktur des Maybe - Werts zu verzweigen (unter Verwendung einer Funktion namens Mustervergleich):

case makeNotSoBroken of
    Nothing  -> --handle situation here
    (Just x) -> useNotSoBroken x

Natürlich muss dieses Snippet in einen Kontext gestellt werden, um es tatsächlich zu kompilieren, aber es zeigt die Grundlagen, wie Haskell mit Nullables umgeht. Hier ist eine schrittweise Erklärung des obigen Codes:

  • Zunächst wird makeNotSoBroken ausgewertet, wodurch garantiert ein Wert vom Typ Maybe NotSoBroken Erzeugt wird.
  • Die Anweisung case überprüft die Struktur dieses Werts.
  • Wenn der Wert Nothing ist, wird der Code "Situation hier behandeln" ausgewertet.
  • Wenn der Wert stattdessen mit einem Wert von Just übereinstimmt, wird der andere Zweig ausgeführt. Beachten Sie, wie die Übereinstimmungsklausel den Wert gleichzeitig als Just -Konstruktion identifiziert und das interne Feld NotSoBroken an einen Namen bindet (in diesem Fall x). x kann dann wie der normale Wert von NotSoBroken verwendet werden.

Der Mustervergleich bietet also eine leistungsstarke Möglichkeit zur Durchsetzung der Typensicherheit, da die Struktur des Objekts untrennbar mit der Verzweigung der Steuerung verbunden ist.

Ich hoffe das war eine verständliche Erklärung. Wenn es keinen Sinn ergibt, springen Sie zu Learn You A Haskell For Great Good! , einem der besten Online-Sprach-Tutorials, die ich je gelesen habe. Hoffentlich sehen Sie in dieser Sprache die gleiche Schönheit wie ich.

Ich denke, Ihr Zitat ist ein Strohmann-Argument.

Moderne Sprachen von heute (einschließlich C #) garantieren Ihnen, dass der Konstruktor entweder vollständig ist oder nicht.

Wenn es im Konstruktor eine Ausnahme gibt und das Objekt teilweise nicht initialisiert ist, macht es keinen wirklichen Unterschied im Destruktorcode, null oder Maybe::none Für den nicht initialisierten Status zu haben.

Sie müssen nur so oder so damit umgehen. Wenn externe Ressourcen verwaltet werden müssen, müssen Sie diese explizit auf irgendeine Weise verwalten. Sprachen und Bibliotheken können helfen, aber Sie müssen darüber nachdenken.

Übrigens: In C # entspricht der Wert von null ziemlich genau Maybe::none. Sie können null nur Variablen und Objektelementen zuweisen, die auf Typebene als nullable deklariert sind:

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

Dies unterscheidet sich in keiner Weise von dem folgenden Ausschnitt:

Maybe<String> optionalString = getOptionalString();

Zusammenfassend sehe ich also nicht, dass die Nullfähigkeit in irgendeiner Weise den Typen Maybe entgegengesetzt ist. Ich würde sogar vorschlagen, dass C # sich in seinen eigenen Typ Maybe geschlichen und ihn Nullable<T> Genannt hat.

Mit Erweiterungsmethoden ist es sogar einfach, die Bereinigung von Nullable zu erhalten, um dem monadischen Muster zu folgen:

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