Mert Özen Her Satırda Daha İleri
Backend C# İleri Seviye — #01

C#'ta Delegate, Func ve Action: Method'u Değer Gibi Taşımak

Mert Özen 7 Kas 2026 9 dk 2 görüntülenme
C#'ta Delegate, Func ve Action: Method'u Değer Gibi Taşımak

Delegate bir method'u tutan tiptir: MulticastDelegate'ten derive eden class, Method ve Target, Func/Action/Predicate, C# 10 ile lambda'nın doğal tipi ve CS8917, method'u parametre olarak geçirmek, multicast delegate'in son sonucu ve kesilen zincir, delegate tipleri arası dönüşüm ve variance, null delegate ve -= ile çıkmayan lambda tuzakları.

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

Bir indirim kuralı tanımlıyoruz, sonra += ile ikinci bir kural ekliyoruz. Fiyat 100. Ekrana ne çıkar: 90 mı, 95 mi, ikisi birden mi, yoksa iki indirim üst üste uygulanmış 85.5 mi?

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Func<decimal, decimal> discount = price => price * 0.90m;
discount += price => price - 5m;

Console.WriteLine(discount(100m));

Cevabı multicast delegate bölümünde konuşacağız. Önce bu satırlardaki Func'ın aslında ne olduğuna bakalım.

Delegate: Method'u Tutan Bir Tip

Bir değişken bir sayıyı ya da bir object'i tutabiliyorsa, bir method'u da tutabilir mi? C#'ta bunun cevabı delegate. Delegate, belirli bir signature'a (parametre tipleri ve dönüş tipi) uyan method'ları gösterebilen bir tiptir. C ailesindeki fonksiyon pointer'larına benzer ama tip güvenlidir: signature'ı uymayan bir method'u atamaya çalışırsan derleyici izin vermez.

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

PriceRule rule = PriceRules.HalfPrice;
Console.WriteLine(rule(45.90m));
Console.WriteLine(rule.Invoke(45.90m));

Coupon coupon = new Coupon(7.50m);
rule = new PriceRule(coupon.Apply);
Console.WriteLine(rule(45.90m));

Console.WriteLine(typeof(PriceRule).BaseType);
Console.WriteLine(rule.Method.Name);
Console.WriteLine(rule.Target);

// decimal alıp decimal döndüren her method bu tipe uyar
public delegate decimal PriceRule(decimal price);

public static class PriceRules
{
    public static decimal HalfPrice(decimal price) => price / 2;
}

public class Coupon
{
    private readonly decimal _amount;

    public Coupon(decimal amount)
    {
        _amount = amount;
    }

    public decimal Apply(decimal price) => Math.Max(0, price - _amount);

    public override string ToString() => $"Coupon({_amount})";
}
22.95
22.95
38.40
System.MulticastDelegate
Apply
Coupon(7.50)

delegate keyword'üyle yazdığın satır aslında bir class tanımıdır. Compiler PriceRule için System.MulticastDelegate'ten derive eden sealed bir class üretir ve içine signature'ına uygun bir Invoke method'u koyar. rule(45.90m) yazmak rule.Invoke(45.90m) yazmanın kısa yoludur.

Atama sırasında parantez yok: PriceRules.HalfPrice method'u çağırmıyor, method'un kendisini veriyor. Buna method group denir. new PriceRule(coupon.Apply) ise aynı işin uzun yazımı. Bir delegate iki şey taşır: çağrılacak method (Method) ve o method bir instance method'u ise hangi object üzerinde çağrılacağı (Target). Static method için Target null olur; coupon.Apply için ise kuponun kendisi. Delegate'in coupon'a referans tuttuğunu aklında tut; event leak'lerini konuşurken bu ayrıntı çok işimize yarayacak.

Func, Action ve Predicate

Her ihtiyaç için ayrı bir delegate tipi tanımlamak yorucu olurdu. .NET bunun için hazır generic delegate'ler sunar ve günlük kodun neredeyse tamamı bunlarla yazılır:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Func<decimal, decimal> addTax = price => price * 1.20m;
Func<decimal, int, decimal> lineTotal = (price, quantity) => price * quantity;
Action<string> log = message => Console.WriteLine($"[log] {message}");
Predicate<int> isLowStock = stock => stock < 5;
Func<DateTime> clock = () => new DateTime(2026, 11, 7, 10, 0, 0);

log($"tax: {addTax(45.90m)}");
log($"line: {lineTotal(12.50m, 3)}");
log($"low stock: {isLowStock(3)}");
log($"clock: {clock():yyyy-MM-dd HH:mm}");
[log] tax: 55.0800
[log] line: 37.50
[log] low stock: True
[log] clock: 2026-11-07 10:00

Kural basit: Func bir değer döndürür ve son generic parametresi dönüş tipidir; Func<decimal, int, decimal> "decimal ve int al, decimal döndür" demektir. Action hiçbir şey döndürmez (void). İkisinin de 0'dan 16'ya kadar parametre alan versiyonları var. Predicate<T> ise Func<T, bool> ile aynı signature'a sahip, daha eski bir tip; List<T>.FindAll ve RemoveAll gibi .NET 2.0'dan kalma API'lerde karşına çıkar. List<T>.Sort'un aldığı Comparison<T> da aynı kuşaktandır.

Kendi delegate tipini ne zaman yazarsın? Signature'da ref ya da out parametre varsa (Func bunları desteklemez) ya da tipin adı bir anlam taşıyorsa. PriceRule okuyana Func<decimal, decimal>'den daha çok şey söyler. Bunun dışında hazır tipleri kullan; kodu okuyan herkes onları tanır.

C# 10: Lambda ve Method Group'un Doğal Tipi

C# 10'a kadar bir lambda'yı var ile tanımlayamazdın, çünkü lambda'nın kendi başına bir tipi yoktu. C# 10 ile lambda'lar ve tek overload'u olan method group'lar doğal bir tip kazandı. Yukarıdaki programın sonuna şu üç satırı ekleyelim:

var applyCoupon = (decimal price, decimal coupon) => price - coupon;
Console.WriteLine(applyCoupon.GetType());

var write = Console.WriteLine;
Program.cs(19,13): error CS8917: The delegate type could not be inferred.

Hata son satırdan geliyor: Console.WriteLine'ın onlarca overload'u var ve compiler hangisini kastettiğini bilemiyor. O satırı silince ilk iki satır çalışır ve System.Func`3[System.Decimal,System.Decimal,System.Decimal] yazdırır. Parametre tipleri açıkça yazılmış lambda için compiler uygun Func ya da Action tipini kendisi seçti. Parametre tipini yazmadığın var f = x => x * 2; gibi bir satır ise aynı CS8917 ile derlenmez; compiler'ın x'in ne olduğunu çıkaracak bir ipucu yoktur.

Method'u Parametre Olarak Geçirmek

Delegate'lerin asıl gücü, davranışı bir method'a argüman olarak verebilmek. Filtreleme mantığını bir kez yazıp neye göre filtreleneceğini çağırana bırakalım:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

List<Book> books = new List<Book>
{
    new Book("Dune", "Frank Herbert", 45.90m, 3),
    new Book("Clean Code", "Robert C. Martin", 62.00m, 0),
    new Book("Refactoring", "Martin Fowler", 58.50m, 12),
    new Book("The Hobbit", "J.R.R. Tolkien", 29.90m, 7)
};

Print("Cheap", Filter(books, book => book.Price < 50m));
Print("In stock", Filter(books, IsInStock));

books.Sort((a, b) => a.Price.CompareTo(b.Price));
Print("By price", books);

static bool IsInStock(Book book) => book.Stock > 0;

// neye göre filtreleneceğini çağıran belirler
static List<Book> Filter(List<Book> source, Func<Book, bool> predicate)
{
    List<Book> result = new List<Book>();
    foreach (Book book in source)
    {
        if (predicate(book))
        {
            result.Add(book);
        }
    }
    return result;
}

static void Print(string title, List<Book> items)
{
    Console.WriteLine($"{title}: {string.Join(", ", items.Select(b => $"{b.Title} ({b.Price})"))}");
}

public record Book(string Title, string Author, decimal Price, int Stock);
Cheap: Dune (45.90), The Hobbit (29.90)
In stock: Dune (45.90), Refactoring (58.50), The Hobbit (29.90)
By price: The Hobbit (29.90), Dune (45.90), Refactoring (58.50), Clean Code (62.00)

Filter döngüyü ve list'i yönetiyor, kararı ise dışarıdan gelen predicate'e bırakıyor. İlk çağrıda bir lambda, ikincisinde bir method group verdik; ikisi de Func<Book, bool>'a dönüşüyor. Bu Filter tanıdık geldiyse sebebi şu: LINQ'in Where'i tam olarak bu fikrin genel hâlidir. LINQ serisinde gördüğün her operatör, Func alan bir method'dan ibaret. Method alan ya da method döndüren method'lara higher-order function denir. Design pattern yazısında strategy'yi tek bir Func<decimal, decimal> ile kurmuştuk; o da aynı fikirdi.

Multicast Delegate

Şimdi tahminin cevabı: ekrana 95 çıkar. Bir delegate birden fazla method tutabilir; += listeye bir method ekler, çağırdığında hepsi sırayla çalışır. Ama dönüş değeri olan bir delegate'te sana yalnızca son method'un sonucu döner. İlk kural da çalıştı, 90 hesapladı, ama o sonucu kimse görmedi. Bu yüzden multicast'in doğal yeri Action, yani sonucu olmayan bildirimler:

Action<string>? onOrderPlaced = SendEmail;
onOrderPlaced += UpdateStock;
onOrderPlaced += WriteAudit;

onOrderPlaced("ORD-1001");
Console.WriteLine($"subscribers: {onOrderPlaced.GetInvocationList().Length}");

// son subscriber da çıkarılabileceği için sonuç null olabilir
onOrderPlaced -= UpdateStock;
onOrderPlaced?.Invoke("ORD-1002");

onOrderPlaced = SendEmail;
onOrderPlaced += FailingSms;
onOrderPlaced += WriteAudit;

try
{
    onOrderPlaced("ORD-1003");
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"caught: {ex.Message}");
}

static void SendEmail(string orderId) => Console.WriteLine($"email sent for {orderId}");
static void UpdateStock(string orderId) => Console.WriteLine($"stock updated for {orderId}");
static void WriteAudit(string orderId) => Console.WriteLine($"audit written for {orderId}");
static void FailingSms(string orderId) => throw new InvalidOperationException("SMS gateway is down");
email sent for ORD-1001
stock updated for ORD-1001
audit written for ORD-1001
subscribers: 3
email sent for ORD-1002
audit written for ORD-1002
email sent for ORD-1003
caught: SMS gateway is down

Üç şeye dikkat et. Birincisi, delegate'ler immutable'dır: += mevcut delegate'i değiştirmez, iki listeyi birleştiren yeni bir delegate üretir ve değişkene onu atar (Delegate.Combine). İkincisi, değişkeni Action<string>? olarak tanımladık; -= sonunda liste boşalırsa sonuç null olur ve compiler bunu bilir. Nullable olmayan bir değişkende -= satırı CS8601, sonraki çağrı CS8602 uyarısı verir. Üçüncüsü, ORD-1003'te SMS gönderimi patlayınca zincir orada kesildi: audit hiç yazılmadı ve exception çağırana kadar çıktı. Subscriber'ların birbirinden bağımsız çalışması gerekiyorsa GetInvocationList() ile listeyi alıp her birini kendi try/catch'i içinde çağırırsın; bunu design pattern yazısındaki observer örneğinde yapmıştık.

Delegate Tipleri Arasında Dönüşüm

Signature'ları birebir aynı iki delegate tipi birbirine atanabilir mi?

PriceRule rule = price => price * 0.90m;
Func<decimal, decimal> func = rule;

public delegate decimal PriceRule(decimal price);
Program.cs(2,31): error CS0029: Cannot implicitly convert type 'PriceRule' to 'System.Func<decimal, decimal>'

Atanamaz. Delegate'ler class olduğu için, aynı şekle sahip olmaları onları aynı tip yapmaz; tıpkı aynı property'lere sahip iki class'ın birbirine atanamaması gibi. Köprü kurmanın yolu Invoke'u method group olarak vermek. Öte yandan Func ve Action'ın generic parametreleri variance taşır, bu yüzden bazı dönüşümler kendiliğinden çalışır:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

PriceRule rule = price => price * 0.90m;
Func<decimal, decimal> func = rule.Invoke;
Console.WriteLine(func(50m));

// Func<out TResult>: Book döndüren, object döndüren yerine geçer
Func<Book> createBook = () => new Book("Dune");
Func<object> createAnything = createBook;
Console.WriteLine(createAnything());

// Action<in T>: her object'i basabilen, Book'u da basabilir
Action<object> printAnything = item => Console.WriteLine($"item: {item}");
Action<Book> printBook = printAnything;
printBook(new Book("Clean Code"));

public delegate decimal PriceRule(decimal price);

public record Book(string Title);
45.00
Book { Title = Dune }
item: Book { Title = Clean Code }

Kitap üreten bir fonksiyon, "bir object üret" bekleyen her yerde kullanılabilir; her object'i yazdırabilen bir fonksiyon da kitap yazdırabilir. Bu yönleri out ve in belirler; mantığını Koleksiyonlar ve Generics serisinin covariance ve contravariance yazısında ayrıntılı anlattık.

Sık Düşülen Tuzaklar

Null delegate'i çağırmak. Hiç subscriber'ı olmayan bir delegate değişkeni null'dır ve doğrudan çağırmak exception fırlatır:

Action<string>? onSold = null;
onSold?.Invoke("Dune");

onSold += title => Console.WriteLine($"sold: {title}");
onSold += title => Console.WriteLine($"audit: {title}");
onSold -= title => Console.WriteLine($"audit: {title}");

onSold("Dune");
Console.WriteLine(onSold.GetInvocationList().Length);

Action<string>? onReturned = null;
onReturned("Dune");
Program.cs(8,1): warning CS8602: Dereference of a possibly null reference.
Program.cs(12,1): warning CS8602: Dereference of a possibly null reference.
sold: Dune
audit: Dune
2
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.

İlk satırdaki ?.Invoke null durumda hiçbir şey yapmadı. Son satır ise compiler'ın uyarısına rağmen runtime'da NullReferenceException fırlattı. Delegate çağırırken ?.Invoke alışkanlık olsun.

Lambda'yı -= ile çıkarmaya çalışmak. Aynı programın ikinci dikkat çeken yeri: audit lambda'sı çıkmadı, listede hâlâ iki method var. Gövdesi aynı olsa da her lambda ayrı bir method'a derlenir ve ayrı bir delegate üretir. -= listede olmayan bir şeyi çıkarmaya çalıştığında hata da vermez, sessizce hiçbir şey yapmaz. Sonradan çıkarman gereken bir subscriber'ı bir değişkende tut ya da isimli bir method kullan.

Dönüş değerli multicast delegate'e güvenmek. Tahmin örneğindeki gibi yalnızca son sonucu alırsın. Birden fazla kuralı zincirlemek istiyorsan onları bir list'te tut ve sırayla uygula.

Her şeyi Func<Func<...>> ile çözmek. Üç seviye iç içe Func signature'ı okunmaz hâle gelir. Böyle bir noktada ya anlamlı adlı bir delegate tipi tanımla ya da işi küçük bir interface'e taşı.

Küçük Bir Deneme

★☆☆ Func<Book, string> alan bir Describe method'u yaz ve aynı kitap list'ini bir kez b => b.Title, bir kez b => $"{b.Author}: {b.Title}" ile yazdır.

★★☆ List<Func<decimal, decimal>> içinde üç fiyat kuralı tut (yüzde 10 indirim, 5 birim kupon, yüzde 20 vergi) ve bir foreach ile fiyata sırayla uygula. Sonucu tahmin örneğindeki multicast sonucuyla karşılaştır.

★★★ onOrderPlaced zincirini, her subscriber'ı kendi try/catch'i içinde çağıran bir SafeInvoke(Action<string>? handlers, string orderId) method'uyla değiştir. Patlayan subscriber'ın Method.Name'ini log'a yaz ve audit satırının artık yazıldığını gör.

Bu yazıda lambda'ları hep kendi parametreleriyle çalışan küçük fonksiyonlar olarak kullandık. Oysa bir lambda, tanımlandığı yerdeki değişkenleri de yakalayabilir. Bir sonraki yazıda closure'lara geçiyoruz: yakalanan değişkenin nerede yaşadığını, döngüde yakalanan değişken tuzağını ve bir lambda'nın görünmez maliyetini göreceğiz.