Curiosidades con Structs en C#

Seguramente todos conocéis de sobra las diferencias entre usar clases y estructuras en C#. La diferencia más notable es que las clases son tipos por referencia y las estructuras son tipos por valor. Aparte de las implicaciones directas de esto, como que un objeto que pertenece a una clase suele almacenarse en el heap y una estructura en el stack, hace poco me topé con un caso curioso.

Las propiedades de tipo struct

Imaginaos que tenemos una estructura así:

public struct Point
{
	public int X;
	public int Y;
}

Y queremos utilizarla dentro de una clase como ésta:

public class Sprite
{
	private Point location;

	public Point Location { get { return location; } }

	public void LegalAndRight()
	{
		location.X = 12;
	}

	public void DoesNotCompile()
	{
		// Error al compilar:
		// Cannot modify the expression because it is not a variable
		Location.X = 12;
	}
}

Tenemos una estructura, Point, similar a la que existe en System.Drawing.Point que usamos para almacenar la posición de un Sprite. En la clase Sprite, podemos acceder al atributo location de dos maneras, usando directamente el atributo o a través de la propiedad Location.

Si intentamos modificar la coordenada X de location a través del atributo, no hay ningún problema. El código compila y funciona como esperamos.

Sin embargo, si tratamos de modificar la coordenada X usando la propiedad Location, la compilación falla con el mensaje que tenemos arriba: Cannot modify the expression because it is not a variable.

Obviamente esto tiene que estar relacionado con que las estructuras son tipos por valor, pero como a veces me gusta entender el porqué de las cosas, decidí profundizar un poco.

Desensamblando el ensamblado

Usando ildasm pude analizar el código que se genera al compilar el primer método. Éste es el código necesario para actualizar el valor de location.X accediendo a través del atributo:

.method public hidebysig instance void  LegalAndRight() cil managed
{
  // Code size       14 (0xe)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  ldflda     valuetype Test.Point Test.Sprite::location
  IL_0006:  ldc.i4.s   12
  IL_0008:  stfld      int32 Test.Point::X
  IL_000d:  ret
} // end of method Sprite::LegalAndRight

Vamos a analizarlo despacio que esto del CIL asusta un poco pero no es tan complicado. Tan sólo hay que recordar que funciona como una máquina de pila, en la que se van apilando argumentos y luego se ejecutan operaciones sobre los argumentos apilados, dejando el resultado en la cima de la pila.

Empezamos:

IL_0000:  ldarg.0
IL_0001:  ldflda     valuetype Test.Point Test.Sprite::location

La primera línea apila el primer argumento del método. En C#, todos los métodos no estáticos reciben de forma implícita como primer argumento una referencia al objeto en el que se invocan. En otras palabras, reciben la referencia this que es lo que estamos dejando en la cima de la pila.

La segunda línea ejecuta la operación load field address. Esta operación apila la dirección de memoria del atributo que le estamos indicando (Test.Sprite::location) dentro del objeto referenciado por la cima de la pila, en este caso this. Es decir, estamos apilando la dirección de memoria de this.location.

Seguimos:

IL_0006:  ldc.i4.s   12
IL_0008:  stfld      int32 Test.Point::X

Primero estamos apilando una constante, el 12 que aparece en nuestro método e invocando la operación store field. Esta operación almacena el valor que hay en la cima de la pila en el atributo indicado (Test.Point::X) de la referencia apilada justo debajo de la cima. Resumiendo, si en la cima tenemos 12 y una posición más abajo tenemos this.location, esto está haciendo this.location.X = 12.

Al final lo que queda claro es que para modificar el atributo location, el código que se está generando necesita una referencia a la dirección de memoria de la estructura para poder invocar el stdfld.

Para ver qué ocurre al acceder mediante la propiedad, podemos añadir un nuevo método a la clase Sprite:

public void LegalButWrong()
{
	var temp = Location;
	temp.X = 12;
}

Este método compila correctamente pero no hace lo que queremos, ya que al asignar la propiedad Location a la variable local temp, estamos haciendo una copia de la estructura (por aquello de que las estructuras son tipos por valor) y la coordenada X que modificamos no es la correcta. De todas formas, el código CIL que genera es:

.method public hidebysig instance void  LegalButWrong() cil managed
{
  // Code size       18 (0x12)
  .maxstack  2
  .locals init ([0] valuetype Test.Point temp)
  IL_0000:  nop
  IL_0001:  ldarg.0
  IL_0002:  call       instance valuetype Test.Point Test.Sprite::get_Location()
  IL_0007:  stloc.0
  IL_0008:  ldloca.s   temp
  IL_000a:  ldc.i4.s   12
  IL_000c:  stfld      int32 Test.Point::X
  IL_0011:  ret
} // end of method Sprite::LegalButWrong

Aparecen algunos nop porque he tenido que compilar el código en modo debug para que Visual Studio no me optimizase el método quitando la asignación a temp (ya que no ésta no hace realmente nada). A estas alturas ya somos expertos en CIL así que esto debería ser fácil de analizar:

.locals init ([0] valuetype Test.Point temp)

Lo primero que se hace es declarar la variable local temp que usaremos un poco más adelante.

IL_0001:  ldarg.0
IL_0002:  call       instance valuetype Test.Point Test.Sprite::get_Location()
IL_0007:  stloc.0

Esto es parecido a lo que teníamos antes, apilamos this y hacemos una llamada a Test.Sprite::get_Location() para dejar en la cima de la pila el valor de location. OJO, que esto es importante. Lo que tenemos en la cima de la pila es el valor de location, no su dirección de memoria. Por último, almacenamos la copia del valor de location en la variable temp.

IL_0008:  ldloca.s   temp
IL_000a:  ldc.i4.s   12
IL_000c:  stfld      int32 Test.Point::X

Para terminar, apilamos la dirección de la variable local temp, apilamos encima la constante 12 y llamamos a store field como hacíamos antes.

Ahora queda claro

Resumiendo, al acceder a la propiedad Location, podemos obtener una copia del valor de location, pero nunca una referencia al atributo como tal, por lo que no es posible obtener su dirección de memoria y por tanto no se puede actualizar. Por eso al intentar escribir Location.X = 12 el compilador se queja y no nos deja hacerlo.

¿Podemos engañar al compilador?

Por supuesto, si lo que hacemos es algo más complicado que una asignación el compilador no lo puede detectar y no generará el error, aunque tampoco obtendremos el resultado que queremos. Vamos a cambiar un poco el código añadiendo un método SetX a Point:

public class Point
{
    //...
    public void SetX(int x) { X = x; }
}

public class Sprite
{
    // ...
    public void LetsFoolTheCompiler()
    {
        Location.SetX(12);
    }
}

Con estos cambios el código compila, porque el compilador no tiene ni idea de lo que implica la llamada a SetX. Sin embargo, esto no hace lo que queremos. Es equivalente al ejemplo que veíamos con la variable temp y de hecho si vemos el CIL queda claro:

.method public hidebysig instance void  LetsFoolTheCompiler() cil managed
{
  // Code size       16 (0x10)
  .maxstack  2
  .locals init ([0] valuetype Test.Point CS$0$0000)
  IL_0000:  ldarg.0
  IL_0001:  call       instance valuetype Test.Point Test.Sprite::get_Location()
  IL_0006:  stloc.0
  IL_0007:  ldloca.s   CS$0$0000
  IL_0009:  ldc.i4.2
  IL_000a:  call       instance void Test.Point::SetX(int32)
  IL_000f:  ret
} // end of method Sprite::LetsFoolTheCompiler

A estas alturas seguro que os resulta fácil ver que lo que hace el compilador es crear una variable local a la que llama C$0$0000 para almacenar temporalmente el valor devuelto por get_Location(), por lo que el efecto real es que estamos invocando el método SetX sobre un location que no es el mismo que tenemos almacenado en el atributo de la clase.

Conclusiones

Está claro que montar todo esto sólo por un error de compilación es un poco excesivo, pero ha sido bastante entretenido analizar como es el código que genera el compilador de C#, así que hay merecido la pena :-)

Además, me quedo con la sensación de que las estructuras deberían ser inmutables. Eso simplificaría mucho la forma de razonar sobre ellas y evitaría posibles bugs por no tener en cuenta si lo que estás asignado es una clase o una estructura.

4 comentarios en “Curiosidades con Structs en C#

  1. Me acabas de dejar de piedra. No por el hecho, si no por todo lo que has liado para explicarlo. No me esperaba que alguien bajara a código CIL.
    Buen trabajo. Felicidades.

  2. Gracias, me alegro de que te haya gustado. Normalmente no me hubiera complicado tanto pero me picó la curiosidad…

  3. Pingback: Diferencias entre Clases y Estructuras (Structs) C# | Tecnico Programador

  4. Es curioso el hecho de que no compile :)
    Yo hubiese dado por sentado que compilaba, pero que obviamente el valor asignado se perdería (exactamente como ocurre en LegalButWrong). Supongo que esta presunción mía viene dada por mis experiencias en C++ :)
    Coincido contigo: las estructuras deberían ser inmutables, y de hecho este es un patrón de diseño que intento seguir en las (pocas) veces que creo y las uso.

    Gran post.

Comentarios cerrados.