Dlaczego rekurencyjne wywołanie konstruktora powoduje kompilację nieprawidłowego kodu C #?


82

Po obejrzeniu webinaru Jon Skeet Inspects ReSharper , zacząłem trochę bawić się rekurencyjnymi wywołaniami konstruktora i stwierdziłem, że poniższy kod jest prawidłowym kodem C # (przez prawidłowy, mam na myśli, że kompiluje).

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

Jak wszyscy zapewne wiemy, inicjalizacja pola jest przenoszona do konstruktora przez kompilator. Więc jeśli masz takie pole int a = 42;, będziesz mieć a = 42we wszystkich konstruktorach. Ale jeśli masz konstruktora wywołującego inny konstruktor, będziesz miał kod inicjujący tylko w wywołanym.

Na przykład, jeśli masz konstruktora z parametrami wywołującymi domyślny konstruktor, przypisanie będziesz mieć a = 42tylko w domyślnym konstruktorze.

Aby zilustrować drugi przypadek, następny kod:

class Foo
{
    int a = 42;

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

Kompiluje do:

internal class Foo
{
    private int a;

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

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

Więc głównym problemem jest to, że mój kod, podany na początku tego pytania, jest wkompilowany w:

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

Jak widać, kompilator nie może zdecydować, gdzie umieścić inicjalizację pola iw rezultacie nigdzie go nie umieszcza. Należy również zauważyć, że nie ma basewywołań konstruktora. Oczywiście nie można tworzyć żadnych obiektów i zawsze skończy się to, StackOverflowExceptionjeśli spróbujesz utworzyć instancję Foo.

Mam dwa pytania:

Dlaczego kompilator w ogóle zezwala na rekurencyjne wywołania konstruktora?

Dlaczego obserwujemy takie zachowanie kompilatora dla pól zainicjowanych w ramach takiej klasy?


Kilka uwag: ReSharper ostrzega Possible cyclic constructor calls. Co więcej, w Javie takie wywołania konstruktorów nie będą kompilować zdarzeń, więc kompilator Javy jest bardziej restrykcyjny w tym scenariuszu (Jon wspomniał o tej informacji na webinarium).

To sprawia, że ​​te pytania są bardziej interesujące, ponieważ z całym szacunkiem dla społeczności Java, kompilator C # jest przynajmniej nowocześniejszy.

Zostało to skompilowane przy użyciu kompilatorów C # 4.0 i C # 5.0 i zdekompilowane przy użyciu dotPeek .


3
Jak on ** przegapiłem ten film ???
Royi Namir

7
Świetne pytanie.
Dennis

2
Ładne inicjatory pól: int a = null; int b = AppDomain.CurrentDomain; int c = "string to int"; int d = NonExistingMethod(); int e = Invalid<Method>Name<<Indeeed();Należy zrobić quiz: "W jakiej sytuacji te deklaracje pól są OK?" (Jest ostrzeżenie o nieużywanych polach, ale możesz pozbyć się tego ostrzeżenia, czytając każde pole wewnątrz ciała jednego z konstruktorów intencji (lub gdzie indziej).)
Jeppe Stig Nielsen

4
Uważam, że jest to dozwolone z tego samego powodu .
GSerg

4
Inicjalizacja pola jest umieszczana we wszystkich konstruktorach, które wywołują konstruktor bazowy. W konsekwencji, jeśli nie ma konstruktora, który wywołuje konstruktor bazowy, inicjalizacja pola nie jest nigdzie umieszczana. Przynajmniej ta część ma dla mnie sens. Nie chodzi o to, że kompilator nie może dowiedzieć się, gdzie go umieścić, to dlatego, że kompilator zauważa, że nie musi go nigdzie umieszczać.

Odpowiedzi:


11

Ciekawe znalezisko.

Wygląda na to, że tak naprawdę istnieją tylko dwa rodzaje konstruktorów instancji:

  1. Konstruktor instancji, który łączy inny konstruktor instancji tego samego typu z rozszerzeniem: this( ...) składnią.
  2. Konstruktor instancji, który tworzy łańcuch konstruktora instancji klasy bazowej . Obejmuje to konstruktory instancji, w których nie określono łańcucha, ponieważ : base()jest to ustawienie domyślne.

(Zignorowałem konstruktor instancji, System.Objectktóry jest przypadkiem specjalnym. Nie System.Objectma klasy bazowej! AleSystem.Object nie ma pól).

Inicjatory pola wystąpienia, które mogą znajdować się w klasie, należy skopiować na początek treści wszystkich konstruktorów wystąpień typu 2. powyżej, podczas gdy żadnych konstruktorów wystąpień typu 1. wymagają kodu przypisania pola.

Więc najwyraźniej nie ma potrzeby, aby kompilator C # przeprowadzał analizę konstruktorów typu 1. aby sprawdzić, czy są cykle, czy nie.

Teraz Twój przykład przedstawia sytuację, w której wszystkie konstruktory instancji są typu 1 .. W takiej sytuacji nie trzeba nigdzie umieszczać kodu inicjalizacji pola. Wydaje się więc, że nie jest to analizowane zbyt głęboko.

Okazuje się, że gdy wszystkie konstruktory instancji są typu 1. , można nawet wyprowadzić z klasy bazowej, która nie ma dostępnego konstruktora. Jednak klasa bazowa musi być niezamykana. Na przykład, jeśli napiszesz klasę tylko z privatekonstruktorami instancji, ludzie mogą nadal tworzyć pochodzenie z Twojej klasy, jeśli sprawią, że wszystkie konstruktory instancji w klasie pochodnej będą typu 1. powyżej. Jednak, oczywiście, nowe wyrażenie tworzenia obiektów nigdy się nie zakończy. Aby stworzyć instancje klasy pochodnej, należałoby "oszukiwać" i używać takich rzeczy jakSystem.Runtime.Serialization.FormatterServices.GetUninitializedObject metoda.

Inny przykład: System.Globalization.TextInfoklasa ma tylko internalkonstruktor instancji. Ale nadal możesz wywodzić się z tej klasy w zestawie innym niżmscorlib.dll pomocą tej techniki.

Wreszcie, jeśli chodzi o

Invalid<Method>Name<<Indeeed()

składnia. Zgodnie z regułami C # należy to czytać jako

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

ponieważ operator przesuwający w lewo <<ma wyższy priorytet niż zarówno operator mniej niż <i operator większości >. Ostatnie dwa operarory mają ten sam priorytet i dlatego są oceniane przez lewą regułę asocjacyjną. Gdyby były typy

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

a jeśli MySpecialTypewprowadzono (MySpecialType, int)przeciążenie operator <, to wyrażenie

Invalid < Method > Name << Indeeed()

byłoby legalne i sensowne.


Moim zdaniem byłoby lepiej, gdyby kompilator wydał ostrzeżenie w tym scenariuszu. Na przykład może powiedzieć unreachable code detectedi wskazać numer wiersza i kolumny inicjatora pola, który nigdy nie jest tłumaczony na IL.


1
Nie rozumiem ... czy tworzenie instancji pola nie jest wywoływane przed ctor?
Royi Namir

2
@RoyiNamir Yes. Ale jeśli spojrzysz na IL, to działa tak, jak pisze asker: „Jak wszyscy zapewne wiemy, inicjalizacja pola jest przenoszona do konstruktora przez kompilator”. Oznacza to, że załóżmy, że piszesz tę klasę w języku C #:, class Example { int field = 42; internal Example() { /* some code here */ field = 100; } }a następnie utworzony przez nią IL umieszcza 42przypisanie w konstruktorze instancji, przed wszystkim innym, dokładnie tak, jakbyś napisał:class Example { int field; internal Example() { field = 42; /* some code here */ field = 100; } }
Jeppe Stig Nielsen

5

Myślę, ponieważ specyfikacja języka wyklucza tylko bezpośrednie wywołanie tego samego konstruktora, który jest definiowany.

Od 10.11.1:

Wszystkie konstruktory wystąpień (z wyjątkiem tych dla klasy object) niejawnie zawierają wywołanie innego konstruktora wystąpienia bezpośrednio przed treścią konstruktora. Konstruktor do niejawnego wywołania jest określany przez inicjator konstruktora

...

  • Inicjator konstruktora wystąpienia formularza powoduje wywołanie konstruktora wystąpienia z samej klasy ... Jeśli deklaracja konstruktora wystąpienia zawiera inicjator konstruktora, który wywołuje sam konstruktor, wystąpi błąd w czasie kompilacjithis(argument-listopt)

To ostatnie zdanie wydaje się jedynie wykluczać bezpośrednie wywołanie samego siebie jako powodujące błąd czasu kompilacji, np

Foo() : this() {}

jest nielegalne.


Przyznaję jednak - nie widzę konkretnego powodu, dla którego miałbym na to pozwolić. Oczywiście na poziomie języka IL takie konstrukcje są dozwolone, ponieważ uważam, że różne konstruktory instancji mogą być wybierane w czasie wykonywania - więc rekurencja byłaby możliwa pod warunkiem, że się zakończy.


Myślę, że innym powodem, dla którego nie sygnalizuje tego ani nie ostrzega, jest to, że nie ma potrzeby wykrywania tej sytuacji. Wyobraź sobie gonienie przez setki różnych konstruktorów, aby sprawdzić, czy cykl działa istnieje - gdy każdy usiłował Wykorzystanie szybko (jak wiemy) wysadzić w czasie wykonywania, za dość przypadku krawędzi.

Kiedy generuje kod dla każdego konstruktora, bierze pod uwagę tylko constructor-initializerinicjatory pól i treść konstruktora - nie bierze pod uwagę żadnego innego kodu:

  • Jeśli constructor-initializerjest konstruktorem instancji dla samej klasy, nie emituje inicjatorów pola - emituje constructor-initializerwywołanie, a następnie treść.

  • Jeśli constructor-initializerjest konstruktorem wystąpienia dla bezpośredniej klasy bazowej, emituje inicjatory pola, następnie constructor-initializerwywołanie, a następnie treść.

W żadnym przypadku nie musi szukać gdzie indziej - więc nie jest tak, że nie jest w stanie zdecydować, gdzie umieścić inicjatory pól - po prostu przestrzega kilku prostych zasad, które dotyczą tylko bieżącego konstruktora.


2
Ale co z tego, że pozwala linie takie jak to: kompilacji int e = Invalid<Method>Name<<Indeeed();. Mówię, że to błąd kompilatora.
Matthew Watson

@MatthewWatson Można to interpretować jako int e = Invalid < Method > Name << Indeed();operatory binarne „mniej niż”, „większe niż” i „przesunięcie w lewo”. Składniowo jest to OK, ale byłoby to naprawdę szalone przeciążenie operatorów, aby było OK przy silnym pisaniu.
Jeppe Stig Nielsen

1
@JeppeStigNielsen Aye, ale nie skompiluje się, jeśli pozostawisz kod taki sam, poza usunięciem rekurencyjnego kodu konstruktora. Dlatego myślę, że to błąd.
Matthew Watson

4
@MatthewWatson Nie można wykryć błędu podczas analizy, ponieważ klasa jest niekompletna. (Może twoja klasa zdefiniuje członków o nazwie Invalidetc, które sprawią, że będzie on ważny.) Błąd jest zwykle wykrywany podczas generowania kodu, ale znalazłeś sposób na napisanie kodu, który nigdy nie jest generowany. Znalazłeś podstępną dziurę w kompilatorze (sposób na napisanie kodu, który nigdy nie zostanie skompilowany), ale nie poważną, ponieważ naruszający kod i tak jest nieosiągalny.
Raymond Chen

2

Twój przykład

class Foo
{
    int a = 42;

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

będzie działać dobrze, w tym sensie, że możesz bez problemów utworzyć instancję obiektu Foo. Jednak poniższy kod byłby bardziej podobny do kodu, o który pytasz

class Foo
{
    int a = 42;

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

Zarówno to, jak i Twój kod utworzą przepełnienie stosu (!), Ponieważ rekursja nigdy się nie kończy. Więc twój kod jest ignorowany, ponieważ nigdy nie może zostać wykonany.

Innymi słowy, kompilator nie może zdecydować, gdzie umieścić błędny kod, ponieważ może stwierdzić, że rekursja nigdy się nie kończy. Myślę, że dzieje się tak, ponieważ musi umieścić go tam, gdzie zostanie wywołany tylko raz, ale rekursywny charakter konstruktorów uniemożliwia to.

Rekurencja w sensie konstruktora tworzącego swoje instancje w ciele konstruktora ma dla mnie sens, ponieważ np. Można jej użyć do tworzenia instancji drzew, w których każdy węzeł wskazuje na inne węzły. Jednak rekurencja za pośrednictwem prekonstruktorów typu zilustrowanego przez to pytanie nie może nigdy osiągnąć dna, więc miałoby to dla mnie sens, gdyby było to zabronione.


1
Tak, zgadzam się, dlatego stworzyłem to pytanie. Dlaczego kompilator nie może zdecydować, gdzie umieścić logikę inicjalizacji i dlaczego w ogóle zezwala na wywołania rekurencyjne? Czy jest tego powód?
Ilya Ivanov

Wydaje mi się jasne, że kompilator nie może zdecydować, gdzie umieścić błędny kod, ponieważ może stwierdzić, że rekursja nigdy się nie kończy. Dlaczego to tajemnica?
Stochastycznie

Jeśli C # nie może zdecydować, jaką metodę wywołać, zgłasza błąd ambiguous method call, nie pomija takiego wywołania metody. Gdybym był kompilatorem, również w tym scenariuszu zgłosiłbym błąd.
Ilya Ivanov

1
źle, że odpowiedzi otrzymują tyle głosów przeciw, że nie głosuję w dół żadnego z nich (tak jest). W tym scenariuszu nie może również zdecydować, gdzie umieścić logikę inicjalizacji. Więc moje główne pytanie brzmi: dlaczego w ogóle zezwalać na wywołania rekurencyjne? Czy jest tego powód? Może czegoś mi brakuje
Ilya Ivanov

3
@IlyaIvanov - myślę, że bardziej trafne pytanie brzmi - po co pisać detektor cykli do wykrywania rekurencyjnych wywołań konstruktora w kompilatorze?
Damien_The_Unbeliever

0

Myślę, że jest to dozwolone, ponieważ nadal możesz (możesz) złapać wyjątek i zrobić z nim coś sensownego.

Inicjalizacja nigdy nie zostanie uruchomiona i prawie na pewno zgłosi wyjątek StackOverflowException. Ale nadal może to być pożądane zachowanie i nie zawsze oznaczało, że proces powinien się zawiesić.

Jak wyjaśniono tutaj https://stackoverflow.com/a/1599236/869482

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.