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());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.
[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);}Name: System.Collections.ArrayListKurzname: ArrayListBasisklasse: System.ObjectInterface: System.ICloneableInterface: System.Collections.ICollectionInterface: System.Collections.IEnumerableInterface: System.Collections.IListArrayList 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());Order: Number (Int32) = 1042 Customer (String) = Maria Becker Total (Double) = 86.4 Discount (Int32) = 10Room: Number (Int32) = 214 SeaView (Boolean) = TrueDiscount 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
- Dump bekommt ein Order-Objekt. Seine Felder sind verborgen, von außen sieht man die Eigenschaften.
- GetType() liefert das Type-Objekt, das die Klasse Order beschreibt.
- Die erste Zeile ist der kurze Name der Klasse.
- GetProperties() liefert 4 PropertyInfo-Objekte: Jedes kennt Namen und Typ der Eigenschaft.
- GetValue(obj) ruft das get der Eigenschaft Number auf und gibt den Wert zurück: 1042. Int32 ist der .NET-Name von int.
- Customer (String) = Maria Becker.
- Total (Double) = 86.4.
- Discount: Die Eigenschaft, die ToString vergessen hat, erscheint von selbst, weil Reflection alle Eigenschaften holt.
- 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 mitBindingFlags:BindingFlags.Instance | BindingFlags.NonPublicliefert die privaten, undBindingFlags.DeclaredOnlynur die der Klasse selbst, ohne die geerbten.- Hinter jede automatische Eigenschaft (
{ get; set; }) setzt der Compiler ein verborgenes privates Feld. Hier sind die Felder vonRoom, abgefragt mitGetFields:
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 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);doc ist eine Variable vom Typ Document, aber GetType() liefert die echte Klasse des Objekts: Invoice. Ihre Basisklasse (BaseType) ist Document. Document nennt nach : keine Basisklasse, also ist seine Basisklasse Object (object in C#), die gemeinsame Basisklasse aus dem Modul „Vererbung und Polymorphie“. Für object selbst ist BaseType null: Darüber gibt es nichts.Sieh es dir an
Objekt, Methode und privates Feld über den Namen
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));Preis nach Rabatt: 8Kaffee: 9.5 €Activator.CreateInstance(type, "Kaffee", 10.0)findet den Konstruktor über die Argumente und ruft ihn auf. Es gibt einobjectzurück, deshalb steht da der Cast zuProduct. Einen Konstruktor über die Typen seiner Parameter findest du auch mittype.GetConstructor(new[] { typeof(string), typeof(double) }).GetMethod("ApplyDiscount")findet die öffentliche Methode, undInvoke(Objekt, Argumente)ruft sie aufcoffeeauf. Die Argumente sind ein Arrayobject[]; ohne Argumente übergibst dunull.- Privates suchst du mit
BindingFlags.Instance | BindingFlags.NonPublic. Ohne sie gibtGetField("price")nullzurück. Nichts wiesetAccessibleist 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
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));System.Collections.Generic.List`1[System.String]Kaffee, Teetypeof(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:
ArgumentException: Object of type 'System.Int32' cannot be converted to type 'System.String'.Probier es aus
Der Preis, den es nicht gibt
NullReferenceException ab. In welcher Zeile ist der Fehler?Wähle die Zeile mit dem Fehler.
Das Feld heißt price, aber hier steht "prise". Für den Compiler ist das gewöhnlicher Text, deshalb sagt er nichts. Ein solches Feld gibt es nicht, aber GetField wirft nichts, sondern gibt null zurück. Der Fehler zeigt sich eine Zeile später, wenn GetValue auf null aufgerufen wird:
System.NullReferenceException: Object reference not set to an instance of an object.Das ist der wichtigste Preis von Reflection:
- der Compiler prüft die Namen und Typen nicht, und der Editor findet sie nicht, wenn du etwas umbenennst;
- der Code ist langsamer als ein gewöhnlicher Aufruf und schwerer zu lesen;
-
BindingFlags.NonPublicumgeht die Kapselung: Du kannst eine Regel brechen, die die Klasse schützt.
System.Text.Json. In gewöhnlichem Code schreibst du sie selten.Die Idee
Eigene Attribute
[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)); } }}Nummer: 501Gast: Jonas WeberNächte: 3class LabelAttribute : Attributedeklariert ein Attribut. Nach Konvention endet der Name aufAttribute, und wenn du es benutzt, schreibst du es ohne diese Endung:[Label("Gast")]. Das Argument in den Klammern geht an den Konstruktor, undTextist 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
@Retentionin Java gibt es hier nicht: Attribute bleiben immer im kompilierten Programm, und Reflection sieht sie. property.GetCustomAttribute<LabelAttribute>()gibt das Attribut zurück, odernull, wenn die Eigenschaft es nicht hat.InternalNotehat kein Etikett und wird nicht ausgegeben.
Genauer, für Neugierige
- Ein Attribut kann auch Eigenschaften mit
sethaben. Du setzt sie über ihren Namen nach den Argumenten:[Label("Gast", Hint = "Vor- und Nachname")], wennLabelAttributeeine EigenschaftHinthat. Eine Eigenschaft, die du nicht setzt, behält ihren Wert aus der Klasse. In Java ist dasselbe@Label(value = "Gast", hint = "…"), mitdefaultfür den Standardwert. AttributeTargetshat außerdemMethod,Class,Field,Parameterund andere, und mehrere kannst du mit|kombinieren. MitAllowMultiple = truekann 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
[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);}FEHLER ExtraFivePercentFrom100: erwartet 85, ist aber 90OK TenPercentOffPrüfungen: 2, bestanden: 1, fehlgeschlagen: 1xUnit macht dasselbe, nur mit viel mehr Details:
- Es holt mit
GetMethods()die öffentlichen Methoden der Klasse und sortiert sie mitOrderBy, damit die Reihenfolge immer gleich ist; - es überspringt die ohne das Attribut:
IsDefinedprüft, ob sie es haben; - es erzeugt mit
Activator.CreateInstancefür jeden Test ein neues Objekt, damit sich die Tests nicht gegenseitig stören; - es ruft die Methode mit
Invokeauf. Wenn der Test wirft, verpacktInvokedie Exception in eineTargetInvocationException, und die echte steckt inInnerException.
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
public geschrieben, alles andere ist gleich:Was gibt RunChecks aus?
class DiscountChecks{ [Check] void TenPercentOff() { ... }
[Check] void ExtraFivePercentFrom100() { ... }}
RunChecks(typeof(DiscountChecks));public ist in C# private. GetMethods() ohne BindingFlags liefert nur die öffentlichen Methoden und sieht keine einzige Prüfung. Das Programm lässt sich kompilieren und läuft, es tut nur nichts. Deshalb wollen xUnit und NUnit, dass Testmethoden public sind. Etwas wie @Retention in Java brauchst du hier nicht: Das Attribut ist noch da, nur die Methode ist nicht sichtbar.Übung
Code, der mit jeder Klasse funktioniert
Löse die Aufgaben in deinem Editor.
1. Alle Eigenschaften. SchreibPrintProperties(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;}Plate = M-AB 1234Year = 2019LoadTons = 12.5ToJson(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;}{"Seat": 14, "Event": "Konzert", "Price": 45, "Vip": false}{"Number": 214, "SeaView": true}[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); }}OK Withdraw40OK Withdraw70FEHLER WithdrawTooMuch: ArgumentException: Es fehlen 30 €Tests: 3, bestanden: 2, fehlgeschlagen: 1Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Listen · Lambdas und Delegates · LINQ · Tests mit xUnit
- Geht weiter in: MVC: Model, View, Controller · Views mit Razor · WebAPI: Daten für andere Programme
- Aufgaben zum Thema: Programme in der echten Welt