Почему вызов рекурсивного конструктора приводит к компиляции недопустимого кода C #?

82

После просмотра веб-семинара Джон Скит инспектирует ReSharper , я начал немного поиграться с вызовами рекурсивных конструкторов и обнаружил, что следующий код является допустимым кодом C # (под допустимым я имею в виду, что он компилируется).

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

Как мы все, наверное, знаем, инициализация поля переносится компилятором в конструктор. Итак, если у вас есть такое поле int a = 42;, у вас будет a = 42во всех конструкторах. Но если у вас есть конструктор, вызывающий другой конструктор, у вас будет код инициализации только в вызываемом.

Например, если у вас есть конструктор с параметрами, вызывающими конструктор по умолчанию, у вас будет назначение a = 42только в конструкторе по умолчанию.

Чтобы проиллюстрировать второй случай, следующий код:

class Foo
{
    int a = 42;

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

Компилируется в:

internal class Foo
{
    private int a;

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

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

Итак, основная проблема заключается в том, что мой код, приведенный в начале этого вопроса, скомпилирован в:

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

Как видите, компилятор не может решить, куда поставить инициализацию поля и, как следствие, никуда ее не помещает. Также обратите внимание, что нет baseвызовов конструкторов. Конечно, объекты не могут быть созданы, и вы всегда получите, StackOverflowExceptionесли попытаетесь создать экземпляр Foo.

У меня два вопроса:

Почему компилятор вообще разрешает рекурсивные вызовы конструктора?

Почему мы наблюдаем такое поведение компилятора для полей, инициализированных внутри такого класса?


Некоторые примечания: ReSharper предупреждает вас с помощью Possible cyclic constructor calls. Более того, в Java такие вызовы конструкторов не будут компилироваться по событиям, поэтому компилятор Java в этом сценарии более ограничен (Джон упомянул эту информацию на веб-семинаре).

Это делает эти вопросы более интересными, потому что при всем уважении к сообществу Java компилятор C #, по крайней мере, более современный.

Он был скомпилирован с использованием компиляторов C # 4.0 и C # 5.0 и декомпилирован с помощью dotPeek .

Илья Иванов
источник
3
Как, черт возьми, я пропустил это видео ???
Рой Намир
7
Отличный вопрос.
Деннис
2
Хорошие инициализаторы полей: int a = null; int b = AppDomain.CurrentDomain; int c = "string to int"; int d = NonExistingMethod(); int e = Invalid<Method>Name<<Indeeed();надо провести викторину: «В какой ситуации эти объявления полей подходят?» (Есть предупреждение о том, что поля не используются, но вы можете избавиться от этого предупреждения, прочитав каждое поле внутри тела одного из конструкторов intance (или где-либо еще).)
Джепп Стиг Нильсен
4
Я считаю, что это разрешено по той же причине .
GSerg
4
Инициализация поля помещается во все конструкторы, которые вызывают базовый конструктор. Как следствие, если нет конструктора, который вызывает базовый конструктор, инициализация поля никуда не помещается. По крайней мере, эта часть имеет для меня смысл. Дело не в том, что компилятор не может понять, куда его поместить, это потому, что компилятор замечает, что ему не нужно его никуда класть.

Ответы:

11

Интересная находка.

Похоже, что на самом деле существует только два типа конструкторов экземпляров:

  1. Конструктор экземпляра, который связывает другой конструктор экземпляра того же типа с : this( ...)синтаксисом.
  2. Конструктор экземпляра, который связывает конструктор экземпляра базового класса . Сюда входят конструкторы экземпляров, в которых цепочка не указана, поскольку : base()это значение по умолчанию.

(Я проигнорировал конструктор экземпляра, System.Objectособый случай которого. Не System.Objectимеет базового класса! Но System.Objectи полей нет.)

Инициализаторы поля экземпляра, которые могут присутствовать в классе, необходимо скопировать в начало тела всех конструкторов экземпляров типа 2 выше, тогда как конструкторам экземпляров типа 1 не требуется код назначения поля.

Таким образом, очевидно, что компилятору C # нет необходимости проводить анализ конструкторов типа 1., чтобы увидеть, есть ли циклы или нет.

Теперь ваш пример дает ситуацию, когда все конструкторы экземпляров имеют тип 1 .. В этой ситуации код инициализатора поля не нужно никуда помещать. Похоже, это не очень глубоко анализируется.

Оказывается, когда все конструкторы экземпляров относятся к типу 1. , вы даже можете наследовать от базового класса, у которого нет доступного конструктора. Однако базовый класс не должен быть запечатанным. Например, если вы пишете класс только с privateконструкторами экземпляров, люди по-прежнему могут быть производными от вашего класса, если они сделают все конструкторы экземпляров в производном классе типом 1. ( см. Выше). Однако выражение создания нового объекта, конечно, никогда не завершится. Чтобы создать экземпляры производного класса, нужно «обмануть» и использовать такие вещи, как System.Runtime.Serialization.FormatterServices.GetUninitializedObjectметод.

Другой пример: у System.Globalization.TextInfoкласса есть только internalконструктор экземпляра. Но вы все равно можете наследовать от этого класса в сборке, отличной от mscorlib.dllэтого метода.

Наконец, что касается

Invalid<Method>Name<<Indeeed()

синтаксис. Согласно правилам C # это следует читать как

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

потому что оператор сдвига влево <<имеет более высокий приоритет, чем оператор «меньше» <и «больше» >. Последние два оператора имеют одинаковый приоритет и поэтому оцениваются по левоассоциативному правилу. Если бы типы были

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

и если MySpecialTypeвведена (MySpecialType, int)перегрузка operator <, то выражение

Invalid < Method > Name << Indeeed()

будет законным и значимым.


На мой взгляд, в этом случае было бы лучше, если бы компилятор выдавал предупреждение. Например, он может сказать unreachable code detectedи указать номер строки и столбца инициализатора поля, который никогда не переводится в IL.

Йеппе Стиг Нильсен
источник
1
Я не понимаю ... не запускается ли экземпляр поля перед ctor?
Рой Намир
2
@RoyiNamir Да. Но если вы посмотрите на IL, он работает так, как пишет спрашивающий: «Как мы все, наверное, знаем, инициализация поля переносится компилятором в конструктор». Под этим подразумевается, что, предположим, вы пишете этот класс на C # : class Example { int field = 42; internal Example() { /* some code here */ field = 100; } }, тогда IL, созданный этим, помещает 42назначение в конструктор экземпляра, прежде всего, точно так, как если бы вы написали:class Example { int field; internal Example() { field = 42; /* some code here */ field = 100; } }
Jeppe Stig Nielsen
5

Я думаю, потому что спецификация языка исключает только прямой вызов того же конструктора, который определяется.

С 10.11.1:

Все конструкторы экземпляров (за исключением конструкторов класса object) неявно включают вызов другого конструктора экземпляра непосредственно перед телом конструктора. Конструктор для неявного вызова определяется конструктором-инициализатором

...

  • Инициализатор конструктора экземпляра формы вызывает вызов конструктора экземпляра из самого класса ... Если объявление конструктора экземпляра включает инициализатор конструктора, который вызывает сам конструктор, возникает ошибка времени компиляцииthis(argument-listopt)

Последнее предложение, похоже, только исключает прямой вызов самого себя как порождающего ошибку времени компиляции, например

Foo() : this() {}

незаконно.


Признаюсь, я не вижу конкретной причины для этого. Конечно, на уровне IL такие конструкции разрешены, потому что я считаю, что во время выполнения могут быть выбраны разные конструкторы экземпляров - так что вы можете иметь рекурсию, если она завершается.


Я думаю, что другая причина, по которой он не сигнализирует и не предупреждает об этом, заключается в том, что ему не нужно обнаруживать эту ситуацию. Представьте себе , чеканку через сотню различных конструкторов, просто чтобы увидеть , если цикл делает существует - при любой попытке использования быстро (как мы знаем) взрывает во время выполнения, для довольно краев корпусов.

Когда он генерирует код для каждого конструктора, он учитывает constructor-initializerтолько инициализаторы полей и тело конструктора - он не учитывает никакой другой код:

  • Если constructor-initializerэто конструктор экземпляра для самого класса, он не генерирует инициализаторы поля - он испускает constructor-initializerвызов, а затем тело.

  • Если constructor-initializerэто конструктор экземпляра для прямого базового класса, он испускает инициализаторы поля, затем constructor-initializerвызов, а затем тело.

Ни в том, ни в другом случае ему не нужно искать в другом месте - так что это не тот случай, когда он «не может» решить, где разместить инициализаторы поля - он просто следует некоторым простым правилам, которые учитывают только текущий конструктор.

Damien_The_Unbeliever
источник
2
Но как насчет того , что она позволяет линию , как эта компиляции: int e = Invalid<Method>Name<<Indeeed();. Я говорю, что это ошибка компилятора.
Мэтью Уотсон
@MatthewWatson Это можно интерпретировать как int e = Invalid < Method > Name << Indeed();бинарные операторы «меньше», «больше» и «сдвиг влево». С синтаксической точки зрения это нормально, но было бы действительно сумасшедшим перегрузом операторов, чтобы сделать это нормально со строгой типизацией.
Йеппе Стиг Нильсен
1
@JeppeStigNielsen Да, но он не будет компилироваться, если вы оставите код таким же, кроме удаления кода рекурсивного конструктора. Вот почему я думаю, что это ошибка.
Мэтью Уотсон
4
@MatthewWatson Ошибка не может быть обнаружена во время синтаксического анализа, поскольку класс является неполным. (Возможно, ваш класс определит члены, называемые и Invalidт. Д., Которые сделают его действительным.) Ошибка обычно обнаруживается при генерации кода, но вы нашли способ написать код, который никогда не генерируется. Вы обнаружили скрытую дыру в компиляторе (способ написать код, который никогда не будет компилироваться), но не серьезную, поскольку вредоносный код в любом случае недоступен.
Раймонд Чен
2

Ваш пример

class Foo
{
    int a = 42;

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

будет работать нормально в том смысле, что вы можете без проблем создать экземпляр этого объекта Foo. Однако следующее будет больше похоже на код, о котором вы спрашиваете

class Foo
{
    int a = 42;

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

И это, и ваш код создадут stackoverflow (!), Потому что рекурсия никогда не завершится. Таким образом, ваш код игнорируется, потому что он никогда не запускается.

Другими словами, компилятор не может решить, куда поместить ошибочный код, потому что он может сказать, что рекурсия никогда не завершится. Я думаю, это потому, что он должен поместить его туда, где он будет вызываться только один раз, но рекурсивный характер конструкторов делает это невозможным.

Рекурсия в том смысле, что конструктор создает экземпляры самого себя в теле конструктора, имеет для меня смысл, потому что, например, это может быть использовано для создания экземпляров деревьев, где каждый узел указывает на другие узлы. Но рекурсия с помощью предварительных конструкторов, проиллюстрированных этим вопросом, никогда не может достичь дна, поэтому для меня это имело бы смысл, если бы это было запрещено.

Стохастически
источник
1
Да, согласен, поэтому я создал этот вопрос. Почему компилятор не может решить, где разместить логику инициализации и, следовательно, почему он вообще разрешает рекурсивные вызовы? Для этого есть причина?
Илья Иванов
Мне кажется очевидным, что компилятор не может решить, куда поместить ошибочный код, потому что он может сказать, что рекурсия никогда не заканчивается. Почему это загадка?
Стохастически
Если C # не может решить, какой метод вызвать, он выдает ошибку ambiguous method call, он не пропускает такой вызов метода. Если бы я был компилятором, я бы тоже выдал ошибку в этом сценарии.
Илья Иванов
1
Плохо, что ответы получают так много отрицательных голосов, что я не отказываюсь ни от одного из них (на всякий случай). В этом сценарии он также не может решить, где разместить логику инициализации. Итак, мой главный вопрос: зачем вообще разрешать рекурсивные вызовы? Есть ли в этом причина? Может я чего-то упускаю
Илья Иванов
3
@IlyaIvanov - Думаю, более уместный вопрос - зачем писать детектор циклов для обнаружения рекурсивных вызовов конструкторов в компиляторе?
Damien_The_Unbeliever
0

Я думаю, что это разрешено, потому что вы все еще можете (могли) поймать исключение и сделать с ним что-то значимое.

Инициализация никогда не будет запущена, и она почти наверняка вызовет исключение StackOverflowException. Но это все еще может быть желаемым поведением и не всегда означает, что процесс должен завершиться сбоем.

Как описано здесь https://stackoverflow.com/a/1599236/869482

Йенс Тиммерман
источник