c#

¿Por qué una llamada de constructor recursiva hace que se compile código C#no válido?



(4)

Después de ver el webinar Jon Skeet Inspects ReSharper , comencé a jugar un poco con las llamadas de constructor recursivas y encontré que el siguiente código es código válido de C # (por válido me refiero a 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 todos sabemos, el compilador mueve la inicialización de campo al constructor. Entonces, si tiene un campo como int a = 42; , tendrá a = 42 en todos los constructores. Pero si tiene un constructor llamando a otro constructor, tendrá el código de inicialización solo en el llamado.

Por ejemplo, si tiene un constructor con parámetros que llaman al constructor predeterminado, tendrá la asignación a = 42 solo en 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, se compila 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 de campo y, como resultado, no lo coloca en ningún lado. También tenga en cuenta que no hay llamadas de constructor base . Por supuesto, no se pueden crear objetos, y siempre se terminará con StackOverflowException si intenta crear una instancia de Foo .

Tengo dos preguntas:

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

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

Algunas notas: ReSharper te advierte con Possible cyclic constructor calls . Además, en Java, tales llamadas de constructor no compilarán 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 respecto a la comunidad Java, el compilador de C # es al menos más moderno.

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


Creo que esto está permitido porque puedes (aún) atrapar la Excepción y hacer algo significativo con ella.

La inicialización nunca se ejecutará y casi arrojará una Exception. Pero esto todavía puede ser un comportamiento deseado, y no siempre significa que el proceso debería fallar.

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


Creo que porque la especificación del lenguaje solo descarta invocar directamente el mismo constructor que se está definiendo.

Desde 10.11.1:

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

...

  • Un inicializador de constructor de instancia del formulario this( argument-list opt ) 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 el propio constructor, se produce un error en tiempo de compilación

Esa última frase parece excluir que la llamada directa misma produzca un error de tiempo de compilación, por ejemplo

Foo() : this() {}

es ilegal.

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

Creo que la otra razón por la que no marca o advierte sobre esto es porque no tiene necesidad de detectar esta situación. Imagínese buscar entre cientos de diferentes constructores, solo para ver si existe un ciclo, cuando cualquier intento de uso explotará rápidamente (como sabemos) en tiempo de ejecución, para un caso extremo.

Cuando está haciendo generación de código para cada constructor, todo lo que considera es constructor-initializer , los inicializadores de campo y el cuerpo del constructor; no considera ningún otro código:

  • Si constructor-initializer es un constructor de instancia para la clase en sí, no emite los inicializadores de campo, sino que emite la llamada de constructor-initializer y luego el cuerpo.

  • Si constructor-initializer es un constructor de instancia para la clase base directa, emite los inicializadores de campo, luego la llamada de constructor-initializer , y luego cuerpo.

En ninguno de los casos necesita buscar en otro lado, por lo que no es "imposible" decidir dónde ubicar los inicializadores de campo, simplemente sigue unas reglas simples que solo consideran el constructor actual.


Hallazgo interesante

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

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

(No hice caso del constructor de instancias de System.Object que es un caso especial. System.Object no tiene clase base, pero System.Object tampoco tiene campos).

Los inicializadores de campo de instancia que pueden estar presentes en la clase, necesitan copiarse en el principio del cuerpo de todos los constructores de instancia de tipo 2 anteriores, mientras que los constructores de instancia de tipo 1 no necesitan el código de asignación de campo.

Entonces, aparentemente no es necesario 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 donde todos los constructores de instancia son de tipo 1 . En esa situación, el código de incializador de campo no necesita colocarse en ningún lado. Por lo tanto, no se analiza muy profundamente, parece.

Resulta que cuando todos los constructores de instancia son de tipo 1. , incluso puede derivar de una clase base que no tiene un constructor accesible. La clase base debe ser no sellada, sin embargo. Por ejemplo, si escribe una clase con solo constructores de instancias private , 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. arriba. 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 método System.Runtime.Serialization.FormatterServices.GetUninitializedObject .

Otro ejemplo: la clase System.Globalization.TextInfo solo tiene un constructor internal instancias. Pero aún puede derivar de esta clase en un ensamblaje distinto de mscorlib.dll con esta técnica.

Finalmente, con respecto al

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 prioridad que el operador menor que < y el operador mayor que > . Los últimos dos operadores tienen la misma precedencia y, por lo tanto, son evaluados por la regla asociativa izquierda. Si los tipos fueran

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

y si MySpecialType introdujo una (MySpecialType, int) del operator < , entonces la expresión

Invalid < Method > Name << Indeeed()

sería legal y significativo.

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


Tu ejemplo

class Foo { int a = 42; Foo() :this(60) { } Foo(int v) { } }

funcionará bien, en el sentido de que puede instanciar ese objeto Foo sin problemas. Sin embargo, lo siguiente sería más como el código sobre el que preguntas

class Foo { int a = 42; Foo() :this(60) { } Foo(int v) : this() { } }

Tanto eso como tu código crearán un (!), Porque la recursión 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 recursión 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 cree instancias de sí mismo dentro del cuerpo del constructor tiene sentido para mí, porque, por ejemplo, podría usarse para crear instancias de árboles en los que cada nodo apunta a otros nodos. Pero la recursión 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 se permitiera.