Към съдържанието
wannacode

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.IList
ArrayList е старият списък без тип на елементите, отпреди 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) = 10
Room:
Number (Int32) = 214
SeaView (Boolean) = True
Един метод отпечатва всеки обект, и новото свойство Discount вече е там. В 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));
}
}
Стъпка по стъпка, с думи
  1. Dump получава обект Order. Полетата му са скрити, а отвън се виждат свойствата.
  2. GetType() дава обекта Type, който описва класа Order.
  3. Първият ред е краткото име на класа.
  4. GetProperties() дава 4 обекта PropertyInfo: всеки знае името и типа на свойството.
  5. GetValue(obj) вика get на свойството Number и връща стойността: 1042. Int32 е името на int в .NET.
  6. Customer (String) = Мария Петрова.
  7. Total (Double) = 86.4.
  8. Discount: свойството, което ToString забрави, излиза само, защото reflection взима всички свойства.
  9. Методът не знае нищо за 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);
Отговор

Виж го

Обект, метод и частно поле по име

През reflection можеш да създадеш обект, да извикаш метод и да промениш поле, като знаеш само имената им:
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 на третия ред. На кой ред е грешката?

Избери реда с грешката.

Идеята

Свои атрибути

Атрибутите са етикети в квадратни скоби: [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
Гост: Иван Георгиев
Нощувки: 3
  • class 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 намира тестовете

Ето малък 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, само с много повече подробности:

  1. взима публичните методи на класа с GetMethods() и ги подрежда с OrderBy, за да е редът един и същ;
  2. пропуска тези без атрибута: IsDefined проверява дали го имат;
  3. прави нов обект за всеки тест с Activator.CreateInstance, затова тестовете не си пречат;
  4. вика метода с 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));
Отговор

Упражнение

Код, който работи с всеки клас

Реши задачите в редактора си.

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;
}
Изход за new Truck()
Plate = СА 1234 ВК
Year = 2019
LoadTons = 12.5
2. Мини JSON. Напиши ToJson(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;
}
Изход за new Ticket() и new Room()
{"Seat": 14, "Event": "Концерт", "Price": 45, "Vip": false}
{"Number": 214, "SeaView": true}
3. Свой пускач на тестове. Направи атрибути [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

Тест

Провери се

1. Какво дава obj.GetType()?
2. Какво трябва, за да намериш private поле с GetField("price", …)?
3. В GetField("prise", …) има печатна грешка. Кога ще разбереш?
4. Защо RunChecks не намира проверки, написани без public?
5. Как xUnit намира методите с [Fact]?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·