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

C#'ta Pattern Matching Derinlemesine: Property, Positional ve Tuple Pattern'ler

Mert Özen 7 Kas 2026 11 dk 1 görüntülenme
C#'ta Pattern Matching Derinlemesine: Property, Positional ve Tuple Pattern'ler

Property pattern ve C# 10'un genişletilmiş yazımı, null ara adımda sessizce başarısız olan eşleşme, Deconstruct ile positional pattern, tuple pattern ile karar tablosu, not/and/or önceliği ve is not A or B tuzağı, var pattern ve when, CS8509, CS8524 ve CS8510 ile eksik ve erişilemeyen arm'lar, pattern matching ile polymorphism arasında seçim.

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

Stoğu biten kitapları ya da ön sipariş olmayan pahalı kitapları işaretlemek istiyoruz. Elimizdeki kitabın stoğu 0, fiyatı 45.90 ve ön siparişte. Ekrana flagged mı çıkar, ok mu?

Book book = new Book("Dune", Stock: 0, Price: 45.90m, IsPreorder: true);

if (book is { Stock: 0 } or { Price: > 100m } and { IsPreorder: false })
{
    Console.WriteLine("flagged");
}
else
{
    Console.WriteLine("ok");
}

public record Book(string Title, int Stock, decimal Price, bool IsPreorder);

Soldan sağa okursan "stok 0 ya da fiyat 100'den büyük, ve ön sipariş değil" dersin; kitap ön siparişte olduğu için ok beklersin. Compiler'ın okuduğu ise başka. Cevap logical pattern'lerin önceliğini konuştuğumuz bölümde.

Property Pattern ve C# 10'un Genişletilmiş Hâli

Temel serisinde type, relational ve logical pattern'lerle tanıştık. Gerçek kodda en çok işine yarayacak olan ise property pattern: bir object'in property'lerine bakıp karar vermek. Bookstore'da siparişin kargo açıklamasını üretelim:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Order[] orders =
{
    new Order(1, new Customer("Ada", false, new Address("Ankara", "TR")), 620m),
    new Order(2, new Customer("Alan", false, new Address("Izmir", "TR")), 45.90m),
    new Order(3, new Customer("Grace", true, new Address("Berlin", "DE")), 80m),
    new Order(4, new Customer("Linus", false, null), 30m),
    new Order(5, new Customer("Edsger", false, new Address("Delft", "NL")), 75m)
};

foreach (Order order in orders)
{
    Console.WriteLine($"{order.Id}: {Describe(order)}");
}

if (orders[0].Customer.Address is { } address)
{
    Console.WriteLine($"first order ships to {address.City}");
}

static string Describe(Order order) => order switch
{
    // C# 10: iç içe property'lere nokta ile
    { Customer.Address.Country: "TR", Total: >= 500m } => "domestic, free shipping",
    { Customer.Address.Country: "TR" } => "domestic",
    { Customer.Address: null } => "missing address",
    // C# 8-9 yazımı: iç içe süslü parantez
    { Customer: { IsPremium: true } } => "international, premium",
    _ => "international"
};

public record Address(string City, string Country);
public record Customer(string Name, bool IsPremium, Address? Address);
public record Order(int Id, Customer Customer, decimal Total);
1: domestic, free shipping
2: domestic
3: international, premium
4: missing address
5: international
first order ships to Ankara

Bir property pattern { Property: pattern } şeklinde yazılır ve virgülle ayrılan her madde aynı anda sağlanmalıdır. C# 10'dan önce iç içe bir property'ye ulaşmak için süslü parantezleri iç içe yazardın; dördüncü arm bu eski yazımla duruyor. C# 10'un extended property pattern'i ile Customer.Address.Country yazabilirsin; ikisi aynı kodu üretir, ama nokta ile yazılan çok daha kolay okunur.

4 numaralı siparişe dikkat et. Adresi null ve ilk iki arm Customer.Address.Country'ye bakıyor, ama NullReferenceException yok. Property pattern, içine bakmadan önce değerin null olmadığını kontrol eder; null bir ara adımda eşleşme sessizce başarısız olur ve sıradaki arm'a geçilir. Aynı nedenle is { } "null değil" demektir; sondaki is { } address hem kontrol eder hem de null olmadığı bilinen değeri yeni bir değişkene bağlar; çıktının son satırı oradan geliyor.

Positional Pattern ve Deconstruct

Bir tip kendini parçalarına ayırabiliyorsa, yani bir Deconstruct method'u varsa, property adları yerine sırayla da eşleştirebilirsin. Positional record'lar bu method'u otomatik alır; sıradan bir class'a kendin yazarsın:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Book[] books =
{
    new Book("Dune", "Frank Herbert", 45.90m),
    new Book("The Art of Computer Programming", "Donald Knuth", 249.00m),
    new Book("Clean Code", "Robert C. Martin", 62.00m)
};

foreach (Book book in books)
{
    Console.WriteLine($"{book.Title}: {Label(book)}");
}

Money refund = new Money(-12.50m, "EUR");
Console.WriteLine(refund is (< 0m, "EUR") ? "EUR refund" : "other");

static string Label(Book book) => book switch
{
    (_, "Frank Herbert", _) => "sci-fi classic",
    (_, _, > 100m) => "premium",
    _ => "regular"
};

public record Book(string Title, string Author, decimal Price);

public class Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    // positional pattern bu method'u kullanır
    public void Deconstruct(out decimal amount, out string currency)
    {
        amount = Amount;
        currency = Currency;
    }
}
Dune: sci-fi classic
The Art of Computer Programming: premium
Clean Code: regular
EUR refund

(_, "Frank Herbert", _) "ikinci parça Frank Herbert olsun, gerisi önemli değil" demek. _ burada discard pattern: her şeyle eşleşir. Positional pattern iki üç parçalı, anlamı sırasından belli olan tiplerde güzel durur: bir para miktarı ve birimi, bir koordinat. Beş alanlı bir record'da (_, _, _, > 0, _) yazmaya başladıysan hangi parçanın ne olduğunu kimse hatırlamaz; o noktada property pattern'e dön.

Tuple Pattern: Birden Çok Değere Birden Bakmak

Bazı kurallar tek bir değere değil, değerlerin birleşimine bağlıdır. Kargo ücreti üyelik tipine, sepet tutarına ve hızlı gönderim isteğine göre değişsin. İç içe if'ler yerine üç değeri bir tuple'da toplayıp tek bir switch'le karar verebilirsin:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Console.WriteLine(ShippingFee(Membership.Gold, 20m, isExpress: true));
Console.WriteLine(ShippingFee(Membership.None, 150m, isExpress: false));
Console.WriteLine(ShippingFee(Membership.None, 150m, isExpress: true));
Console.WriteLine(ShippingFee(Membership.Silver, 40m, isExpress: true));
Console.WriteLine(ShippingFee(Membership.None, 40m, isExpress: false));

static decimal ShippingFee(Membership membership, decimal total, bool isExpress) =>
    (membership, total, isExpress) switch
    {
        (Membership.Gold, _, _) => 0m,
        (_, >= 100m, false) => 0m,
        (Membership.Silver, _, true) => 5.00m,
        (_, _, true) => 12.50m,
        _ => 4.90m
    };

public enum Membership
{
    None,
    Silver,
    Gold
}
0
0
12.50
5.00
4.90

Switch bir karar tablosu gibi okunuyor: her satır bir kural, her sütun bir girdi. Arm'lar yukarıdan aşağıya denenir ve ilk eşleşen kazanır; bu yüzden en özel kurallar yukarıda, en genel olan en altta. Gold üye her durumda ücretsiz kargo alıyor, çünkü ilk arm onu yakalıyor. 150'lik ama hızlı gönderim isteyen sipariş ikinci arm'a uymadı (isExpress false değil) ve dördüncü arm'a düştü. Tuple'dan çekinmene gerek yok: ValueTuple bir struct, heap'te bir şey ayırmaz.

Relational ve Logical Pattern'ler, Öncelik

Tahminin cevabı: flagged. Logical pattern'lerde öncelik sırası not, sonra and, en son or; tıpkı !, && ve || gibi. Compiler tahmin örneğini { Stock: 0 } or ({ Price: > 100m } and { IsPreorder: false }) diye okudu ve stok 0 olduğu için ilk parça yetti. Kastettiğin "ikisinden biri, ve ön sipariş değil" ise parantezi kendin koymalısın. Birkaç yaygın kullanım ve bir tuzak daha:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

foreach (decimal price in new[] { 0m, 12.99m, 45.90m, 249m })
{
    Console.WriteLine($"{price}: {Bucket(price)}");
}

Console.WriteLine(IsAsciiLetter('q'));
Console.WriteLine(IsAsciiLetter('7'));

OrderStatus status = OrderStatus.Delivered;
Console.WriteLine(status is not OrderStatus.Shipped or OrderStatus.Delivered);
Console.WriteLine(status is not (OrderStatus.Shipped or OrderStatus.Delivered));

static string Bucket(decimal price) => price switch
{
    < 0m => throw new ArgumentOutOfRangeException(nameof(price)),
    0m => "free",
    > 0m and < 20m => "budget",
    >= 20m and < 100m => "standard",
    _ => "premium"
};

static bool IsAsciiLetter(char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z');

public enum OrderStatus
{
    Pending,
    Shipped,
    Delivered
}
0: free
12.99: budget
45.90: standard
249: premium
True
False
True
False

>= 20m and < 100m bir aralık tanımlar ve Bucket'taki arm'lar aralıkları boşluksuz kaplıyor. Switch expression'ın bir arm'ı değer döndürmek yerine throw da edebilir; geçersiz girdiyi ilk arm'da reddetmek okunur bir alışkanlık.

Tuzak son iki satırda. "Shipped ya da Delivered değilse" demek isterken is not OrderStatus.Shipped or OrderStatus.Delivered yazmak, not daha önce bağlandığı için (not Shipped) or Delivered demektir. Delivered bir sipariş için sonuç True çıktı; tam da dışarıda bırakmak istediğin durum. Compiler bu satırı sessizce kabul etti. not'u bir gruba uygulamak istiyorsan parantez şart.

var Pattern ve when

Bazı kurallar pattern'lerin anlatamayacağı şeylere bakar: iki property'yi birbiriyle karşılaştırmak ya da bir hesap yapmak. Pattern'ler yalnızca sabitlerle karşılaştırma yapabilir; bunun için var pattern ile değeri bir değişkene bağlar, when ile istediğin koşulu yazarsın:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Book dune = new Book("Dune", 45.90m, 3);
Book knuth = new Book("TAOCP Box Set", 249.00m, 20);

Console.WriteLine(Check(new OrderItem(dune, 0)));
Console.WriteLine(Check(new OrderItem(dune, 5)));
Console.WriteLine(Check(new OrderItem(knuth, 5)));
Console.WriteLine(Check(new OrderItem(dune, 2)));

static string Check(OrderItem item) => item switch
{
    { Quantity: <= 0 } => "invalid quantity",
    { Quantity: var quantity, Book.Stock: var stock } when quantity > stock => $"only {stock} left",
    { Book.Price: var price, Quantity: var quantity } when price * quantity > 1000m => "needs approval",
    _ => "ok"
};

public record Book(string Title, decimal Price, int Stock);
public record OrderItem(Book Book, int Quantity);
invalid quantity
only 3 left
needs approval
ok

var quantity her şeyle eşleşir, null ile bile; işi değeri yakalamak. Sonra when o arm'a ek bir şart koyar ve şart false ise arm eşleşmemiş sayılır, sıradaki arm'a geçilir. Bir pattern'de değişkene karşılaştırma yazamayacağını tuzaklar bölümünde göreceksin; when tam o boşluğu doldurur. Yine de her arm'a when eklemeye başladıysan, aslında bir if zinciri yazıyorsundur.

Exhaustiveness: Eksik ve Erişilemeyen Arm'lar

Switch expression her zaman bir değer döndürmek zorunda. Hiçbir arm eşleşmezse ne olur? Compiler bunu önceden görmeye çalışır:

Console.WriteLine(Describe(OrderStatus.Paid));
Console.WriteLine(Describe(OrderStatus.Cancelled));

static string Describe(OrderStatus status) => status switch
{
    OrderStatus.Pending => "waiting for payment",
    OrderStatus.Paid => "preparing",
    OrderStatus.Shipped => "on the way",
    OrderStatus.Delivered => "delivered"
};

public enum OrderStatus
{
    Pending,
    Paid,
    Shipped,
    Delivered,
    Cancelled
}
Program.cs(4,54): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'OrderStatus.Cancelled' is not covered.
preparing
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was Cancelled.

Uyarı, eksik değeri adıyla söylüyor. Bu uyarıyı ciddiye al; enum'a yeni bir değer eklendiğinde onu ele almayan bütün switch'leri sana gösteren şey bu. Cancelled arm'ını ekleyince uyarı değişti: warning CS8524: The switch expression does not handle some values of its input type (it is not exhaustive) involving an unnamed enum value. For example, the pattern '(OrderStatus)5' is not covered. Enum'lar aslında birer sayı ve (OrderStatus)42 geçerli bir değer; denediğimde aynı exception Unmatched value was 42. ile geldi. Dışarıdan gelen veriyle çalışıyorsan sona bir _ => throw new ArgumentOutOfRangeException(nameof(status)) arm'ı koy. Tamamen kendi kontrolündeki bir enum'da ise CS8524'ü bilinçli olarak kabul etmek, CS8509'un koruyuculuğunu kaybetmemenin bedelidir: _ arm'ı eklediğin anda yeni enum değerleri için de uyarı almazsın.

Compiler'ın ikinci kontrolü ters yönde çalışır: hiçbir zaman ulaşılamayacak bir arm yazdıysan hata verir.

Console.WriteLine(StockLabel(25));

static string StockLabel(int stock) => stock switch
{
    > 0 => "in stock",
    > 10 => "plenty",
    _ => "out of stock"
};
Program.cs(6,5): error CS8510: The pattern is unreachable. It has already been handled by a previous arm of the switch expression or it is impossible to match.

10'dan büyük her sayı zaten 0'dan büyük; ikinci arm asla çalışamaz. Sıralamayı ters çevirmek, yani özel olanı yukarı almak, hatayı düzeltir. Aynı kontrol if/else zincirlerinde yoktur; switch'in bir avantajı da bu.

Pattern Matching mi, Polymorphism mi

OOP serisinde davranışı tiplerin içine, virtual method'lara koymayı öğrendik. Pattern matching ise kararı dışarıda, tek bir yerde verir. Hangisi ne zaman? Yeni tip ekleme ihtimali yüksekse ve her tip kendi davranışını bilmeliyse polymorphism; her yeni tip, hiçbir switch'i değiştirmeden kendi override'ını getirir. Tipler sabitse ama üzerlerinde çalışan işlemler çoğalıyorsa pattern matching; kargo ücreti, vergi, rapor etiketi gibi her yeni işlem tipleri değiştirmeden tek bir switch olarak eklenir. Bookstore'un siparişleri, ödeme durumları ve kargo kuralları ikinci gruba girer. Bir de kuralın bir değil birkaç object'e birden bağlı olduğu durumlar var; tuple pattern örneğindeki gibi bir kararı hiçbir tip tek başına sahiplenemez.

C# 11 ile list pattern'ler ([var first, .., var last]) geldi; onları C# Sürümleri serisinin C# 11 yazısında göreceğiz.

Sık Düşülen Tuzaklar

Pattern'de değişken kullanmaya çalışmak. Pattern'lerdeki değerler compile time'da bilinen sabitler olmalıdır:

decimal limit = 100m;
decimal price = 120m;
bool expensive = price is > limit;
Program.cs(3,29): error CS9135: A constant value of type 'decimal' is expected

Ya limit'i const yap ya da when ve sıradan bir karşılaştırma kullan.

Arm'ların sırasını unutmak. İlk eşleşen arm kazanır. Genel bir arm'ı özel olanın üstüne koyarsan compiler bazen CS8510 ile yakalar, ama when içeren arm'larda yakalayamaz; çünkü when'in sonucunu önceden bilemez.

not ile or'u parantezsiz birleştirmek. Yukarıda gördüğün gibi is not A or B neredeyse her zaman yanlış bir niyetin ifadesidir.

CS8509'u _ => null ile susturmak. Uyarıyı gidermek için sona anlamsız bir varsayılan eklemek, enum'a eklenecek yeni değerler için gelecekteki bütün uyarıları da kapatır. Gerçekten bir varsayılan yoksa exception fırlat.

Küçük Bir Deneme

★☆☆ Tahmin örneğindeki koşulu, "stok 0 ya da fiyat 100'den büyük olan, ön siparişte olmayan kitaplar" anlamına gelecek şekilde parantezle düzelt ve ok çıktısını al.

★★☆ ShippingFee'ye ülke bilgisini ekle: (Membership, string Country, decimal Total, bool IsExpress). Türkiye dışı siparişlerde Gold üyelik de 9.90 ödesin. Hangi arm'ı nereye koyman gerektiğini çıktıyla doğrula.

★★★ OrderStatus için iki durumu alan bir CanTransition(OrderStatus from, OrderStatus to) yaz: Pending'den Paid ya da Cancelled'a, Paid'den Shipped ya da Cancelled'a, Shipped'den Delivered'a geçilebilsin. Tuple pattern ve or kullan, sonra enum'a Returned ekle. Compiler sana bir şey söyledi mi? Söylemediyse neden?

Bu yazıdaki 4 numaralı siparişte adres null'dı ve pattern matching bunu zarifçe ele aldı. Ama null'ın nereden gelebileceğini compiler'a anlatmanın daha sistemli bir yolu var. Bir sonraki yazıda nullable reference types'ı derinlemesine açıyoruz: NotNullWhen, MaybeNull gibi attribute'lar ve null'a dayanıklı bir library API'si tasarlamak.