Warum kompiliert ein rekursiver Konstruktoraufruf ungültigen C # -Code?

82

Nachdem ich mir das Webinar Jon Skeet Inspects ReSharper angesehen hatte , begann ich ein wenig mit rekursiven Konstruktoraufrufen zu spielen und stellte fest, dass der folgende Code gültiger C # -Code ist (mit gültig meine ich, dass er kompiliert wird).

class Foo
{
    int a = null;
    int b = AppDomain.CurrentDomain;
    int c = "string to int";
    int d = NonExistingMethod();
    int e = Invalid<Method>Name<<Indeeed();

    Foo()       :this(0)  { }
    Foo(int v)  :this()   { }
}

Wie wir alle wahrscheinlich wissen, wird die Feldinitialisierung vom Compiler in den Konstruktor verschoben. Wenn Sie also ein Feld wie haben int a = 42;, haben Sie a = 42in allen Konstruktoren. Wenn Sie jedoch einen Konstruktor haben, der einen anderen Konstruktor aufruft, haben Sie Initialisierungscode nur in aufgerufenem.

Wenn Sie beispielsweise einen Konstruktor mit Parametern haben, die den Standardkonstruktor aufrufen, haben Sie die Zuweisung a = 42nur im Standardkonstruktor.

Um den zweiten Fall zu veranschaulichen, folgender Code:

class Foo
{
    int a = 42;

    Foo() :this(60)  { }
    Foo(int v)       { }
}

Kompiliert in:

internal class Foo
{
    private int a;

    private Foo()
    {
        this.ctor(60);
    }

    private Foo(int v)
    {
        this.a = 42;
        base.ctor();
    }
}

Das Hauptproblem ist also, dass mein Code, der zu Beginn dieser Frage angegeben wurde, wie folgt kompiliert ist:

internal class Foo
{
    private int a;
    private int b;
    private int c;
    private int d;
    private int e;

    private Foo()
    {
        this.ctor(0);
    }

    private Foo(int v)
    {
        this.ctor();
    }
}

Wie Sie sehen, kann der Compiler nicht entscheiden, wo die Feldinitialisierung abgelegt werden soll, und legt sie daher nirgendwo ab. Beachten Sie auch, dass es keine baseKonstruktoraufrufe gibt. Natürlich können keine Objekte erstellt werden, und Sie werden immer am Ende sein, StackOverflowExceptionwenn Sie versuchen, eine Instanz von zu erstellen Foo.

Ich habe zwei Fragen:

Warum erlaubt der Compiler überhaupt rekursive Konstruktoraufrufe?

Warum beobachten wir ein solches Verhalten des Compilers für Felder, die innerhalb einer solchen Klasse initialisiert wurden?


Einige Hinweise: ReSharper warnt Sie mit Possible cyclic constructor calls. Darüber hinaus werden solche Konstruktoraufrufe in Java nicht als Ereignis kompiliert, sodass der Java-Compiler in diesem Szenario restriktiver ist (Jon erwähnte diese Informationen im Webinar).

Dies macht diese Fragen interessanter, da der C # -Compiler in Bezug auf die Java-Community zumindest moderner ist.

Dies wurde mit C # 4.0- und C # 5.0- Compilern kompiliert und mit dotPeek dekompiliert .

Ilya Ivanov
quelle
3
Wie zum Teufel habe ich dieses Video verpasst ???
Royi Namir
7
Ausgezeichnete Frage.
Dennis
2
Dort gibt es nette Feldinitialisierer: int a = null; int b = AppDomain.CurrentDomain; int c = "string to int"; int d = NonExistingMethod(); int e = Invalid<Method>Name<<Indeeed();Man sollte ein Quiz machen: "In welcher Situation sind diese Felddeklarationen in Ordnung?" (Es gibt eine Warnung, dass die Felder nicht verwendet werden, aber Sie können diese Warnung entfernen, indem Sie jedes Feld im Körper eines der Intance-Konstruktoren (oder anderswo) lesen.)
Jeppe Stig Nielsen
4
Ich glaube, das ist aus dem gleichen Grund erlaubt .
GSerg
4
Die Feldinitialisierung wird in alle Konstruktoren eingefügt, die einen Basiskonstruktor aufrufen. Wenn es keinen Konstruktor gibt, der einen Basiskonstruktor aufruft, wird die Feldinitialisierung folglich nirgendwo abgelegt. Zumindest macht dieser Teil für mich vollkommen Sinn. Es ist nicht , dass der Compiler kann nicht herausfinden , wo es zu setzen, ist es, weil der Compiler merkt es nicht funktioniert , um es überall hat.

Antworten:

11

Interessanter Fund.

Es scheint, dass es wirklich nur zwei Arten von Instanzkonstruktoren gibt:

  1. Ein Instanzkonstruktor, der einen anderen Instanzkonstruktor desselben Typs mit der : this( ...)Syntax verkettet.
  2. Ein Instanzkonstruktor, der einen Instanzkonstruktor der Basisklasse verkettet . Dies schließt Instanzkonstruktoren ein, bei denen keine Kettenangabe angegeben ist, da dies : base()die Standardeinstellung ist.

(Ich habe den Instanzkonstruktor ignoriert, dessen System.ObjectSonderfall ein Sonderfall ist. Er System.Objecthat keine Basisklasse! Aber System.Objectauch keine Felder.)

Die Instanzfeldinitialisierer, die möglicherweise in der Klasse vorhanden sind, müssen in den Anfang des Hauptteils aller Instanzkonstruktoren vom Typ 2 oben kopiert werden , während keine Instanzkonstruktoren vom Typ 1 den Feldzuweisungscode benötigen.

Anscheinend muss der C # -Compiler also keine Analyse der Konstruktoren vom Typ 1 durchführen, um festzustellen, ob Zyklen vorhanden sind oder nicht.

Ihr Beispiel zeigt nun eine Situation, in der alle Instanzkonstruktoren vom Typ 1 sind . In dieser Situation muss der Feldinitiierungscode nirgendwo platziert werden. Es wird also anscheinend nicht sehr tief analysiert.

Es stellt sich heraus, dass Sie, wenn alle Instanzkonstruktoren vom Typ 1 sind , sogar von einer Basisklasse ableiten können, für die kein zugänglicher Konstruktor vorhanden ist. Die Basisklasse darf jedoch nicht versiegelt sein. Wenn Sie beispielsweise eine Klasse nur mit privateInstanzkonstruktoren schreiben , können Personen weiterhin von Ihrer Klasse abgeleitet werden, wenn alle Instanzkonstruktoren in der abgeleiteten Klasse vom Typ 1 oben sind. Ein neuer Objekterstellungsausdruck wird jedoch natürlich nie beendet. Um Instanzen der abgeleiteten Klasse zu erstellen, müsste man "schummeln" und Dinge wie die System.Runtime.Serialization.FormatterServices.GetUninitializedObjectMethode verwenden.

Ein weiteres Beispiel: Die System.Globalization.TextInfoKlasse hat nur einen internalInstanzkonstruktor. Sie können diese Klasse jedoch auch in einer anderen Assembly als mscorlib.dllmit dieser Technik ableiten .

Schließlich in Bezug auf die

Invalid<Method>Name<<Indeeed()

Syntax. Nach den C # -Regeln ist dies als zu lesen

(Invalid < Method) > (Name << Indeeed())

weil der Linksverschiebungsoperator <<eine höhere Priorität hat als sowohl der Klein-als-Operator <als auch der Größer-als-Operator >. Die beiden letztgenannten Operaroren haben den gleichen Vorrang und werden daher nach der linksassoziativen Regel bewertet. Wenn die Typen wären

MySpecialType Invalid;
int Method;
int Name;
int Indeed() { ... }

und wenn das MySpecialTypeeine (MySpecialType, int)Überladung des einführt operator <, dann der Ausdruck

Invalid < Method > Name << Indeeed()

wäre legal und sinnvoll.


Meiner Meinung nach wäre es besser, wenn der Compiler in diesem Szenario eine Warnung ausgeben würde. Zum Beispiel könnte es sagen unreachable code detectedund auf die Zeilen- und Spaltennummer des Feldinitialisierers zeigen, die niemals in IL übersetzt wird.

Jeppe Stig Nielsen
quelle
1
Ich verstehe nicht ... wird die Feldinstanziierung nicht vor ctor aufgerufen?
Royi Namir
2
@ RoyiNamir Ja. Aber wenn Sie sich die IL ansehen, funktioniert es so, wie der Fragesteller schreibt: "Wie wir alle wahrscheinlich wissen, wird die Feldinitialisierung vom Compiler in den Konstruktor verschoben." Damit ist gemeint, dass Sie diese Klasse in C #: schreiben. class Example { int field = 42; internal Example() { /* some code here */ field = 100; } }Dann setzt die von ihr erzeugte IL die 42Zuweisung vor allem anderen in den Instanzkonstruktor, genau so, als hätten Sie geschrieben:class Example { int field; internal Example() { field = 42; /* some code here */ field = 100; } }
Jeppe Stig Nielsen
5

Ich denke, weil die Sprachspezifikation nur ausschließt, dass derselbe Konstruktor, der definiert wird, direkt aufgerufen wird.

Ab 10.11.1:

Alle Instanzkonstruktoren (außer denen für die Klasse object) enthalten implizit einen Aufruf eines anderen Instanzkonstruktors unmittelbar vor dem Konstruktorkörper. Der Konstruktor, der implizit aufgerufen werden soll, wird vom Konstruktor-Initialisierer bestimmt

...

  • Ein Instanzkonstruktorinitialisierer des Formulars bewirkt, dass ein Instanzkonstruktor aus der Klasse selbst aufgerufen wird ... Wenn eine Instanzkonstruktordeklaration einen Konstruktorinitialisierer enthält, der den Konstruktor selbst aufruft, tritt ein Fehler zur Kompilierungszeit aufthis(argument-listopt)

Dieser letzte Satz scheint nur auszuschließen, dass ein direkter Aufruf selbst einen Fehler bei der Kompilierungszeit erzeugt, z

Foo() : this() {}

ist illegal.


Ich gebe jedoch zu - ich kann keinen bestimmten Grund dafür erkennen. Natürlich sind solche Konstrukte auf IL-Ebene zulässig, da zur Laufzeit verschiedene Instanzkonstruktoren ausgewählt werden könnten, glaube ich - Sie könnten also eine Rekursion haben, vorausgesetzt, sie wird beendet.


Ich denke, der andere Grund, warum es nicht darauf hinweist oder warnt, ist, dass es nicht notwendig ist, diese Situation zu erkennen . Stellen Sie sich vor , durch Hunderte von unterschiedlichen Herstellern zu jagen, nur um zu sehen , ob ein Zyklus tut exist - wenn jede versuchte Nutzung wird schnell (wie wir wissen) sprengt zur Laufzeit, für einen ziemlich Rand Fall.

Bei der Codegenerierung für jeden Konstruktor werden constructor-initializerlediglich die Feldinitialisierer und der Hauptteil des Konstruktors berücksichtigt - es wird kein anderer Code berücksichtigt:

  • Wenn constructor-initializeres sich um einen Instanzkonstruktor für die Klasse selbst handelt, werden die Feldinitialisierer nicht ausgegeben, sondern der constructor-initializerAufruf und dann der Body.

  • Wenn constructor-initializeres sich um einen Instanzkonstruktor für die direkte Basisklasse handelt, werden die Feldinitialisierer, dann der constructor-initializerAufruf und dann der Body ausgegeben.

In keinem Fall muss es woanders gesucht werden - es ist also nicht so, dass es "nicht in der Lage" ist, zu entscheiden, wo die Feldinitialisierer platziert werden sollen - es folgt lediglich einigen einfachen Regeln, die nur den aktuellen Konstruktor berücksichtigen.

Damien_The_Unbeliever
quelle
2
Aber was ist mit der Tatsache, dass damit Zeilen wie diese kompiliert werden können : int e = Invalid<Method>Name<<Indeeed();. Ich sage, das ist ein Compiler-Fehler.
Matthew Watson
@MatthewWatson Es kann wie int e = Invalid < Method > Name << Indeed();bei binären Operatoren "kleiner als", "größer als" und "Linksverschiebung" interpretiert werden . Das ist syntaktisch in Ordnung, aber es wäre eine wirklich verrückte Überlastung der Operatoren, es mit starker Eingabe in Ordnung zu bringen.
Jeppe Stig Nielsen
1
@JeppeStigNielsen Aye, aber es wird nicht kompiliert, wenn Sie den Code unverändert lassen, außer den rekursiven Konstruktorcode zu entfernen. Deshalb denke ich, dass es ein Fehler ist.
Matthew Watson
4
@MatthewWatson Der Fehler kann zum Zeitpunkt der Analyse nicht erkannt werden, da die Klasse unvollständig ist. (Möglicherweise definiert Ihre Klasse Mitglieder mit dem Namen Invalidetc, die sie gültig machen.) Der Fehler wird normalerweise bei der Codegenerierung erkannt, aber Sie haben einen Weg gefunden, Code zu schreiben, der niemals generiert wird. Sie haben eine hinterhältige Lücke im Compiler gefunden (eine Möglichkeit, Code zu schreiben, der niemals kompiliert wird), aber keine ernsthafte, da der fehlerhafte Code ohnehin nicht erreichbar ist.
Raymond Chen
2

Dein Beispiel

class Foo
{
    int a = 42;

    Foo() :this(60)  { }
    Foo(int v)       { }
}

funktioniert gut, in dem Sinne, dass Sie dieses Foo-Objekt ohne Probleme instanziieren können. Das Folgende entspricht jedoch eher dem Code, nach dem Sie fragen

class Foo
{
    int a = 42;

    Foo() :this(60)     { }
    Foo(int v) : this() { }
}

Sowohl das als auch Ihr Code erzeugen einen Stapelüberlauf (!), Da die Rekursion niemals endet. Ihr Code wird also ignoriert, da er nie ausgeführt werden kann.

Mit anderen Worten, der Compiler kann nicht entscheiden, wo der fehlerhafte Code abgelegt werden soll, da er erkennen kann, dass die Rekursion niemals zu Ende geht. Ich denke, das liegt daran, dass es dort platziert werden muss, wo es nur einmal aufgerufen wird, aber die rekursive Natur der Konstruktoren macht dies unmöglich.

Rekursion im Sinne eines Konstruktors, der Instanzen von sich selbst im Körper des Konstruktors erstellt, ist für mich sinnvoll, da dies beispielsweise verwendet werden kann, um Bäume zu instanziieren, bei denen jeder Knoten auf andere Knoten zeigt. Eine Rekursion über die Vorkonstruktoren der in dieser Frage dargestellten Art kann jedoch niemals einen Tiefpunkt erreichen. Daher wäre es für mich sinnvoll, wenn dies nicht zulässig wäre.

Stochastisch
quelle
1
Ja, ich stimme zu, deshalb habe ich diese Frage erstellt. Warum kann der Compiler nicht entscheiden, wo die Initialisierungslogik abgelegt werden soll, und warum erlaubt er überhaupt rekursive Aufrufe? Gibt es einen Grund dafür?
Ilya Ivanov
Mir scheint klar zu sein, dass der Compiler nicht entscheiden kann, wo der fehlerhafte Code abgelegt werden soll, da er erkennen kann, dass die Rekursion niemals zu Ende geht. Warum ist das ein Rätsel?
Stochastisch
Wenn C # nicht entscheiden kann, welche Methode aufgerufen werden soll, wird ein Fehler ambiguous method callausgegeben und ein solcher Methodenaufruf nicht übersprungen. Wenn ich ein Compiler wäre, würde ich auch in diesem Szenario einen Fehler auslösen.
Ilya Ivanov
1
Es ist schlecht, dass Antworten so viele Abstimmungen erhalten, dass ich keine von ihnen abstimme (nur ist der Fall). In diesem Szenario kann auch nicht entschieden werden, wo die Initialisierungslogik abgelegt werden soll. Meine Hauptfrage ist also, warum man rekursive Aufrufe überhaupt zulässt. Gibt es einen Grund dafür? Vielleicht fehlt mir etwas
Ilya Ivanov
3
@IlyaIvanov - Ich denke, die relevantere Frage ist - warum einen Zyklusdetektor schreiben, um rekursive Konstruktoraufrufe im Compiler zu erkennen?
Damien_The_Unbeliever
0

Ich denke, das ist erlaubt, weil man die Ausnahme noch fangen kann (könnte) und etwas Sinnvolles damit machen kann.

Die Initialisierung wird niemals ausgeführt und löst mit ziemlicher Sicherheit eine StackOverflowException aus. Dies kann jedoch immer noch erwünscht sein und bedeutete nicht immer, dass der Prozess abstürzen sollte.

Wie hier erklärt https://stackoverflow.com/a/1599236/869482

Jens Timmerman
quelle