C# Dosya ve Konsol: ReadLine, File, Path, Directory, using ve Encoding
Aynı metin neden 15 ve 18 bayt tutuyor? UTF-8 ve BOM, null dönebilen Console.ReadLine, Path.Combine ile Path.Join farkı, File ve Directory ile tek satırda okuma-yazma, göreli yollar ve çalışma klasörü, Dispose edilmeyen StreamWriter'ın boş kalan dosyası.
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
Aynı kitap adını iki ayrı dosyaya yazıyoruz. Tek fark, ilk satırda encoding'i açıkça Encoding.UTF8 olarak vermemiz. "Les Misérables" 14 karakter. İki dosya diskte kaç bayt tutar?
using System.Text;
File.WriteAllText("title-a.txt", "Les Misérables", Encoding.UTF8);
File.WriteAllText("title-b.txt", "Les Misérables");
Console.WriteLine(new FileInfo("title-a.txt").Length);
Console.WriteLine(new FileInfo("title-b.txt").Length);
Aynı Metin, Farklı Bayt Sayısı
Çıktı 18 ve 15. İkisi de 14 değil, ikisi birbirine de eşit değil. Sebebini görmek için dosyanın baytlarına bakalım:
Console.WriteLine(BitConverter.ToString(File.ReadAllBytes("title-a.txt")));
// EF-BB-BF-4C-65-73-20-4D-69-73-C3-A9-72-61-62-6C-65-73
Console.WriteLine(File.ReadAllText("title-a.txt").Length); // 14
Console.WriteLine(File.ReadAllText("title-b.txt", Encoding.Latin1)); // Les Misérables
Dosyalar karakter değil bayt tutar. Encoding, karakterleri bayta ve baytları geri karaktere çeviren kuraldır. UTF-8'de İngilizce harfler birer bayt tutar, "é" ise iki bayt: C3-A9. 14 karakter bu yüzden 15 bayt oldu. İlk dosyadaki fazladan üç bayt, en baştaki EF-BB-BF; buna BOM (byte order mark) denir ve "bu dosya UTF-8" diyen bir signature'dır.
Kafa karıştıran kısım şu: File.WriteAllText encoding vermezsen de UTF-8 yazar, ama BOM'suz. Encoding.UTF8 property'si ise BOM yazan bir UTF-8 encoding object'i döndürür. İkisi de geçerli UTF-8; ama bazı araçlar, özellikle Unix tarafındaki script'ler ve bazı CSV okuyucuları, baştaki BOM'u metnin parçası sanıp ilk satırın ilk field'ını bozar. Excel ise tam tersine, Türkçe karakterli bir CSV'yi doğru açmak için çoğu zaman BOM ister. Hangisini yazacağına dosyayı kimin okuyacağına bakarak karar ver; BOM'suz UTF-8 istiyorsan new UTF8Encoding(false) kullan.
Son satır yanlış encoding ile okumanın sonucunu gösteriyor. UTF-8 ile yazılmış iki baytı Latin-1 ile okuyunca her bayt ayrı bir karakter oldu ve "é" yerine "é" çıktı. Türkçe metinlerde aynı hatayı "ç" ve "ı" gibi tanıdık bozukluklar olarak görürsün; bunların hepsi encoding uyuşmazlığıdır, veri kaybı değil. Doğru encoding ile okuyunca metin geri gelir.
Eski Windows sistemlerinden gelen dosyalar hâlâ Windows-1254 (Türkçe) gibi code page'lerle yazılmış olabilir. .NET Framework bunları kendiliğinden tanırdı; .NET Core ve sonrasında önce bir provider kaydetmen gerekir:
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Console.WriteLine(Encoding.GetEncoding(1254).EncodingName); // Turkish (Windows)
İlk satırı yazmadan GetEncoding(1254) çağırırsan NotSupportedException: No data is available for encoding 1254. alırsın.
Konsoldan Girdi Okumak
Dosyalara geçmeden önce konsolun kendisine bakalım. Kullanıcıdan bir satır okumak için Console.ReadLine() var:
Console.Write("Your name: ");
string? name = Console.ReadLine();
if (string.IsNullOrWhiteSpace(name))
{
Console.WriteLine("No name given.");
return;
}
Console.Write("How many books? ");
string? input = Console.ReadLine();
if (int.TryParse(input, out int quantity) && quantity > 0)
{
Console.WriteLine($"Thanks {name.Trim()}, {quantity} book(s) added to your cart.");
}
else
{
Console.WriteLine($"'{input}' is not a valid quantity.");
}
Dönüş tipinin string? olduğuna dikkat et. ReadLine, okunacak satır kalmadığında null döner. Klavyeden çalışırken bu, Ctrl+Z ve Enter (Windows) ya da Ctrl+D (macOS, Linux) ile girdinin bitirildiği andır. Girdi bir dosyadan ya da başka bir programdan geldiğinde ise çok daha sık olur. Programı girdiyi pipe ile vererek çalıştırdım. dotnet run'ın her seferinde en başta yazdığı NETSDK1138 build uyarısını çıkardım; net6.0'ın desteğinin bittiğini söylüyor, hata ayıklama yazısında ona döneceğiz:
$ printf 'Ada\n3\n' | dotnet run
Your name: How many books? Thanks Ada, 3 book(s) added to your cart.
$ printf 'Ada\nthree\n' | dotnet run
Your name: How many books? 'three' is not a valid quantity.
$ printf 'Ada\n' | dotnet run
Your name: How many books? '' is not a valid quantity.
$ printf '' | dotnet run
Your name: No name given.
Girdi pipe'tan geldiği için kullanıcının yazdıkları ekrana yansımadı, bu yüzden sorular yan yana göründü. Üçüncü çalıştırmada ikinci soru için satır kalmadığından input null oldu; interpolation null'ı boş string olarak yazdı. Nullable yazısında konuştuğumuz uyarılar burada işe yarıyor: name.Trim() satırına gelmeden önce null kontrolü yapmasaydık derleyici CS8602 ile uyarırdı. Girdiyi sayıya çevirirken de her zaman TryParse kullan; kullanıcı "three" yazdığında programın exception ile kapanmasını istemezsin.
Path ile Yol Kurmak
Dosya yollarını string birleştirerek kurmak cazip gelir: "data\\" + fileName. Bu kod Windows'ta çalışır, macOS'ta ve Linux'ta, yani büyük ihtimalle uygulamanın çalışacağı sunucuda ise adı ters bölü içeren tek bir dosya üretir. Yolları Path class'ı ile kur:
Console.WriteLine(Path.Combine("data", "2026", "books.txt")); // data/2026/books.txt
Console.WriteLine(Path.DirectorySeparatorChar); // /
Console.WriteLine(Path.GetFileName("data/2026/books.txt")); // books.txt
Console.WriteLine(Path.GetFileNameWithoutExtension("data/2026/books.txt")); // books
Console.WriteLine(Path.GetExtension("data/2026/books.txt")); // .txt
Console.WriteLine(Path.GetDirectoryName("data/2026/books.txt")); // data/2026
Console.WriteLine(Path.ChangeExtension("data/2026/books.txt", ".bak")); // data/2026/books.bak
Console.WriteLine(Path.Combine("data", "/etc/hosts")); // /etc/hosts
Console.WriteLine(Path.Join("data", "/etc/hosts")); // data/etc/hosts
Çıktıları macOS'ta aldım; Windows'ta ayırıcı \ olur. Path sadece string üzerinde çalışır, diske hiç dokunmaz; olmayan bir dosyanın uzantısını da sorabilirsin.
Son iki satır önemli bir farkı gösteriyor. Path.Combine, argümanlardan biri kök ile başlıyorsa öncekileri atar ve doğrudan o yolu döndürür. Kullanıcının girdiği bir dosya adını Combine ile birleştiriyorsan, "/etc/hosts" yazan biri senin klasörünün dışına çıkabilir. Path.Join (.NET Core 3.0'dan beri var) böyle bir şey yapmaz, parçaları sadece birleştirir. Yine de dışarıdan gelen dosya adlarına hiçbir zaman güvenme; Path.GetFileName ile sadece ad kısmını al.
File ve Directory: Tek Satırda Okuma ve Yazma
Küçük dosyalar için File class'ının tek satırlık method'ları çoğu zaman yeterlidir. Kitap listesini bir klasöre yazıp geri okuyalım:
string dataDir = Path.Combine("data", "2026");
Directory.CreateDirectory(dataDir);
Directory.CreateDirectory(dataDir); // klasör zaten varsa hata vermez
string booksPath = Path.Combine(dataDir, "books.txt");
string[] books = { "978-0441172719;Dune", "978-0141439518;Emma" };
File.WriteAllLines(booksPath, books);
File.AppendAllText(booksPath, "978-0199535675;Ulysses" + Environment.NewLine);
Console.WriteLine(File.Exists(booksPath)); // True
Console.WriteLine(File.ReadAllText(booksPath).Length); // 63
string[] lines = File.ReadAllLines(booksPath);
Console.WriteLine($"{lines.Length} lines, first: {lines[0]}"); // 3 lines, first: 978-0441172719;Dune
foreach (string line in File.ReadLines(booksPath))
{
string[] parts = line.Split(';');
Console.WriteLine($"{parts[1],-8} {parts[0]}");
}
// Dune 978-0441172719
// Emma 978-0141439518
// Ulysses 978-0199535675
File.Copy(booksPath, Path.ChangeExtension(booksPath, ".bak"), overwrite: true);
foreach (string file in Directory.GetFiles(dataDir))
{
Console.WriteLine(Path.GetFileName(file));
}
// books.txt
// books.bak
Directory.CreateDirectory ara klasörleri de oluşturur ve klasör zaten varsa sessizce geçer; yazmadan önce bir kez çağırmak yeterli. WriteAllText ve WriteAllLines dosya varsa üzerine yazar, AppendAllText sonuna ekler.
ReadAllLines ile ReadLines arasındaki fark büyük dosyalarda ortaya çıkar. ReadAllLines dosyanın tamamını belleğe alıp bir array döndürür. ReadLines ise satırları sen döngüde ilerledikçe tek tek okur; 2 GB'lık bir log dosyasında ilki belleği bitirir, ikincisi rahatça çalışır. Directory.GetFiles'ın döndürdüğü sıra da garanti değildir; sıralı bir liste istiyorsan kendin sırala.
Olmayan bir dosyayı okumaya çalışırsan FileNotFoundException (Could not find file '.../missing.txt'.), olmayan bir klasöre yazmaya çalışırsan DirectoryNotFoundException (Could not find a part of the path '.../nofolder/a.txt'.) alırsın. Exception handling yazısındaki kural burada da geçerli: File.Exists ile önce sormak iyi bir alışkanlık ama yeterli değil. Kontrol ile okuma arasında başka bir program dosyayı silebilir; gerçek dosya işlemlerini yine de try/catch ile koru.
Göreli Yollar ve Çalışma Klasörü
Yukarıdaki örneklerde "data" gibi göreli yollar kullandık. Göreli bir yol, programın bulunduğu klasöre göre değil, çalışma klasörüne (current directory) göre çözülür. İkisini yan yana yazdırabilirsin:
Console.WriteLine(Environment.CurrentDirectory);
Console.WriteLine(AppContext.BaseDirectory);
Proje klasöründe dotnet run dediğimde ilk satır proje klasörünü, ikincisi bin/Debug/net6.0/ klasörünü gösterdi. Aynı programı bir üst klasörden dotnet run --project ile çalıştırdığımda ise ilk satır o üst klasöre döndü ve data klasörü orada aranmaya başladı. IDE'ler de çalışma klasörünü kendi ayarlarına göre seçer. "Benim makinemde dosya bulunuyordu" hatalarının büyük kısmı buradan çıkar.
Programla birlikte deploy ettiğin dosyaları (örnek veri, template) AppContext.BaseDirectory'ye göre bul. Kullanıcının seçtiği ya da komut satırından verdiği yolları çalışma klasörüne göre çöz. Uygulamanın kendi verisini yazacağı yer için de Environment.GetFolderPath ile işletim sisteminin uygulama verisi klasörünü kullanmak, programın kurulu olduğu klasöre yazmaktan daha doğrudur.
Stream'e İlk Bakış: using Neden Şart
File.ReadAllText gibi method'ların altında stream'ler çalışır. Stream, bir bayt akışıdır: dosyadan, ağdan ya da bellekten parça parça okursun veya yazarsın. Metin yazıp okumak için onun üstüne StreamWriter ve StreamReader oturur. Şu denemeye bak:
string path = "orders.log";
StreamWriter writer = new StreamWriter(path);
writer.WriteLine("1001;Dune;2");
writer.WriteLine("1002;Emma;1");
Console.WriteLine($"Before Dispose: {new FileInfo(path).Length} bytes"); // Before Dispose: 0 bytes
writer.Dispose();
Console.WriteLine($"After Dispose: {new FileInfo(path).Length} bytes"); // After Dispose: 24 bytes
using (StreamWriter appender = new StreamWriter(path, append: true))
{
appender.WriteLine("1003;Ulysses;3");
}
using StreamReader reader = new StreamReader(path);
string? line;
int count = 0;
while ((line = reader.ReadLine()) != null)
{
count++;
Console.WriteLine($"{count}: {line}");
}
// 1: 1001;Dune;2
// 2: 1002;Emma;1
// 3: 1003;Ulysses;3
İki satır yazdık ama dosya hâlâ 0 bayt. StreamWriter performans için yazdıklarını önce bellekteki bir tamponda biriktirir ve diske toplu gönderir. Dispose() çağrılınca tampon boşaltılır ve dosya kapatılır. Dispose'u unutursan son yazdıkların kaybolabilir, dosya da program kapanana kadar açık kalır; Windows'ta bu sürede başka bir program o dosyaya yazamaz, onu silemez.
using bu işi garantiye alır. using (...) { } bloğu bitince, araya bir exception girse bile Dispose çağrılır. C# 8 ile gelen using StreamReader reader = ...; yazımında ise süslü parantez yok; object, içinde bulunduğu scope bitince dispose edilir. IDisposable ve object ömrü konusunu OOP serisinde ayrıntılı göreceğiz. Şimdilik kural basit: new ile bir stream, reader ya da writer oluşturuyorsan başına using koy.
Bu yazıdaki method'ların hepsinin async karşılıkları da var: File.ReadAllTextAsync, WriteAllLinesAsync, ReadLineAsync. Bir web uygulamasında dosya okurken bunları kullanırsın; neden önemli olduklarını Asenkron Programlama serisinde konuşacağız.
Sık Düşülen Tuzaklar
Console.ReadLine()'ın her zaman bir string döndüreceğini sanmak. Girdi bittiğinde null döner. Nullable uyarılarını kapatma, onlar seni tam bu hataya karşı uyarıyor.
Yolları "\\" ya da "/" ile elle birleştirmek. Path.Combine ya da Path.Join kullan. Kullanıcıdan gelen dosya adını Combine'a vermeden önce Path.GetFileName ile temizle.
Göreli yolun programın yanındaki dosyayı bulacağını varsaymak. Çalışma klasörü programı nasıl başlattığına göre değişir. Programla gelen dosyalar için AppContext.BaseDirectory kullan.
Stream'leri using olmadan açmak. Veri tamponda kalır, dosya kilitli kalır. Kural: new StreamWriter gördüğün her yerde bir using görmelisin.
Encoding'i düşünmemek. Kendi yazdığın ve okuduğun dosyalarda UTF-8 kullan ve iki tarafta da aynı encoding'i ver. Dışarıdan gelen bir dosyada "ç" gibi karakterler görürsen veriyi düzeltmeye çalışmadan önce doğru encoding ile okumayı dene.
Büyük dosyaları ReadAllText ya da ReadAllLines ile okumak. Boyutunu bilmediğin dosyalarda File.ReadLines ya da StreamReader ile satır satır ilerle.
Küçük Bir Deneme
★☆☆ Kullanıcıdan boş satır girene kadar kitap adları okuyan ve hepsini wishlist.txt dosyasına File.WriteAllLines ile yazan bir program yaz. Girdinin null olabileceğini unutma.
★★☆ books.txt dosyasını File.ReadLines ile okuyup her satırı ; ile böl ve sadece adı "U" harfiyle başlayan kitapları yazdır. Dosya yoksa kullanıcıya düzgün bir mesaj göster.
★★★ Aynı CSV içeriğini bir kez new UTF8Encoding(false), bir kez Encoding.UTF8 ile yaz ve iki dosyanın ilk üç baytını File.ReadAllBytes ile karşılaştır. Sonra BOM'lu dosyayı Encoding.Latin1 ile okuyup ilk satırın başında ne gördüğünü not et.
Bu yazıda bütün kodu tek bir Program.cs'te yazdık. Program büyüdükçe bu dosya taşınmaz hâle gelir. Bir sonraki yazıda kodu namespace'lerle, file-scoped namespace ve global using ile düzenliyor, birden fazla projeyi bir solution altında topluyor ve projeye NuGet paketi ekliyoruz.