Reflection и атрибути
Накратко: Reflection е достъпът на програмата до собствените ѝ типове, докато работи: свойства и методи по име, а атрибутите са бележки, които тя може да прочете.
Проблем
Свойството, което ToString забрави
ToString на поръчката е писан на ръка. По-късно някой добавя свойство за отстъпката:
class Order{ public int Number { get; set; } = 1042; public string Customer { get; set; } = "Мария Петрова"; public double Total { get; set; } = 86.40; public int Discount { get; set; } = 10; // добавено по-късно
public override string ToString() { return "Order[Number=" + Number + ", Customer=" + Customer + ", Total=" + Total + "]"; }}
Console.WriteLine(new Order());Order[Number=1042, Customer=Мария Петрова, Total=86.4]Отстъпката я няма в изхода, защото никой не се е сетил да обнови ToString. При десет класа това се случва постоянно.
[Fact] над тестов метод (в NUnit е [Test]), xUnit го намира и пуска, макар че никога не е виждал твоя клас. Как? И двете неща стават с едно и също: програмата може да разглежда собствените си класове, докато работи.Идеята
Класът като обект
За всеки тип .NET пази обект от тип Type, който го описва: име, родител, интерфейси, свойства, полета, методи, конструктори. Достъпът до него се казва reflection (отражение: програмата се вижда като в огледало). Класовете за частите на типа (PropertyInfo, MethodInfo и др.) са в System.Reflection. Има три начина да вземеш обекта Type:
typeof(Order), когато знаеш класа, докато пишеш кода;obj.GetType(), когато имаш обект (дава истинския му клас, не типа на променливата);Type.GetType("System.Collections.ArrayList"), когато имаш само името като текст.
Type type = Type.GetType("System.Collections.ArrayList");Console.WriteLine("Име: " + type.FullName);Console.WriteLine("Кратко име: " + type.Name);Console.WriteLine("Родител: " + type.BaseType.FullName);foreach (Type parent in type.GetInterfaces().OrderBy(t => t.Name)){ Console.WriteLine("Интерфейс: " + parent.FullName);}Име: System.Collections.ArrayListКратко име: ArrayListРодител: System.ObjectИнтерфейс: System.ICloneableИнтерфейс: System.Collections.ICollectionИнтерфейс: System.Collections.IEnumerableИнтерфейс: System.Collections.IListArrayList е старият списък без тип на елементите, отпреди List<T>. Ако тип с такова име няма, Type.GetType не хвърля, а връща null. Интерфейсите са подредени по име с OrderBy, защото редът на GetInterfaces() не е обещан. Името на генеричен клас завършва с ` и броя на типовите му параметри: typeof(List<string>).Name е List`1.Виж го
Свойствата на всеки обект
GetProperties() дава публичните свойства на класа като обекти PropertyInfo (от System.Reflection). Всеки знае името и типа си и може да прочете стойността си от даден обект: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) = Мария Петрова Total (Double) = 86.4 Discount (Int32) = 10Room: Number (Int32) = 214 SeaView (Boolean) = TrueDiscount вече е там. В C# данните на класа се виждат отвън като свойства, а полетата остават скрити. Затова reflection тук, а и библиотеките като System.Text.Json и Entity Framework, гледат свойствата. Те са публични и нищо не трябва да се отключва, както setAccessible в Java. Типовете са с имената си в .NET: int е Int32, double е Double, bool е 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)); }}Стъпка по стъпка, с думи
- Dump получава обект Order. Полетата му са скрити, а отвън се виждат свойствата.
- GetType() дава обекта Type, който описва класа Order.
- Първият ред е краткото име на класа.
- GetProperties() дава 4 обекта PropertyInfo: всеки знае името и типа на свойството.
- GetValue(obj) вика get на свойството Number и връща стойността: 1042. Int32 е името на int в .NET.
- Customer (String) = Мария Петрова.
- Total (Double) = 86.4.
- Discount: свойството, което ToString забрави, излиза само, защото reflection взима всички свойства.
- Методът не знае нищо за Order. За Room печата Number и SeaView по същия начин.
По-точно, за любопитните
GetProperties()дава само публичните свойства, и наследените. Други неща се искат сBindingFlags:BindingFlags.Instance | BindingFlags.NonPublicдава частните, аBindingFlags.DeclaredOnlyсамо тези на самия клас, без наследените.- Зад всяко автоматично свойство (
{ get; set; }) компилаторът слага скрито частно поле. Ето полетата наRoom, поискани сGetFields:
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. Днешният .NET ги връща в реда от класа, но код, който разчита на реда, е крехък.
Пробвай
Чий клас?
Какво ще отпечата програмата?
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 е променлива от тип Document, но GetType() дава истинския клас на обекта: Invoice. Негов родител (BaseType) е Document. Document не изрежда родител след :, затова родителят му е Object (object в C#), общият родител от модула за наследяването. За самия object BaseType е null: над него няма нищо.Виж го
Обект, метод и частно поле по име
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, "Кафе", 10.0);
MethodInfo discount = type.GetMethod("ApplyDiscount");discount.Invoke(coffee, new object[] { 20 });
FieldInfo price = type.GetField("price", BindingFlags.Instance | BindingFlags.NonPublic);Console.WriteLine("Цена след отстъпка: " + price.GetValue(coffee));price.SetValue(coffee, 9.5);
MethodInfo label = type.GetMethod("Label", BindingFlags.Instance | BindingFlags.NonPublic);Console.WriteLine(label.Invoke(coffee, null));Цена след отстъпка: 8Кафе: 9.5 €Activator.CreateInstance(type, "Кафе", 10.0)намира конструктора по аргументите и го вика. Връщаobject, затова е cast-ът къмProduct. Конструктор по типовете на параметрите се намира и сtype.GetConstructor(new[] { typeof(string), typeof(double) }).GetMethod("ApplyDiscount")намира публичния метод, аInvoke(обект, аргументи)го вика върхуcoffee. Аргументите са масивobject[], а без аргументи се подаваnull.- Частното се търси с
BindingFlags.Instance | BindingFlags.NonPublic. Без тяхGetField("price")връщаnull. Нещо катоsetAccessibleне трябва: щом е намерено, частното поле се чете и пише, а частният метод се вика.
Проверени изключения в C# няма, затова Main не обявява нищо. Ако името е грешно, GetField и GetMethod не хвърлят, а връщат null.
По-точно, за любопитните
List<string>, създаден и попълнен, без в кода да има new List или .Add:Type type = typeof(List<>).MakeGenericType(typeof(string));object list = Activator.CreateInstance(type);MethodInfo add = type.GetMethod("Add");add.Invoke(list, new object[] { "Кафе" });add.Invoke(list, new object[] { "Чай" });Console.WriteLine(type);Console.WriteLine(string.Join(", ", (List<string>)list));System.Collections.Generic.List`1[System.String]Кафе, Чайtypeof(List<>) е генеричният клас без типов параметър, а MakeGenericType прави от него List<string>. За разлика от Java, в .NET типът на елементите остава и докато програмата работи: Add на този списък приема само string. Ако подадеш число на Invoke, той хвърля:
ArgumentException: Object of type 'System.Int32' cannot be converted to type 'System.String'.Пробвай
Цената, която я няма
NullReferenceException на третия ред. На кой ред е грешката?Избери реда с грешката.
Полето се казва price, а тук пише "prise". За компилатора това е обикновен текст, затова не казва нищо. Такова поле няма, но GetField не хвърля, а връща null. Грешката излиза ред по-късно, когато GetValue се вика върху null:
System.NullReferenceException: Object reference not set to an instance of an object.Това е главната цена на reflection:
- компилаторът не проверява имената и типовете, а редакторът не ги намира при преименуване;
- кодът е по-бавен от обикновено извикване и по-труден за четене;
-
BindingFlags.NonPublicзаобикаля капсулацията: можеш да счупиш правило, което класът пази.
System.Text.Json. В обикновения код се пише рядко.Идеята
Свои атрибути
[Obsolete], [Fact], [HttpGet]. В Java същото се казва анотации и се пише с @. Сами по себе си не правят нищо. Значение им дава код, който ги чете, често през reflection. Можеш да направиш и свой: атрибутът е клас, наследник на Attribute. Този слага етикет на свойство, а методът печата само свойствата с етикет:[AttributeUsage(AttributeTargets.Property)]class LabelAttribute : Attribute{ public string Text { get; }
public LabelAttribute(string text) { Text = text; }}class Booking{ [Label("Номер")] public int Number { get; set; } = 501;
[Label("Гост")] public string Guest { get; set; } = "Иван Георгиев";
[Label("Нощувки")] 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)); } }}Номер: 501Гост: Иван ГеоргиевНощувки: 3class LabelAttribute : Attributeобявява атрибут. Името по уговорка завършва наAttribute, а при употреба се пише без него:[Label("Гост")]. Аргументът в скобите отива в конструктора, аTextе свойство само за четене.[AttributeUsage(AttributeTargets.Property)]казва къде може да се слага: тук само на свойства.[Label]над метод е грешка при компилиране (CS0592).- Нещо като
@Retentionв Java тук няма: атрибутите винаги остават в компилираната програма и reflection ги вижда. property.GetCustomAttribute<LabelAttribute>()връща атрибута илиnull, ако свойството го няма.InternalNoteняма етикет и не се печата.
По-точно, за любопитните
- Атрибутът може да има и свойства със
set. Те се задават по име след аргументите:[Label("Гост", Hint = "име и фамилия")], акоLabelAttributeима свойствоHint. Свойство без стойност по име остава със стойността си от класа. В Java същото е@Label(value = "Гост", hint = "…")сdefaultза стойността по подразбиране. AttributeTargetsима ощеMethod,Class,Field,Parameterи други, а няколко се съчетават с|. СAllowMultiple = trueедин атрибут може да стои няколко пъти на едно място.IsDefined(typeof(LabelAttribute))само проверява дали атрибутът е там, без да го създава. Ще го ползваме в следващата карта.
Виж го
Как xUnit намира тестовете
[Check] е за методи, а тестовият клас проверява отстъпката от първия урок на модула (със старата грешка 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("очаквах " + expected + ", а е " + 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("ОК " + method.Name); passed++; } catch (TargetInvocationException e) { Console.WriteLine("ПАДА " + method.Name + ": " + e.InnerException.Message); failed++; } } Console.WriteLine("Проверки: " + (passed + failed) + ", минали: " + passed + ", паднали: " + failed);}ПАДА ExtraFivePercentFrom100: очаквах 85, а е 90ОК TenPercentOffПроверки: 2, минали: 1, паднали: 1Това прави и xUnit, само с много повече подробности:
- взима публичните методи на класа с
GetMethods()и ги подрежда сOrderBy, за да е редът един и същ; - пропуска тези без атрибута:
IsDefinedпроверява дали го имат; - прави нов обект за всеки тест с
Activator.CreateInstance, затова тестовете не си пречат; - вика метода с
Invoke. Ако тестът хвърли,Invokeобвива изключението вTargetInvocationException, а истинското е вInnerException.
GetMethods() дава и публичните методи, наследени от object (ToString, Equals и др.). Те нямат атрибута и се пропускат. Assert.Equal в xUnit също е метод, който хвърля изключение, когато стойностите се различават. Точно като Expect тук.
Пробвай
Проверките, които изчезнаха
public, всичко останало е същото:Какво ще отпечата RunChecks?
class DiscountChecks{ [Check] void TenPercentOff() { ... }
[Check] void ExtraFivePercentFrom100() { ... }}
RunChecks(typeof(DiscountChecks));public в C# е private. GetMethods() без BindingFlags дава само публичните методи и не вижда нито една проверка. Програмата се компилира и работи, просто не прави нищо. Затова xUnit и NUnit искат тестовите методи да са public. Нещо като @Retention в Java тук не трябва: атрибутът си е там, просто методът не се вижда.Упражнение
Код, който работи с всеки клас
Реши задачите в редактора си.
1. Всички свойства. НапишиPrintProperties(object obj), който печата всяко свойство като име = стойност, включително наследените, като свойствата на родителя са първи. GetProperties() дава и наследените, но първо тези на наследника. Затова тръгни от obj.GetType(), качвай се с BaseType до object и за всеки клас вземи само неговите свойства с BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly.class Vehicle{ public string Plate { get; set; } = "СА 1234 ВК"; public int Year { get; set; } = 2019;}
class Truck : Vehicle{ public double LoadTons { get; set; } = 12.5;}Plate = СА 1234 ВКYear = 2019LoadTons = 12.5ToJson(object obj), който връща свойствата като JSON: имената в кавички, текстът в кавички, числата и true/false без кавички. Ползвай List<string> със string.Join, is string и is bool: false.ToString() е False, а в JSON трябва false. 45.0 в C# се печата като 45. (Готовото е JsonSerializer.Serialize(obj) от System.Text.Json.)class Ticket{ public int Seat { get; set; } = 14; public string Event { get; set; } = "Концерт"; public double Price { get; set; } = 45.0; public bool Vip { get; set; } = false;}{"Seat": 14, "Event": "Концерт", "Price": 45, "Vip": false}{"Number": 214, "SeaView": true}[MyTest] и [MyBeforeEach] и метод Run(Type type). За всеки метод с [MyTest]: нов обект, после методът с [MyBeforeEach] (ако има), после тестът. Ако тестът хвърли, отпечатай вида (GetType().Name) и съобщението на изключението. Пробвай го с тези тестове:[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("Не стигат " + (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("очаквах 60, а е " + account.Balance); }
[MyTest] public void Withdraw70() { account.Withdraw(70); if (account.Balance != 30) throw new Exception("очаквах 30, а е " + account.Balance); }
[MyTest] public void WithdrawTooMuch() { account.Withdraw(130); }}ОК Withdraw40ОК Withdraw70ПАДА WithdrawTooMuch: ArgumentException: Не стигат 30 €Тестове: 3, минали: 2, паднали: 1Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Списъци · Ламбди и делегати · LINQ · Тестове с xUnit
- Продължава в: MVC: модел, изглед, контролер · Изгледи с Razor · WebAPI: данни за други програми
- Задачи по темата: Програми в истинския свят