Ir al contenido
wannacode

Reflexión y atributos

En resumen: La reflexión es el acceso del programa a sus propios tipos mientras se ejecuta: propiedades y métodos por nombre, y los atributos son notas que puede leer.

Problema

La propiedad que ToString olvidó

El ToString del pedido está escrito a mano. Más tarde, alguien añade una propiedad para el descuento:

class Order
{
public int Number { get; set; } = 1042;
public string Customer { get; set; } = "María López";
public double Total { get; set; } = 86.40;
public int Discount { get; set; } = 10; // añadido después
public override string ToString()
{
return "Order[Number=" + Number + ", Customer=" + Customer + ", Total=" + Total + "]";
}
}
Console.WriteLine(new Order());
Salida
Order[Number=1042, Customer=María López, Total=86.4]

El descuento no está en la salida, porque nadie se acordó de actualizar ToString. Con diez clases, esto pasa constantemente.

Hay además otra pregunta del principio del módulo. Cuando escribes [Fact] encima de un método de prueba (en NUnit es [Test]), xUnit lo encuentra y lo ejecuta, aunque nunca ha visto tu clase. ¿Cómo? Las dos cosas funcionan con la misma idea: un programa puede examinar sus propias clases mientras se ejecuta.

La idea

La clase como objeto

Para cada tipo, .NET guarda un objeto de tipo Type que lo describe: nombre, padre, interfaces, propiedades, campos, métodos, constructores. El acceso a él se llama reflexión (en inglés, reflection: el programa se ve a sí mismo como en un espejo). Las clases para las partes de un tipo (PropertyInfo, MethodInfo y otras) están en System.Reflection. Hay tres formas de obtener el objeto Type:

  • typeof(Order), cuando conoces la clase mientras escribes el código;
  • obj.GetType(), cuando tienes un objeto (da la clase real del objeto, no el tipo de la variable);
  • Type.GetType("System.Collections.ArrayList"), cuando solo tienes el nombre como texto.
Type type = Type.GetType("System.Collections.ArrayList");
Console.WriteLine("Nombre: " + type.FullName);
Console.WriteLine("Nombre corto: " + type.Name);
Console.WriteLine("Padre: " + type.BaseType.FullName);
foreach (Type parent in type.GetInterfaces().OrderBy(t => t.Name))
{
Console.WriteLine("Interfaz: " + parent.FullName);
}
Salida
Nombre: System.Collections.ArrayList
Nombre corto: ArrayList
Padre: System.Object
Interfaz: System.ICloneable
Interfaz: System.Collections.ICollection
Interfaz: System.Collections.IEnumerable
Interfaz: System.Collections.IList
ArrayList es la lista antigua sin tipo de elementos, de antes de List<T>. Si no hay ningún tipo con ese nombre, Type.GetType no lanza nada: devuelve null. Las interfaces se ordenan por nombre con OrderBy, porque el orden de GetInterfaces() no está garantizado. El nombre de una clase genérica termina en ` y el número de sus parámetros de tipo: typeof(List<string>).Name es List`1.

Míralo

Las propiedades de cualquier objeto

GetProperties() da las propiedades públicas de la clase como objetos PropertyInfo (de System.Reflection). Cada una conoce su nombre y su tipo, y puede leer su valor de un objeto dado:
static void Dump(object obj)
{
Type type = obj.GetType();
Console.WriteLine(type.Name + ":");
foreach (PropertyInfo property in type.GetProperties())
{
Console.WriteLine(" " + property.Name + " (" + property.PropertyType.Name + ") = " + property.GetValue(obj));
}
}
class Room
{
public int Number { get; set; } = 214;
public bool SeaView { get; set; } = true;
}
Dump(new Order());
Dump(new Room());
Salida
Order:
Number (Int32) = 1042
Customer (String) = María López
Total (Double) = 86.4
Discount (Int32) = 10
Room:
Number (Int32) = 214
SeaView (Boolean) = True
Un solo método imprime cualquier objeto, y la propiedad nueva Discount ya está ahí. En C#, los datos de una clase se ven desde fuera como propiedades, y los campos quedan ocultos. Por eso la reflexión aquí, y también bibliotecas como System.Text.Json y Entity Framework, mira las propiedades. Son públicas, así que no hay que abrir nada, como con setAccessible en Java. Los tipos aparecen con sus nombres de .NET: int es Int32, double es Double, bool es Boolean.

static void Dump(object obj)
{
Type type = obj.GetType();
Console.WriteLine(type.Name + ":");
foreach (PropertyInfo property in type.GetProperties())
{
Console.WriteLine(" " + property.Name + " (" + property.PropertyType.Name + ") = " + property.GetValue(obj));
}
}
Paso a paso, en palabras
  1. Dump recibe un objeto Order. Sus campos están ocultos; desde fuera se ven sus propiedades.
  2. GetType() da el objeto Type que describe la clase Order.
  3. La primera línea es el nombre corto de la clase.
  4. GetProperties() da 4 objetos PropertyInfo: cada uno conoce el nombre y el tipo de la propiedad.
  5. GetValue(obj) llama al get de la propiedad Number y devuelve el valor: 1042. Int32 es el nombre de int en .NET.
  6. Customer (String) = María López.
  7. Total (Double) = 86.4.
  8. Discount: la propiedad que ToString olvidó aparece sola, porque la reflexión obtiene todas las propiedades.
  9. El método no sabe nada de Order. Para Room imprime Number y SeaView de la misma forma.
Más precisamente, para curiosos
  • GetProperties() da solo las propiedades públicas, también las heredadas. Otras cosas se piden con BindingFlags: BindingFlags.Instance | BindingFlags.NonPublic da las privadas, y BindingFlags.DeclaredOnly solo las de la propia clase, sin las heredadas.
  • Detrás de cada propiedad automática ({ get; set; }), el compilador pone un campo privado oculto. Estos son los campos de Room, pedidos con GetFields:
Room room = new Room();
foreach (FieldInfo field in typeof(Room).GetFields(BindingFlags.Instance | BindingFlags.NonPublic))
{
Console.WriteLine(field.Name + " = " + field.GetValue(room));
}
Salida
<Number>k__BackingField = 214
<SeaView>k__BackingField = True
  • .NET no garantiza el orden de las propiedades. El .NET actual las devuelve en el orden de la clase, pero el código que depende del orden es frágil.

Pruébalo

¿De qué clase es?

¿Qué imprimirá el programa?

class Document
{
}
class Invoice : Document
{
}
Document doc = new Invoice();
Console.WriteLine(doc.GetType().Name);
Console.WriteLine(doc.GetType().BaseType.Name);
Console.WriteLine(typeof(Document).BaseType.Name);
Respuesta

Míralo

Objeto, método y campo privado por nombre

Con reflexión puedes crear un objeto, llamar a un método y cambiar un campo conociendo solo sus nombres:
class Product
{
private string name;
private double price;
public Product(string name, double price)
{
this.name = name;
this.price = price;
}
public void ApplyDiscount(int percent)
{
price = price * (100 - percent) / 100;
}
private string Label()
{
return name + ": " + price + " €";
}
}
Type type = typeof(Product);
Product coffee = (Product)Activator.CreateInstance(type, "Café", 10.0);
MethodInfo discount = type.GetMethod("ApplyDiscount");
discount.Invoke(coffee, new object[] { 20 });
FieldInfo price = type.GetField("price", BindingFlags.Instance | BindingFlags.NonPublic);
Console.WriteLine("Precio con descuento: " + price.GetValue(coffee));
price.SetValue(coffee, 9.5);
MethodInfo label = type.GetMethod("Label", BindingFlags.Instance | BindingFlags.NonPublic);
Console.WriteLine(label.Invoke(coffee, null));
Salida
Precio con descuento: 8
Café: 9.5 €
  • Activator.CreateInstance(type, "Café", 10.0) encuentra el constructor por los argumentos y lo llama. Devuelve un object, por eso está el cast a Product. Un constructor también se puede encontrar por los tipos de sus parámetros con type.GetConstructor(new[] { typeof(string), typeof(double) }).
  • GetMethod("ApplyDiscount") encuentra el método público, e Invoke(objeto, argumentos) lo llama sobre coffee. Los argumentos son un array object[]; sin argumentos se pasa null.
  • Lo privado se busca con BindingFlags.Instance | BindingFlags.NonPublic. Sin ellas, GetField("price") devuelve null. No hace falta nada parecido a setAccessible: una vez encontrado, el campo privado se lee y se escribe, y el método privado se llama.

C# no tiene excepciones comprobadas, así que Main no declara nada. Si el nombre está mal, GetField y GetMethod no lanzan nada: devuelven null.

Más precisamente, para curiosos
Un List<string>, creado y rellenado sin que en el código aparezca new List ni .Add:
Type type = typeof(List<>).MakeGenericType(typeof(string));
object list = Activator.CreateInstance(type);
MethodInfo add = type.GetMethod("Add");
add.Invoke(list, new object[] { "Café" });
add.Invoke(list, new object[] { "Té" });
Console.WriteLine(type);
Console.WriteLine(string.Join(", ", (List<string>)list));
Salida
System.Collections.Generic.List`1[System.String]
Café, Té

typeof(List<>) es la clase genérica sin su parámetro de tipo, y MakeGenericType crea a partir de ella un List<string>. A diferencia de Java, en .NET el tipo de los elementos sigue ahí mientras el programa se ejecuta: Add en esta lista acepta solo un string. Si le pasas un número a Invoke, lanza una excepción:

Salida
ArgumentException: Object of type 'System.Int32' cannot be converted to type 'System.String'.

Pruébalo

El precio que no está

El programa compila, pero se detiene con una NullReferenceException en la tercera línea. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Tus propios atributos

Los atributos son etiquetas entre corchetes: [Obsolete], [Fact], [HttpGet]. En Java lo mismo se llama anotación y se escribe con @. Por sí solos no hacen nada. Les da sentido el código que los lee, a menudo con reflexión. También puedes crear los tuyos: un atributo es una clase que hereda de Attribute. Este pone una etiqueta a una propiedad, y el método imprime solo las propiedades con etiqueta:
[AttributeUsage(AttributeTargets.Property)]
class LabelAttribute : Attribute
{
public string Text { get; }
public LabelAttribute(string text)
{
Text = text;
}
}
class Booking
{
[Label("Número")]
public int Number { get; set; } = 501;
[Label("Huésped")]
public string Guest { get; set; } = "Carlos Gómez";
[Label("Noches")]
public int Nights { get; set; } = 3;
public string InternalNote { get; set; } = "VIP";
}
static void PrintLabeled(object obj)
{
foreach (PropertyInfo property in obj.GetType().GetProperties())
{
LabelAttribute label = property.GetCustomAttribute<LabelAttribute>();
if (label != null)
{
Console.WriteLine(label.Text + ": " + property.GetValue(obj));
}
}
}
Salida
Número: 501
Huésped: Carlos Gómez
Noches: 3
  • class LabelAttribute : Attribute declara un atributo. Por convención, el nombre termina en Attribute, y al usarlo se escribe sin esa parte: [Label("Huésped")]. El argumento entre paréntesis va al constructor, y Text es una propiedad de solo lectura.
  • [AttributeUsage(AttributeTargets.Property)] dice dónde se puede poner: aquí, solo en propiedades. [Label] encima de un método es un error de compilación (CS0592).
  • Aquí no hay nada parecido a @Retention de Java: los atributos siempre se quedan en el programa compilado, y la reflexión los ve.
  • property.GetCustomAttribute<LabelAttribute>() devuelve el atributo, o null si la propiedad no lo tiene. InternalNote no tiene etiqueta y no se imprime.
Más precisamente, para curiosos
  • Un atributo también puede tener propiedades con set. Se asignan por nombre después de los argumentos: [Label("Huésped", Hint = "nombre y apellidos")], si LabelAttribute tiene una propiedad Hint. Una propiedad que no asignas conserva su valor de la clase. En Java, lo mismo es @Label(value = "Huésped", hint = "…"), con default para el valor por defecto.
  • AttributeTargets tiene además Method, Class, Field, Parameter y otros, y puedes combinar varios con |. Con AllowMultiple = true, un atributo puede aparecer varias veces en el mismo sitio.
  • IsDefined(typeof(LabelAttribute)) solo comprueba si el atributo está ahí, sin crearlo. Lo usarás en la siguiente tarjeta.

Míralo

Cómo encuentra xUnit las pruebas

Aquí tienes un xUnit en miniatura. El atributo [Check] es para métodos, y la clase de pruebas comprueba el descuento de la primera lección del módulo (con el viejo error price > 100):
[AttributeUsage(AttributeTargets.Method)]
class CheckAttribute : Attribute
{
}
class DiscountChecks
{
[Check]
public void TenPercentOff()
{
Expect(45.0, Discount.Apply(50, 10));
}
[Check]
public void ExtraFivePercentFrom100()
{
Expect(85.0, Discount.Apply(100, 10));
}
void Expect(double expected, double actual)
{
if (expected != actual)
{
throw new Exception("esperaba " + expected + ", pero es " + actual);
}
}
}
static void RunChecks(Type type)
{
int passed = 0;
int failed = 0;
foreach (MethodInfo method in type.GetMethods().OrderBy(m => m.Name))
{
if (!method.IsDefined(typeof(CheckAttribute)))
{
continue;
}
object instance = Activator.CreateInstance(type);
try
{
method.Invoke(instance, null);
Console.WriteLine("OK " + method.Name);
passed++;
}
catch (TargetInvocationException e)
{
Console.WriteLine("FALLA " + method.Name + ": " + e.InnerException.Message);
failed++;
}
}
Console.WriteLine("Comprobaciones: " + (passed + failed) + ", pasan: " + passed + ", fallan: " + failed);
}
Salida
FALLA ExtraFivePercentFrom100: esperaba 85, pero es 90
OK TenPercentOff
Comprobaciones: 2, pasan: 1, fallan: 1

xUnit hace lo mismo, solo que con muchos más detalles:

  1. obtiene los métodos públicos de la clase con GetMethods() y los ordena con OrderBy, para que el orden sea siempre el mismo;
  2. se salta los que no tienen el atributo: IsDefined comprueba si lo tienen;
  3. crea un objeto nuevo para cada prueba con Activator.CreateInstance, así las pruebas no se estorban entre sí;
  4. llama al método con Invoke. Si la prueba lanza una excepción, Invoke la envuelve en una TargetInvocationException, y la verdadera está en InnerException.

GetMethods() también da los métodos públicos heredados de object (ToString, Equals y otros). No tienen el atributo y se saltan. Assert.Equal en xUnit también es un método que lanza una excepción cuando los valores no coinciden. Igual que Expect aquí.

Pruébalo

Las comprobaciones que desaparecieron

Las comprobaciones están escritas sin public, y todo lo demás es igual:

¿Qué imprimirá RunChecks?

class DiscountChecks
{
[Check]
void TenPercentOff() { ... }
[Check]
void ExtraFivePercentFrom100() { ... }
}
RunChecks(typeof(DiscountChecks));
Respuesta

Ejercicio

Código que funciona con cualquier clase

Resuelve las tareas en tu editor.

1. Todas las propiedades. Escribe PrintProperties(object obj), que imprima cada propiedad como nombre = valor, incluidas las heredadas, con las propiedades del padre primero. GetProperties() también da las heredadas, pero primero las de la subclase. Así que empieza por obj.GetType(), sube con BaseType hasta object y, para cada clase, toma solo sus propias propiedades con BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly.
class Vehicle
{
public string Plate { get; set; } = "1234 BCD";
public int Year { get; set; } = 2019;
}
class Truck : Vehicle
{
public double LoadTons { get; set; } = 12.5;
}
Salida para new Truck()
Plate = 1234 BCD
Year = 2019
LoadTons = 12.5
2. Mini JSON. Escribe ToJson(object obj), que devuelva las propiedades como JSON: los nombres entre comillas, el texto entre comillas, los números y true/false sin comillas. Usa una List<string> con string.Join, is string e is bool: false.ToString() es False, pero JSON necesita false. 45.0 en C# se imprime como 45. (La versión ya hecha es JsonSerializer.Serialize(obj) de System.Text.Json).
class Ticket
{
public int Seat { get; set; } = 14;
public string Event { get; set; } = "Concierto";
public double Price { get; set; } = 45.0;
public bool Vip { get; set; } = false;
}
Salida para new Ticket() y new Room()
{"Seat": 14, "Event": "Concierto", "Price": 45, "Vip": false}
{"Number": 214, "SeaView": true}
3. Tu propio ejecutor de pruebas. Crea los atributos [MyTest] y [MyBeforeEach] y un método Run(Type type). Para cada método con [MyTest]: un objeto nuevo, luego el método con [MyBeforeEach] (si lo hay) y luego la prueba. Si la prueba lanza una excepción, imprime el tipo (GetType().Name) y el mensaje de la excepción. Pruébalo con estas pruebas:
[AttributeUsage(AttributeTargets.Method)]
class MyTestAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Method)]
class MyBeforeEachAttribute : Attribute
{
}
class Account
{
public int Balance { get; private set; }
public Account(int balance)
{
Balance = balance;
}
public void Withdraw(int amount)
{
if (amount > Balance)
{
throw new ArgumentException("Faltan " + (amount - Balance) + " €");
}
Balance -= amount;
}
}
class AccountChecks
{
private Account account;
[MyBeforeEach]
public void Fresh()
{
account = new Account(100);
}
[MyTest]
public void Withdraw40()
{
account.Withdraw(40);
if (account.Balance != 60) throw new Exception("esperaba 60, pero es " + account.Balance);
}
[MyTest]
public void Withdraw70()
{
account.Withdraw(70);
if (account.Balance != 30) throw new Exception("esperaba 30, pero es " + account.Balance);
}
[MyTest]
public void WithdrawTooMuch()
{
account.Withdraw(130);
}
}
Salida
OK Withdraw40
OK Withdraw70
FALLA WithdrawTooMuch: ArgumentException: Faltan 30 €
Tests: 3, pasan: 2, fallan: 1

Test

Ponte a prueba

1. ¿Qué te da obj.GetType()?
2. ¿Qué necesitas para encontrar un campo private con GetField("price", …)?
3. En GetField("prise", …) hay una errata. ¿Cuándo te enterarás?
4. ¿Por qué RunChecks no encuentra las comprobaciones escritas sin public?
5. ¿Cómo encuentra xUnit los métodos con [Fact]?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·