Eric Lippert hat in seiner Diskussion, warum C # null
eher einen als einen Maybe<T>
Typ verwendet, einen sehr interessanten Punkt angesprochen :
Die Konsistenz des Typsystems ist wichtig; Können wir immer wissen, dass ein nicht nullwertfähiger Verweis unter keinen Umständen als ungültig angesehen wird? Was ist mit dem Konstruktor eines Objekts mit einem nicht nullwertfähigen Referenzfeldtyp? 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 hat mir die Augen geöffnet. 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 mit dem Typ "Vielleicht" anstelle eines Nullwerts Randfälle wie Initialisierung und Fehlerbehebung, bei denen ein angeblich garantierter Nicht-Nullwert-Verweis tatsächlich nicht gültig ist?
quelle
Type?
(vielleicht) undType
(nicht null) zu unterscheidenMaybe T
, muss es nicht seinNone
und daher können Sie seinen Speicher nicht auf den Nullzeiger initialisieren.Antworten:
Dieses Zitat weist auf ein Problem hin, das auftritt, wenn die Deklaration und Zuweisung von Bezeichnern (hier: Instanzmitglieder) voneinander getrennt sind. Als kurze Pseudocode-Skizze:
Das Szenario sieht jetzt so aus, dass während der Erstellung einer Instanz ein Fehler ausgegeben wird, sodass die Erstellung abgebrochen wird, bevor die Instanz vollständig erstellt wurde. Diese Sprache bietet eine Destruktormethode, die ausgeführt wird, bevor die Zuordnung des Speichers aufgehoben wird, um z. B. 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 folgt zugewiesen wurde
if (foo != null) foo.cleanup()
. Ohne Nullen befindet sich das Objekt jetzt in einem undefinierten Zustand - wie hoch ist der Wert vonbar
?Dieses Problem besteht jedoch aufgrund der Kombination von drei Aspekten:
null
oder die garantierte Initialisierung für die Mitgliedsvariablen.let
Anweisung, wie sie in funktionalen Sprachen angezeigt wird) ist eine einfache Aufgabe, die garantierte Initialisierung zu erzwingen - schränkt die Sprache jedoch auf andere Weise ein.Es ist einfach, ein anderes Design zu wählen, das diese Probleme nicht aufweist, indem beispielsweise immer eine Deklaration mit einer Zuweisung kombiniert wird und die Sprache mehrere Finalizer-Blöcke anstelle einer einzelnen Finalisierungsmethode bietet:
Es gibt also kein Problem mit dem Fehlen von Null, sondern mit der Kombination einer Reihe anderer Merkmale mit dem Fehlen von Null.
Die interessante Frage ist nun, warum C # ein Design gewählt hat, das andere jedoch nicht. Hier werden im Kontext des Zitats viele weitere Argumente für eine Null in der C # -Sprache aufgeführt, die meistens als „Vertrautheit und Kompatibilität“ zusammengefasst werden können - und das sind gute Gründe.
quelle
null
s befassen muss : Die Reihenfolge der Finalisierung ist aufgrund der Möglichkeit von Referenzzyklen nicht garantiert. Aber ich denke, IhrFINALIZE
Design löst auch das Problem: Wennfoo
es bereits fertiggestellt wurde, wird seinFINALIZE
Abschnitt einfach nicht ausgeführt.Auf die gleiche Weise garantieren Sie, dass alle anderen Daten in einem gültigen Zustand sind.
Man kann die Semantik strukturieren und den Ablauf so steuern, dass man keine Variablen / Felder haben kann, ohne einen Wert dafür zu erstellen. Anstatt ein Objekt zu erstellen und es einem Konstruktor zu ermöglichen, seinen Feldern "Anfangswerte" zuzuweisen, können Sie ein Objekt nur erstellen, indem Sie Werte für alle Felder gleichzeitig angeben. Anstatt eine Variable zu deklarieren und anschließend einen Anfangswert zuzuweisen, können Sie eine Variable nur mit einer Initialisierung einführen.
In Rust erstellen Sie beispielsweise ein Objekt vom Typ struct über,
Point { x: 1, y: 2 }
anstatt einen Konstruktor zu schreiben, der dies tutself.x = 1; self.y = 2;
. Dies kann natürlich mit dem von Ihnen gewünschten Sprachstil in Konflikt geraten.Ein weiterer, komplementärer Ansatz besteht darin, die Verfügbarkeitsanalyse zu verwenden, um den Zugriff auf den Speicher vor seiner Initialisierung zu verhindern. Auf diese Weise können Sie eine Variable deklarieren, ohne sie sofort zu initialisieren, sofern dies vor dem ersten Lesevorgang nachweislich der Fall ist. Es können auch einige störungsbedingte Fälle wie abgefangen werden
Technisch gesehen können Sie auch eine beliebige Standardinitialisierung für Objekte definieren, z. B. alle numerischen Felder auf Null setzen, leere Arrays für Arrayfelder erstellen usw. Dies ist jedoch eher willkürlich, weniger effizient als andere Optionen und kann Fehler maskieren.
quelle
Das macht Haskell folgendermaßen: (Dies ist kein 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 sich Haskell von C # unterscheidet. Anstatt die Logistik des Tragwerksbaus an einen Konstrukteur zu delegieren, muss diese im umgebenden Code abgewickelt werden. Es gibt keine Möglichkeit, dass ein Nullwert (oder
Nothing
in Haskell) auftritt, wenn ein Nicht-Nullwert erwartet wird, da Nullwerte nur in speziellen aufgerufenen Wrapper-Typen auftreten können,Maybe
die nicht mit / direkt in reguläre, nicht austauschbare Werte konvertierbar sind. nullbare Typen. Um einen Wert zu verwenden, der durch Umschließen mit einem nullwertfähig gemacht wurdeMaybe
, müssen wir zuerst den Wert mithilfe des Mustervergleichs extrahieren. Dadurch werden wir gezwungen, den Kontrollfluss in einen Zweig umzuleiten, von dem wir sicher wissen, dass er nicht null ist.Deshalb:
Ja.
Int
undMaybe Int
sind zwei völlig getrennte Typen. Das FindenNothing
in einer EbeneInt
wäre vergleichbar mit dem Finden der Zeichenfolge "Fisch" in einer EbeneInt32
.Kein Problem: Wertekonstruktoren in Haskell können nichts anderes tun, als die angegebenen Werte zu nehmen und sie zusammenzufügen. Die gesamte Initialisierungslogik findet statt, bevor der Konstruktor aufgerufen wird.
Es gibt keine Finalizer in Haskell, daher kann ich das nicht wirklich ansprechen. Meine erste Antwort steht jedoch noch.
Vollständige Antwort :
Haskell hat keine Null und verwendet den
Maybe
Datentyp, um Nullables darzustellen. Vielleicht ist ein algabraischer Datentyp wie folgt definiert:Wenn Sie mit Haskell nicht vertraut sind, lesen Sie dies als "A
Maybe
ist entweder einNothing
oder einJust a
". Speziell:Maybe
ist der Typkonstruktor : Er kann (fälschlicherweise) als generische Klasse betrachtet werden (wobeia
die Typvariable ist). Die C # Analogie istclass Maybe<a>{}
.Just
ist ein Wertekonstruktor : Es ist eine Funktion, die ein Argument vom Typa
annimmt und einen Wert vom Typ zurückgibtMaybe a
, der den Wert enthält. Der Codex = Just 17
ist also analog zuint? x = 17;
.Nothing
ist ein anderer Wertekonstruktor, der jedoch keine Argumente akzeptiert und für denMaybe
kein anderer Wert als "Nothing" zurückgegeben wird.x = Nothing
ist analog zuint? x = null;
(unter der Annahme, dass wir unsera
in Haskell beschränkt habenInt
, was durch Schreiben geschehen kannx = Nothing :: Maybe Int
).Maybe
Wie vermeidet Haskell die in der Frage des OP erörterten Probleme, nachdem die Grundlagen des Typs nicht mehr vorhanden sind?Nun, Haskell unterscheidet sich wirklich von den meisten bisher diskutierten Sprachen. Ich beginne mit der Erläuterung einiger grundlegender 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 #, wobei variable Deklaration und Zuordnung sind zwei getrennte Vorgänge, in Haskell Werte durch Definition deren Wert erzeugt werden ( zum Beispiel
x = 15
,y = "quux"
,z = Nothing
), die sich nie ändern kann. Daher Code wie:Ist in Haskell nicht möglich. Es gibt keine Probleme beim Initialisieren von Werten für,
null
da alles explizit auf einen Wert initialisiert werden muss, damit es existiert.Zweitens ist Haskell keine objektorientierte Sprache : Es ist eine rein funktionale Sprache, es gibt also keine Objekte im eigentlichen Sinne des Wortes. Stattdessen gibt es einfach Funktionen (Wertekonstruktoren), die ihre Argumente verwenden und eine verschmolzene Struktur zurückgeben.
Als nächstes gibt es absolut keinen zwingenden Stilcode. Damit meine ich, dass die meisten Sprachen einem ähnlichen Muster folgen:
Das Programmverhalten wird als eine Reihe von Anweisungen ausgedrückt. In objektorientierten Sprachen spielen auch Klassen- und Funktionsdeklarationen eine große Rolle im Programmfluss, aber das "Fleisch" der Programmausführung besteht im Wesentlichen aus einer Reihe auszuführender Anweisungen.
In Haskell ist dies nicht möglich. Stattdessen wird der Programmfluss ausschließlich durch Verkettungsfunktionen bestimmt. Sogar die imperativ aussehende
do
Anmerkung ist nur syntaktischer Zucker, um anonyme Funktionen an den>>=
Bediener weiterzugeben . Alle Funktionen haben folgende Form:Wo
body-expression
kann alles sein, was zu einem Wert ausgewertet wird. Offensichtlich stehen mehr Syntaxfunktionen zur Verfügung, aber der Hauptaspekt ist das völlige Fehlen von Anweisungsfolgen.Schließlich und wahrscheinlich am wichtigsten ist Haskells Typensystem unglaublich streng. Wenn ich die zentrale Designphilosophie von Haskells Typensystem zusammenfassen müsste, würde ich sagen: "Machen Sie zur Kompilierungszeit so viele Fehler wie möglich, damit zur Laufzeit so wenig wie möglich schief geht." Es gibt keinerlei implizite Konvertierungen (Sie möchten eine
Int
in eineDouble
? Verwenden Sie diefromIntegral
Funktion). Das einzige, was möglicherweise zur Laufzeit zu einem ungültigen Wert führt, ist die VerwendungPrelude.undefined
(die anscheinend nur vorhanden sein muss und unmöglich zu entfernen ist ).Schauen wir uns in diesem Sinne das "kaputte" Beispiel von amon an und versuchen, diesen Code in Haskell erneut auszudrücken. Zuerst die Datendeklaration (unter Verwendung der Datensatzsyntax für benannte Felder):
(
foo
undbar
sind hier wirklich Accessorfunktionen für anonyme Felder anstelle von tatsächlichen Feldern, aber wir können dieses Detail ignorieren).Der
NotSoBroken
Wertekonstruktor ist nicht in der Lage, andere Aktionen als aFoo
und aBar
(die nicht nullwertfähig sind)NotSoBroken
auszuführen und daraus ein zu machen. Es gibt keinen Platz, um Imperativcode einzufügen oder die Felder auch nur manuell zuzuweisen. Die gesamte Initialisierungslogik muss an einer anderen Stelle stattfinden, höchstwahrscheinlich in einer dedizierten Factory-Funktion.Im Beispiel
Broken
schlägt die Konstruktion von immer fehl. Es gibt keine Möglichkeit, denNotSoBroken
Wertekonstruktor 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.(Die erste Zeile ist eine Typensignaturdeklaration:
makeNotSoBroken
Nimmt einFoo
und einBar
als Argument und erzeugt einMaybe NotSoBroken
).Der Rückgabetyp muss
Maybe NotSoBroken
und nicht einfach sein,NotSoBroken
weil wir ihm gesagt haben, dass er ausgewertet werdenNothing
soll. Dies ist ein Wertekonstruktor fürMaybe
. 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,
useNotSoBroken
die aNotSoBroken
als Argument erwartet :(
useNotSoBroken
akzeptiert aNotSoBroken
als Argument und erzeugt aWhatever
).Und benutze es so:
In den meisten Sprachen kann dieses Verhalten eine Nullzeigerausnahme verursachen. In Haskell stimmen die Typen nicht überein:
makeNotSoBroken
Gibt a zurückMaybe NotSoBroken
,useNotSoBroken
erwartet jedoch aNotSoBroken
. 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 desMaybe
Werts zu verzweigen (mithilfe eines Features namens Mustervergleich ):Natürlich muss dieses Snippet in einen bestimmten Kontext gestellt werden, um tatsächlich kompiliert zu werden, aber es demonstriert die Grundlagen, wie Haskell mit NULL-Werten umgeht. Hier finden Sie eine schrittweise Erklärung des obigen Codes:
makeNotSoBroken
wird ausgewertet, was garantiert einen Wert vom Typ ergibtMaybe NotSoBroken
.case
Anweisung überprüft die Struktur dieses Werts.Nothing
, wird der Code "Situation hier behandeln" ausgewertet.Just
Wert übereinstimmt, wird der andere Zweig ausgeführt. Beachten Sie, wie die Übereinstimmungsklausel den Wert gleichzeitig alsJust
Konstruktion identifiziert und sein internesNotSoBroken
Feld an einen Namen bindet (in diesem Fallx
).x
kann dann wie der normaleNotSoBroken
Wert verwendet werden.Die Mustererkennung bietet daher 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 nachvollziehbare Erklärung. Wenn es keinen Sinn ergibt, springen Sie zu Learn You A Haskell For Great Good! , eines der besten Online-Tutorials, die ich je gelesen habe. Hoffentlich sehen Sie in dieser Sprache die gleiche Schönheit wie ich.
quelle
Ich denke, Ihr Zitat ist ein Strohmann-Argument.
Die heutigen modernen Sprachen (einschließlich C #) garantieren, dass der Konstruktor entweder vollständig ausgeführt wird oder nicht.
Wenn es im Konstruktor eine Ausnahme gibt und das Objekt teilweise nicht initialisiert wird, hat der Status
null
oderMaybe::none
für den nicht initialisierten Status keinen wirklichen Unterschied im Destruktorcode.Sie müssen nur so oder so damit umgehen. Wenn externe Ressourcen zu verwalten sind, müssen Sie diese auf irgendeine Weise explizit verwalten. Sprachen und Bibliotheken können helfen, aber Sie müssen sich Gedanken darüber machen.
Btw: In C # ist der
null
Wert so ziemlich gleichbedeutend mitMaybe::none
. Sie könnennull
nur Variablen und Objektelementen zuweisen , die auf Typebene als nullable deklariert sind :Dies ist in keiner Weise anders als das folgende Snippet:
Zusammenfassend sehe ich also nicht, wie Nullwertigkeit in irgendeiner Weise den
Maybe
Typen entgegengesetzt ist. Ich würde sogar vorschlagen, dass C # sich in seinen eigenenMaybe
Typ eingeschlichen und ihn genannt hatNullable<T>
.Mit Erweiterungsmethoden ist es sogar einfach, die Bereinigung der Nullable nach dem monadischen Muster durchzuführen :
quelle
null
als implizites Mitglied von jedem Typ undMaybe<T>
mit dem WillenMaybe<T>
, kann man auch nur habenT
, der keinen Standardwert hat.null
ist nicht jeder Typ ein implizites Mitglied. Umnull
ein Lebal-Wert zu sein, müssen Sie den Typ so definieren, dass er explizit nullbar ist, was eineT?
(Syntax sugar forNullable<T>
) im Wesentlichen äquivalent zu machtMaybe<T>
.C ++ greift dazu auf den Initialisierer zu, der vor dem Konstruktortext auftritt. C # führt den Standardinitialisierer vor dem Konstruktortext aus, ordnet grob alles 0 zu,
floats
wird 0.0,bools
wird false, Verweise werden null usw. In C ++ können Sie einen anderen Initialisierer ausführen, um sicherzustellen, dass ein Verweistyp ungleich null niemals null ist .quelle
null
, und die einzige Möglichkeit, das Fehlen eines Werts anzuzeigen, besteht darin, einenMaybe
Typ (auch bekannt alsOption
) zu verwenden, den AFAIK C ++ in der nicht hat Standardbibliothek. Das Fehlen von Nullen garantiert, dass ein Feld immer als Eigenschaft des Typsystems gültig ist . Dies ist eine stärkere Garantie, als manuell sicherzustellen, dass kein Codepfad vorhanden ist, in dem sich möglicherweise noch eine Variable befindetnull
.