C#'ta Attribute ve Reflection: Kendi Attribute'unu Yazmak ve Okumak
Attribute'un metadata'da durup okunduğunda oluşması, Obsolete ve CS0618, AttributeUsage, positional ve named argümanlar, decimal'in attribute parametresi olamaması ve CS0181, reflection ile küçük bir validator, IDiscountRule uygulayan tipleri keşfedip Activator ve Invoke ile çalıştırmak, BenchmarkDotNet ile GetValue, cache'lenmiş PropertyInfo ve typed delegate karşılaştırması, trimming ve source generator sınırları.
Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.
Önce Tahmin Et
Kendi attribute'umuzu yazdık ve constructor'ına bir Console.WriteLine koyduk. Book class'ı bu attribute ile işaretli. Program bir kitap oluşturuyor, sonra attribute'u iki kez okuyor. "attribute constructed" satırı kaç kez ve nerede çıkar? Son satır ne yazdırır?
using System.Reflection;
Console.WriteLine("start");
Book book = new Book();
Console.WriteLine("book created");
DiscountAttribute? first = typeof(Book).GetCustomAttribute<DiscountAttribute>();
DiscountAttribute? second = typeof(Book).GetCustomAttribute<DiscountAttribute>();
Console.WriteLine(ReferenceEquals(first, second));
[Discount(10)]
public class Book
{
}
public sealed class DiscountAttribute : Attribute
{
public DiscountAttribute(int percent)
{
Percent = percent;
Console.WriteLine($"attribute constructed: {percent}");
}
public int Percent { get; }
}
Attribute'u class'ın üstüne yazdığımız için kitapla birlikte oluşturulduğunu düşünebilirsin. Çıktıya bakalım.
Attribute: Koda Eklenen Bilgi
start
book created
attribute constructed: 10
attribute constructed: 10
False
Kitap oluşurken attribute'tan ses çıkmadı. Attribute, compiler'ın assembly'nin metadata'sına yazdığı bir bilgidir: "Book'un üstünde Discount var, argümanı 10". Runtime'da bu bilgi orada durur ve biri sorana kadar hiçbir object oluşturulmaz. GetCustomAttribute her çağrıldığında metadata'yı okur, constructor'ı çalıştırır ve yeni bir instance döndürür; iki çağrının sonucu farklı object'ler. Attribute'un constructor'ına iş mantığı koymamanın, onu basit bir veri taşıyıcısı olarak tutmanın sebebi bu.
Bir attribute'u iki farklı taraf okuyabilir. Bazılarını compiler okur ve derleme davranışını değiştirir; önceki yazıdaki NotNullWhen böyleydi, [Obsolete] da öyle:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Book book = new Book("Dune", 45.90m);
Console.WriteLine(book.PriceWithTax());
public class Book
{
public Book(string title, decimal price)
{
Title = title;
Price = price;
}
public string Title { get; }
public decimal Price { get; }
[Obsolete("Use PriceWithVat; the tax rate is no longer fixed.")]
public decimal PriceWithTax() => Price * 1.18m;
public decimal PriceWithVat(decimal rate) => Price * (1 + rate);
}
Program.cs(6,19): warning CS0618: 'Book.PriceWithTax()' is obsolete: 'Use PriceWithVat; the tax rate is no longer fixed.'
54.1620
Diğerlerini ise runtime'da framework'ler okur: ASP.NET Core [HttpGet]'e bakıp route kurar, System.Text.Json [JsonPropertyName]'e bakıp JSON field adını seçer, EF Core [Key]'e bakıp primary key'i bulur. Bunların hepsi aynı mekanizmayla çalışır: reflection.
Kendi Attribute'unu Yazmak
Bir attribute, System.Attribute'tan derive eden sıradan bir class'tır. Adı geleneksel olarak Attribute ile biter ve kullanırken bu son ek yazılmaz: DiscountAttribute, [Discount] olarak kullanılır. Nereye konabileceğini [AttributeUsage] belirler: class, method, property gibi hedefler, aynı yere birden çok kez konup konamayacağı (AllowMultiple, varsayılanı false) ve derived class'lara geçip geçmeyeceği (Inherited, varsayılanı true).
Attribute argümanları compile time'da metadata'ya yazıldığı için her tip kullanılamaz. Para için doğal seçim olan decimal bile:
[AttributeUsage(AttributeTargets.Property)]
public sealed class MinPriceAttribute : Attribute
{
public MinPriceAttribute(decimal value)
{
Value = value;
}
public decimal Value { get; }
}
public class Book
{
[MinPrice(0.99m)]
public decimal Price { get; set; }
}
Program.cs(14,6): error CS0181: Attribute constructor parameter 'value' has type 'decimal', which is not a valid attribute parameter type
Kullanılabilen tipler: bool, char, string, tamsayı tipleri, float ve double, Type, enum'lar, object ve bunların tek boyutlu array'leri. Değerler de sabit olmalı: bir değişken ya da new Book() veremezsin. decimal gerekiyorsa double ya da string alıp attribute'un içinde dönüştürürsün; aşağıdaki validator'da öyle yapacağız.
Reflection ile Attribute Okumak: Küçük Bir Validator
Reflection, runtime'da tiplerin yapısını sorgulamaktır: bu tipin hangi property'leri var, tipleri ne, üstlerinde hangi attribute'lar duruyor, bu object'te değerleri ne. Giriş noktası Type object'i; typeof(Book) ile compile time'da bildiğin bir tipten, instance.GetType() ile elindeki object'in gerçek tipinden alırsın. Şimdi property'leri attribute'larla işaretleyip onları reflection ile denetleyen bir validator yazalım:
using System.Globalization;
using System.Reflection;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Book[] books =
{
new Book { Isbn = "9780441172719", Title = "Dune", Price = 45.90m, Stock = 3 },
new Book { Isbn = "123", Title = " ", Price = -5m, Stock = 2000 }
};
foreach (Book book in books)
{
List<string> errors = SimpleValidator.Validate(book);
Console.WriteLine(errors.Count == 0 ? $"{book.Isbn}: valid" : $"{book.Isbn}: {string.Join("; ", errors)}");
}
public class Book
{
[Length(13)]
public string Isbn { get; set; } = "";
[RequiredText]
public string Title { get; set; } = "";
[Between(0, 10_000, Message = "Price must not be negative")]
public decimal Price { get; set; }
[Between(0, 1_000)]
public int Stock { get; set; }
}
[AttributeUsage(AttributeTargets.Property)]
public sealed class RequiredTextAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Property)]
public sealed class LengthAttribute : Attribute
{
public LengthAttribute(int exact)
{
Exact = exact;
}
public int Exact { get; }
}
[AttributeUsage(AttributeTargets.Property)]
public sealed class BetweenAttribute : Attribute
{
// decimal kullanılamadığı için double
public BetweenAttribute(double min, double max)
{
Min = min;
Max = max;
}
public double Min { get; }
public double Max { get; }
public string? Message { get; set; }
}
public static class SimpleValidator
{
public static List<string> Validate(object instance)
{
List<string> errors = new List<string>();
foreach (PropertyInfo property in instance.GetType().GetProperties())
{
object? value = property.GetValue(instance);
if (property.GetCustomAttribute<RequiredTextAttribute>() is not null && string.IsNullOrWhiteSpace(value as string))
{
errors.Add($"{property.Name} is required");
}
if (property.GetCustomAttribute<LengthAttribute>() is { } length && value is string text && text.Length != length.Exact)
{
errors.Add($"{property.Name} must be {length.Exact} characters");
}
if (property.GetCustomAttribute<BetweenAttribute>() is { } between && value is not null)
{
double number = Convert.ToDouble(value, CultureInfo.InvariantCulture);
if (number < between.Min || number > between.Max)
{
errors.Add(between.Message ?? $"{property.Name} must be between {between.Min} and {between.Max}");
}
}
}
return errors;
}
}
9780441172719: valid
123: Isbn must be 13 characters; Title is required; Price must not be negative; Stock must be between 0 and 1000
Attribute'ları kullanırken iki tür argüman gördün. Between(0, 10_000)'deki değerler constructor'a sırayla giden positional argümanlar; Message = "..." ise public bir property'ye isimle atanan named argüman. Zorunlu bilgiyi constructor'a, isteğe bağlı olanı property'ye koymak yerleşik bir alışkanlık.
SimpleValidator hiçbir tipi tanımıyor; yalnızca "her property'ye bak, üstündeki attribute'a göre karar ver" diyor. Aynı method Customer ya da Order için de çalışır. ASP.NET Core'un model validation'ı tam olarak bu fikrin olgun hâlidir ve hazır attribute'ları System.ComponentModel.DataAnnotations'ta durur; gerçek projede kendi validator'ını değil onları kullanırsın. Bu örneğin amacı mekanizmayı görmek.
Tipleri Keşfetmek ve Çağırmak
Reflection yalnızca okumaz; tipleri bulabilir, onlardan object üretebilir ve method'larını çağırabilir. İndirim kurallarını bir list'e elle eklemek yerine, assembly'deki IDiscountRule uygulayan bütün class'ları bulalım ve attribute'larındaki sıraya göre uygulayalım:
using System.Globalization;
using System.Reflection;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
List<IDiscountRule> rules = typeof(Program).Assembly.GetTypes()
.Where(type => typeof(IDiscountRule).IsAssignableFrom(type) && type is { IsClass: true, IsAbstract: false })
.Select(type => (Type: type, Info: type.GetCustomAttribute<DiscountRuleAttribute>()))
.Where(rule => rule.Info is { Enabled: true })
.OrderBy(rule => rule.Info!.Order)
.Select(rule => (IDiscountRule)Activator.CreateInstance(rule.Type)!)
.ToList();
decimal price = 100m;
foreach (IDiscountRule rule in rules)
{
price = rule.Apply(price);
Console.WriteLine($"{rule.GetType().Name,-16} {price}");
}
MethodInfo apply = typeof(CouponRule).GetMethod(nameof(CouponRule.Apply))!;
object? result = apply.Invoke(new CouponRule(), new object[] { 20m });
Console.WriteLine($"invoked: {result}");
public interface IDiscountRule
{
decimal Apply(decimal price);
}
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class DiscountRuleAttribute : Attribute
{
public DiscountRuleAttribute(int order)
{
Order = order;
}
public int Order { get; }
public bool Enabled { get; set; } = true;
}
[DiscountRule(2)]
public class CouponRule : IDiscountRule
{
public decimal Apply(decimal price) => Math.Max(0, price - 15m);
}
[DiscountRule(1)]
public class MemberRule : IDiscountRule
{
public decimal Apply(decimal price) => price * 0.90m;
}
[DiscountRule(3, Enabled = false)]
public class BlackFridayRule : IDiscountRule
{
public decimal Apply(decimal price) => price * 0.50m;
}
MemberRule 90.00
CouponRule 75.00
invoked: 5
Yeni bir kural eklemek artık yalnızca yeni bir class yazmak demek; listeyi kuran koda dokunmuyorsun. BlackFridayRule attribute'unda kapatıldığı için hiç uygulanmadı. Activator.CreateInstance parametresiz constructor ile object oluşturdu; MethodInfo.Invoke ise argümanları bir object[] içinde aldı ve sonucu object olarak döndürdü. Dependency injection container'ları bu tekniğin daha gelişmiş bir hâlini kullanır: constructor parametrelerine reflection ile bakıp hangi service'leri vereceğine karar verirler.
Reflection'ın Bedeli
Bu esnekliğin bir fiyatı var. Bin kitabın fiyatlarını dört farklı yolla toplayıp BenchmarkDotNet ile ölçtüm: doğrudan property erişimi, getter'dan bir kez oluşturulup saklanan bir delegate, saklanan bir PropertyInfo ile GetValue ve her seferinde GetProperty ile arayıp GetValue:
[MemoryDiagnoser]
[InProcess]
public class PropertyAccessBenchmarks
{
private readonly Book[] _books = Enumerable.Range(1, 1_000)
.Select(i => new Book { Title = $"Book {i}", Price = i % 50 + 0.99m })
.ToArray();
private readonly PropertyInfo _priceProperty = typeof(Book).GetProperty(nameof(Book.Price))!;
private readonly Func<Book, decimal> _priceGetter;
public PropertyAccessBenchmarks()
{
// getter method'undan bir kez typed delegate üret
_priceGetter = _priceProperty.GetMethod!.CreateDelegate<Func<Book, decimal>>();
}
[Benchmark(Baseline = true)]
public decimal Direct()
{
decimal total = 0;
foreach (Book book in _books)
{
total += book.Price;
}
return total;
}
[Benchmark]
public decimal CachedDelegate()
{
decimal total = 0;
foreach (Book book in _books)
{
total += _priceGetter(book);
}
return total;
}
[Benchmark]
public decimal CachedPropertyInfo()
{
decimal total = 0;
foreach (Book book in _books)
{
total += (decimal)_priceProperty.GetValue(book)!;
}
return total;
}
[Benchmark]
public decimal LookupEveryTime()
{
decimal total = 0;
foreach (Book book in _books)
{
total += (decimal)typeof(Book).GetProperty("Price")!.GetValue(book)!;
}
return total;
}
}
BenchmarkDotNet v0.15.8, macOS 27.0.1 (26A434) [Darwin 27.0.0]
Apple M5, 1 CPU, 10 logical and 10 physical cores
.NET SDK 10.0.402
[Host] : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|------------------- |----------:|----------:|----------:|------:|--------:|-------:|----------:|------------:|
| Direct | 2.783 us | 0.0220 us | 0.0205 us | 1.00 | 0.01 | - | - | NA |
| CachedDelegate | 3.889 us | 0.0060 us | 0.0050 us | 1.40 | 0.01 | - | - | NA |
| CachedPropertyInfo | 8.920 us | 0.0298 us | 0.0249 us | 3.20 | 0.02 | 3.8147 | 32000 B | NA |
| LookupEveryTime | 16.050 us | 0.0992 us | 0.0928 us | 5.77 | 0.05 | 3.8147 | 32000 B | NA |
Proje net6.0 hedefli, Release modda derlendi ve makinede .NET 6 runtime olmadığı için DOTNET_ROLL_FORWARD=Major ile .NET 8.0.25 üzerinde, in-process çalıştı. Mutlak sayılar makineden makineye değişir; oranlara bak.
GetValue doğrudan erişimden 3,2 kat yavaş ve her çağrıda 32 byte ayırıyor: decimal bir value type, object olarak döndürülürken box'lanıyor. Her seferinde GetProperty ile aramak süreyi neredeyse ikiye katlıyor. Getter'dan bir kez üretilen typed delegate ise doğrudan erişime çok yakın ve hiç allocation yapmıyor. Ders şu: reflection'ı keşif için kullan, sonucunu sakla. PropertyInfo'yu bir kez bul, sık çağrılacaksa ondan bir delegate üret. Serializer'lar ve ORM'ler tam olarak böyle çalışır.
Reflection Nerede, Ne Zaman
Günlük uygulama kodunda reflection'ı nadiren doğrudan yazarsın. Ama kullandığın framework'lerin çoğu onunla çalışır: model binding, validation, JSON serialization, dependency injection, EF Core'un modeli kurması. Reflection'ı anlamak, bu framework'lerin neden belirli kurallar koyduğunu da açıklar: public property ister, parametresiz constructor ister, attribute'a bakar.
İki modern sınır var. Birincisi trimming ve Native AOT: publish sırasında kullanılmayan kodu atan araçlar, yalnızca reflection ile erişilen bir member'ın kullanıldığını göremez ve onu silebilir. .NET bu durumlar için publish sırasında uyarılar üretir. İkincisi source generator'lar: reflection'ın runtime'da yaptığı keşfi compile time'a taşırlar. .NET 6 ile System.Text.Json'a gelen source generation desteği bunun bir örneği. Kendi source generator'ımızı serinin ilerleyen bir yazısında yazacağız.
Sık Düşülen Tuzaklar
Member adlarını string olarak yazmak. GetProperty("Price") property'nin adı değişince sessizce null döner ve ! yüzünden runtime'da patlar. Mümkün olduğunda nameof(Book.Price) kullan.
Sıraya güvenmek. GetProperties()'in property'leri belirli bir sırada döndüreceğinin garantisi yok; dokümantasyon bunu açıkça söyler. Bugün declaration sırasıyla geliyorlar diye bir CSV'nin kolon sırasını buna bağlama. Sıra önemliyse bir attribute ile kendin belirt.
Encapsulation'ı delmek. BindingFlags.NonPublic ile private field'lara ulaşabilirsin. Test ya da teşhis dışında bunu yapmak, kütüphanenin bir sonraki sürümünde kaybolacak bir iç ayrıntıya bağlanmak demektir.
Invoke'tan gelen exception'ı tanımamak. Reflection ile çağrılan method bir exception fırlatırsa onu doğrudan değil, TargetInvocationException'ın InnerException'ı olarak alırsın. catch bloğun buna göre yazılmalı.
Küçük Bir Deneme
★☆☆ SimpleValidator'a [Pattern("^978")] gibi bir attribute ekle ve Regex.IsMatch ile ISBN'in 978 ile başladığını denetle.
★★☆ DiscountRuleAttribute'a AllowMultiple = true ve bir string Campaign parametresi ekle. Aynı kuralı iki kampanyaya bağla ve GetCustomAttributes<DiscountRuleAttribute>() ile yalnızca "Summer" kampanyasının kurallarını uygula.
★★★ Validator'da her Validate çağrısında yapılan GetProperties ve GetCustomAttribute işini tip başına bir kez yapan bir cache yaz (Dictionary<Type, ...>). BenchmarkDotNet ile önce ve sonrayı karşılaştır.
Benchmark'taki en hızlı reflection yolu, bir method'dan delegate üretmekti. Peki runtime'da bir delegate'i sıfırdan, bir kod ağacı olarak kurmak mümkün mü? Bir sonraki yazıda expression tree'lere geçiyoruz: bir lambda'yı çalıştırılacak kod yerine incelenecek veri olarak tutmak ve EF Core'un LINQ sorgularını SQL'e nasıl çevirdiği.