Unveränderliche Objekte, die aufeinander verweisen?

77

Heute habe ich versucht, meinen Kopf um unveränderliche Objekte zu wickeln, die aufeinander verweisen. Ich bin zu dem Schluss gekommen, dass man das unmöglich machen kann, ohne eine faule Auswertung zu verwenden, aber dabei habe ich diesen (meiner Meinung nach) interessanten Code geschrieben.

public class A
{
    public string Name { get; private set; }
    public B B { get; private set; }
    public A()
    {
        B = new B(this);
        Name = "test";
    }
}

public class B
{
    public A A { get; private set; }
    public B(A a)
    {
        //a.Name is null
        A = a;
    }
}

Was ich interessant finde, ist, dass ich mir keine andere Möglichkeit vorstellen kann, ein Objekt vom Typ A in einem Zustand zu beobachten, der noch nicht vollständig konstruiert ist und Threads enthält. Warum ist das überhaupt gültig? Gibt es andere Möglichkeiten, den Zustand eines Objekts zu beobachten, das nicht vollständig konstruiert ist?

Stilgar
quelle
17
Warum erwarten Sie, dass es ungültig ist?
Leppie
1
Mein Verständnis ist, dass ein Konstruktor sicherstellen soll, dass der darin enthaltene Code ausgeführt wird, bevor externer Code den Status des Objekts beobachten kann.
Stilgar
1
Der Code ist gültig, aber nicht sehr zuverlässig. Stilgar hat recht - Adie an B.ctor übergebene Instanz der Klasse ist nicht vollständig initialisiert. Sie müssen eine neue Instanz von Bin erstellen, Anachdem die Instanz von Avollständig initialisiert wurde - dies sollte die letzte Zeile in .ctor sein.
Karel Frajták

Antworten:

106

Warum ist das überhaupt gültig?

Warum erwarten Sie, dass es ungültig ist?

Weil ein Konstruktor sicherstellen soll, dass der darin enthaltene Code ausgeführt wird, bevor externer Code den Status des Objekts beobachten kann.

Richtig. Aber der Compiler ist nicht verantwortlich für diese Invarianten beibehalten wird . Du bist . Wenn Sie Code schreiben, der diese Invariante bricht, und es tut weh, wenn Sie das tun, dann hören Sie damit auf .

Gibt es andere Möglichkeiten, den Zustand eines Objekts zu beobachten, das nicht vollständig konstruiert ist?

Sicher. Bei Referenztypen müssen alle "dies" offensichtlich aus dem Konstruktor übergeben werden, da der einzige Benutzercode, der die Referenz auf den Speicher enthält, der Konstruktor ist. Einige Möglichkeiten, wie der Konstruktor "dies" auslaufen kann, sind:

  • Fügen Sie "this" in ein statisches Feld ein und verweisen Sie von einem anderen Thread darauf
  • Führen Sie einen Methoden- oder Konstruktoraufruf durch und übergeben Sie "this" als Argument
  • einen virtuellen Aufruf ausführen - besonders unangenehm, wenn die virtuelle Methode von einer abgeleiteten Klasse überschrieben wird, da sie dann ausgeführt wird, bevor der ctor-Body der abgeleiteten Klasse ausgeführt wird.

Ich sagte, dass der einzige Benutzercode , der eine Referenz enthält, der ctor ist, aber natürlich enthält der Garbage Collector auch eine Referenz. Eine andere interessante Art und Weise, wie beobachtet werden kann, dass sich ein Objekt in einem halbkonstruierten Zustand befindet, besteht darin, dass das Objekt einen Destruktor hat und der Konstruktor eine Ausnahme auslöst (oder eine asynchrone Ausnahme wie einen Thread-Abbruch erhält; dazu später mehr). ) In diesem Fall ist das Objekt kurz vor dem Tod und muss daher finalisiert werden, aber der Finalizer-Thread kann den halb initialisierten Zustand des Objekts sehen. Und jetzt sind wir wieder im Benutzercode, der das halb konstruierte Objekt sehen kann!

Destruktoren müssen angesichts dieses Szenarios robust sein. Ein Destruktor darf nicht von einer Invariante des vom Konstruktor eingerichteten Objekts abhängen, da das zerstörte Objekt möglicherweise nie vollständig konstruiert wurde.

Eine andere verrückte Möglichkeit, ein halb konstruiertes Objekt durch externen Code zu beobachten, besteht natürlich darin, dass der Destruktor das halb initialisierte Objekt im obigen Szenario sieht und dann einen Verweis auf dieses Objekt in ein statisches Feld kopiert , wodurch sichergestellt wird, dass die Hälfte -konstruiertes, halbfertiges Objekt wird vor dem Tod gerettet. Bitte tue das nicht. Wie ich schon sagte, wenn es weh tut, tu es nicht.

Wenn Sie sich im Konstruktor eines Wertetyps befinden, sind die Dinge im Grunde die gleichen, aber es gibt einige kleine Unterschiede im Mechanismus. Die Sprache erfordert, dass ein Konstruktoraufruf für einen Werttyp eine temporäre Variable erstellt, auf die nur der Ctor Zugriff hat, diese Variable mutiert und dann eine Strukturkopie des mutierten Werts in den tatsächlichen Speicher erstellt. Dies stellt sicher, dass sich der endgültige Speicher nicht in einem halb mutierten Zustand befindet, wenn der Konstruktor wirft.

Beachten Sie, dass seit struct Kopien sind nicht atomar sein garantiert, es ist möglich , ein anderer Thread die Lagerung in einem halbmutiertes Zustand zu sehen; Verwenden Sie Schlösser richtig, wenn Sie sich in dieser Situation befinden. Es ist auch möglich, dass eine asynchrone Ausnahme wie ein Thread-Abbruch zur Hälfte einer Strukturkopie ausgelöst wird. Diese Nichtatomaritätsprobleme treten unabhängig davon auf, ob die Kopie von einer temporären oder einer "regulären" Kopie stammt. Und im Allgemeinen werden nur sehr wenige Invarianten beibehalten, wenn es asynchrone Ausnahmen gibt.

In der Praxis optimiert der C # -Compiler die temporäre Zuordnung und kopiert sie, wenn er feststellen kann, dass dieses Szenario nicht möglich ist. Wenn der neue Wert beispielsweise ein lokales Element initialisiert, das nicht von einem Lambda und nicht in einem Iteratorblock geschlossen wird, S s = new S(123);mutiert er einfach sdirekt.

Weitere Informationen zur Funktionsweise von Werttypkonstruktoren finden Sie unter:

Einen weiteren Mythos über Werttypen entlarven

Weitere Informationen darüber, wie die C # -Sprachensemantik versucht, Sie vor sich selbst zu retten, finden Sie unter:

Warum werden Initialisierer als Konstruktoren in der entgegengesetzten Reihenfolge ausgeführt? Teil eins

Warum werden Initialisierer als Konstruktoren in der entgegengesetzten Reihenfolge ausgeführt? Zweiter Teil

Ich scheine von dem vorliegenden Thema abgewichen zu sein. In einer Struktur können Sie natürlich beobachten, dass ein Objekt auf die gleiche Weise zur Hälfte konstruiert wird - kopieren Sie das halb konstruierte Objekt in ein statisches Feld, rufen Sie eine Methode mit "this" als Argument auf und so weiter. (Offensichtlich ist das Aufrufen einer virtuellen Methode für einen abgeleiteten Typ bei Strukturen kein Problem.) Und wie gesagt, die Kopie vom temporären zum endgültigen Speicher ist nicht atomar, und daher kann ein anderer Thread die halb kopierte Struktur beobachten.


Betrachten wir nun die Hauptursache Ihrer Frage: Wie stellen Sie unveränderliche Objekte her, die aufeinander verweisen?

Wie Sie festgestellt haben, ist dies normalerweise nicht der Fall. Wenn Sie zwei unveränderliche Objekte haben, die aufeinander verweisen, bilden sie logischerweise einen gerichteten zyklischen Graphen . Sie könnten einfach einen unveränderlichen gerichteten Graphen erstellen! Das ist ganz einfach. Ein unveränderlicher gerichteter Graph besteht aus:

  • Eine unveränderliche Liste unveränderlicher Knoten, von denen jeder einen Wert enthält.
  • Eine unveränderliche Liste unveränderlicher Knotenpaare, von denen jedes den Start- und Endpunkt einer Diagrammkante hat.

Die Art und Weise, wie Sie die Knoten A und B "referenzieren", ist nun:

A = new Node("A");
B = new Node("B");
G = Graph.Empty.AddNode(A).AddNode(B).AddEdge(A, B).AddEdge(B, A);

Und wenn Sie fertig sind, haben Sie eine Grafik, in der A und B sich gegenseitig "referenzieren".

Das Problem ist natürlich, dass Sie nicht von A nach B gelangen können, ohne G in der Hand zu haben. Diese zusätzliche Indirektionsebene ist möglicherweise nicht akzeptabel.

Eric Lippert
quelle
Vielen Dank. Ich habe den Artikel über den Werttyp gelesen und sie waren Teil des Grundes, warum ich dachte, dass die Sprache versucht, die vollständige Konstruktion des Objekts zu gewährleisten, bevor es beobachtet werden kann. Schließlich wird der Wert deshalb kopiert.
Stilgar
1
@Stilgar: Wir versuchen, eine "Qualitätsgrube" zu sein, in der man wirklich hart arbeiten muss, um ein Programm zu schreiben, das etwas Verrücktes macht. Leider ist es sehr schwierig, eine nützliche Sprache zu entwerfen, in der garantiert wird, dass ein Objekt niemals in einem inkonsistenten Zustand beobachtet wird, daher versuchen wir nicht, dies zu garantieren . Wir versuchen nur, Sie stark in diese Richtung zu bewegen. (Dies ist im Grunde der Grund, warum nicht nullbare Referenztypen in .NET nicht funktionieren. Es ist sehr schwierig, im Typsystem zu garantieren , dass ein Feld des nicht nullbaren Referenztyps niemals als null angesehen wird.)
Eric Lippert
3
Ja, es scheint, als hätten Sie so gute Arbeit geleistet, dass ich erwartet hatte, dass Sie mich daran hindern, den obigen Code zu schreiben.
Stilgar
@Stilgar: Das Problem ist, dass wir Sie in diesem Fall auch daran hindern, viel nützlichen Code zu schreiben. Es ist manchmal sehr nützlich, "dies" an eine Methode oder einen Konstruktor einer anderen Klasse übergeben zu können, insbesondere in solchen Initialisierungsszenarien. Ich schreibe jeden Tag solchen Code: Im Compiler befinden wir uns oft in Situationen, in denen unveränderliche "Code-Analysatoren" unveränderliche "Symbole" konstruieren und in der Lage sein müssen, sich gegenseitig zu referenzieren.
Eric Lippert
@ EricLippert: Es ist in der Tat nützlich. Ich habe Fälle, in denen es nützlich war, einen thisVerweis auf andere Objekte innerhalb des Konstruktors zu übergeben. Aber warum erlaubt der Compiler dann nicht die Verwendung des thisSchlüsselworts aus Feldinitialisierern? Beide können teilweise konstruierte Objekte sehen. Diese Frage lieferte keinen Grund für diese Einschränkung. Wenn Sie den Grund kennen, teilen Sie uns dies bitte mit.
Allon Guralnek
47

Ja, dies ist die einzige Möglichkeit für zwei unveränderliche Objekte, sich aufeinander zu beziehen - mindestens eines von ihnen muss das andere nicht vollständig konstruiert sehen.

Es ist im Allgemeinen eine schlechte Idee, thissich von Ihrem Konstruktor entziehen zu lassen , aber in Fällen, in denen Sie sicher sind, was beide Konstruktoren tun, und es die einzige Alternative zur Veränderlichkeit ist, denke ich nicht, dass es zu schlecht ist.

Jon Skeet
quelle
2
Ich habe ein Beispiel für gegenseitige Verweise thisin dieser Antwort auf eine andere Frage angeboten.
Brian
22

"Vollständig konstruiert" wird durch Ihren Code definiert, nicht durch die Sprache.

Dies ist eine Variation beim Aufrufen einer virtuellen Methode vom Konstruktor.
Die allgemeine Richtlinie lautet: Tun Sie das nicht .

Um den Begriff "vollständig konstruiert" korrekt zu implementieren, verlassen Sie thisIhren Konstruktor nicht.

Henk Holterman
quelle
8

Wenn Sie die thisReferenz während des Konstruktors verlieren, können Sie dies tun. Dies kann natürlich zu Problemen führen, wenn Methoden für das unvollständige Objekt aufgerufen werden. Wie für "andere Möglichkeiten, den Zustand eines Objekts zu beobachten, das nicht vollständig konstruiert ist":

  • eine virtualMethode in einem Konstruktor aufrufen ; Der Unterklassenkonstruktor wurde noch nicht aufgerufen, daher overridekann versucht werden, auf einen unvollständigen Status zuzugreifen (Felder, die in der Unterklasse deklariert oder initialisiert wurden usw.).
  • Reflexion, vielleicht mit FormatterServices.GetUninitializedObject(wodurch ein Objekt erstellt wird, ohne den Konstruktor überhaupt aufzurufen )
Marc Gravell
quelle
1
@Stilgar Wenn ich den Zustand eines Objekts beobachten kann, das nicht vollständig konstruiert ist, dann ... meh
Marc Gravell
6

Wenn Sie die Initialisierungsreihenfolge berücksichtigen

  • Abgeleitete statische Felder
  • Abgeleiteter statischer Konstruktor
  • Abgeleitete Instanzfelder
  • Statische Basisfelder
  • Statischer Basiskonstruktor
  • Basisinstanzfelder
  • Basisinstanzkonstruktor
  • Abgeleiteter Instanzkonstruktor

Durch Upcasting können Sie eindeutig auf die Klasse zugreifen, BEVOR der Konstruktor der abgeleiteten Instanz aufgerufen wird (aus diesem Grund sollten Sie keine virtuellen Methoden von Konstruktoren verwenden. Sie können leicht auf abgeleitete Felder zugreifen, die nicht vom Konstruktor / Konstruktor in der abgeleiteten Klasse initialisiert wurden hätte die abgeleitete Klasse nicht in einen "konsistenten" Zustand bringen können)

Xanatos
quelle
4

Sie können das Problem vermeiden, indem Sie B zuletzt in Ihrem Konstruktor instanziieren:

 public A() 
    { 
        Name = "test"; 
        B = new B(this); 
    } 

Wenn das, was Sie vorschlagen, nicht möglich wäre, wäre A nicht unveränderlich.

Edit: behoben, dank Leppie.

Nick
quelle
Sie schreiben, um B zuletzt im Konstruktor zu instanziieren, aber im Beispiel initiieren Sie es zuerst, genau wie im Code von OP. Tippfehler?
Avada Kedavra
2
Ich denke, das OP weiß das und hat eine grundlegendere Frage gestellt.
Henk Holterman
@ Nick: Wery gut bis du 3 unveränderliche Klassen hast :) `public A () {Name =" test "; B = neues B (dies); C = neues C (dies); } `
VMykyt
3

Das Prinzip ist, dass Sie dieses Objekt nicht aus dem Konstruktorkörper entkommen lassen .

Eine andere Möglichkeit, ein solches Problem zu beobachten, besteht darin, virtuelle Methoden im Konstruktor aufzurufen.

Prinz John Wesley
quelle
1

Wie bereits erwähnt, kann der Compiler nicht wissen, an welchem ​​Punkt ein Objekt so gut konstruiert wurde, dass es nützlich ist. Es wird daher davon ausgegangen, dass ein Programmierer, der thisvon einem Konstruktor ausgeht, weiß, ob ein Objekt gut genug konstruiert wurde, um seine Anforderungen zu erfüllen.

Ich möchte jedoch hinzufügen, dass für Objekte, die wirklich unveränderlich sein sollen, vermieden werden muss, thisan einen Code zu übergeben, der den Status eines Feldes untersucht, bevor ihm sein endgültiger Wert zugewiesen wurde. Dies bedeutet, dass es thisnicht an einen beliebigen externen Code übergeben wird, bedeutet jedoch nicht, dass etwas falsch daran ist, dass sich ein im Bau befindliches Objekt an ein anderes Objekt weitergibt, um eine Rückreferenz zu speichern, die erst nach dem ersten tatsächlich verwendet wird Konstruktor ist abgeschlossen .

Wenn man eine Sprache entwirft, um die Konstruktion und Verwendung unveränderlicher Objekte zu erleichtern, kann es hilfreich sein, Methoden als nur während der Konstruktion, nur nach der Konstruktion oder einer der beiden verwendbar zu deklarieren. Felder könnten während der Erstellung als nicht dereferenzierbar und danach schreibgeschützt deklariert werden. Parameter könnten ebenfalls markiert werden, um anzuzeigen, dass sie nicht dereferenzierbar sein sollten. Unter einem solchen System wäre es einem Compiler möglich, die Konstruktion von Datenstrukturen zu ermöglichen, die sich aufeinander beziehen, bei denen sich jedoch keine Eigenschaft ändern könnte, nachdem sie beobachtet wurde. Ich bin mir nicht sicher, ob die Vorteile einer solchen statischen Überprüfung die Kosten überwiegen würden, aber es könnte interessant sein.

Ein verwandtes Merkmal, das hilfreich wäre, wäre übrigens die Möglichkeit, Parameter und Funktionsrückgaben als kurzlebig, rückgabbar oder (standardmäßig) dauerhaft zu deklarieren. Wenn ein Parameter oder eine Funktionsrückgabe als kurzlebig deklariert wurde, konnte er weder in ein Feld kopiert noch als dauerhafter Parameter an eine Methode übergeben werden. Darüber hinaus würde das Übergeben eines kurzlebigen oder rückzahlbaren Werts als rückzahlbarer Parameter an eine Methode dazu führen, dass der Rückgabewert der Funktion die Einschränkungen dieses Werts erbt (wenn eine Funktion zwei rückgabbare Parameter hat, würde ihr Rückgabewert die restriktivere Einschränkung von ihrem erben Parameter). Eine große Schwäche bei Java und .net besteht darin, dass alle Objektreferenzen promiskuitiv sind. Sobald externer Code einen in die Hände bekommt, ist nicht abzusehen, wer damit enden könnte. Wenn Parameter als kurzlebig deklariert werden könnten, Es wäre häufiger möglich, dass Code, der den einzigen Verweis auf etwas enthielt, weiß, dass er den einzigen Verweis enthält, und somit unnötige defensive Kopiervorgänge vermeiden. Darüber hinaus könnten Dinge wie Schließungen recycelt werden, wenn der Compiler wissen könnte, dass nach ihrer Rückkehr keine Verweise auf sie vorhanden sind.

Superkatze
quelle