C# Hata Ayıklama: Breakpoint, Call Stack, Debug.Assert ve Derleyici Uyarıları
Debug.WriteLine neden terminalde görünmez, Debug.Assert neden Release'te yok olur? IDE'den bağımsız debugger kavramları, watch ile tamsayı bölmesi hatasını bulmak, stack trace okumak, dotnet build uyarılarının anatomisi ve WarningsAsErrors ile nullable uyarılarını hataya çevirmek.
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
Stok kontrolüne iki "hata ayıklama" satırı ekledik: biri ara değeri yazıyor, diğeri stokun yeterli olduğunu doğruluyor. Programı terminalden dotnet run ile çalıştırınca ekranda ne görürsün? Aynı programı Release modunda derleyip çalıştırınca ne değişir?
using System.Diagnostics;
int stock = 3;
Debug.WriteLine($"Stock before sale: {stock}");
Debug.Assert(stock >= 5, "Stock is too low");
Console.WriteLine("Sale completed");
Debug Class'ı Konsola Yazmaz
dotnet run ile aldığım çıktı şu oldu:
Process terminated. Assertion failed.
Stock is too low
at Program.<Main>$(String[] args) in /.../Bookstore.Debugging/Program.cs:line 5
Program "Sale completed" yazamadan kapandı ve macOS'ta çıkış kodu 134 oldu. dotnet run'ın en başta yazdığı NETSDK1138 build uyarısını çıkardım ve dosya yolunun başını kısalttım; gerisi aynen böyle. O uyarıya aşağıda ayrıca döneceğiz. Dikkat edersen "Stock before sale: 3" satırı hiçbir yerde yok. Debug.WriteLine konsola değil, trace listener'lara yazar. Varsayılan listener, çıktıyı bağlı bir debugger varsa onun Output ya da Debug Console penceresine gönderir. Terminalden çalıştırdığında dinleyen bir debugger olmadığı için yazı kaybolur.
Debug.Assert ise koşul yanlışsa programı durdurur. Bu bir exception değildir, try/catch ile yakalayamazsın; "buraya hiç gelinmemeliydi" diyen bir alarmdır. Debugger bağlıyken IDE tam o satırda durur ve sana neler olduğunu inceleme fırsatı verir.
Release modunda ise tablo tamamen değişiyor:
$ dotnet build -c Release
$ dotnet bin/Release/net6.0/Bookstore.Debugging.dll
Sale completed
Build çıktısını göstermedim. Assert'in koşulu hâlâ yanlış ama program sorunsuz bitti. Debug class'ının method'ları [Conditional("DEBUG")] attribute'u taşır: DEBUG sembolü tanımlı değilse derleyici bu çağrıları koddan tamamen çıkarır, argümanları bile hesaplanmaz. Debug konfigürasyonu bu sembolü tanımlar, Release tanımlamaz. Yani Debug.Assert geliştirirken seni uyaran bir araçtır, canlı ortamda kullanıcıyı korumaz. Canlıda da geçerli olması gereken kontroller için normal bir if ve exception kullan; exception handling yazısında bunu konuşmuştuk.
Debug.WriteLine çıktısını terminalde görmek istersen bir listener eklersin:
using System.Diagnostics;
Trace.Listeners.Add(new ConsoleTraceListener());
int stock = 3;
Debug.WriteLine($"Stock before sale: {stock}");
Debug.Indent();
Debug.WriteLine("Checking reservations...");
Debug.Unindent();
Debug.WriteLineIf(stock < 5, "Stock is running low");
Console.WriteLine("Sale completed");
Stock before sale: 3
Checking reservations...
Stock is running low
Sale completed
Bunu küçük denemeler için kullan. Gerçek bir uygulamada ne olduğunu kaydetmek için logging kullanırsın; ASP.NET Core Web API serisinin logging yazısında ILogger ile bunu ayrıntılı anlattık.
Debugger'ın Temel Kavramları
Visual Studio, VS Code (C# eklentisiyle) ya da Rider, hangisini kullanırsan kullan, debugger aynı birkaç kavram üzerine kuruludur. Kısayollar değişir, fikir değişmez.
Breakpoint, programın duracağı satırdır. Satır numarasının yanına tıklayınca kırmızı bir nokta belirir. Programı debugger ile başlattığında (Visual Studio ve VS Code'da F5) o satıra gelindiği anda her şey donar: değişkenler, call stack, o anki durum. Satır henüz çalışmamıştır; durduğun satır, bir sonraki adımda çalışacak olandır.
Durduktan sonra adım adım ilerlersin. Step over (F10) mevcut satırı çalıştırır ve bir sonrakine geçer; satırda bir method çağrısı varsa onun içine girmez. Step into (F11) çağrılan method'un içine girer. Step out (Shift+F11) mevcut method'un kalanını çalıştırıp seni onu çağıran yere geri götürür. Continue (F5) bir sonraki breakpoint'e kadar devam eder. Rider'da kısayollar seçtiğin keymap'e göre değişir, ama menüdeki isimler aynıdır.
Durduğun anda değerleri görmek için üç yer var. Locals (ya da Variables) penceresi, o scope'taki bütün değişkenleri gösterir. Watch penceresine istediğin expression'ı yazarsın ve her adımda yeniden hesaplanır: sadece değişken değil, price * rate ya da counts.Length gibi expression'lar da olur. Fareyi koddaki bir değişkenin üstüne getirmek de değerini gösterir.
Bir Hatayı Debugger'la Avlamak
Teoriyi bir hatayla deneyelim. İndirim fonksiyonu 200 liralık kitaba %15 indirim uygulamalı, ama sonuç değişmiyor:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine(ApplyDiscount(200m, 15)); // 200
Console.WriteLine(ApplyDiscountFixed(200m, 15)); // 170.00
decimal ApplyDiscount(decimal price, int percent)
{
decimal rate = percent / 100;
return price - price * rate;
}
decimal ApplyDiscountFixed(decimal price, int percent)
{
decimal rate = percent / 100m;
return price - price * rate;
}
return satırına bir breakpoint koyup programı debugger ile başlattığında Locals penceresi price = 200, percent = 15 ve rate = 0 gösterir. Sorun görünür oldu: oran sıfır. Watch penceresine percent / 100 yazınca 0, percent / 100m yazınca 0.15 görürsün. Sayılar yazısındaki tamsayı bölmesi tuzağı bu: iki int'in bölümü yine int'tir ve sonuç ancak bölmeden sonra decimal'a çevrilir. Debugger olmadan bu hatayı bulmak için satır satır Console.WriteLine eklerdin; debugger'la tek bir duruşta gördün.
Bir döngünün içindeki hatada her turda durmak can sıkar. Bunun için conditional breakpoint var: breakpoint'e sağ tıklayıp bir koşul yazarsın (i == 2 ya da quantity > 100) ve debugger sadece koşul doğru olduğunda durur. Hit count ile "100. gelişte dur" diyebilir, bazı IDE'lerde durmadan sadece bir mesaj yazdıran logpoint'ler (tracepoint) kullanabilirsin. Logpoint'ler koda Console.WriteLine ekleyip sonra silmeyi unutmaktan çok daha temizdir.
Call Stack'i Okumak
Call stack, şu anki satıra hangi çağrılar zinciriyle gelindiğini gösterir. Debugger'da ayrı bir pencere olarak durur, ama aynı bilgiyi her yakalanmayan exception'ın stack trace'inde de görürsün. Sepet toplamını hesaplayan şu kodda fiyat ve adet array'lerinin uzunlukları farklı:
decimal[] prices = { 120m, 85.5m, 240m };
int[] quantities = { 1, 2 };
decimal total = CalculateTotal(prices, quantities);
Console.WriteLine($"Total: {total}");
decimal CalculateTotal(decimal[] unitPrices, int[] counts)
{
decimal sum = 0;
for (int i = 0; i < unitPrices.Length; i++)
{
sum += LineTotal(unitPrices[i], counts[i]);
}
return sum;
}
decimal LineTotal(decimal price, int quantity) => price * quantity;
Unhandled exception. System.IndexOutOfRangeException: Index was outside the bounds of the array.
at Program.<<Main>$>g__CalculateTotal|0_0(Decimal[] unitPrices, Int32[] counts) in /.../Program.cs:line 12
at Program.<Main>$(String[] args) in /.../Program.cs:line 4
Stack yukarıdan aşağı okunur. En üstteki satır exception'ın fırlatıldığı yer: CalculateTotal, 12. satır. Altındaki her satır, bir üsttekini çağıran yerdir: CalculateTotal'ı 4. satırdaki top-level kod çağırmış. LineTotal listede yok, çünkü exception counts[i] argümanı hesaplanırken, yani LineTotal daha çağrılmadan fırladı.
Garip isimler compiler'ın eseri. Top-level statements, Program class'ında <Main>$ adlı bir method'a dönüşür; içindeki local function'lar da <<Main>$>g__CalculateTotal|0_0 gibi üretilmiş adlar alır. Bu isimlerin içinde senin verdiğin adı bulmak yeterli.
Debugger'da call stack penceresindeki bir satıra çift tıklayınca o çağrının olduğu yere gider ve o andaki yerel değişkenleri görürsün. 4. satıra gidip quantities.Length'in 2 olduğunu görmek hatayı açıklar. Exception'ların fırlatıldığı anda durmak için IDE'lerin exception ayarlarında "break when thrown" seçeneği var; özellikle bir yerde yakalanıp yutulan exception'ları bulurken çok işe yarar.
Derleyici Uyarılarını Okumak
Hataların bir kısmını programı çalıştırmadan da yakalayabilirsin. Şu kodda en az beş sorun var:
string? coupon = Environment.GetEnvironmentVariable("BOOKSTORE_COUPON");
int discount = 10;
int unused;
if (coupon.Length > 0)
{
Console.WriteLine($"Coupon: {coupon}");
}
string code = Environment.GetEnvironmentVariable("BOOKSTORE_CODE");
Console.WriteLine(code);
return;
Console.WriteLine("Done");
dotnet build hepsini söylüyor. Yolları kısalttım:
.../Microsoft.NET.EolTargetFrameworks.targets(32,5): warning NETSDK1138: The target framework 'net6.0' is out of support and will not receive security updates in the future. Please refer to https://aka.ms/dotnet-core-support for more information about the support policy. [.../Bookstore.Debugging.csproj]
Program.cs(5,5): warning CS8602: Dereference of a possibly null reference. [.../Bookstore.Debugging.csproj]
Program.cs(10,15): warning CS8600: Converting null literal or possible null value to non-nullable type. [.../Bookstore.Debugging.csproj]
Program.cs(14,1): warning CS0162: Unreachable code detected [.../Bookstore.Debugging.csproj]
Program.cs(2,5): warning CS0219: The variable 'discount' is assigned but its value is never used [.../Bookstore.Debugging.csproj]
Program.cs(3,5): warning CS0168: The variable 'unused' is declared but never used [.../Bookstore.Debugging.csproj]
Her satırın yapısı aynı: dosya, parantez içinde satır ve sütun, önem derecesi (warning ya da error), kod, mesaj ve köşeli parantez içinde proje. Kodun ön eki sana kimin konuştuğunu söyler. CS C# compiler'ından gelir. NETSDK .NET SDK'dan gelir; buradaki, bu serinin bilerek hedeflediği .NET 6'nın desteğinin bittiğini hatırlatıyor. MSB MSBuild'den, NU NuGet'ten gelir. Bir kodu tanımıyorsan aratmak çoğu zaman ilk sonuçta Microsoft dokümanını getirir.
Uyarıları tek tek okuyalım. CS8602: coupon null olabilir ve .Length onu null kontrolü olmadan kullanıyor; environment variable yoksa bu satır NullReferenceException fırlatır. CS8600: null olabilecek bir değer null olamaz diye işaretlenmiş bir değişkene atanıyor. CS0162: return'den sonraki satır hiç çalışmayacak. CS0219 ve CS0168: kullanılmayan değişkenler; zararsız görünür ama çoğu zaman yarım kalmış bir işin izidir. Nullable uyarıları bu listenin en değerlisi: her biri, runtime'da çıkabilecek bir NullReferenceException'ın compile time'daki habercisi.
Bu arada .NET 10 SDK, etkileşimli bir terminalde dotnet build çıktısını daha derli toplu bir görünümde (terminal logger) gösterir; yukarıdaki klasik satırları, çıktı bir dosyaya ya da başka bir programa gittiğinde görürsün. Bilgi aynıdır.
Uyarıları Hataya Çevirmek
Uyarıların en büyük düşmanı alışkanlıktır. Projede 200 uyarı varsa, 201. uyarıyı kimse görmez. Bunu önlemenin yolu, önemli uyarıları hataya çevirmek. csproj'a şu satırı ekledim:
<WarningsAsErrors>nullable</WarningsAsErrors>
Program.cs(10,15): error CS8600: Converting null literal or possible null value to non-nullable type. [.../Bookstore.Debugging.csproj]
Program.cs(14,1): warning CS0162: Unreachable code detected [.../Bookstore.Debugging.csproj]
Program.cs(2,5): warning CS0219: The variable 'discount' is assigned but its value is never used [.../Bookstore.Debugging.csproj]
Program.cs(3,5): warning CS0168: The variable 'unused' is declared but never used [.../Bookstore.Debugging.csproj]
Program.cs(5,5): error CS8602: Dereference of a possibly null reference. [.../Bookstore.Debugging.csproj]
nullable değeri, bütün nullable uyarılarını kapsayan bir kısayol. Artık iki nullable uyarısı error oldu ve build başarısız; diğerleri uyarı olarak kaldı. Bütün compiler uyarılarını hataya çevirmek için <TreatWarningsAsErrors>true</TreatWarningsAsErrors> var. Yeni bir projede baştan açmak kolaydır; yüzlerce uyarısı olan eski bir projede ise önce WarningsAsErrors ile en önemli grupla başla.
Bir uyarının gerçekten yanlış alarm olduğundan eminsen, onu sadece o satır için susturabilirsin: #pragma warning disable CS0168 ve hemen arkasından #pragma warning restore CS0168. Bütün projede kapatmak için csproj'daki <NoWarn> var. İkisini de nadiren ve yanına bir yorumla kullan; susturulan her uyarı, bir sonraki kişiye "burada neden?" sorusunu bırakır.
Sık Düşülen Tuzaklar
Debug.WriteLine çıktısını terminalde aramak. Varsayılan olarak debugger'ın çıktı penceresine gider. Terminalde görmek istiyorsan bir listener ekle, ya da daha iyisi logging kullan.
Canlıda geçerli olması gereken bir kontrolü Debug.Assert ile yazmak. Release build'de o satır yoktur. Kullanıcı girdisini, dosya içeriğini, dış servislerin cevabını if ve exception ile doğrula.
Debug.Assert'in içine yan etkisi olan kod yazmak. Debug.Assert(TryReserve(book)) Debug'da kitabı ayırır, Release'te çağrı silindiği için hiç ayırmaz. Önce sonucu bir değişkene al, sonra onu assert et.
Release build'i debug etmeye çalışıp değişkenlerin "optimized away" görünmesine şaşırmak. Release'te compiler ve JIT kodu optimize eder; bazı değişkenler hiç var olmaz, satırlar yer değiştirir. Hata ayıklarken Debug konfigürasyonunu kullan.
Uyarıları görmezden gelmek. Her build'de akıp giden uyarılar bir süre sonra görünmez olur. Nullable uyarılarını WarningsAsErrors ile hataya çevir ve kalanları temizle.
Stack trace'i aşağıdan yukarı okumak. En üstteki satır sorunun olduğu yer, alttakiler oraya nasıl gelindiği. Kendi kodunun ilk göründüğü satırdan başla.
Küçük Bir Deneme
★☆☆ Yazıdaki uyarı veren kodu kendi projende derle, beş uyarıyı tek tek düzelt ve dotnet build'in sadece NETSDK1138 bırakana kadar sessizleştiğini gör.
★★☆ CalculateTotal örneğinde for satırına i == 1 koşullu bir breakpoint koy, debugger'la çalıştır ve durduğunda Watch penceresine unitPrices.Length ve counts.Length yaz. Sonra fonksiyonun başına iki uzunluğu karşılaştıran ve farklıysa ArgumentException fırlatan bir kontrol ekle.
★★★ Uyarı veren projede <TreatWarningsAsErrors>true</TreatWarningsAsErrors> aç ve hangi uyarıların hataya döndüğünü, hangisinin uyarı olarak kaldığını not et. NETSDK1138'in neden etkilenmediğini ön eklerden yola çıkarak açıkla. Sonra <WarningsNotAsErrors>CS0168</WarningsNotAsErrors> ekleyip CS0168'in yeniden uyarıya döndüğünü gör.
Serinin son yazısında öğrendiklerimizi bir araya getiriyoruz: konsolda çalışan küçük bir Bookstore uygulaması yazıyor, kitapları listeliyor, ekliyor, arıyor ve dosyaya kaydediyoruz. Enum'lar, tarihler, dosyalar, exception'lar ve bu yazıdaki hata ayıklama alışkanlıkları orada birlikte iş görecek.