¿Por qué una llamada recursiva al constructor hace que el código C # no sea válido?

82

Después de ver el seminario web Jon Skeet Inspects ReSharper , comencé a jugar un poco con las llamadas recursivas al constructor y descubrí que el siguiente código es un código C # válido (por válido me refiero a que se compila).

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

Como probablemente todos sabemos, el compilador mueve la inicialización del campo al constructor. Entonces, si tiene un campo como int a = 42;, lo tendrá a = 42en todos los constructores. Pero si tiene un constructor llamando a otro constructor, tendrá el código de inicialización solo en uno llamado.

Por ejemplo, si tiene un constructor con parámetros que llaman al constructor predeterminado, solo tendrá asignación a = 42en el constructor predeterminado.

Para ilustrar el segundo caso, el siguiente código:

class Foo
{
    int a = 42;

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

Compila en:

internal class Foo
{
    private int a;

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

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

Entonces, el problema principal es que mi código, dado al comienzo de esta pregunta, está compilado en:

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

Como puede ver, el compilador no puede decidir dónde colocar la inicialización del campo y, como resultado, no lo coloca en ninguna parte. También tenga en cuenta que no hay basellamadas al constructor. Por supuesto, no se pueden crear objetos y siempre terminará con StackOverflowExceptionsi intenta crear una instancia de Foo.

Tengo dos preguntas:

¿Por qué el compilador permite llamadas recursivas al constructor?

¿Por qué observamos tal comportamiento del compilador para campos, inicializados dentro de dicha clase?


Algunas notas: ReSharper le advierte con Possible cyclic constructor calls. Además, en Java, tales llamadas al constructor no compilan eventos, por lo que el compilador de Java es más restrictivo en este escenario (Jon mencionó esta información en el seminario web).

Esto hace que estas preguntas sean más interesantes, porque con todo respeto a la comunidad Java, el compilador de C # es al menos más moderno.

Esto fue compilado usando compiladores C # 4.0 y C # 5.0 y descompilado usando dotPeek .

Ilya Ivanov
fuente
3
¿Cómo diablos me perdí este video?
Royi Namir
7
Excelente pregunta.
Dennis
2
Buenos inicializadores de campo allí: int a = null; int b = AppDomain.CurrentDomain; int c = "string to int"; int d = NonExistingMethod(); int e = Invalid<Method>Name<<Indeeed();Uno debería hacer una prueba: "¿En qué situación están bien estas declaraciones de campo?" (Hay una advertencia sobre los campos que no se están utilizando, pero puede deshacerse de esa advertencia leyendo cada campo dentro del cuerpo de uno de los constructores de intance (o en otro lugar).)
Jeppe Stig Nielsen
4
Creo que esto está permitido por la misma razón .
GSerg
4
La inicialización del campo se coloca en todos los constructores que llaman a un constructor base. Como consecuencia, si no hay ningún constructor que llame a un constructor base, la inicialización del campo no se coloca en ninguna parte. Al menos esa parte tiene mucho sentido para mí. No es que el compilador no pueda averiguar dónde ponerlo, es porque el compilador nota que no tiene que ponerlo en ningún lado.

Respuestas:

11

Interesante hallazgo.

Parece que en realidad solo hay dos tipos de constructores de instancias:

  1. Un constructor de instancias que encadena otro constructor de instancias del mismo tipo , con la : this( ...)sintaxis.
  2. Un constructor de instancias que encadena un constructor de instancias de la clase base . Esto incluye constructores de instancias donde no se especifica ningún chainig, ya que : base()es el predeterminado.

(No tuve en cuenta el constructor de la instancia, System.Objectque es un caso especial. ¡No System.Objecttiene una clase base! Pero System.Objecttampoco tiene campos).

Los inicializadores de campo de instancia que pueden estar presentes en la clase, deben copiarse al principio del cuerpo de todos los constructores de instancia del tipo 2. arriba, mientras que ningún constructor de instancia del tipo 1. necesita el código de asignación de campo.

Entonces, aparentemente, no hay necesidad de que el compilador de C # haga un análisis de los constructores de tipo 1. para ver si hay ciclos o no.

Ahora su ejemplo da una situación en la que todos los constructores de instancias son de tipo 1 .. En esa situación, no es necesario colocar el código del inicializador de campo en ningún lugar. Por tanto, parece que no se analiza muy a fondo.

Resulta que cuando todos los constructores de instancias son de tipo 1. , incluso puede derivar de una clase base que no tenga un constructor accesible. Sin embargo, la clase base no debe estar sellada. Por ejemplo, si escribe una clase con solo privateconstructores de instancia, las personas aún pueden derivar de su clase si hacen que todos los constructores de instancia en la clase derivada sean del tipo 1. anterior. Sin embargo, una nueva expresión de creación de objetos nunca terminará, por supuesto. Para crear instancias de la clase derivada, uno tendría que "hacer trampa" y usar cosas como el System.Runtime.Serialization.FormatterServices.GetUninitializedObjectmétodo.

Otro ejemplo: la System.Globalization.TextInfoclase solo tiene un internalconstructor de instancia. Pero aún puede derivar de esta clase en un ensamblado que no sea mscorlib.dllcon esta técnica.

Finalmente, con respecto a la

Invalid<Method>Name<<Indeeed()

sintaxis. De acuerdo con las reglas de C #, esto debe leerse como

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

porque el operador de desplazamiento a la izquierda <<tiene mayor precedencia que el operador menor que <y el operador mayor que >. Los dos últimos operadores tienen la misma precedencia y, por lo tanto, son evaluados por la regla asociativa de izquierda. Si los tipos fueran

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

y si MySpecialTypeintrodujo una (MySpecialType, int)sobrecarga de operator <, entonces la expresión

Invalid < Method > Name << Indeeed()

sería legal y significativo.


En mi opinión, sería mejor si el compilador emitiera una advertencia en este escenario. Por ejemplo, podría decir unreachable code detectedy señalar la línea y el número de columna del inicializador de campo que nunca se traduce a IL.

Jeppe Stig Nielsen
fuente
1
No entiendo ... ¿no se invoca la instanciación de campo antes de ctor?
Royi Namir
2
@RoyiNamir Sí. Pero si observa el IL, funciona como escribe el autor de la pregunta: "Como probablemente todos sabemos, el compilador mueve la inicialización de campo al constructor". Lo que significa eso es, suponga que escribe esta clase en C #:, class Example { int field = 42; internal Example() { /* some code here */ field = 100; } }luego el IL producido por eso coloca la 42asignación en el constructor de instancia, antes que todo lo demás, exactamente como si hubiera escrito:class Example { int field; internal Example() { field = 42; /* some code here */ field = 100; } }
Jeppe Stig Nielsen
5

Creo que porque la especificación del lenguaje solo descarta la invocación directa del mismo constructor que se está definiendo.

Desde el 10.11.1:

Todos los constructores de instancias (excepto los de clase object) incluyen implícitamente una invocación de otro constructor de instancias inmediatamente antes del cuerpo del constructor. El constructor a invocar implícitamente está determinado por el constructor-inicializador

...

  • Un inicializador de constructor de instancia del formulario hace que se invoque un constructor de instancia de la propia clase ... Si una declaración de constructor de instancia incluye un inicializador de constructor que invoca al propio constructor, se produce un error en tiempo de compilaciónthis(argument-listopt)

Esa última oración parece impedir que la autodenominación directa produzca un error de tiempo de compilación, por ejemplo

Foo() : this() {}

es ilegal.


Sin embargo, lo admito, no veo una razón específica para permitirlo. Por supuesto, en el nivel de IL, tales construcciones están permitidas porque creo que se podrían seleccionar diferentes constructores de instancias en tiempo de ejecución, por lo que podría tener recursividad siempre que termine.


Creo que la otra razón por la que no marca ni advierte sobre esto es porque no tiene necesidad de detectar esta situación. Imagínese que persigue a través de cientos de diferentes constructores, sólo para ver si un ciclo hace existir - cuando cualquier intento de uso será rápidamente (como sabemos) estallar en tiempo de ejecución, por un caso bastante borde.

Cuando realiza la generación de código para cada constructor, todo lo que considera son constructor-initializerlos inicializadores de campo y el cuerpo del constructor; no considera ningún otro código:

  • Si constructor-initializeres un constructor de instancia para la clase en sí, no emite los inicializadores de campo; emite la constructor-initializerllamada y luego el cuerpo.

  • Si constructor-initializeres un constructor de instancias para la clase base directa, emite los inicializadores de campo, luego la constructor-initializerllamada y luego el cuerpo.

En ninguno de los casos necesita buscar en otra parte, por lo que no es "incapaz" de decidir dónde colocar los inicializadores de campo, solo sigue algunas reglas simples que solo consideran el constructor actual.

Damien_The_Unbeliever
fuente
2
Pero ¿qué pasa con el hecho de que permite líneas de este tipo de compilación: int e = Invalid<Method>Name<<Indeeed();. Digo que es un error del compilador.
Matthew Watson
@MatthewWatson Se podría interpretar como int e = Invalid < Method > Name << Indeed();con operadores binarios "menor que", "mayor que" y "desplazamiento a la izquierda". Eso está bien sintácticamente, pero serían algunas sobrecargas realmente locas de los operadores para hacerlo bien con una escritura fuerte.
Jeppe Stig Nielsen
1
@JeppeStigNielsen Sí, pero no se compilará si deja el código igual aparte de eliminar el código del constructor recursivo. Por eso creo que es un error.
Matthew Watson
4
@MatthewWatson No se puede detectar el error en el momento del análisis porque la clase está incompleta. (Tal vez su clase defina miembros llamados Invalidetc que la harán válida). El error normalmente se detecta en la generación del código, pero encontró una manera de escribir código que nunca se genera. Encontraste un agujero furtivo en el compilador (una forma de escribir código que nunca será compilado), pero no uno serio ya que el código ofensivo es inalcanzable de todos modos.
Raymond Chen
2

Tu ejemplo

class Foo
{
    int a = 42;

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

funcionará bien, en el sentido de que puede crear una instancia de ese objeto Foo sin problemas. Sin embargo, lo siguiente sería más parecido al código sobre el que está preguntando

class Foo
{
    int a = 42;

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

Tanto eso como su código crearán un stackoverflow (!), Porque la recursividad nunca toca fondo. Entonces su código es ignorado porque nunca llega a ejecutarse.

En otras palabras, el compilador no puede decidir dónde colocar el código defectuoso porque puede decir que la recursividad nunca toca fondo. Creo que esto se debe a que tiene que ponerlo donde solo se llamará una vez, pero la naturaleza recursiva de los constructores lo hace imposible.

La recursividad en el sentido de que un constructor crea instancias de sí mismo dentro del cuerpo del constructor tiene sentido para mí, porque por ejemplo, eso podría usarse para instanciar árboles donde cada nodo apunta a otros nodos. Pero la recursividad a través de los preconstructores del tipo ilustrado por esta pregunta nunca puede tocar fondo, por lo que tendría sentido para mí si eso no estuviera permitido.

Estocásticamente
fuente
1
Sí, estoy de acuerdo, por eso he creado esta pregunta. ¿Por qué el compilador no puede decidir dónde colocar la lógica de inicialización y, por lo tanto, por qué permite llamadas recursivas? ¿Hay alguna razón para esto?
Ilya Ivanov
Me parece claro que el compilador no puede decidir dónde colocar el código defectuoso porque puede decir que la recursividad nunca toca fondo. ¿Por qué es un misterio?
Estocásticamente
Si C # no puede decidir qué método llamar, arroja un error ambiguous method call , no omite dicha llamada al método. Si fuera un compilador, también arrojaría un error en este escenario.
Ilya Ivanov
1
es malo, que las respuestas reciban tantos votos negativos, no voy a rechazar ninguno de ellos (solo es el caso). En este escenario, tampoco puede decidir dónde colocar la lógica de inicialización. Entonces, mi pregunta principal es ¿ por qué permitir llamadas recursivas? ¿Hay alguna razón detrás de esto? Quizás me estoy perdiendo algo
Ilya Ivanov
3
@IlyaIvanov: creo que la pregunta más pertinente es: ¿por qué escribir un detector de ciclo para detectar llamadas recursivas al constructor en el compilador?
Damien_The_Unbeliever
0

Creo que esto está permitido porque aún puede (podría) capturar la Excepción y hacer algo significativo con ella.

La inicialización nunca se ejecutará, y casi con certeza lanzará una StackOverflowException. Pero esto todavía puede ser un comportamiento deseado y no siempre significaba que el proceso debería fallar.

Como se explica aquí https://stackoverflow.com/a/1599236/869482

Jens Timmerman
fuente