Sabemos que está feo y no sea especialmente recomendable en muchos casos, pero hay veces en las que es totalmente necesario acceder a miembros privados de una clase. Por ejemplo, sería un requisito imprescindible si fuéramos a implementar un serializador o deserializador personalizado, o necesitásemos clonar objetos, o simplemente realizar pruebas unitarias a una clase que no ha sido diseñada para ello.
Para conseguirlo, siempre hemos utilizado los mecanismos de introspección de .NET, conocidos por todos como reflexión o por su término en inglés reflection. Por ejemplo, imaginemos una clase como la siguiente, virtualmente inexpugnable:
public class BlackBox
{
private string _message = "This is a private message";
private void ShowMessage(string msg) => Console.WriteLine(msg);
public override string ToString() => _message;
}
Aunque a priori no podemos hacer nada con ella desde fuera, usando reflexión podríamos acceder sin problema a sus miembros privados, ya sean propiedades, campos, métodos o cualquier tipo de elemento, como podemos ver en el siguiente ejemplo:
var instance = new BlackBox();
// Obtenemos el valor del campo privado _message:
var field = typeof(BlackBox)
.GetField("_message", BindingFlags.NonPublic | BindingFlags.Instance);
var value = field!.GetValue(instance);
// Ahora llamamos al método privado ShowMessage():
var method = typeof(BlackBox)
.GetMethod("ShowMessage", BindingFlags.NonPublic | BindingFlags.Instance);
method.Invoke(instance, [value]);
// Al ejecutar, se nuestra en consola: "This is a private message"
Sin embargo, de todos es sabido que la reflexión es un mecanismo muy lento, a veces farragoso en su implementación y, sobre todo, incompatible con tree-shaking, que es como se suele llamar la técnica que usan los compiladores de eliminar del código final todas las clases, métodos y propiedades que no se utilizan en el código final. Esta técnica va tomando cada vez más relevancia conforme los compiladores son capaces de crear directamente ejecutables nativos puros, porque permiten reducir de forma considerable el peso de los artefactos creados.
Por esta razón, en .NET 8 se ha incluido una nueva fórmula para acceder a miembros privados, mucho más eficiente y amistosa con AOT, porque se lleva a tiempo de compilación algo que antes obligatoriamente se resolvía en tiempo de ejecución.
Accediendo a miembros privados con .NET 8 sin usar reflection
En .NET 8 podemos acceder a miembros privados de una clase sin usar reflection usando la declaración de un método estático externo, con una signatura compatible con el miembro al que queremos acceder, y decorándolo con el nuevo atributo [UnsafeAccessor]
. Por ejemplo, para acceder al campo privado _message
de la clase BlackBox
que hemos visto antes, podríamos hacer lo siguiente:
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_message")]
static extern ref string GetMessageField(BlackBox instance);
Esta declaración habilita el método GetMessageField()
(nombre arbitrario, podría haber usado cualquier otro) para acceder a una referencia hacia el campo indicado en el atributo, sobre la instancia suministrada como parámetro cuando es invocado. Podríamos usarlo así:
var obj = new BlackBox();
Console.WriteLine(GetMessageField(obj));
// Muestra: "This is a private message"
En este punto, fijaos en una cosa muy interesante: el método mediante el que accedemos retorna un ref
, lo que quiere decir que podemos usarlo de forma bidireccional, es decir, tanto para leer como para establecer su valor. Por ejemplo, podríamos hacer lo siguiente:
// Usamos de forma directa la referencia para establecerlo:
var obj = new BlackBox();
GetMessageField(obj) = "Hey, I've changed the message!";
Console.WriteLine(obj); // Muestra "Hey, I've changed the message!"
// O bien, más claro, usando variables:
ref var message = ref GetMessageField(obj);
message = "Changed again!"
Console.WriteLine(obj); // Muestra "Changed again!"
De forma muy similar, podemos obtener referencias hacia otro tipo de elementos. Los tipos permitidos vienen dados por los distintos valores del enumerado UnsafeAccessorKind
, que son los siguientes:
public enum UnsafeAccessorKind
{
Constructor,
Method,
StaticMethod,
Field,
StaticField
}
Es decir, podemos acceder a constructores, métodos de instancia o estáticos y campos de instancia o estáticos (no necesariamente privados, aunque es cuando tiene más sentido usar esta técnica). Por tanto, sería posible declarar un acceso al método privado ShowMessage()
de la clase BlackBox
de la siguiente forma:
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "ShowMessage")]
static extern void ShowMessage(BlackBox instance, string message);
Para poder luego invocarlo de la siguiente manera:
var obj = new BlackBox();
ShowMessage(obj, "Hello world!"); // Muestra "Hello world!"
Habréis notado que en los tipos de miembro permitidos no se encuentran las propiedades. Esto es así porque porque, en realidad, son simplemente getters y setters y, por tanto, deben ser gestionadas como métodos. Veámoslo con un ejemplo:
// Clase con propiedad privada:
class BlackBox
{
private string Message { get; set; } = "Default value";
}
// Accesos a getter y setter de la propiedad privada:
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_Message")]
static extern string GetMessage(BlackBox instance);
[UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_Message")]
static extern void SetMessage(BlackBox instance, string message);
// Uso:
var obj = new BlackBox();
Console.WriteLine(GetMessage(obj)); // "Default value"
SetMessage(obj, "Changed!");
Console.WriteLine(GetMessage(obj)); // "Changed!"
Y como habéis visto antes, también es posible declarar accesos a constructores, lo que en la práctica es una forma de crear instancias de clases que tengan un constructor privado, como en el siguiente ejemplo:
// Clase con constructor privado:
class BlackBox
{
private readonly string _message;
private BlackBox(string msg) => _message = msg;
public override string ToString() => _message;
}
// Declaración del acceso al constructor privado:
[UnsafeAccessor(UnsafeAccessorKind.Constructor)]
static extern BlackBox CreateBlackBox(string msg);
// Creación de instancia:
var obj = CreateBlackBox("Hello!");
Console.WriteLine(obj); // Muestra: "Hello!"
¿Cómo de eficiente es esto respecto a reflection?
Aunque la eficiencia no es el principal motivo por el que se ha introducido esta nueva funcionalidad, es interesante saber que, en efecto, se comporta bastante mejor que la reflexión.
Para comprobarlo, nada como crear una prueba rápida con BenchmarkDotNet, que arroja los siguientes resultados:
BenchmarkDotNet v0.13.8, Windows 11 (10.0.22621.2283/22H2/2022Update/SunValley2)
Intel Core i9-9900K CPU 3.60GHz (Coffee Lake), 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.100-rc.1.23455.8
[Host] : .NET 8.0.0 (8.0.23.41904), X64 RyuJIT AVX2
DefaultJob : .NET 8.0.0 (8.0.23.41904), X64 RyuJIT AVX2
| Method | Mean | Error | StdDev | Gen0 | Allocated |
|---------------------------------- |----------:|----------:|----------:|-------:|----------:|
| GetPrivateFieldUsingUnsafe | 2.890 ns | 0.0734 ns | 0.0613 ns | 0.0038 | 32 B |
| GetPrivateFieldUsingReflection | 57.386 ns | 0.6291 ns | 0.5885 ns | 0.0067 | 56 B |
| GetPrivatePropertyUsingUnsafe | 2.966 ns | 0.1121 ns | 0.1842 ns | 0.0038 | 32 B |
| GetPrivatePropertyUsingReflection | 30.555 ns | 0.3644 ns | 0.3230 ns | 0.0038 | 32 B |
| CreateInstanceUsingAccesor | 4.290 ns | 0.1400 ns | 0.1556 ns | 0.0038 | 32 B |
| CreateInstanceUsingReflection | 72.788 ns | 0.4917 ns | 0.4359 ns | 0.0162 | 136 B |
Espero que os haya resultado interesante y podáis sacarle partido en vuestros próximos proyectos 🙂
Publicado en: www.variablenotfound.com.
2 Comentarios:
Esto la verdad es que me será muy útil para mi framework, que hace un uso bastante intensivo de reflexión.
Gracias José María.
PD: Cerraste los comentarios por aluvión de spam, pues toma spam, con enlace y todo a mi GitHub xD.
Nada, sin problema... en tu caso no es spam, son aportaciones interesantes para la humanidad 😆
Saludos!
Enviar un nuevo comentario