Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

18 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 18 de febrero de 2025
Un patito de goma al que se ha forzado a ser un coche

Imaginad que tenemos un objeto clase Coche y otro del tipo PatitoDeGoma, sin ningún tipo de relación entre ellos. C# no permite hacer un cast y convertir libremente de uno a otro tipo o viceversa, ¿verdad?

Pues eso pensaba yo también, hasta que hace poco descubrí una característica de .NET que no he usado nunca y dudo que vaya a usar 😝, pero me ha parecido de lo más curiosa y quería compartirla con vosotros. Como podréis comprobar, es una buena candidata para la serie C# bizarro, de la que ya llevamos varias entregas, aunque en realidad se trata de una característica de la plataforma y no del lenguaje.

La protagonista es la clase estática System.Runtime.CompilerServices.Unsafe, que, según indican en la documentación oficial, implementa funcionalidades de bajo nivel para manipular punteros gestionados y no gestionados. Su objetivo, según esta misma fuente, es permitir a desarrolladores de bibliotecas escribir código de alto rendimiento evitando los chequeos de seguridad de .NET.

Esta clase proporciona métodos de aritmética de punteros, conversión de tipos y otras operaciones que no son seguras en C# y que, por tanto, no se permiten salvo en contextos unsafe. Sin embargo, en ocasiones, puede ser útil para realizar operaciones que no son seguras pero que sabemos que no van a causar problemas en tiempo de ejecución.

El que más me ha llamado la atención ha sido el método As(), que permite forzar un cast entre dos tipos que a priori no son compatibles sin realizar comprobaciones de tipo en tiempo de ejecución.

Veámoslo lo con un ejemplo. Imaginad estas dos clases (usamos constructores primarios para simplificar) y un pequeño código que las utiliza:

var friend = new Friend("John");
friend.SayHello(); // Hello, my name is John!

var dog = new Dog("Bobby");
dog.SayHello();   // (Bobby) ¡Woof! ¡Arf arf! ¡Grrr!


public class Dog(string name)
{
    public string DogName { get;  set; } = name;

    public void SayHello()
    {
        Console.WriteLine($"({DogName}) ¡Woof! ¡Arf arf! ¡Grrr!");
    }
}

public class Friend(string name)
{
    public string Name { get; set; } = name;

    public void SayHello()
    {
        Console.WriteLine($"Hello, my name is {Name}!");
    }
}

De partida, estos dos tipos son incompatibles. Cualquier intento de convertir uno en otro lanzará un error de compilación:

var f = (Friend) dog; // CS0030	Cannot convert type 'Dog' to 'Friend'
var d = (Dog) friend; // CS0030	Cannot convert type 'Friend' to 'Dog'

Incluso si despistamos al compilador y conseguimos forzar el cast como se muestra a continuación, el error lo obtendremos en tiempo de ejecución:

var f = (Friend)(object)dog; 
// InvalidCastException: Unable to cast object of type 'Dog' to type 'Friend'.

Pues bien, el método Unsafe.As() nos permite hacer esto sin problema:

var dog = new Dog("Bobby");
dog.SayHello();   // (Bobby) ¡Woof! ¡Arf arf! ¡Grrr!

Friend dogAsFriend = Unsafe.As<Friend>(dog); // Voilà!
dogAsFriend.SayHello(); // Prints "Hello, my name is Bobby!"

A todos los efectos, estamos convirtiendo un objeto de tipo Dog en un Friend 😯. Al llamar a SayHello() podemos ver que el comportamiento es de Friend, pero los datos mostrados son los que habíamos establecido en el objeto dog.

En realidad, internamente no ha cambiado nada en el objeto ni se está convirtiendo nada, lo único que se hace es "reinterpretar" como objeto de tipo Friend las posiciones de memoria ocupadas por el dog que suministramos como parámetro. Qué locura, ¿verdad? 😂

Para que esta interpretación sea correcta, la estructura en memoria de los campos debe coincidir. Si os fijáis en ambos casos tenemos un campo string en primera posición, por lo que la interpretación es correcta aunque el nombre de estos sea distinto (Name vs DogName). Si no coincidieran, el resultado sería impredecible.

Por ejemplo, si en la clase Friend insertamos un campo adicional City y volvemos a ejecutar el código anterior, el resultado será distinto:

public class Friend(string name)
{
    public string City { get; set; } = "Chicago";
    public string Name { get; set; } = name;

    public void SayHello()
    {
        Console.WriteLine($"Hello, my name is {Name} from {City}!");
    }
}

Friend dogAsFriend = Unsafe.As<Friend>(dog);
dogAsFriend.SayHello(); // Hello, my name is  from Bobby!

Pues parece que se ha liado un poco. Como podemos intuir, el campo City ocupa la posición de DogName, por esa razón se le asigna su valor "Bobby". En la posición del campo Name de Friend no existe nada en el objeto Dog, por lo que se obtiene un valor nulo.

Otro aspecto interesante es que Unsafe.As() retorna una referencia al objeto original, por lo que cualquier cambio que realicemos sobre ella afectará obviamente al primero:

var dog = new Dog("Bobby");
dog.SayHello();   // (Bobby) ¡Woof! ¡Arf arf! ¡Grrr!

Friend dogAsFriend = Unsafe.As<Friend>(dog);
dogAsFriend.SayHello(); // Hello, my name is Bobby!

dogAsFriend.Name = "Charlie";
dogAsFriend.SayHello(); // Hello, my name is Charlie!

dog.SayHello(); // (Charlie) ¡Woof! ¡Arf arf! ¡Grrr!

En definitiva, como decía al principio, creo que para la mayoría de los mortales esta característica no tiene demasiada utilidad más allá de probar algunas aberraciones y llevar C# y .NET a sus límites. Hay que tener en cuenta que todo lo que sea jugar con punteros, aunque divertido, puede ser peligroso y dar lugar a errores o comportamientos inesperados.

Pero, como siempre, es interesante conocer estas cosas para saber que están ahí y, quién sabe, quizás algún día nos saquen de un apuro (o nos metan en uno, no sé 😂).

Publicado en Variable not found.

2 Comentarios:

Rfog dijo...

Si C# tuviera herencia múltiple... Eso se usa para acceder a la parte "derecha" o "izquierda" en C++ cuando un objeto padre con herencia múltiple comparte una clase hija, y el compilador te está llamando a un miembro del hijo "derecho" y quieres el del "izquierdo". Y también para mutar a un objeto padre no accesible por ocultación.

Vamos, en resumen, que si necesitas algo de eso, ve revisándote la arquitectura, pero a veces es necesario porque no vas a tirar a la basura 100.000 líneas de código que llevan 20 años funcionando bien.

José María Aguilar dijo...

Hola!

Pues sí, no es algo para usar todos los días, ni mucho menos. De hecho, como sugieres, si te ves obligado a utilizar estas triquiñuelas, probablemente tengas problemas mucho mayores, pero bueno, siempre está bien conocer que existen estas posibilidades por si acaso.

Saludos & gracias por comentar!

Pero