Wszystkie wpisy

Refleksja i atrybuty w C# - System.Reflection, Type, Activator, własne atrybuty

Odczytywanie metadanych w trakcie wykonania, dynamiczne tworzenie obiektów i wywoływanie metod przez refleksję, atrybuty jako żółte karteczki dla kodu, projektowanie własnych atrybutów z AttributeUsage. Ósma część kursu C#.


W poprzednim wpisie Wyjątki i IDisposable w C# opanowałeś obsługę błędów i deterministyczne sprzątanie zasobów. Teraz wchodzimy na poziom meta - kod, który patrzy w lustro i czyta sam siebie. To refleksja (System.Reflection) plus jej naturalny partner: atrybuty - znaczniki przyklejane do klas, metod i właściwości.

W tej części zobaczysz, jak C# pozwala odczytać strukturę swojego własnego kodu w trakcie wykonania, jak dynamicznie tworzyć obiekty i wywoływać metody nieznane w momencie kompilacji oraz jak projektować własne atrybuty zgodnie z konwencjami Microsoftu.

1. Czym jest refleksja #

Co to jest: refleksja to mechanizm odczytywania metadanych typu - jego nazwy, klas bazowych, metod, właściwości, pól, atrybutów - w trakcie wykonania programu. Wszystko, co kompilator zapisał o typie, możesz przeczytać programowo.

Dlaczego istnieje: każdy plik DLL/EXE .NET zawiera opis siebie samego. Dokumentacja Microsoftu mówi to wprost: all .NET assemblies contain a specified set of metadata that describes the types and type members defined in the assembly. Refleksja to API, którym ten opis czytasz - z poziomu Twojego własnego kodu.

Wszystkie narzędzia żyją w przestrzeni nazw System.Reflection. Punktem wyjścia jest klasa Type - meta-opis każdego typu w systemie.

using System;
using System.Reflection;

public class User
{
    public string Name { get; set; }
    public void Greet() => Console.WriteLine($"Hello, I'm {Name}");
}

// Pobranie Type:
Type userType = typeof(User);
// Alternatywnie, jeśli masz już obiekt:
// Type userType = someUser.GetType();

Console.WriteLine($"Class name: {userType.Name}");

Console.WriteLine("Properties:");
foreach (PropertyInfo prop in userType.GetProperties())
{
    Console.WriteLine($"- {prop.Name} ({prop.PropertyType.Name})");
}

Console.WriteLine("Methods:");
foreach (MethodInfo method in userType.GetMethods())
{
    Console.WriteLine($"- {method.Name}");
}

typeof kontra GetType #

Dwa sposoby na ten sam Type, ale w innym momencie:

typeof(T)obj.GetType()
Kiedyprzy kompilacji (statyczny)przy wykonaniu (dynamiczny)
Co bierzenazwę typu jako stałąrzeczywisty typ instancji
Polimorfizmnie - zawsze Ttak - typ konkretny, nie deklarowany
Vehicle v = new Car();
Console.WriteLine(typeof(Vehicle));   // System.Vehicle - typ ZMIENNEJ
Console.WriteLine(v.GetType());       // System.Car - typ OBIEKTU

Reguła: gdy znasz typ w momencie pisania → typeof(T). Gdy masz obiekt i chcesz wiedzieć, czym naprawdę jestobj.GetType().

2. Dynamiczne tworzenie obiektów: Activator.CreateInstance #

Refleksja pozwala nie tylko czytać o kodzie, ale też go wykonywać, nie znając konkretnego typu w trakcie kompilacji. Wyobraź sobie, że nazwa klasy przychodzi ze stringa - z pliku konfiguracyjnego, z bazy, od użytkownika. Dynamiczne tworzenie to robota klasy Activator.

Type userType = typeof(User);

// 1. Stworzenie instancji - zwraca object, bo Activator nie zna T
object dynamicUser = Activator.CreateInstance(userType);

// 2. Dynamiczne ustawienie właściwości
PropertyInfo nameProperty = userType.GetProperty("Name");
nameProperty.SetValue(dynamicUser, "Zygmunt");

// 3. Dynamiczne wywołanie metody
MethodInfo greetMethod = userType.GetMethod("Greet");
greetMethod.Invoke(dynamicUser, null);
// Wypisze: Hello, I'm Zygmunt

Pod spodem Activator.CreateInstance wywołuje bezparametrowy konstruktor typu - jeśli go nie ma, rzuca wyjątek. Dla konstruktorów z parametrami istnieje przeciążenie Activator.CreateInstance(Type, object[] args) przyjmujące tablicę argumentów.

Invoke na metodzie: pierwszy argument to instancja (lub null dla metod statycznych), drugi to tablica object[] z argumentami metody (lub null, gdy metoda nie ma parametrów).

Wada refleksji: koszt #

Refleksja omija standardową ścieżkę kompilatora - JIT nie może jej zoptymalizować, każde wywołanie to dynamiczne wyszukanie w metadanych. Pojedyncze użycie jest niezauważalne, ale w pętli wykonującej miliony operacji - to wymierna strata wydajności.

Zasada ogólna: refleksja jest świetna do pluginów, deserializerów, mapperów ORM (sam EF Core używa jej intensywnie), kontenerów wstrzykiwania zależności i frameworków testów. Do codziennej logiki biznesowej - nie.

3. Atrybuty - żółte karteczki dla kodu #

Co to jest: atrybut to deklaratywna informacja dołączana do kodu - klasy, metody, właściwości, pola, parametru, a nawet całego pliku DLL. Cytat z dokumentacji MS: Attributes provide a powerful way to associate metadata, or declarative information, with code. Po prostu metadane - dodatkowy opis.

Dlaczego istnieją: atrybuty same w sobie nic nie robią. Są jak żółte karteczki Post-it przyklejone do kodu. Ich prawdziwa moc ujawnia się dopiero wtedy, gdy inny kod (zwykle przez refleksję) odczyta tę karteczkę i na nią zareaguje. To pozwala oddzielić logikę (np. walidację, autoryzację, routing) od miejsca, w którym pracuje - kod biznesowy zostaje czysty, a “okablowanie” leży w atrybutach.

Wbudowane atrybuty - już je znasz #

[Obsolete("Use NewMethod instead")]
public void OldMethod() { }              // kompilator pokaże ostrzeżenie

[Serializable]
public class Document { }                 // można serializować

public class User
{
    [Required]
    public string Email { get; set; }     // ASP.NET Core: pole obowiązkowe

    [MaxLength(100)]
    public string DisplayName { get; set; }  // ASP.NET / EF: maksymalna długość
}

[HttpPost("/api/users")]
public IActionResult CreateUser(User user) { ... }   // ASP.NET MVC: route

Składnia atrybutów #

Atrybut w kodzie podajesz w nawiasach kwadratowych [] nad deklaracją, której dotyczy:

[Serializable]          // sufiks "Attribute" można pominąć
public class Document
{
    [Required]                              // pojedynczy atrybut
    [MaxLength(100)]                        // kolejny pod nim
    public string Title { get; set; }

    [Required, MaxLength(5000)]             // można też oddzielić przecinkami
    public string Content { get; set; }
}

Pozycyjne kontra nazwane parametry #

Atrybut może przyjąć dwa typy argumentów:

  • Pozycyjne - argumenty konstruktora atrybutu. Obowiązkowe, kolejność istotna, podawane jako pierwsze.
  • Nazwane - publiczne właściwości lub pola atrybutu. Opcjonalne, kolejność dowolna, podawane po pozycyjnych w formie Nazwa = wartość.
// "user32.dll" - pozycyjny (z konstruktora)
// SetLastError, ExactSpelling - nazwane (właściwości klasy DllImportAttribute)
[DllImport("user32.dll", SetLastError = true, ExactSpelling = false)]
extern static void SampleMethod();

4. Tworzenie własnego atrybutu #

Wyobraźmy sobie system, w którym niektóre metody mogą być wywoływane tylko przez użytkowników o określonej roli. Zbudujmy atrybut [RequiresRole("Admin")] plus mechanizm sprawdzania.

Krok 1: Klasa atrybutu #

// Atrybut wolno przykleić tylko do metod
[AttributeUsage(AttributeTargets.Method)]
public class RequiresRoleAttribute : Attribute
{
    public string RoleName { get; }

    public RequiresRoleAttribute(string roleName)
    {
        RoleName = roleName;
    }
}

Trzy konwencje, których trzymamy się zgodnie z MS:

  1. Klasa dziedziczy po Attribute - to znak rozpoznawczy, że to atrybut.
  2. Nazwa kończy się sufiksem Attribute - RequiresRoleAttribute. Przy użyciu kompilator pozwoli pominąć sufiks: [RequiresRole("Admin")].
  3. [AttributeUsage(...)] ogranicza, gdzie atrybut wolno przykleić. AttributeTargets.Method mówi: tylko nad metodami. Wartości można łączyć przez |: AttributeTargets.Class | AttributeTargets.Struct. Z AllowMultiple = true można przyklejać wielokrotnie.

Krok 2: Dekorujemy kod #

public class AdminPanel
{
    // Bez atrybutu - dostępna dla każdego
    public void ShowStats()
    {
        Console.WriteLine("Statistics: 42 users online");
    }

    // Z atrybutem - wymaga roli "Admin"
    [RequiresRole("Admin")]
    public void DropDatabase()
    {
        Console.WriteLine("Database dropped! (BOOM)");
    }
}

Sama dekoracja nic nie zmienia w działaniu metody. DropDatabase() wywołane bezpośrednio nadal wywoła kod - atrybut to tylko metadana. Potrzebujemy czegoś, co tę metadaną odczyta i na nią zareaguje.

Krok 3: Strażnik z refleksją #

public static class MethodGuard
{
    public static void TryExecute(object target, string methodName, string userRole)
    {
        Type type = target.GetType();
        MethodInfo method = type.GetMethod(methodName);
        if (method == null) return;

        // Odczyt atrybutu - zwraca null, jeśli go nie ma
        RequiresRoleAttribute attr = method.GetCustomAttribute<RequiresRoleAttribute>();

        if (attr != null && attr.RoleName != userRole)
        {
            Console.WriteLine($"ERROR: Method requires role '{attr.RoleName}'");
            return;
        }

        method.Invoke(target, null);
    }
}

Krok 4: Test #

AdminPanel panel = new AdminPanel();

MethodGuard.TryExecute(panel, "ShowStats", "User");
// → Statistics: 42 users online (atrybutu brak, przepuszczone)

MethodGuard.TryExecute(panel, "DropDatabase", "User");
// → ERROR: Method requires role 'Admin' (atrybut blokuje)

MethodGuard.TryExecute(panel, "DropDatabase", "Admin");
// → Database dropped! (BOOM) (rola się zgadza)

To jest siła kombinacji atrybutów i refleksji: oddzieliliśmy logikę autoryzacji (w MethodGuard) od samej metody (DropDatabase). Kod biznesowy zostaje czysty - nie ma w nim if-ów sprawdzających role. Decyzja “kto może” jest deklaratywna, leży w atrybucie na metodzie.

Identyczna technika stoi za:

  • ASP.NET MVC: [Authorize(Roles = "Admin")] blokuje endpoint
  • xUnit / NUnit: [Fact] / [Test] oznacza metodę jako test
  • Entity Framework: [Key], [Required], [MaxLength] konfigurują schemat
  • Newtonsoft.Json / System.Text.Json: [JsonPropertyName("xyz")] mapuje nazwy pól

5. Pułapki produkcyjne #

Refleksja nie wykrywa private ze zwykłym GetMethod #

Domyślnie Type.GetMethod("Name") szuka tylko metod publicznych instancyjnych. Żeby dosięgnąć private, static lub dziedziczonych, podajesz BindingFlags:

MethodInfo m = type.GetMethod(
    "SecretMethod",
    BindingFlags.NonPublic | BindingFlags.Instance);

Wyjątki w Invoke są opakowane w TargetInvocationException #

Gdy metoda wywołana przez refleksję rzuci wyjątek, dostajesz go opakowanego w TargetInvocationException. Oryginalny wyjątek leży w InnerException:

try
{
    method.Invoke(target, null);
}
catch (TargetInvocationException ex)
{
    // ex.InnerException to ten "prawdziwy" wyjątek
    Console.WriteLine(ex.InnerException?.Message);
}

Wydajność: cachuj MethodInfo i PropertyInfo #

GetType().GetMethod("X") wykonuje wyszukanie w metadanych za każdym razem. Jeśli wywołujesz wielokrotnie tę samą metodę przez refleksję, cachuj wynik:

private static readonly MethodInfo _cachedMethod =
    typeof(SomeClass).GetMethod(nameof(SomeClass.SomeMethod));

Dla naprawdę “gorącej” ścieżki istnieje też możliwość kompilacji wyrażeń Expression<T> do delegata - praktycznie z prędkością zwykłego wywołania. To zaawansowana technika spotkanaprawie wyłącznie w wysokowydajnych frameworkach (np. AutoMapper).

Podsumowanie tematu #

W tej części kursu poznałeś:

  • Refleksja - czytanie metadanych typu w trakcie wykonania przez System.Reflection; Type jako punkt wyjścia
  • typeof(T) kontra obj.GetType() - pierwsze statyczne (kompilacja), drugie dynamiczne (typ obiektu w trakcie wykonania)
  • Activator.CreateInstance(type) - dynamiczne new dla typu poznanego dopiero w trakcie wykonania
  • MethodInfo.Invoke + PropertyInfo.SetValue/GetValue - dynamiczne wywołanie metody i ustawianie wartości właściwości
  • Atrybuty - deklaratywne metadane przyklejane do kodu, same nic nie robią - dopiero w połączeniu z refleksją
  • Konwencje atrybutów - sufiks Attribute, dziedziczenie po Attribute, [AttributeUsage(AttributeTargets.X)] na klasie atrybutu, pozycyjne (konstruktor) i nazwane (właściwości) parametry
  • Praktyczna kombinacja - dekorujemy metody, refleksja czyta atrybuty, oddzielamy logikę (auth, walidacja, routing) od kodu biznesowego
  • Pułapki - BindingFlags dla nie-publicznych, TargetInvocationException zawija prawdziwy wyjątek, kosztowność refleksji wymaga cache’owania w gorących ścieżkach

W następnym wpisie Architektura pluginów w C# zobaczysz najpotężniejsze zastosowanie refleksji w praktyce - ładowanie kodu z plików .dll, których Twoja aplikacja nigdy wcześniej nie widziała. To wzorzec stojący za rozszerzeniami Visual Studio, skryptami w Unity, modami w grach i auto-discovery kontrolerów w ASP.NET Core.

Quiz i zadanie poniżej. Zadanie łączy oba tematy z dzisiejszego wpisu: własny atrybut z AttributeUsage plus MethodGuard używający refleksji do odczytania i interpretacji tego atrybutu. To dokładnie ten sam wzorzec, który stoi za [Authorize] w ASP.NET Core.

Podobne wpisy

🔗 Linkują tu