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());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.
[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);}Nombre: System.Collections.ArrayListNombre corto: ArrayListPadre: System.ObjectInterfaz: System.ICloneableInterfaz: System.Collections.ICollectionInterfaz: System.Collections.IEnumerableInterfaz: System.Collections.IListArrayList 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());Order: Number (Int32) = 1042 Customer (String) = María López Total (Double) = 86.4 Discount (Int32) = 10Room: Number (Int32) = 214 SeaView (Boolean) = TrueDiscount 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
- Dump recibe un objeto Order. Sus campos están ocultos; desde fuera se ven sus propiedades.
- GetType() da el objeto Type que describe la clase Order.
- La primera línea es el nombre corto de la clase.
- GetProperties() da 4 objetos PropertyInfo: cada uno conoce el nombre y el tipo de la propiedad.
- GetValue(obj) llama al get de la propiedad Number y devuelve el valor: 1042. Int32 es el nombre de int en .NET.
- Customer (String) = María López.
- Total (Double) = 86.4.
- Discount: la propiedad que ToString olvidó aparece sola, porque la reflexión obtiene todas las propiedades.
- 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 conBindingFlags:BindingFlags.Instance | BindingFlags.NonPublicda las privadas, yBindingFlags.DeclaredOnlysolo 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 deRoom, pedidos conGetFields:
Room room = new Room();foreach (FieldInfo field in typeof(Room).GetFields(BindingFlags.Instance | BindingFlags.NonPublic)){ Console.WriteLine(field.Name + " = " + field.GetValue(room));}<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);doc es una variable de tipo Document, pero GetType() da la clase real del objeto: Invoice. Su padre (BaseType) es Document. Document no indica ningún padre después de :, así que su padre es Object (object en C#), el padre común del módulo de herencia. Para el propio object, BaseType es null: no hay nada por encima.Míralo
Objeto, método y campo privado por nombre
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));Precio con descuento: 8Café: 9.5 €Activator.CreateInstance(type, "Café", 10.0)encuentra el constructor por los argumentos y lo llama. Devuelve unobject, por eso está el cast aProduct. Un constructor también se puede encontrar por los tipos de sus parámetros contype.GetConstructor(new[] { typeof(string), typeof(double) }).GetMethod("ApplyDiscount")encuentra el método público, eInvoke(objeto, argumentos)lo llama sobrecoffee. Los argumentos son un arrayobject[]; sin argumentos se pasanull.- Lo privado se busca con
BindingFlags.Instance | BindingFlags.NonPublic. Sin ellas,GetField("price")devuelvenull. No hace falta nada parecido asetAccessible: 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
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));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:
ArgumentException: Object of type 'System.Int32' cannot be converted to type 'System.String'.Pruébalo
El precio que no está
NullReferenceException en la tercera línea. ¿En qué línea está el error?Elige la línea con el error.
El campo se llama price, pero aquí pone "prise". Para el compilador es texto normal, así que no dice nada. No existe ese campo, pero GetField no lanza nada: devuelve null. El error aparece una línea después, cuando se llama a GetValue sobre null:
System.NullReferenceException: Object reference not set to an instance of an object.Este es el precio principal de la reflexión:
- el compilador no comprueba los nombres ni los tipos, y el editor no los encuentra cuando cambias un nombre;
- el código es más lento que una llamada normal y más difícil de leer;
-
BindingFlags.NonPublicse salta la encapsulación: puedes romper una regla que la clase protege.
System.Text.Json. En el código normal se escribe poco.La idea
Tus propios atributos
[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)); } }}Número: 501Huésped: Carlos GómezNoches: 3class LabelAttribute : Attributedeclara un atributo. Por convención, el nombre termina enAttribute, y al usarlo se escribe sin esa parte:[Label("Huésped")]. El argumento entre paréntesis va al constructor, yTextes 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
@Retentionde Java: los atributos siempre se quedan en el programa compilado, y la reflexión los ve. property.GetCustomAttribute<LabelAttribute>()devuelve el atributo, onullsi la propiedad no lo tiene.InternalNoteno 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")], siLabelAttributetiene una propiedadHint. Una propiedad que no asignas conserva su valor de la clase. En Java, lo mismo es@Label(value = "Huésped", hint = "…"), condefaultpara el valor por defecto. AttributeTargetstiene ademásMethod,Class,Field,Parametery otros, y puedes combinar varios con|. ConAllowMultiple = 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
[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);}FALLA ExtraFivePercentFrom100: esperaba 85, pero es 90OK TenPercentOffComprobaciones: 2, pasan: 1, fallan: 1xUnit hace lo mismo, solo que con muchos más detalles:
- obtiene los métodos públicos de la clase con
GetMethods()y los ordena conOrderBy, para que el orden sea siempre el mismo; - se salta los que no tienen el atributo:
IsDefinedcomprueba si lo tienen; - crea un objeto nuevo para cada prueba con
Activator.CreateInstance, así las pruebas no se estorban entre sí; - llama al método con
Invoke. Si la prueba lanza una excepción,Invokela envuelve en unaTargetInvocationException, y la verdadera está enInnerException.
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
public, y todo lo demás es igual:¿Qué imprimirá RunChecks?
class DiscountChecks{ [Check] void TenPercentOff() { ... }
[Check] void ExtraFivePercentFrom100() { ... }}
RunChecks(typeof(DiscountChecks));public en C# es private. GetMethods() sin BindingFlags da solo los métodos públicos y no ve ni una comprobación. El programa compila y funciona, simplemente no hace nada. Por eso xUnit y NUnit quieren que los métodos de prueba sean public. Aquí no hace falta nada parecido a @Retention de Java: el atributo sigue ahí, simplemente el método no se ve.Ejercicio
Código que funciona con cualquier clase
Resuelve las tareas en tu editor.
1. Todas las propiedades. EscribePrintProperties(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;}Plate = 1234 BCDYear = 2019LoadTons = 12.5ToJson(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;}{"Seat": 14, "Event": "Concierto", "Price": 45, "Vip": false}{"Number": 214, "SeaView": true}[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); }}OK Withdraw40OK Withdraw70FALLA WithdrawTooMuch: ArgumentException: Faltan 30 €Tests: 3, pasan: 2, fallan: 1Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas · Lambdas y delegados · LINQ · Tests con xUnit
- Continúa en: MVC: modelo, vista, controlador · Vistas con Razor · WebAPI: datos para otros programas
- Ejercicios sobre el tema: Programas en el mundo real