Zum Inhalt
wannacode

Reflection und Attribute

Kurz gesagt: Reflection ist der Zugriff eines Programms auf seine eigenen Typen, während es läuft: Eigenschaften und Methoden über ihren Namen, und Attribute sind Notizen, die es lesen kann.

Problem

Die Eigenschaft, die ToString vergessen hat

ToString der Bestellung ist von Hand geschrieben. Später fügt jemand eine Eigenschaft für den Rabatt hinzu:

class Order
{
public int Number { get; set; } = 1042;
public string Customer { get; set; } = "Maria Becker";
public double Total { get; set; } = 86.40;
public int Discount { get; set; } = 10; // später hinzugefügt
public override string ToString()
{
return "Order[Number=" + Number + ", Customer=" + Customer + ", Total=" + Total + "]";
}
}
Console.WriteLine(new Order());
Ausgabe
Order[Number=1042, Customer=Maria Becker, Total=86.4]

Der Rabatt fehlt in der Ausgabe, weil niemand daran gedacht hat, ToString anzupassen. Bei zehn Klassen passiert das ständig.

Dazu kommt eine Frage vom Anfang des Moduls. Wenn du [Fact] über eine Testmethode schreibst (in NUnit ist es [Test]), findet xUnit sie und führt sie aus, obwohl es deine Klasse nie gesehen hat. Wie? Beides funktioniert mit derselben Idee: Ein Programm kann seine eigenen Klassen betrachten, während es läuft.

Die Idee

Die Klasse als Objekt

Für jeden Typ hält .NET ein Objekt vom Typ Type, das ihn beschreibt: Name, Basisklasse, Interfaces, Eigenschaften, Felder, Methoden, Konstruktoren. Der Zugriff darauf heißt Reflection (Spiegelung: Das Programm sieht sich wie in einem Spiegel). Die Klassen für die Teile eines Typs (PropertyInfo, MethodInfo und andere) liegen in System.Reflection. Es gibt drei Wege zum Type-Objekt:

  • typeof(Order), wenn du die Klasse schon beim Schreiben des Codes kennst;
  • obj.GetType(), wenn du ein Objekt hast (es liefert die echte Klasse des Objekts, nicht den Typ der Variable);
  • Type.GetType("System.Collections.ArrayList"), wenn du nur den Namen als Text hast.
Type type = Type.GetType("System.Collections.ArrayList");
Console.WriteLine("Name: " + type.FullName);
Console.WriteLine("Kurzname: " + type.Name);
Console.WriteLine("Basisklasse: " + type.BaseType.FullName);
foreach (Type parent in type.GetInterfaces().OrderBy(t => t.Name))
{
Console.WriteLine("Interface: " + parent.FullName);
}
Ausgabe
Name: System.Collections.ArrayList
Kurzname: ArrayList
Basisklasse: System.Object
Interface: System.ICloneable
Interface: System.Collections.ICollection
Interface: System.Collections.IEnumerable
Interface: System.Collections.IList
ArrayList ist die alte Liste ohne Typ der Elemente, aus der Zeit vor List<T>. Wenn es keinen Typ mit diesem Namen gibt, wirft Type.GetType nichts, sondern gibt null zurück. Die Interfaces sind mit OrderBy nach Namen sortiert, weil die Reihenfolge von GetInterfaces() nicht garantiert ist. Der Name einer generischen Klasse endet mit ` und der Anzahl ihrer Typparameter: typeof(List<string>).Name ist List`1.

Sieh es dir an

Die Eigenschaften jedes Objekts

GetProperties() liefert die öffentlichen Eigenschaften der Klasse als PropertyInfo-Objekte (aus System.Reflection). Jedes kennt seinen Namen und seinen Typ und kann seinen Wert aus einem gegebenen Objekt lesen:
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());
Ausgabe
Order:
Number (Int32) = 1042
Customer (String) = Maria Becker
Total (Double) = 86.4
Discount (Int32) = 10
Room:
Number (Int32) = 214
SeaView (Boolean) = True
Eine Methode gibt jedes Objekt aus, und die neue Eigenschaft Discount ist schon dabei. In C# sieht man die Daten einer Klasse von außen als Eigenschaften (Properties), und die Felder bleiben verborgen. Deshalb schaut Reflection hier auf die Eigenschaften, und Bibliotheken wie System.Text.Json und Entity Framework auch. Sie sind öffentlich, also muss nichts geöffnet werden, wie mit setAccessible in Java. Die Typen haben ihre .NET-Namen: int ist Int32, double ist Double, bool ist 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));
}
}
Schritt für Schritt, in Worten
  1. Dump bekommt ein Order-Objekt. Seine Felder sind verborgen, von außen sieht man die Eigenschaften.
  2. GetType() liefert das Type-Objekt, das die Klasse Order beschreibt.
  3. Die erste Zeile ist der kurze Name der Klasse.
  4. GetProperties() liefert 4 PropertyInfo-Objekte: Jedes kennt Namen und Typ der Eigenschaft.
  5. GetValue(obj) ruft das get der Eigenschaft Number auf und gibt den Wert zurück: 1042. Int32 ist der .NET-Name von int.
  6. Customer (String) = Maria Becker.
  7. Total (Double) = 86.4.
  8. Discount: Die Eigenschaft, die ToString vergessen hat, erscheint von selbst, weil Reflection alle Eigenschaften holt.
  9. Die Methode weiß nichts über Order. Für Room gibt sie Number und SeaView genauso aus.
Genauer, für Neugierige
  • GetProperties() liefert nur die öffentlichen Eigenschaften, auch die geerbten. Anderes verlangst du mit BindingFlags: BindingFlags.Instance | BindingFlags.NonPublic liefert die privaten, und BindingFlags.DeclaredOnly nur die der Klasse selbst, ohne die geerbten.
  • Hinter jede automatische Eigenschaft ({ get; set; }) setzt der Compiler ein verborgenes privates Feld. Hier sind die Felder von Room, abgefragt mit GetFields:
Room room = new Room();
foreach (FieldInfo field in typeof(Room).GetFields(BindingFlags.Instance | BindingFlags.NonPublic))
{
Console.WriteLine(field.Name + " = " + field.GetValue(room));
}
Ausgabe
<Number>k__BackingField = 214
<SeaView>k__BackingField = True
  • .NET garantiert die Reihenfolge der Eigenschaften nicht. Das heutige .NET liefert sie in der Reihenfolge der Klasse, aber Code, der sich auf die Reihenfolge verlässt, ist zerbrechlich.

Probier es aus

Wessen Klasse?

Was gibt das Programm aus?

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);
Antwort

Sieh es dir an

Objekt, Methode und privates Feld über den Namen

Mit Reflection kannst du ein Objekt erzeugen, eine Methode aufrufen und ein Feld ändern, wenn du nur ihre Namen kennst:
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, "Kaffee", 10.0);
MethodInfo discount = type.GetMethod("ApplyDiscount");
discount.Invoke(coffee, new object[] { 20 });
FieldInfo price = type.GetField("price", BindingFlags.Instance | BindingFlags.NonPublic);
Console.WriteLine("Preis nach Rabatt: " + price.GetValue(coffee));
price.SetValue(coffee, 9.5);
MethodInfo label = type.GetMethod("Label", BindingFlags.Instance | BindingFlags.NonPublic);
Console.WriteLine(label.Invoke(coffee, null));
Ausgabe
Preis nach Rabatt: 8
Kaffee: 9.5 €
  • Activator.CreateInstance(type, "Kaffee", 10.0) findet den Konstruktor über die Argumente und ruft ihn auf. Es gibt ein object zurück, deshalb steht da der Cast zu Product. Einen Konstruktor über die Typen seiner Parameter findest du auch mit type.GetConstructor(new[] { typeof(string), typeof(double) }).
  • GetMethod("ApplyDiscount") findet die öffentliche Methode, und Invoke(Objekt, Argumente) ruft sie auf coffee auf. Die Argumente sind ein Array object[]; ohne Argumente übergibst du null.
  • Privates suchst du mit BindingFlags.Instance | BindingFlags.NonPublic. Ohne sie gibt GetField("price") null zurück. Nichts wie setAccessible ist nötig: Sobald es gefunden ist, kann das private Feld gelesen und geschrieben und die private Methode aufgerufen werden.

C# hat keine geprüften Exceptions, deshalb deklariert Main nichts. Wenn der Name falsch ist, werfen GetField und GetMethod nichts, sondern geben null zurück.

Genauer, für Neugierige
Eine List<string>, erzeugt und gefüllt, ohne dass new List oder .Add im Code steht:
Type type = typeof(List<>).MakeGenericType(typeof(string));
object list = Activator.CreateInstance(type);
MethodInfo add = type.GetMethod("Add");
add.Invoke(list, new object[] { "Kaffee" });
add.Invoke(list, new object[] { "Tee" });
Console.WriteLine(type);
Console.WriteLine(string.Join(", ", (List<string>)list));
Ausgabe
System.Collections.Generic.List`1[System.String]
Kaffee, Tee

typeof(List<>) ist die generische Klasse ohne ihren Typparameter, und MakeGenericType macht daraus eine List<string>. Anders als in Java bleibt in .NET der Typ der Elemente auch zur Laufzeit erhalten: Add dieser Liste nimmt nur einen string. Wenn du Invoke eine Zahl übergibst, wirft es:

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

Probier es aus

Der Preis, den es nicht gibt

Das Programm lässt sich kompilieren, aber es bricht in der dritten Zeile mit einer NullReferenceException ab. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Eigene Attribute

Attribute sind Etiketten in eckigen Klammern: [Obsolete], [Fact], [HttpGet]. In Java heißt dasselbe Annotation und wird mit @ geschrieben. Für sich allein tun sie nichts. Bedeutung bekommen sie durch Code, der sie liest, oft über Reflection. Du kannst auch eigene machen: Ein Attribut ist eine Klasse, die von Attribute erbt. Dieses hier setzt ein Etikett an eine Eigenschaft, und die Methode gibt nur die Eigenschaften mit Etikett aus:
[AttributeUsage(AttributeTargets.Property)]
class LabelAttribute : Attribute
{
public string Text { get; }
public LabelAttribute(string text)
{
Text = text;
}
}
class Booking
{
[Label("Nummer")]
public int Number { get; set; } = 501;
[Label("Gast")]
public string Guest { get; set; } = "Jonas Weber";
[Label("Nächte")]
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));
}
}
}
Ausgabe
Nummer: 501
Gast: Jonas Weber
Nächte: 3
  • class LabelAttribute : Attribute deklariert ein Attribut. Nach Konvention endet der Name auf Attribute, und wenn du es benutzt, schreibst du es ohne diese Endung: [Label("Gast")]. Das Argument in den Klammern geht an den Konstruktor, und Text ist eine Eigenschaft, die man nur lesen kann.
  • [AttributeUsage(AttributeTargets.Property)] sagt, wo es stehen darf: hier nur an Eigenschaften. [Label] über einer Methode ist ein Fehler beim Kompilieren (CS0592).
  • Etwas wie @Retention in Java gibt es hier nicht: Attribute bleiben immer im kompilierten Programm, und Reflection sieht sie.
  • property.GetCustomAttribute<LabelAttribute>() gibt das Attribut zurück, oder null, wenn die Eigenschaft es nicht hat. InternalNote hat kein Etikett und wird nicht ausgegeben.
Genauer, für Neugierige
  • Ein Attribut kann auch Eigenschaften mit set haben. Du setzt sie über ihren Namen nach den Argumenten: [Label("Gast", Hint = "Vor- und Nachname")], wenn LabelAttribute eine Eigenschaft Hint hat. Eine Eigenschaft, die du nicht setzt, behält ihren Wert aus der Klasse. In Java ist dasselbe @Label(value = "Gast", hint = "…"), mit default für den Standardwert.
  • AttributeTargets hat außerdem Method, Class, Field, Parameter und andere, und mehrere kannst du mit | kombinieren. Mit AllowMultiple = true kann ein Attribut mehrmals an derselben Stelle stehen.
  • IsDefined(typeof(LabelAttribute)) prüft nur, ob das Attribut da ist, ohne es zu erzeugen. Du brauchst es in der nächsten Karte.

Sieh es dir an

Wie xUnit die Tests findet

Hier ist ein winziges xUnit. Das Attribut [Check] ist für Methoden, und die Testklasse prüft den Rabatt aus der ersten Lektion des Moduls (mit dem alten Fehler 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("erwartet " + expected + ", ist aber " + 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("FEHLER " + method.Name + ": " + e.InnerException.Message);
failed++;
}
}
Console.WriteLine("Prüfungen: " + (passed + failed) + ", bestanden: " + passed + ", fehlgeschlagen: " + failed);
}
Ausgabe
FEHLER ExtraFivePercentFrom100: erwartet 85, ist aber 90
OK TenPercentOff
Prüfungen: 2, bestanden: 1, fehlgeschlagen: 1

xUnit macht dasselbe, nur mit viel mehr Details:

  1. Es holt mit GetMethods() die öffentlichen Methoden der Klasse und sortiert sie mit OrderBy, damit die Reihenfolge immer gleich ist;
  2. es überspringt die ohne das Attribut: IsDefined prüft, ob sie es haben;
  3. es erzeugt mit Activator.CreateInstance für jeden Test ein neues Objekt, damit sich die Tests nicht gegenseitig stören;
  4. es ruft die Methode mit Invoke auf. Wenn der Test wirft, verpackt Invoke die Exception in eine TargetInvocationException, und die echte steckt in InnerException.

GetMethods() liefert auch die öffentlichen Methoden, die von object geerbt sind (ToString, Equals und andere). Sie haben das Attribut nicht und werden übersprungen. Assert.Equal in xUnit ist auch eine Methode, die eine Exception wirft, wenn sich die Werte unterscheiden. Genau wie Expect hier.

Probier es aus

Die Prüfungen, die verschwanden

Die Prüfungen sind ohne public geschrieben, alles andere ist gleich:

Was gibt RunChecks aus?

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

Übung

Code, der mit jeder Klasse funktioniert

Löse die Aufgaben in deinem Editor.

1. Alle Eigenschaften. Schreib PrintProperties(object obj), das jede Eigenschaft als Name = Wert ausgibt, auch die geerbten, und zwar die Eigenschaften der Basisklasse zuerst. GetProperties() liefert auch die geerbten, aber die der Unterklasse kommen zuerst. Fang deshalb bei obj.GetType() an, geh mit BaseType nach oben bis object und nimm für jede Klasse nur ihre eigenen Eigenschaften mit BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly.
class Vehicle
{
public string Plate { get; set; } = "M-AB 1234";
public int Year { get; set; } = 2019;
}
class Truck : Vehicle
{
public double LoadTons { get; set; } = 12.5;
}
Ausgabe für new Truck()
Plate = M-AB 1234
Year = 2019
LoadTons = 12.5
2. Mini-JSON. Schreib ToJson(object obj), das die Eigenschaften als JSON zurückgibt: die Namen in Anführungszeichen, Text in Anführungszeichen, Zahlen und true/false ohne Anführungszeichen. Benutze eine List<string> mit string.Join, is string und is bool: false.ToString() ist False, aber JSON braucht false. 45.0 wird in C# als 45 ausgegeben. (Die fertige Version ist JsonSerializer.Serialize(obj) aus System.Text.Json.)
class Ticket
{
public int Seat { get; set; } = 14;
public string Event { get; set; } = "Konzert";
public double Price { get; set; } = 45.0;
public bool Vip { get; set; } = false;
}
Ausgabe für new Ticket() und new Room()
{"Seat": 14, "Event": "Konzert", "Price": 45, "Vip": false}
{"Number": 214, "SeaView": true}
3. Dein eigener Test-Runner. Mach die Attribute [MyTest] und [MyBeforeEach] und eine Methode Run(Type type). Für jede Methode mit [MyTest]: ein neues Objekt, dann die Methode mit [MyBeforeEach] (falls es eine gibt), dann der Test. Wenn der Test wirft, gib die Art (GetType().Name) und die Meldung der Exception aus. Probier ihn mit diesen Tests aus:
[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("Es fehlen " + (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("erwartet 60, ist aber " + account.Balance);
}
[MyTest]
public void Withdraw70()
{
account.Withdraw(70);
if (account.Balance != 30) throw new Exception("erwartet 30, ist aber " + account.Balance);
}
[MyTest]
public void WithdrawTooMuch()
{
account.Withdraw(130);
}
}
Ausgabe
OK Withdraw40
OK Withdraw70
FEHLER WithdrawTooMuch: ArgumentException: Es fehlen 30 €
Tests: 3, bestanden: 2, fehlgeschlagen: 1

Test

Teste dich

1. Was liefert obj.GetType()?
2. Was brauchst du, um ein private Feld mit GetField("price", …) zu finden?
3. In GetField("prise", …) ist ein Tippfehler. Wann merkst du es?
4. Warum findet RunChecks keine Prüfungen, die ohne public geschrieben sind?
5. Wie findet xUnit die Methoden mit [Fact]?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·