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

C#'ta Nullable Reference Types Derinlemesine: NotNullWhen, MaybeNull ve API Tasarımı

Mert Özen 7 Kas 2026 9 dk 6 görüntülenme
C#'ta Nullable Reference Types Derinlemesine: NotNullWhen, MaybeNull ve API Tasarımı

Flow analysis'in method sınırında durması ve CS8602, NotNullWhen ile koşullu contract ve CS8762, kısıtsız T?'nin value type'ta düz T kalması, MaybeNullWhen ile generic Try pattern'i, NotNullIfNotNull, NotNull ve AllowNull, CS8618 için MemberNotNull, DoesNotReturn, runtime'da korumanın olmaması ve ThrowIfNull, kademeli geçiş için #nullable ve WarningsAsErrors.

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

Bookstore kataloğunda ISBN ile kitap arayan klasik bir TryFind var. Bulursa true döner ve kitabı out parametresiyle verir. Kodu nullable açık bir projede derliyoruz. book.Title satırı için compiler bir şey söyler mi?

Catalog catalog = new Catalog();
catalog.Add(new Book("978-0441172719", "Dune"));

if (catalog.TryFind("978-0441172719", out Book? book))
{
    Console.WriteLine(book.Title);
}

public record Book(string Isbn, string Title);

public class Catalog
{
    private readonly Dictionary<string, Book> _books = new Dictionary<string, Book>();

    public void Add(Book book) => _books[book.Isbn] = book;

    public bool TryFind(string isbn, out Book? book) => _books.TryGetValue(isbn, out book);
}

Sen ve ben biliyoruz ki true döndüyse kitap null değildir. Peki compiler bunu nereden bilecek?

Compiler Method Sınırında Durur

Program çalışıyor ve Dune yazıyor, ama derlerken bir uyarı geliyor:

Program.cs(6,23): warning CS8602: Dereference of a possibly null reference.

Temel serisinde nullable reference types'ın bir flow analysis olduğunu görmüştük: compiler bir method'un içinde her değişkenin o noktada null olup olamayacağını takip eder. Ama bu analiz method sınırında durur. TryFind'ı çağıran kod onun gövdesine bakmaz, yalnızca signature'ına bakar. Signature'da out Book? book yazıyor, yani "book null olabilir". Dönüş değeri ile book arasındaki ilişki signature'da yok, bu yüzden compiler en kötüsünü varsayıyor.

Bu uyarıyı book!.Title ile susturabilirsin, ama o zaman TryFind'ı kullanan her yerde aynı !'i yazmak zorunda kalırsın ve compiler'ın gerçek hataları yakalama yeteneğini körleştirirsin. Doğru çözüm, ilişkiyi signature'a yazmak. .NET Core 3.0'dan beri System.Diagnostics.CodeAnalysis namespace'inde tam bunun için attribute'lar var. Bunlar runtime'da hiçbir şey yapmaz; tek işleri compiler'a, signature'ın tek başına anlatamadığı null contract'ını anlatmak.

Koşullu Contract: NotNullWhen

using System.Diagnostics.CodeAnalysis;

Catalog catalog = new Catalog();
catalog.Add(new Book("978-0441172719", "Dune"));

if (catalog.TryFind("978-0441172719", out Book? book))
{
    Console.WriteLine(book.Title);
}

string? note = catalog.NoteFor("978-0441172719");
if (!string.IsNullOrEmpty(note))
{
    Console.WriteLine(note.Length);
}

public record Book(string Isbn, string Title);

public class Catalog
{
    private readonly Dictionary<string, Book> _books = new Dictionary<string, Book>();

    public void Add(Book book) => _books[book.Isbn] = book;

    public string? NoteFor(string isbn) => isbn.StartsWith("978") ? "signed copy" : null;

    public bool TryFind(string isbn, [NotNullWhen(true)] out Book? book) => _books.TryGetValue(isbn, out book);

    public bool TryFindBroken(string isbn, [NotNullWhen(true)] out Book? book)
    {
        book = null;
        return true;
    }
}
Program.cs(32,9): warning CS8762: Parameter 'book' must have a non-null value when exiting with 'true'.
Dune
11

[NotNullWhen(true)] "method true döndürürse bu parametre null değildir" der. Artık if bloğunun içinde book null olmayan bir değer olarak biliniyor ve book.Title uyarısı kayboldu. Parametrenin tipi hâlâ Book?, çünkü false durumunda null olabilir; attribute bu iki durumu ayırıyor.

Attribute bir söz ve compiler sözü method'un içinde de denetliyor. TryFindBroken null bırakıp true döndüğü için CS8762 uyarısı aldı. Bu denetim her durumu yakalamaz ama bariz çelişkileri yakalar.

BCL bu attribute'ları baştan sona kullanır. string.IsNullOrEmpty'nin parametresi [NotNullWhen(false)] ile işaretli; bu yüzden !string.IsNullOrEmpty(note) bloğunda note.Length uyarısız derlendi. Dictionary<TKey, TValue>.TryGetValue'nun out parametresi ise [MaybeNullWhen(false)] taşır. Bu ikisinin farkını generic'lerde göreceğiz.

Generic'lerde T? ve MaybeNullWhen

Generic kodda null ayrı bir dert. C# 9'dan beri kısıtsız bir T için T? yazabilirsin, ama anlamı sandığın şey olmayabilir:

int[] stock = { 3, 0, 12 };
string[] titles = { "Dune", "Clean Code" };

Console.WriteLine(FindFirst(stock, s => s > 100));
Console.WriteLine(FindFirst(titles, t => t.StartsWith("X")) ?? "(null)");

static T? FindFirst<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
    foreach (T item in source)
    {
        if (predicate(item))
        {
            return item;
        }
    }
    return default;
}
0
(null)

Stoğu 100'den büyük kitap yok, ama sonuç null değil, 0. Kısıtsız T?, reference type'lar için "null olabilir" anlamına gelir; value type'lar için ise Nullable<T>'ye dönüşmez, düz T olarak kalır. int ile çağırınca dönüş tipi int oldu ve default 0 döndü. "Bulunamadı" ile "stoğu 0 olan bir kitap bulundu" ayırt edilemiyor. LINQ'teki FirstOrDefault da aynı şekilde davranır. Ayrım önemliyse Try pattern'ine dön:

using System.Diagnostics.CodeAnalysis;

Cache<int> stockCache = new Cache<int>();
stockCache.Set("dune", 3);

Cache<Book> bookCache = new Cache<Book>();
bookCache.Set("dune", new Book("Dune"));

if (stockCache.TryGet("dune", out int count))
{
    Console.WriteLine($"stock: {count}");
}

if (bookCache.TryGet("dune", out Book? book))
{
    Console.WriteLine($"title: {book.Title}");
}

Console.WriteLine(bookCache.TryGet("missing", out Book? missing) ? missing.Title : "not cached");

public record Book(string Title);

public class Cache<T>
{
    private readonly Dictionary<string, T> _items = new Dictionary<string, T>();

    public void Set(string key, T value) => _items[key] = value;

    public bool TryGet(string key, [MaybeNullWhen(false)] out T value) => _items.TryGetValue(key, out value);
}
stock: 3
title: Dune
not cached

Burada parametre tipi T, T? değil. Cache<Book> içinde null bir değer saklamak istemiyoruz, dolayısıyla tip "null değil" diyor. [MaybeNullWhen(false)] ise istisnayı ekliyor: "method false döndürürse bu değer null (ya da default) olabilir". NotNullWhen nullable bir tipi koşullu olarak narrow eder, MaybeNullWhen null olmayan bir tipi koşullu olarak genişletir. Generic bir API'de ikincisi daha doğrudur, çünkü T zaten string? gibi nullable bir tip de olabilir ve sen onun nullability'sini bozmamış olursun.

Giriş ve Çıkış Attribute'ları

Bir grup attribute daha, bir değerin method'a girerken ve çıkarken nasıl davrandığını anlatır:

using System.Diagnostics.CodeAnalysis;

string normalized = Isbn.Normalize("978-0-441-17271-9");
string? nothing = Isbn.Normalize(null);
Console.WriteLine(normalized);
Console.WriteLine(nothing ?? "(null)");

string? raw = Environment.GetEnvironmentVariable("BOOKSTORE_DEFAULT_ISBN") ?? "9780441172719";
Isbn.EnsureValid(raw);
Console.WriteLine(raw.Length);

BookNote bookNote = new BookNote();
bookNote.Text = null;
Console.WriteLine($"[{bookNote.Text}] {bookNote.Text.Length}");

public static class Isbn
{
    [return: NotNullIfNotNull("isbn")]
    public static string? Normalize(string? isbn) => isbn?.Replace("-", "");

    public static void EnsureValid([NotNull] string? isbn)
    {
        if (isbn is null || isbn.Length != 13)
        {
            throw new ArgumentException("ISBN must have 13 digits.", nameof(isbn));
        }
    }
}

public class BookNote
{
    private string _text = "";

    [AllowNull]
    public string Text
    {
        get => _text;
        set => _text = value ?? "";
    }
}
9780441172719
(null)
13
[] 0

Üçü de uyarısız derlendi. [return: NotNullIfNotNull("isbn")]: "girdi null değilse çıktı da null değildir". Null olmayan bir string verince sonucu doğrudan string'e atayabildik; null verince sonuç nullable kaldı. Parametre adını string olarak yazdık, çünkü attribute içinde parametreye nameof ile ulaşmak C# 11 ile geldi.

[NotNull] bir parametrede "method normal dönerse bu değer artık null değildir" anlamına gelir. EnsureValid null için exception fırlattığından, çağrıdan sonraki satırda raw null olmayan biliniyor. [AllowNull] ise bir property'nin setter'ına null kabul ettirir ama getter'ı null olmayan bırakır: Text'e null atayabilirsin, okuduğunda boş string alırsın. Tersini yapan [DisallowNull] ("nullable tip, ama bana null verme") ve dönüş için [MaybeNull] da aynı ailedendir.

Constructor Yardımcıları: MemberNotNull

Constructor'ındaki kurulumu bir yardımcı method'a taşıyan class'larda sık görülen bir uyarı var:

OrderSession session = new OrderSession("Ada");
Console.WriteLine(session.Summary);

public class OrderSession
{
    private List<string> _items;
    private string _customerName;

    public OrderSession(string customerName)
    {
        Reset(customerName);
    }

    public string Summary => $"{_customerName}: {_items.Count} items";

    public void Reset(string customerName)
    {
        _items = new List<string>();
        _customerName = customerName;
    }
}
Program.cs(9,12): warning CS8618: Non-nullable field '_customerName' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
Program.cs(9,12): warning CS8618: Non-nullable field '_items' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
Ada: 0 items

Field'lar aslında atanıyor, ama Reset'in içinde; compiler yine method sınırında duruyor. Mesajdaki required önerisi C# 11'in bir özelliği, bu yüzden bizim için seçenek değil. Çözüm, Reset'in neyi garanti ettiğini yazmak:

[MemberNotNull(nameof(_items), nameof(_customerName))]
public void Reset(string customerName)

Bu satırı ekleyip dosyanın başına using System.Diagnostics.CodeAnalysis; koyunca iki uyarı da kayboldu. Compiler sözü Reset'in içinde de denetler: field'lardan birini atamayı unutursan bu kez Reset uyarı alır. Durumu bir bool property'ye bağlı field'lar için [MemberNotNullWhen(true, nameof(_x))] de var; IsLoaded true ise verinin dolu olduğunu böyle anlatırsın.

Akışı Bitiren Method'lar: DoesNotReturn

Hata fırlatmayı tek bir yardımcıda toplamak yaygın bir alışkanlık. Compiler ise yardımcının asla geri dönmediğini bilmez:

Console.WriteLine(Describe(new Book("Dune")));

static string Describe(Book? book)
{
    if (book is null)
    {
        Fail("A book is required.");
    }
    return book.Title;
}

static void Fail(string message) => throw new ArgumentException(message);

public record Book(string Title);
Program.cs(9,12): warning CS8602: Dereference of a possibly null reference.
Dune

Compiler'a göre Fail dönebilir ve book hâlâ null olabilir. Fail'in üstüne [DoesNotReturn] yazınca uyarı kayboldu; compiler artık if bloğunun içinden çıkılamayacağını biliyor. Kardeşi [DoesNotReturnIf(false)] bir bool parametreye konur; Debug.Assert'in koşul parametresi böyle işaretlidir.

Runtime'da Koruma Yok

Bütün bunlar compile time'da olan şeyler. Nullable reference types runtime'a tek bir kontrol eklemez; string ve string? derlenmiş kodda aynı tiptir. Nullable kapalı eski bir kod, ya da ! ile uyarıyı susturan biri, null olmayan bir parametreye null verebilir:

try
{
    Book book = LegacyImporter.Import();
    Console.WriteLine(book.Title.Length);
}
catch (ArgumentNullException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}

public record Book
{
    public string Title { get; }

    public Book(string title)
    {
        ArgumentNullException.ThrowIfNull(title);
        Title = title;
    }
}

#nullable disable
public static class LegacyImporter
{
    public static Book Import()
    {
        string title = null;
        return new Book(title);
    }
}
#nullable restore
ArgumentNullException: Value cannot be null. (Parameter 'title')

#nullable disable bölgesinde compiler hiçbir uyarı vermedi ve null, Book'un constructor'ına kadar geldi. Orada .NET 6 ile gelen ArgumentNullException.ThrowIfNull yakaladı. Parametre adını yazmamıza gerek kalmadı: method, C# 10'un [CallerArgumentExpression] attribute'u sayesinde argüman olarak verilen expression'ı, yani title'ı, kendisi alıyor. Kural: public bir API'nin sınırında nullable annotation'lar okuyana yol gösterir, ThrowIfNull ise gerçekten korur. İkisi birbirinin yerine geçmez.

Büyük bir projeyi nullable'a geçirirken de bu direktifler işe yarar: proje genelinde açıp henüz geçirilmemiş dosyaların başına #nullable disable koyabilir, ya da tersine kapalı projede dosya dosya #nullable enable ile ilerleyebilirsin. Geçiş bitince csproj'a <WarningsAsErrors>nullable</WarningsAsErrors> ekleyip bütün nullable uyarılarını hataya çevirmek kazanımı kalıcı yapar.

Sık Düşülen Tuzaklar

null! ve default! ile susturmak. private string _name = null!; compiler'a "bana güven" demektir; güvenin boşa çıktığı gün NullReferenceException'ı uyarının hiç olmadığı bir yerde alırsın. Attribute'la anlatabildiğin her contract'ı attribute'la anlat.

Kısıtsız T?'yi Nullable<T> sanmak. Value type'larda T? düz T'dir; "yok" ile default'u ayırmak istiyorsan Try pattern'i ya da where T : struct ile gerçek T? kullan.

Attribute'u yanlış yöne koymak. [NotNullWhen(true)] ile [NotNullWhen(false)]'ı karıştırmak compiler'a yanlış bilgi verir ve bu yalan çağıran tarafta yakalanmaz. CS8762 gibi iç denetimler bariz durumları yakalar, gerisini testler yakalar.

Annotation'ı runtime kontrolü sanmak. Library yazıyorsan seni çağıracak kodun nullable açık olduğunu varsayamazsın. Public method'larda null kontrolünü yine yap.

Küçük Bir Deneme

★☆☆ Catalog'a bool TryFindByTitle(string title, [NotNullWhen(true)] out Book? book) ekle ve if bloğunda book.Isbn'i uyarısız yazdır. Sonra attribute'u [NotNullWhen(false)] yap ve uyarının nereye taşındığına bak.

★★☆ FindFirst<T>'in yanına where T : struct kısıtlı bir FindFirstValue<T> yaz ve T? döndürsün. stock array'iyle çağırınca bu kez null aldığını gör. İki method aynı isimde olabilir mi?

★★★ OrderSession'a public string? InvoiceNumber { get; private set; } ve [MemberNotNullWhen(true, nameof(InvoiceNumber))] ile işaretli bir public bool IsCheckedOut => InvoiceNumber is not null; ekle. Çağıran tarafta if (session.IsCheckedOut) bloğunda session.InvoiceNumber.Length'in uyarısız derlendiğini, attribute'u kaldırınca CS8602'nin geri geldiğini gör.

Bu yazıda attribute'ları yalnızca compiler'a bilgi vermek için kullandık. Oysa attribute'lar runtime'da da okunabilir; ASP.NET Core'un [HttpGet]'i, EF Core'un [Key]'i ve validation attribute'ları böyle çalışır. Bir sonraki yazıda kendi attribute'umuzu yazıyor ve onu reflection ile okuyoruz.