Guard Clause Yardımcıları, Equality Helper'ları ve .NET 8'den 11'e BCL'de Gelenler
ArgumentNullException.ThrowIfNull ve .NET 8'in ArgumentOutOfRangeException helper'ları, CallerArgumentExpression sayesinde parametre adını, değeri ve kuralı tek satırda exception'a yazar. Bookstore'un Order constructor'ını bu helper'lara çeviriyor; kendi guard method'larını, multi-targeting tuzağını, HashCode.Combine ve değişen key'leri, comparer seçimini, Lazy<T>'nin exception saklamasını ve .NET 8'den 11'e BCL'ye gelen API'lerin tablosunu gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; EqualityComparer<T>.Create'in key selector overload'u .NET 11 SDK (11.0.100) ile çalıştırılmıştır.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) sipariş satırı, adedi sıfır olan bir satırı kabul etmiyor. Aşağıdaki kod üç satır yazıyor. Exception'ın tipi, ParamName'i ve mesajı ne olur?
try
{
_ = new OrderItem(Dune, 0, 349.90m);
}
catch (ArgumentException ex)
{
Console.WriteLine(ex.GetType().Name);
Console.WriteLine(ex.ParamName);
Console.WriteLine(ex.Message);
}
ArgumentOutOfRangeException
quantity
quantity ('0') must be a non-negative and non-zero value. (Parameter 'quantity')
Actual value was 0.
OrderItem'ın constructor'ında ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity) yazıyor; parametre adını hiç vermedik. Buna rağmen exception doğru parametre adını, gelen değeri ve kuralı biliyor. Bunu sağlayan, C# 10 ile gelen CallerArgumentExpression attribute'u: derleyici, çağrıdaki argüman ifadesini (quantity) string olarak helper'a geçiriyor. Tek satırlık bu çağrı, elle yazılmış dört satırlık bir if/throw bloğunun yerini alıyor.
Serinin son yazısında üç konuyu birleştiriyoruz: argument doğrulaması için throw helper'lar ve kendi guard method'larını yazmak, equality ve comparer seçimi (HashCode.Combine, EqualityComparer<T>.Create, StringComparer), kısaca Lazy<T>. En sonda da .NET 8'den 11'e BCL'ye (Base Class Library; her .NET uygulamasıyla gelen temel class library) gelen ve bu seride kullandığımız API'leri tek tabloda topluyoruz.
dotnet run --project samples/Bookstore.Bcl.Samples -- guard-compare --culture en-US
Elle Yazılmış Kontroller ve Throw Helper'lar
Bookstore'un Order class'ı yıllar içinde birkaç geliştiricinin elinden geçmiş. Constructor'ı şöyleydi:
// Bu yazıdan önceki constructor: elle yazılmış kontroller, her geliştiricinin kendi mesaj stili.
public LegacyOrder(string orderNumber, string customerEmail, IReadOnlyList<OrderItem> items, DateTimeOffset placedAt)
{
if (orderNumber == null)
throw new ArgumentNullException(nameof(orderNumber));
if (orderNumber.Trim().Length == 0)
throw new ArgumentException("Order number is empty");
if (customerEmail == null || !customerEmail.Contains('@'))
throw new ArgumentException("Invalid email", "email");
if (items == null)
throw new ArgumentNullException("items");
if (items.Count == 0)
throw new ArgumentOutOfRangeException(nameof(items), "Order must have items");
if (placedAt == default)
throw new ArgumentException(nameof(placedAt));
// ...
}
İlk bakışta sorun yok, ama dört hata saklı. İkinci throw parametre adı vermiyor. Üçüncüsü parametre adını "email" diye yazmış, parametrenin adı ise customerEmail; bir refactoring sonrası kimse fark etmemiş. Dördüncüsü nameof yerine string kullanıyor. Sonuncusu klasik bir karışıklık: ArgumentException'ın tek parametreli constructor'ı mesaj alır, parametre adı değil, bu yüzden mesaj "placedAt" oluyor. Throw helper'larla aynı constructor:
public Order(string orderNumber, string customerEmail, IReadOnlyList<OrderItem> items, DateTimeOffset placedAt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(orderNumber);
CustomerEmail = Guard.Email(customerEmail);
ArgumentNullException.ThrowIfNull(items);
ArgumentOutOfRangeException.ThrowIfZero(items.Count, nameof(items));
ArgumentOutOfRangeException.ThrowIfEqual(placedAt, default);
OrderNumber = orderNumber;
Items = [.. items];
PlacedAt = placedAt;
}
Dört hatalı girdiyi iki constructor'a da verdim:
blank order number
legacy: ArgumentException ParamName=(null) Order number is empty
helper: ArgumentException ParamName=orderNumber The value cannot be an empty string or composed entirely of whitespace. (Parameter 'orderNumber')
bad e-mail
legacy: ArgumentException ParamName=email Invalid email (Parameter 'email')
helper: ArgumentException ParamName=customerEmail 'ada.example.com' is not an e-mail address. (Parameter 'customerEmail')
no items
legacy: ArgumentOutOfRangeException ParamName=items Order must have items (Parameter 'items')
helper: ArgumentOutOfRangeException ParamName=items items ('0') must be a non-zero value. (Parameter 'items') | Actual value was 0.
default date
legacy: ArgumentException ParamName=(null) placedAt
helper: ArgumentOutOfRangeException ParamName=placedAt placedAt ('1/1/0001 12:00:00 AM +00:00') must not be equal to '1/1/0001 12:00:00 AM +00:00'. (Parameter 'placedAt') | Actual value was 1/1/0001 12:00:00 AM +00:00.
Helper'lı sürümde her exception doğru parametre adını taşıyor ve mesajlar aynı kalıpta. Bu, ASP.NET Core'daki bir hata filtresinin ya da log sorgusunun ParamName'e güvenebilmesi demek. Helper'ların hangi sürümde geldiğini bilmek de önemli, çünkü bir library'yi eski bir hedefe derlediğinde bunlar yok: ArgumentNullException.ThrowIfNull .NET 6, ArgumentException.ThrowIfNullOrEmpty ve ObjectDisposedException.ThrowIf .NET 7, ThrowIfNullOrWhiteSpace ile ArgumentOutOfRangeException'ın ThrowIfNegative, ThrowIfZero, ThrowIfGreaterThan, ThrowIfEqual gibi generic method'ları .NET 8 ile geldi. Sayı alan method'lar generic math'e (INumberBase<T>) dayanıyor, bu yüzden int, decimal ve double ile aynı şekilde çalışıyor. Karşılaştırma yapanlar (ThrowIfGreaterThan, ThrowIfEqual) ise IComparable<T> ve IEquatable<T> isteyen her tiple, DateTimeOffset dahil, kullanılabiliyor.
Helper Mesajları ve CallerArgumentExpression
CallerArgumentExpression parametre adını değil, çağrıdaki ifadenin metnini yakalar. Çoğu zaman ikisi aynıdır; aynı olmadığı durumlar sürpriz yapar:
string? missing = null;
IReadOnlyList<OrderItem> items = [];
Run(() => ArgumentNullException.ThrowIfNull(missing));
Run(() => ArgumentException.ThrowIfNullOrEmpty(""));
Run(() => ArgumentOutOfRangeException.ThrowIfNegative(-5m));
Run(() => ArgumentOutOfRangeException.ThrowIfGreaterThan(501, OrderItem.MaxQuantity));
Run(() => ArgumentOutOfRangeException.ThrowIfZero(items.Count));
Run(() => ArgumentOutOfRangeException.ThrowIfLessThan(PlacedAt, PlacedAt.AddDays(1)));
OrderExport export = new();
export.Dispose();
Run(() => export.Write("BK-1001"));
ArgumentNullException ParamName=missing Value cannot be null. (Parameter 'missing')
ArgumentException ParamName="" The value cannot be an empty string. (Parameter '""')
ArgumentOutOfRangeException ParamName=-5m -5m ('-5') must be a non-negative value. (Parameter '-5m') | Actual value was -5.
ArgumentOutOfRangeException ParamName=501 501 ('501') must be less than or equal to '500'. (Parameter '501') | Actual value was 501.
ArgumentOutOfRangeException ParamName=items.Count items.Count ('0') must be a non-zero value. (Parameter 'items.Count') | Actual value was 0.
ArgumentOutOfRangeException ParamName=PlacedAt PlacedAt ('11/14/2026 9:30:00 AM +03:00') must be greater than or equal to '11/15/2026 9:30:00 AM +03:00'. (Parameter 'PlacedAt') | Actual value was 11/14/2026 9:30:00 AM +03:00.
ObjectDisposedException ParamName=- Cannot access a disposed object. | Object name: 'Bookstore.Bcl.Samples.Guards.GuardDemos+OrderExport'.
Literal değerlerde ParamName anlamsız hâle geliyor ("", -5m, 501); helper'lar parametreler için tasarlandı, hesaplanmış ifadeler için değil. items.Count satırı gerçek koddaki karşılığı: Order constructor'ında ThrowIfZero(items.Count, nameof(items)) yazmamızın nedeni bu. Parametre adı açıkça verilmezse ParamName "items.Count" olur ve parametre listesinde böyle bir ad yoktur. Mesajlardaki tarih biçimi de dikkat çekiyor: değerler çalıştığı thread'in culture'ıyla yazılıyor; bu çıktı en-US ile alındı.
ObjectDisposedException.ThrowIf dispose edilmiş bir nesneye erişimi tek satırda engelliyor ve mesaja tipin tam adını yazıyor:
public void Write(string orderNumber)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Console.WriteLine(orderNumber);
}
Kendi Guard Method'ların
BCL'de olmayan kontroller için aynı kalıpta kendi helper'ını yazabilirsin. Bookstore kütüphanesindeki Guard class'ı iki kontrol içeriyor:
// BCL'de olmayan kontroller için projeye özgü guard'lar, yerleşik helper'larla aynı stilde.
public static class Guard
{
public static Isbn NotDefault(Isbn value, [CallerArgumentExpression(nameof(value))] string? paramName = null) =>
value == default ? throw new ArgumentException("An ISBN is required.", paramName) : value;
public static string Email(string value, [CallerArgumentExpression(nameof(value))] string? paramName = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value, paramName);
int at = value.IndexOf('@');
if (at <= 0 || at != value.LastIndexOf('@') || at == value.Length - 1)
throw new ArgumentException($"'{value}' is not an e-mail address.", paramName);
return value;
}
}
ArgumentException ParamName=customer.Email 'ada(at)example.com' is not an e-mail address. (Parameter 'customer.Email')
ArgumentException ParamName=isbn An ISBN is required. (Parameter 'isbn')
İlk satır Guard.Email(customer.Email) çağrısından; ifade olduğu gibi yakalandı. Değeri geri döndürmek, atamayı ve kontrolü tek satırda yazmayı sağlıyor (CustomerEmail = Guard.Email(customerEmail)). Kendi guard'ında paramName'i içerideki helper'a iletmeyi unutma; unutursan iç helper kendi parametresinin adını (value) yazar. E-posta kontrolü burada kasıtlı olarak basit; adresin gerçekten var olduğunu ancak bir doğrulama e-postası söyler, formatı regex ile ayrıntılı kontrol etmek ise Regular Expressions serisinin konusu.
Eski Hedef Framework'ler ve Multi-Targeting
Bookstore'un fiyat kuralları, hâlâ .NET Framework'te çalışan eski bir muhasebe uygulamasıyla paylaşılan küçük bir library'de. Library net10.0 ve netstandard2.0 için derleniyor. Helper'ları koşulsuz kullanınca:
PriceRules.cs(7,37): error CS0117: 'ArgumentOutOfRangeException' does not contain a definition for 'ThrowIfNegative'
PriceRules.cs(8,37): error CS0117: 'ArgumentOutOfRangeException' does not contain a definition for 'ThrowIfGreaterThan'
Hatalar yalnız netstandard2.0 hedefinden geliyor; net10.0 derlemesi temiz. Çözüm, helper'ı yalnız destekleyen hedeflerde kullanmak:
public static decimal ApplyDiscount(decimal price, decimal rate)
{
#if NET8_0_OR_GREATER
ArgumentOutOfRangeException.ThrowIfNegative(price);
ArgumentOutOfRangeException.ThrowIfGreaterThan(rate, 1m);
#else
if (price < 0)
throw new ArgumentOutOfRangeException(nameof(price), price, "Price must be non-negative.");
if (rate > 1m)
throw new ArgumentOutOfRangeException(nameof(rate), rate, "Rate must be at most 1.");
#endif
return price * (1 - rate);
}
NET8_0_OR_GREATER SDK'nın her hedef için tanımladığı sembollerden biri. Bu tür kontroller çoğalırsa her method'da #if yazmak yerine kendi Guard class'ını yazıp #if'i oraya toplamak daha temiz; bazı ekipler bu iş için polyfill paketleri de kullanıyor. Multi-targeting'in kendisi (TargetFrameworks, koşullu paket referansları, platform API'leri) .NET SDK, CLI ve MSBuild serisinin multi-targeting yazısında anlatılıyor. Örnek repoda samples/Bookstore.Bcl.MultiTarget projesi #if'li hâliyle duruyor ve iki hedefte de derleniyor.
Equality: Record'lar, HashCode.Combine ve Değişen Key'ler
OrderItem bir record; equality'si field'larından otomatik üretiliyor. Elle yazılan equality'de ise GetHashCode için HashCode.Combine var:
OrderItem first = new(Dune, 2, 349.90m);
OrderItem second = new(Isbn.Parse("9780441172719"), 2, 349.90m);
Console.WriteLine($"record Equals: {first.Equals(second)}, ==: {first == second}, same hash: {first.GetHashCode() == second.GetHashCode()}");
ShelfLocation location = new() { Aisle = "B", Shelf = 4 };
HashSet<ShelfLocation> occupied = [location];
Console.WriteLine($"Contains before change: {occupied.Contains(location)}");
location.Aisle = "C";
Console.WriteLine($"Contains after change : {occupied.Contains(location)}, Count: {occupied.Count}");
Console.WriteLine($"Any(x => x.Equals(...)): {occupied.Any(x => x.Equals(location))}");
public bool Equals(ShelfLocation? other) => other is not null && Aisle == other.Aisle && Shelf == other.Shelf;
public override bool Equals(object? obj) => Equals(obj as ShelfLocation);
public override int GetHashCode() => HashCode.Combine(Aisle, Shelf);
record Equals: True, ==: True, same hash: True
Contains before change: True
Contains after change : False, Count: 1
Any(x => x.Equals(...)): True
Farklı string'lerden parse edilen iki ISBN (International Standard Book Number; kitaplara verilen uluslararası numara) aynı değere normalize olduğu için record'lar eşit. HashCode.Combine en fazla sekiz değeri alır, iyi dağılan bir hash üretir ve her process'te farklı bir seed kullanır; hash değerini dosyaya ya da veritabanına yazma, bir sonraki çalıştırmada değişir. Asıl ders son üç satırda: depo raf konumunu set'e ekledikten sonra Aisle'ı değiştirdik. Nesne hâlâ set'in içinde (Count: 1, Any onu buluyor) ama Contains bulamıyor, çünkü hash değişti ve set nesneyi eski hash'in bucket'ında saklıyor. Hash'e giren property'ler değişebiliyorsa o nesneyi dictionary key'i ya da set elemanı yapma; key olacak tipleri immutable tasarla (record ya da readonly struct).
Comparer Seçimi: StringComparer ve EqualityComparer<T>.Create
Collection'lar equality'yi tipin kendisinden ya da verilen bir comparer'dan alır. Comparer'ın ayrıntıları C# Koleksiyonlar ve Generics serisinin "C# Dictionary ve HashSet" yazısında; burada her gün karşılaşılan seçimler:
string[] volumes = ["Cilt 10", "Cilt 2", "Cilt 1"];
Console.WriteLine($"Ordinal : {string.Join(", ", volumes.Order(StringComparer.Ordinal))}");
StringComparer numeric = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);
Console.WriteLine($"NumericOrdering : {string.Join(", ", volumes.Order(numeric))}");
Dictionary<string, decimal> coupons = new(StringComparer.OrdinalIgnoreCase) { ["KITAP10"] = 0.10m };
Console.WriteLine($"OrdinalIgnoreCase finds 'kitap10': {coupons.ContainsKey("kitap10")}");
StringComparer turkish = StringComparer.Create(CultureInfo.GetCultureInfo("tr-TR"), ignoreCase: true);
Console.WriteLine($"tr-TR ignoreCase 'kitap10' == 'KITAP10': {turkish.Equals("kitap10", "KITAP10")}");
EqualityComparer<OrderItem> sameBook = EqualityComparer<OrderItem>.Create(
(x, y) => x?.Isbn == y?.Isbn,
item => item.Isbn.GetHashCode());
OrderItem[] lines = [new(Dune, 1, 349.90m), new(Dune, 3, 329.90m), new(Isbn.Parse("9780140449136"), 1, 189.50m)];
Console.WriteLine($"Distinct books (.NET 8 Create): {lines.Distinct(sameBook).Count()} of {lines.Length} lines");
Ordinal : Cilt 1, Cilt 10, Cilt 2
NumericOrdering : Cilt 1, Cilt 2, Cilt 10
OrdinalIgnoreCase finds 'kitap10': True
tr-TR ignoreCase 'kitap10' == 'KITAP10': False
Distinct books (.NET 8 Create): 2 of 3 lines
Çok ciltli bir serinin ciltleri ordinal sıralamada "Cilt 10"u "Cilt 2"nin önüne koyuyor. .NET 10 ile gelen CompareOptions.NumericOrdering, metnin içindeki sayıları sayı olarak karşılaştırıyor; ekranda gösterilen listeler için doğru seçim bu. Kupon kodu gibi makineye ait key'lerde OrdinalIgnoreCase kullan: tr-TR culture'ıyla harf duyarsız karşılaştırma kitap10'u KITAP10'a eşit saymıyor, çünkü Türkçede i'nin büyüğü İ. Bu, serinin 3. yazısındaki Türkçe I sorununun dictionary key'lerindeki hâli. EqualityComparer<T>.Create (.NET 8) iki lambda'dan bir comparer kuruyor ve ayrı bir class yazma ihtiyacını kaldırıyor. .NET 11 bunu bir adım ileri taşıdı:
EqualityComparer<Book> byIsbn = EqualityComparer<Book>.Create(book => book?.Isbn);
HashSet<Book> uniqueByIsbn = new(books, byIsbn);
Console.WriteLine($"Distinct by ISBN : {uniqueByIsbn.Count} of {books.Length}");
EqualityComparer<Book> byAuthor = EqualityComparer<Book>.Create(book => book?.Author, StringComparer.OrdinalIgnoreCase);
Console.WriteLine($"Distinct authors : {string.Join(", ", books.Distinct(byAuthor).Select(book => book.Author))}");
Console.WriteLine($"byIsbn.Equals : {byIsbn.Equals(books[0], books[1])}, same hash: {byIsbn.GetHashCode(books[0]) == byIsbn.GetHashCode(books[1])}");
Distinct by ISBN : 4 of 5
Distinct authors : Frank Herbert, Homer, HARPER LEE
byIsbn.Equals : True, same hash: True
Key selector overload'u, equality'yi bir key'e göre tanımlıyor ve hash'i de aynı key'den üretiyor; Equals ile GetHashCode'un birbiriyle tutarsız olma riski ortadan kalkıyor. İkinci parametre key için bir comparer alıyor; yazar adları burada büyük/küçük harf farkı gözetilmeden karşılaştırıldı. Selector'ın parametresi nullable (Func<T?, TKey?>); ilk denememde book.Isbn yazdım ve derleyici CS8602 uyarısı verdi, bu yüzden book?.Isbn.
Lazy<T> ve LazyInitializer
Pahalı bir değeri ilk ihtiyaç anında ve yalnız bir kez üretmek için Lazy<T> var. Varsayılan modu thread-safe ama bir davranışı çoğu kişiyi şaşırtır:
int loads = 0;
Lazy<IReadOnlyDictionary<Isbn, decimal>> catalog = new(() =>
{
Interlocked.Increment(ref loads);
return new Dictionary<Isbn, decimal> { [Dune] = 349.90m };
});
Parallel.For(0, 8, _ => _ = catalog.Value.Count);
Console.WriteLine($"Lazy<T> factory runs: {loads}, IsValueCreated: {catalog.IsValueCreated}");
int attempts = 0;
Lazy<string> flaky = new(() =>
{
attempts++;
if (attempts == 1)
throw new TimeoutException("catalog service timed out");
return "loaded";
});
for (int i = 0; i < 2; i++)
{
try { Console.WriteLine($"flaky.Value: {flaky.Value}"); }
catch (TimeoutException ex) { Console.WriteLine($"attempt {i + 1}: {ex.Message} (factory runs: {attempts})"); }
}
string? settings = null;
int initializations = 0;
for (int i = 0; i < 3; i++)
LazyInitializer.EnsureInitialized(ref settings, () => { initializations++; return "settings"; });
Console.WriteLine($"LazyInitializer runs: {initializations}, value: {settings}");
Lazy<T> factory runs: 1, IsValueCreated: True
attempt 1: catalog service timed out (factory runs: 1)
attempt 2: catalog service timed out (factory runs: 1)
LazyInitializer runs: 1, value: settings
Sekiz paralel erişimde factory bir kez çalıştı. Ama factory exception fırlattığında Lazy<T> o exception'ı da saklıyor: ikinci denemede factory hiç çalışmadı ve aynı timeout tekrar fırlatıldı. Geçici bir ağ hatası, uygulama yeniden başlayana kadar kalıcı hâle geliyor. Factory hata verebiliyorsa LazyThreadSafetyMode.PublicationOnly modu exception'ı saklamaz (ama factory birden çok thread'de aynı anda çalışabilir), ya da hatayı factory'nin içinde ele alırsın. LazyInitializer.EnsureInitialized ayrı bir nesne yaratmadan bir field'ı ilk erişimde doldurur; yarışta factory birden çok kez çalışabilir, kazanan değer saklanır. Uzaktan gelen ve süresi dolan veriler için ise lazy değil cache gerekir.
.NET 8'den 11'e BCL'de Gelenler
Seri boyunca "bu .NET 8 ile geldi", "bu .NET 11'de var" dediğimiz API'leri bir tabloda topluyorum. Liste eksiksiz değil; her sürümün resmi "What's new in .NET libraries" sayfası tam listeyi veriyor. Son sütun API'nin bu seride işlendiği yazı:
| Sürüm | API | Ne işe yarar | Yazı |
|---|---|---|---|
| .NET 8 | CompositeFormat, SearchValues<T> | Format string'ini bir kez parse etmek, karakter kümesinde hızlı arama | 1 |
| .NET 8 | TimeProvider | Test edilebilir saat | 6 |
| .NET 8 | Random.GetItems, Random.Shuffle | Rastgele seçim ve karıştırma | 4 |
| .NET 8 | ThrowIfNullOrWhiteSpace, ThrowIfNegative, ThrowIfGreaterThan ve diğerleri | Argument doğrulaması | 9 |
| .NET 8 | EqualityComparer<T>.Create(equals, getHashCode) | Lambda'dan comparer | 9 |
| .NET 9 | Guid.CreateVersion7 | Zaman sıralı Guid | 4 |
| .NET 9 | Base64Url, Convert.ToHexStringLower | URL güvenli Base64, küçük harfli hex | 5 |
| .NET 10 | CompareOptions.NumericOrdering | Metindeki sayılara göre sıralama | 9 |
| .NET 10 | ISOWeek'in DateOnly overload'ları | Hafta numarası | 6 |
| .NET 10 | UTF-8 byte'larla Convert.FromHexString/TryToHexString, span'li normalization | Allocation'sız dönüşüm | 1, 5 |
| .NET 10 | ProcessStartInfo.CreateNewProcessGroup | Windows'ta child'ı ayrı process group'ta başlatmak | - |
| .NET 11 | Process.RunAsync, RunAndCaptureTextAsync, ProcessExitStatus, KillOnParentExit | Tek çağrıda process çalıştırmak | 7 |
| .NET 11 | Console'da FORCE_COLOR | Yönlendirilen çıktıda renk | 8 |
| .NET 11 | String method'larında Rune overload'ları | Emoji gibi karakterlerle arama ve bölme | 1 |
| .NET 11 | Random.NextInteger<T>, doğru yuvarlanan decimal dönüşümü, hex float | Sayı yardımcıları | 2, 4 |
| .NET 11 | EqualityComparer<T>.Create(keySelector) | Key'e göre equality | 9 |
| .NET 11 | RegexOptions.AnyNewLine, StringStream | Satır sonları, string'i stream olarak okumak | - |
Tablodaki son satırdaki iki API bu serinin sınırı dışında kaldı: RegexOptions.AnyNewLine Regular Expressions serisinde, StringStream ve diğer in-memory stream adapter'ları .NET'te I/O İşlemleri serisinin .NET 11 yazısında. .NET 11 bir LTS (Long Term Support; 3 yıl desteklenen sürüm) değil, STS (Standard Term Support; 24 ay desteklenen ara sürüm) sürümü; .NET 10 LTS üzerinde kalan bir proje bu satırları ancak .NET 11'e geçince kullanabilir. Yükseltme kararı ve adımları C# Sürümleri serisinin "C# ve .NET Sürüm Yükseltme" yazısında anlatılıyor.
Üretimde Dikkat
Throw helper'lar programcı hatalarını yakalamak içindir: bir method'a geçersiz argüman vermek, kodda bir bug olduğu anlamına gelir. Kullanıcı girdisinin doğrulaması ise exception ile değil, bir sonuçla yapılmalı: bir web API'sinde 400 yanıtı ve alan bazında hata listesi, bir CLI'da exit code 2 ve satır numaralı mesajlar. Domain kurallarını constructor'da guard'larla korumak iyi bir son savunma hattıdır, ama kullanıcıya gösterilecek mesajı bu exception'ların metninden üretme; mesajlar İngilizce, culture'a göre biçimlenmiş ve teknik.
Equality ve hash kodu, nesneleri dictionary, set ya da cache key'i olarak kullandığın her yerde doğru olmalı. Bir tipin equality'sini değiştirmek, onu key olarak kullanan her yeri etkiler; bu yüzden bu değişiklikleri unit test'lerle koru. string key'li her dictionary'de comparer'ı açıkça ver; varsayılan ordinal'dir ama kodu okuyan kişi bunu bir karar olarak görmeli.
Multi-targeting yapan bir library'de yeni API'leri #if ile kullanırken iki dalın da test edildiğinden emin ol: CI'da her hedef için testleri çalıştır. Yalnız net10.0'da test edilen bir #else dalı, eski hedefi kullanan müşteride ilk kez çalışır.
Sık Düşülen Tuzaklar
Eski hedef framework'te olmayan helper'ı kullanmak. netstandard2.0 ya da .NET Framework hedefleyen bir library'de .NET 8 helper'ları derlenmez; #if NET8_0_OR_GREATER ya da kendi guard class'ını kullan.
Throw helper'ın hata mesajını özelleştirememek. Helper'ların mesaj parametresi yok; mesaj sabit ve İngilizce. Özel bir mesaj gerekiyorsa (domain'e özgü bir açıklama, bir hata kodu) kendi guard method'unu yaz ya da exception'ı kendin fırlat.
Hesaplanmış bir ifadeye helper uygulamak. ThrowIfZero(items.Count) parametre adı olarak "items.Count" yazar. Gerçek parametre adını nameof ile ver.
ArgumentException'a mesaj yerine parametre adı vermek. Tek parametreli constructor mesaj alır; new ArgumentException(nameof(x)) mesajı "x" olan, ParamName'i boş bir exception üretir.
Değişebilen bir nesneyi dictionary key'i yapmak. Hash'e giren bir property değişince nesne collection'da kaybolur. Key'leri immutable tasarla.
Exception fırlatabilen factory'yi varsayılan Lazy<T> ile sarmak. İlk hata kalıcı olarak saklanır.
Küçük Bir Deneme
★☆☆ OrderItem'a "birim fiyat 10.000'den büyük olamaz" kuralını ThrowIfGreaterThan ile ekle ve 12.500 fiyatlı bir satırla dene. Mesajdaki sayının hangi culture ile yazıldığına bak.
★★☆ Guard class'ına NotInFuture(DateTimeOffset value, TimeProvider clock) method'unu ekle: sipariş tarihi şimdiden ilerideyse exception fırlatsın. 6. yazıdaki FakeTimeProvider ile test et.
★★★ Bookstore.Bcl.MultiTarget projesine net8.0 hedefini de ekle ve PriceRules'u üç hedefte derle. Ardından #if'leri tek bir Guard class'ına taşı ve ApplyDiscount'ı hiç #if içermeyecek şekilde yeniden yaz.
Böylece serinin sonuna geldik. Dokuz yazıda her gün yazılan BCL API'lerine baktık: string method'ları ve StringComparison, format string'leri, globalization ve ICU (International Components for Unicode; culture verisini sağlayan library), sayılar ve Guid v7, encoding ve URI'ler (web adresi gibi kaynak tanımlayıcıları), tarih/saat ve time zone'lar, Environment ve Process, CLI araçları, son olarak guard'lar ve equality. Ortak ders hep aynıydı: varsayılanlar çoğu zaman makineye, culture'a ya da platforma bağlı ve sorunlar ancak kod başka bir ortamda çalışınca görünüyor; culture'ı, encoding'i, zone'u, comparer'ı ve exit code'u açıkça seçmek bu sürprizlerin çoğunu ortadan kaldırıyor. Buradan devam etmek istersen pattern ile metin işlemek için Regular Expressions serisine, dosya ve stream tarafı için .NET'te I/O İşlemleri serisine, dilin kendisindeki yenilikler için C# Sürümleri serisine bakabilirsin. Örnek kodun son hâli repodaki 09 etiketinde.