Pliki tekstowe — program, który pamięta po zamknięciu
Lista wyników znika po zakończeniu programu. Zapiszemy ją w pliku, a potem odczytamy — ustalając po drodze, gdzie ten plik naprawdę powstaje i co zrobić, gdy dane w nim są zepsute.
Czego się dziś nauczysz
- Wyjaśnisz, dlaczego dane w zmiennych giną po zamknięciu programu, a dane w pliku nie.
- Ustalisz, gdzie naprawdę powstaje plik o ścieżce względnej, i zbudujesz ścieżkę świadomie.
- Złożysz ścieżkę metodą
Path.Combinei utworzysz folder metodąDirectory.CreateDirectory. - Dobierzesz właściwą metodę klasy
Filei odróżnisz nadpisanie od dopisania. - Zapiszesz i odczytasz polskie znaki, podając to samo kodowanie w obie strony.
- Użyjesz
StreamWriteriStreamReaderw blokuusingi wyjaśnisz, po co ten blok jest. - Zamienisz obiekt na wiersz CSV i wiersz CSV na obiekt, zapisując liczby w
InvariantCulture. - Odrzucisz zepsute wiersze pliku z konkretnym powodem i policzysz, ile pominięto.
- Zabezpieczysz operacje na plikach blokami
catchdla właściwych typów wyjątków.
Przygotowanie: lekcje 01–35. Przewidywany czas: 90 min z zadaniami. Przykłady wymagają .NET 8 lub nowszego, z włączonymi ImplicitUsings i Nullable.
Pamięć, która przeżywa zamknięcie
Każdy program, jaki do tej pory napisałeś, miał jedną wspólną wadę: po zamknięciu okna konsoli wszystko przepadało. Lista wyników, zapisani zawodnicy, policzone średnie — nic z tego nie zostawało. Przy następnym uruchomieniu trzeba było wpisać dane od nowa.
Powód jest prosty. Zmienne, tablice i listy żyją w pamięci roboczej, a pamięć robocza należy do działającego procesu. Kończy się proces, kończy się pamięć.
| Gdzie trzymamy dane | Jak długo żyją | Przykład |
|---|---|---|
zmienna, tablica, List<T> | do zakończenia programu | wszystko z lekcji 01–35 |
| plik na dysku | do usunięcia pliku | ta lekcja |
| baza danych | do usunięcia rekordu | osobne zajęcia |
| usługa w sieci | zależy od serwera | kurs aplikacji webowych |
Plik tekstowy to najprostsza trwała pamięć, jaką ma program. Nie wymaga serwera, instalacji ani konfiguracji — wystarczy jedna metoda:
File.WriteAllText("notatka.txt", "Turniej retro, 12 maja");
string tresc = File.ReadAllText("notatka.txt");
Console.WriteLine(tresc);
Turniej retro, 12 maja
Dwie metody i program już pamięta po zamknięciu. Reszta lekcji odpowiada na trzy pytania, które natychmiast się pojawiają: gdzie ten plik właściwie powstał, co się dzieje z poprzednią zawartością i co zrobić, gdy pliku nie ma albo dane w nim są zepsute.
Czego nie musisz dopisywać
Klasy File, Directory i Path leżą w przestrzeni System.IO. W projektach .NET 8 z ImplicitUsings jest ona dodana automatycznie, więc żadnego using nie trzeba pisać. Jeśli kompilator nie widzi klasy File, dopisz na górze using System.IO;
Ścieżki względne, bezwzględne i Path
Pierwsze zaskoczenie: po uruchomieniu programu z powyższego przykładu pliku notatka.txt nie ma obok pliku Program.cs. Nie zniknął — powstał w innym miejscu.
Nazwa "notatka.txt" to ścieżka względna: nie mówi, gdzie plik jest, tylko „w katalogu bieżącym”. A katalogiem bieżącym uruchomionego programu nie jest folder z kodem źródłowym, lecz folder ze skompilowanym programem:
Console.WriteLine(Directory.GetCurrentDirectory());
Console.WriteLine(AppContext.BaseDirectory);
C:\kurs\Turniej\bin\Debug\net8.0
C:\kurs\Turniej\bin\Debug\net8.0\
| Zapis | Co daje | Uwaga |
|---|---|---|
Directory.GetCurrentDirectory() | katalog bieżący procesu | Można go zmienić w trakcie działania, a niektóre środowiska ustawiają go inaczej. |
AppContext.BaseDirectory | folder, z którego uruchomiono program | Nie zmienia się. Pewniejszy wybór przy budowaniu ścieżek. |
Najczęstsze „gdzie jest mój plik”
Folder bin\Debug\net8.0 jest czyszczony przy niektórych przebudowach projektu. Jeśli trzymasz tam dane, mogą zniknąć bez ostrzeżenia. Dlatego do prawdziwych danych zawsze buduj ścieżkę świadomie — albo w podfolderze przy programie, albo w folderze dokumentów użytkownika.
Ścieżka bezwzględna i znak lewego ukośnika
Ścieżka bezwzględna mówi dokładnie, gdzie plik jest. Na Windowsie zaczyna się literą dysku. Kłopot jest w tym, że lewy ukośnik w napisie ma specjalne znaczenie — to znak rozpoczynający sekwencję ucieczki (lekcja 07):
// string zle = "C:\kurs\dane\wyniki.txt"; // BLAD - \k i \d to nieznane sekwencje
string a = "C:\\kurs\\dane\\wyniki.txt"; // kazdy ukosnik podwojony
string b = @"C:\kurs\dane\wyniki.txt"; // napis dosłowny - czytelniej
string c = "C:/kurs/dane/wyniki.txt"; // prawy ukosnik tez dziala
Console.WriteLine(a == b); // True - to ten sam napis
Zapis z @ przed cudzysłowem nazywa się napisem dosłownym: wewnątrz nie ma żadnych sekwencji ucieczki, więc lewy ukośnik znaczy po prostu lewy ukośnik. Do ścieżek używaj właśnie jego — jest o połowę krótszy i nie da się w nim pomylić liczby ukośników.
Path.Combine — jedyny poprawny sposób składania ścieżek
string folder = @"C:\kurs\dane";
string nazwa = "wyniki.txt";
// string zle = folder + "\\" + nazwa; // dziala na Windowsie, psuje sie na Linuksie
string dobrze = Path.Combine(folder, nazwa);
Console.WriteLine(dobrze); // C:\kurs\dane\wyniki.txt
Path.Combine sam wstawia właściwy separator dla systemu i radzi sobie z tym, że jeden z fragmentów ma już ukośnik na końcu. Sklejanie ścieżek plusem działa do pierwszego uruchomienia programu na innym systemie — a w szkole programy uruchamia się i na Windowsie, i na Linuksie.
Pozostałe metody klasy Path
string sciezka = @"C:\kurs\dane\wyniki.csv";
Console.WriteLine(Path.GetFileName(sciezka)); // wyniki.csv
Console.WriteLine(Path.GetFileNameWithoutExtension(sciezka)); // wyniki
Console.WriteLine(Path.GetExtension(sciezka)); // .csv
Console.WriteLine(Path.GetDirectoryName(sciezka)); // C:\kurs\dane
Console.WriteLine(Path.GetFullPath("notatka.txt")); // sciezka bezwzgledna
Folder, którego jeszcze nie ma
string folder = Path.Combine(AppContext.BaseDirectory, "dane");
Directory.CreateDirectory(folder); // tworzy, a jesli juz jest - nic nie robi
string plik = Path.Combine(folder, "wyniki.csv");
File.WriteAllText(plik, "pierwszy wiersz");
Zapamiętaj ten czterowierszowy wzorzec
AppContext.BaseDirectory → Path.Combine z nazwą podfolderu → Directory.CreateDirectory → Path.Combine z nazwą pliku. Cztery wiersze, po których zawsze wiesz, gdzie leżą dane, i nigdy nie dostaniesz DirectoryNotFoundException. CreateDirectory jest bezpieczne do wywołania przy każdym uruchomieniu — istniejącego folderu nie rusza.
Sześć metod klasy File
Klasa File ma sześć metod, które wystarczają do praktycznie wszystkiego, co robi się z plikami tekstowymi w programie szkolnym. Dzielą się na dwie trójki: dla całego tekstu i dla wierszy.
| Metoda | Co robi | Gdy plik nie istnieje |
|---|---|---|
File.WriteAllText(p, t) | zapisuje napis, nadpisując całość | tworzy |
File.AppendAllText(p, t) | dopisuje napis na końcu | tworzy |
File.ReadAllText(p) | czyta całość jako jeden napis | wyjątek |
File.WriteAllLines(p, t[]) | zapisuje tablicę wierszy, nadpisując | tworzy |
File.AppendAllLines(p, t[]) | dopisuje wiersze na końcu | tworzy |
File.ReadAllLines(p) | czyta plik do tablicy wierszy | wyjątek |
Write nadpisuje bez pytania
WriteAllText i WriteAllLines usuwają całą dotychczasową zawartość pliku. Nie ma pytania „czy nadpisać?”, nie ma kosza, nie ma cofnięcia. Jedno omyłkowe Write zamiast Append w programie do dziennika ocen kasuje wszystko. Jeśli chcesz dodać coś do istniejącego pliku, to zawsze Append.
Różnica w praktyce:
string plik = "log.txt";
File.WriteAllText(plik, "pierwszy\n");
File.WriteAllText(plik, "drugi\n"); // pierwszy PRZEPADL
File.AppendAllText(plik, "trzeci\n"); // dopisane
Console.WriteLine(File.ReadAllText(plik));
drugi
trzeci
Cały tekst czy tablica wierszy
string[] wiersze =
{
"Kasia Nowak;3TP;51",
"Marek Lis;2TP;34",
"Ala Wrona;3TP;42"
};
File.WriteAllLines("wyniki.csv", wiersze); // wygodniej niz skladanie napisu
string[] zPliku = File.ReadAllLines("wyniki.csv"); // od razu gotowa tablica
Console.WriteLine($"wierszy: {zPliku.Length}");
foreach (string w in zPliku)
{
Console.WriteLine($" {w}");
}
| Zadanie | Użyj |
|---|---|
| zapisać jedną notatkę albo cały raport | WriteAllText |
| zapisać listę rekordów, po jednym na wiersz | WriteAllLines |
| dopisać jeden wpis do dziennika zdarzeń | AppendAllText albo AppendAllLines |
| odczytać plik i przejść rekordy pętlą | ReadAllLines |
| policzyć znaki albo poszukać fragmentu w całości | ReadAllText |
| przeczytać plik o rozmiarze setek megabajtów | żadna z nich — strumień, patrz dalej |
Sprawdzanie, kopiowanie, usuwanie
string plik = "wyniki.csv";
if (File.Exists(plik))
{
Console.WriteLine($"plik istnieje, rozmiar {new FileInfo(plik).Length} B");
}
else
{
Console.WriteLine("pliku nie ma - zaczynam od pustej listy");
}
File.Copy(plik, "wyniki-kopia.csv", true); // true = nadpisz kopie, jesli byla
File.Move(plik, "stare-wyniki.csv", true); // przenies albo zmien nazwe
File.Delete("wyniki-kopia.csv"); // usuniecie NIE trafia do kosza
Console.WriteLine(Directory.Exists("dane")); // czy folder istnieje
File.Exists nie daje gwarancji
Między sprawdzeniem File.Exists a odczytem pliku mija chwila, w której plik może zostać usunięty albo zablokowany przez inny program. Dlatego Exists służy do wyboru ścieżki działania („czytać czy zacząć od zera”), a nie do zabezpieczenia odczytu. Do zabezpieczenia jest try — o tym w sekcji o błędach.
Polskie znaki i krzaczki
Plik tekstowy to w istocie ciąg bajtów. Żeby zamienić bajty na litery, trzeba wiedzieć, według jakiej tabeli zostały zapisane. Ta tabela nazywa się kodowaniem, a dla polskich znaków ma to bardzo widoczne skutki.
using System.Text;
string tekst = "Zażółć gęślą jaźń";
File.WriteAllText("a.txt", tekst); // UTF-8 - domyslnie
File.WriteAllText("b.txt", tekst, Encoding.UTF8); // to samo, jawnie
File.WriteAllText("c.txt", tekst, Encoding.Unicode); // UTF-16
Console.WriteLine(File.ReadAllText("a.txt")); // Zazolc gesla jazn - poprawnie
Console.WriteLine(File.ReadAllText("c.txt")); // KRZACZKI - zle kodowanie przy odczycie
Console.WriteLine(File.ReadAllText("c.txt", Encoding.Unicode)); // znowu poprawnie
Zasada jest jedna i krótka: zapisuj i czytaj tym samym kodowaniem. Jeśli nie podasz żadnego, .NET używa UTF-8 w obie strony, więc wszystko się zgadza. Kłopot zaczyna się dopiero wtedy, gdy plik powstał w innym programie.
| Kodowanie | Kiedy je spotkasz | Polskie znaki |
|---|---|---|
| UTF-8 | domyślne w .NET, w internecie, w nowoczesnych edytorach | poprawne — tego używaj |
| UTF-8 z BOM | pliki z Notatnika Windows i starszego Excela | poprawne, ale pierwszy wiersz ma niewidoczny znak |
| windows-1250 | stare pliki polskie, Excel w wersjach polskich | poprawne tylko przy jawnym podaniu kodowania |
UTF-16 (Encoding.Unicode) | rzadko; niektóre eksporty z Windowsa | poprawne przy jawnym podaniu |
| ASCII | stare systemy | polskich znaków nie zapisze — zamieni na znaki zapytania |
Niewidzialny znak na początku pliku
BOM (znacznik kolejności bajtów) to trzy bajty, które niektóre programy wstawiają na początku pliku UTF-8. Przy odczycie .NET je pomija, więc zwykle ich nie zauważysz — ale jeśli porównujesz pierwszy wiersz z oczekiwanym napisem i warunek „bez powodu” jest fałszywy, to bardzo prawdopodobny sprawca. Pomaga Trim() albo zapis pliku z new UTF8Encoding(false), które BOM-u nie dopisuje.
StreamWriter, StreamReader i using
Metody File.ReadAllLines i WriteAllLines robią całą robotę jednym wywołaniem, ale mają dwa ograniczenia: wczytują cały plik do pamięci i przy zapisie otwierają go i zamykają za każdym razem. Przy pliku na 500 MB pierwsze jest problemem, a przy tysiącu osobnych dopisań — drugie.
Strumień to otwarte połączenie z plikiem, z którego czyta się albo do którego pisze się po kawałku, trzymając otwarty plik tak długo, jak trzeba.
Zapis strumieniem
using System.Text;
using (StreamWriter pisz = new StreamWriter("raport.txt", false, Encoding.UTF8))
{
pisz.WriteLine("RAPORT Z TURNIEJU");
pisz.WriteLine(new string('=', 40));
foreach (Wynik w in wyniki)
{
pisz.WriteLine($"{w.Imie,-14} {w.Punkty,3} pkt");
}
pisz.Write("koniec raportu"); // Write - bez przejscia do nowego wiersza
}
// tutaj plik jest JUZ zamkniety i zapisany
Drugi argument konstruktora to append: false nadpisuje plik, true dopisuje na końcu. Metody Write i WriteLine działają identycznie jak w klasie Console — tylko wynik idzie do pliku zamiast na ekran.
Odczyt strumieniem, wiersz po wierszu
using (StreamReader czytaj = new StreamReader("raport.txt", Encoding.UTF8))
{
int nr = 1;
while (!czytaj.EndOfStream)
{
string? linia = czytaj.ReadLine();
Console.WriteLine($"{nr,3}: {linia}");
nr++;
}
}
W cudzym kodzie spotkasz częściej inny zapis tej samej pętli — przypisanie wprost w warunku:
string? linia;
while ((linia = czytaj.ReadLine()) != null)
{
Console.WriteLine(linia);
}
Nawiasy wokół przypisania są konieczne. Czyta się to: „wczytaj wiersz do linia, a potem sprawdź, czy nie jest null”. ReadLine() zwraca null po ostatnim wierszu — i to właśnie dlatego jego typ to string? (lekcja 34). Wersja z EndOfStream jest czytelniejsza dla początkującego i robi dokładnie to samo.
Po co słowo using
// BEZ using - tak nie rob
StreamWriter pisz = new StreamWriter("plik.txt");
pisz.WriteLine("cos");
// jesli tutaj poleci wyjatek, plik zostaje OTWARTY i moze byc niezapisany
pisz.Close();
// Z using - plik zamknie sie zawsze
using (StreamWriter pisz = new StreamWriter("plik.txt"))
{
pisz.WriteLine("cos");
} // <- tutaj automatyczne zamkniecie, nawet gdy poleci wyjatek
// od C# 8 krocej - zamkniecie na koncu metody albo bloku
using StreamWriter pisz2 = new StreamWriter("plik2.txt");
pisz2.WriteLine("cos");
Co using robi pod spodem
Klasy takie jak StreamWriter trzymają zasób systemowy — otwarty uchwyt do pliku. Taki zasób trzeba zwolnić, i to niezależnie od tego, czy program skończył pracę normalnie, czy przerwał ją wyjątkiem. Klasy te spełniają umowę IDisposable z metodą Dispose(), a blok using gwarantuje jej wywołanie — dokładnie tak jak finally z lekcji 35. Do samej umowy IDisposable wrócimy przy destruktorach w lekcji 40, a do interfejsów w lekcji 48.
Zapomniane zamknięcie to jeden z tych błędów, które nie dają komunikatu: plik zostaje zablokowany dla innych programów, a część zapisanych danych może nie trafić na dysk, bo siedzi jeszcze w buforze. Dlatego każdy strumień otwieraj w bloku using — bez wyjątków.
| Zadanie | Metody File | Strumień |
|---|---|---|
| mały plik, jedno wczytanie | tak | niepotrzebnie rozwlekle |
| mały plik, jeden zapis | tak | niepotrzebnie rozwlekle |
| tysiąc dopisań w pętli | tysiąc otwarć pliku | tak — jedno otwarcie |
| plik większy od pamięci | nie da się | tak — wiersz po wierszu |
| zatrzymanie po znalezieniu wiersza | czyta całość i tak | tak — break i koniec |
| formatowany raport z wielu części | sklejanie napisu | tak — kolejne WriteLine |
CSV, kropka i walidacja
Plik tekstowy nie przechowuje obiektów — przechowuje znaki. Żeby zapisać listę zawodników, trzeba każdy obiekt zamienić na wiersz tekstu, a przy odczycie wykonać drogę powrotną. Najprostszy i najczęstszy format to jeden rekord na wiersz, z polami rozdzielonymi umówionym znakiem — to format CSV.
Obiekt → wiersz
using System.Globalization;
static string DoWiersza(Wynik w)
{
return string.Join(";", w.Imie, w.Klasa,
w.Punkty.ToString(CultureInfo.InvariantCulture),
w.Czas.ToString(CultureInfo.InvariantCulture));
}
Wynik w = new Wynik("Kasia Nowak", "3TP", 51, 62.5);
Console.WriteLine(DoWiersza(w));
Kasia Nowak;3TP;51;62.5
Przecinek czy kropka — ten błąd trafia każdego
Na polskich ustawieniach 62.5.ToString() daje "62,5" — z przecinkiem. Jeśli separatorem pól w pliku też jest przecinek, jeden rekord rozpada się na dwa pola. A plik zapisany na polskim komputerze nie da się odczytać na angielskim. Dlatego liczby w plikach zapisuj i czytaj zawsze z CultureInfo.InvariantCulture, w którym separatorem jest kropka. Format do pokazania człowiekowi (F2 z przecinkiem) i format do zapisania w pliku (kropka) to dwie różne rzeczy.
Wiersz → obiekt, z walidacją
Dane z pliku są danymi z zewnątrz, więc obowiązuje cała lekcja 34: nie ufamy im. Plik mógł zostać otwarty w Notatniku i poprawiony ręcznie, mógł powstać w innej wersji programu, mógł się urwać przy zapisie. Metoda parsująca ma odrzucić zły wiersz i powiedzieć dlaczego, a nie przerwać cały program:
static Wynik? Zparsuj(string wiersz, out string blad)
{
blad = "";
if (string.IsNullOrWhiteSpace(wiersz))
{
blad = "pusty wiersz";
return null;
}
string[] pola = wiersz.Split(';');
if (pola.Length != 4)
{
blad = $"oczekiwano 4 pol, jest {pola.Length}";
return null;
}
string imie = pola[0].Trim();
if (imie.Length < 3)
{
blad = "imie za krotkie";
return null;
}
if (!int.TryParse(pola[2].Trim(), out int punkty))
{
blad = $"\"{pola[2].Trim()}\" nie jest liczba punktow";
return null;
}
if (punkty < 0 || punkty > 100)
{
blad = $"punkty {punkty} poza zakresem 0-100";
return null;
}
if (!double.TryParse(pola[3].Trim(), NumberStyles.Float,
CultureInfo.InvariantCulture, out double czas))
{
blad = $"\"{pola[3].Trim()}\" nie jest czasem";
return null;
}
if (!double.IsFinite(czas) || czas <= 0)
{
blad = "czas musi byc dodatni";
return null;
}
return new Wynik(imie, pola[1].Trim().ToUpper(), punkty, czas);
}
Metoda zwraca Wynik? — obiekt albo null, gdy wiersz jest do odrzucenia. Użycie wygląda tak:
int pominiete = 0;
List<Wynik> wyniki = new List<Wynik>();
foreach (string wiersz in File.ReadAllLines("wyniki.csv"))
{
Wynik? w = Zparsuj(wiersz, out string blad);
if (w is null)
{
Console.WriteLine($"pominieto: {blad}");
pominiete++;
continue;
}
wyniki.Add(w);
}
Console.WriteLine($"przyjeto {wyniki.Count}, pominieto {pominiete}");
Zawsze licz pominięte wiersze
Program, który cicho przemilcza zepsute rekordy, jest groźniejszy od takiego, który się przerywa — dane są niekompletne, a nikt o tym nie wie. Licznik pominiętych i jeden wiersz podsumowania na końcu kosztują dwie linie kodu i ratują przed obliczeniem średniej z połowy danych.
Ograniczenie prostego CSV
Split(';') wystarcza, dopóki żadne pole nie zawiera średnika. Gdy zawiera, prawdziwy format CSV otacza takie pole cudzysłowami i wtedy prosty podział przestaje działać. W zadaniach szkolnych rozwiązanie jest proste: wybierz separator, którego dane nie zawierają — średnik, tabulator albo pionową kreskę — i przy zapisie sprawdź, czy pole go nie ma.
Czego warunkiem nie sprawdzisz
Operacje na plikach to podręcznikowy przykład rzeczy, których nie da się sprawdzić z góry (lekcja 34). Plik może zniknąć między File.Exists a odczytem, dysk może się zapełnić w trakcie zapisu, a inny program może trzymać plik otwarty. Dlatego tutaj wyjątki z lekcji 35 są na miejscu.
| Wyjątek | Przyczyna | Co zrobić |
|---|---|---|
FileNotFoundException | odczyt pliku, którego nie ma | zacząć od pustych danych i powiedzieć o tym użytkownikowi |
DirectoryNotFoundException | ścieżka wskazuje nieistniejący folder | Directory.CreateDirectory przed zapisem |
UnauthorizedAccessException | brak uprawnień albo plik tylko do odczytu | wybrać inny folder — zwykle dokumenty użytkownika |
IOException | plik otwarty w innym programie, dysk pełny, zerwane połączenie | poprosić o zamknięcie pliku, zwolnić miejsce |
PathTooLongException | ścieżka dłuższa niż limit systemu | skrócić nazwy folderów |
ArgumentException | w nazwie pliku jest niedozwolony znak | sprawdzić nazwę przed użyciem |
IOException jest klasą bazową dla większości z nich (lekcja 45), więc jeden blok catch może objąć całą rodzinę — byle stał po blokach dla przypadków szczegółowych:
try
{
string[] wiersze = File.ReadAllLines(plik, Encoding.UTF8);
Console.WriteLine($"wczytano {wiersze.Length} wierszy");
}
catch (FileNotFoundException)
{
Console.WriteLine("Nie ma pliku z wynikami - zaczynam od pustej listy.");
}
catch (UnauthorizedAccessException)
{
Console.WriteLine("Brak uprawnien do tego pliku. Wybierz inny folder.");
}
catch (IOException ex)
{
Console.WriteLine($"Nie udalo sie odczytac pliku: {ex.Message}");
}
Console.WriteLine("Program dziala dalej.");
Zapis, który nie niszczy poprzedniej wersji
Jeśli zapis do pliku przerwie się w połowie — zabraknie miejsca, program zostanie zamknięty — stracisz i nowe dane, i stare. Zawodowy wzorzec jest trzyetapowy: zapisz do pliku tymczasowego, sprawdź, że zapis się udał, i dopiero wtedy podmień pliki metodą File.Move(tymczasowy, docelowy, true). W programie na ocenę to przesada, ale w programie, który przechowuje czyjeś dane — nie.
Wyniki turnieju na dysku
Program przechodzi przez wszystko, co jest w tej lekcji: buduje ścieżkę, zapisuje, dopisuje, odczytuje z walidacją, liczy statystyki, tworzy raport strumieniem, czyta go wiersz po wierszu i obsługuje brak pliku. Plik danych jest celowo zanieczyszczony dwoma złymi wierszami.
// Program.cs - pelny plik
using System.Globalization;
using System.Text;
string folder = Path.Combine(AppContext.BaseDirectory, "dane");
Directory.CreateDirectory(folder);
string plikWynikow = Path.Combine(folder, "wyniki.csv");
string plikRaportu = Path.Combine(folder, "raport.txt");
Console.WriteLine($"Folder danych: {folder}");
Console.WriteLine();
// ---------- 1. zapis, ktory nadpisuje ----------
string[] poczatkowe =
{
"Kasia Nowak;3TP;51;62.5",
"Marek Lis;2TP;34;71.0",
"Ala Wrona;3TP;42;58.2"
};
File.WriteAllLines(plikWynikow, poczatkowe, Encoding.UTF8);
Console.WriteLine($"1. Zapisano {poczatkowe.Length} wierszy (nadpisanie).");
// ---------- 2. dopisanie, w tym dwa wiersze do odrzucenia ----------
string[] dopisane =
{
"Piotr Dab;1TP;51;55.9",
"Ewa Sosna;2TP;bardzo duzo;80.4",
""
};
File.AppendAllLines(plikWynikow, dopisane, Encoding.UTF8);
Console.WriteLine($"2. Dopisano {dopisane.Length} wiersze.");
// ---------- 3. odczyt z walidacja ----------
Console.WriteLine();
Console.WriteLine("3. Odczyt i sprawdzanie:");
List<Wynik> wyniki = new List<Wynik>();
int pominiete = 0;
if (!File.Exists(plikWynikow))
{
Console.WriteLine(" Pliku nie ma - pracuje na pustej liscie.");
}
else
{
string[] zPliku = File.ReadAllLines(plikWynikow, Encoding.UTF8);
Console.WriteLine($" wierszy w pliku: {zPliku.Length}");
for (int i = 0; i < zPliku.Length; i++)
{
Wynik? w = Zparsuj(zPliku[i], out string blad);
if (w is null)
{
Console.WriteLine($" wiersz {i + 1}: POMINIETY - {blad}");
pominiete++;
continue;
}
wyniki.Add(w);
}
}
Console.WriteLine($" przyjeto {wyniki.Count}, pominieto {pominiete}");
// ---------- 4. statystyki ----------
Console.WriteLine();
Console.WriteLine("4. Statystyki:");
if (wyniki.Count == 0)
{
Console.WriteLine(" Brak danych - nie licze statystyk.");
return;
}
int sumaPunktow = 0;
double sumaCzasu = 0;
Wynik najszybszy = wyniki[0];
foreach (Wynik w in wyniki)
{
sumaPunktow += w.Punkty;
sumaCzasu += w.Czas;
if (w.Czas < najszybszy.Czas)
{
najszybszy = w;
}
}
double sredniaPunktow = (double)sumaPunktow / wyniki.Count;
double sredniCzas = sumaCzasu / wyniki.Count;
Console.WriteLine($" srednia punktow: {sredniaPunktow:F2}");
Console.WriteLine($" sredni czas: {sredniCzas:F2} s");
Console.WriteLine($" najszybszy: {najszybszy.Imie} ({najszybszy.Czas:F2} s)");
// ---------- 5. raport strumieniem ----------
using (StreamWriter pisz = new StreamWriter(plikRaportu, false, Encoding.UTF8))
{
pisz.WriteLine("RAPORT Z TURNIEJU");
pisz.WriteLine(new string('=', 42));
foreach (Wynik w in wyniki)
{
pisz.WriteLine($"{w.Imie,-14} {w.Klasa,-5} {w.Punkty,3} pkt {w.Czas,7:F2} s");
}
pisz.WriteLine(new string('=', 42));
pisz.WriteLine($"Zawodnikow: {wyniki.Count}");
pisz.WriteLine($"Pominietych: {pominiete}");
pisz.WriteLine($"Srednia punktow: {sredniaPunktow:F2}");
pisz.WriteLine($"Sredni czas: {sredniCzas:F2} s");
}
Console.WriteLine();
Console.WriteLine("5. Raport zapisany.");
// ---------- 6. odczyt raportu wiersz po wierszu ----------
Console.WriteLine();
Console.WriteLine("6. Zawartosc raportu:");
using (StreamReader czytaj = new StreamReader(plikRaportu, Encoding.UTF8))
{
int nr = 1;
while (!czytaj.EndOfStream)
{
string? linia = czytaj.ReadLine();
Console.WriteLine($" {nr,2}| {linia}");
nr++;
}
}
// ---------- 7. plik, ktorego nie ma ----------
Console.WriteLine();
Console.WriteLine("7. Odczyt pliku, ktorego nie ma:");
try
{
string tresc = File.ReadAllText(Path.Combine(folder, "nie-ma.txt"));
Console.WriteLine(tresc);
}
catch (FileNotFoundException)
{
Console.WriteLine(" Nie ma takiego pliku - i program dziala dalej.");
}
static Wynik? Zparsuj(string wiersz, out string blad)
{
blad = "";
if (string.IsNullOrWhiteSpace(wiersz))
{
blad = "pusty wiersz";
return null;
}
string[] pola = wiersz.Split(';');
if (pola.Length != 4)
{
blad = $"oczekiwano 4 pol, jest {pola.Length}";
return null;
}
string imie = pola[0].Trim();
if (imie.Length < 3)
{
blad = "imie za krotkie";
return null;
}
if (!int.TryParse(pola[2].Trim(), out int punkty))
{
blad = $"\"{pola[2].Trim()}\" nie jest liczba punktow";
return null;
}
if (punkty < 0 || punkty > 100)
{
blad = $"punkty {punkty} poza zakresem 0-100";
return null;
}
if (!double.TryParse(pola[3].Trim(), NumberStyles.Float,
CultureInfo.InvariantCulture, out double czas))
{
blad = $"\"{pola[3].Trim()}\" nie jest czasem";
return null;
}
if (!double.IsFinite(czas) || czas <= 0)
{
blad = "czas musi byc dodatni";
return null;
}
return new Wynik(imie, pola[1].Trim().ToUpper(), punkty, czas);
}
class Wynik
{
public string Imie { get; }
public string Klasa { get; }
public int Punkty { get; }
public double Czas { get; }
public Wynik(string imie, string klasa, int punkty, double czas)
{
Imie = imie;
Klasa = klasa;
Punkty = punkty;
Czas = czas;
}
public string DoWiersza()
{
return string.Join(";", Imie, Klasa,
Punkty.ToString(CultureInfo.InvariantCulture),
Czas.ToString(CultureInfo.InvariantCulture));
}
public override string ToString()
{
return $"{Imie} ({Klasa}) {Punkty} pkt, {Czas:F2} s";
}
}
Folder danych: C:\kurs\Turniej\bin\Debug\net8.0\dane
1. Zapisano 3 wierszy (nadpisanie).
2. Dopisano 3 wiersze.
3. Odczyt i sprawdzanie:
wierszy w pliku: 6
wiersz 5: POMINIETY - "bardzo duzo" nie jest liczba punktow
wiersz 6: POMINIETY - pusty wiersz
przyjeto 4, pominieto 2
4. Statystyki:
srednia punktow: 44,50
sredni czas: 61,90 s
najszybszy: Piotr Dab (55,90 s)
5. Raport zapisany.
6. Zawartosc raportu:
1| RAPORT Z TURNIEJU
2| ==========================================
3| Kasia Nowak 3TP 51 pkt 62,50 s
4| Marek Lis 2TP 34 pkt 71,00 s
5| Ala Wrona 3TP 42 pkt 58,20 s
6| Piotr Dab 1TP 51 pkt 55,90 s
7| ==========================================
8| Zawodnikow: 4
9| Pominietych: 2
10| Srednia punktow: 44,50
11| Sredni czas: 61,90 s
7. Odczyt pliku, ktorego nie ma:
Nie ma takiego pliku - i program dziala dalej.
Co dzieje się po kolei
- Ścieżki.
AppContext.BaseDirectoryplusPath.CombineplusDirectory.CreateDirectory— trzy wywołania, po których wiesz, gdzie leżą dane, i folder na pewno istnieje. Program wypisuje tę ścieżkę, żeby dało się tam zajrzeć. - Blok 1 i 2.
WriteAllLinesnadpisuje plik od zera,AppendAllLinesdopisuje na końcu. Gdyby w bloku 2 byłoWritezamiastAppend, pierwsze trzy wiersze przepadłyby bez śladu. - Blok 3. W pliku jest sześć wierszy, w programie zostają cztery obiekty. Wiersz piąty ma w polu punktów tekst, szósty jest pusty — oba są odrzucone z konkretnym powodem i numerem wiersza, dzięki czemu wiadomo, co poprawić w pliku.
- Metoda
ZparsujzwracaWynik?, a sprawdzenie toif (w is null). Nie ma tu ani jednego wykrzyknika wyciszającego ostrzeżenie — kompilator sam widzi, że za tymcontinueobiekt istnieje. - Czas w pliku ma kropkę (
62.5), a na ekranie przecinek (62,50). To nie niekonsekwencja: do pliku zapisujemy wInvariantCulture, żeby plik dał się odczytać na każdym komputerze, a człowiekowi pokazujemy format jego systemu. - Blok 4. Sprawdzenie
wyniki.Count == 0stoi przed liczeniem — bez niego pusty plik dałby dzielenie przez zero. Średnia punktów to 178 / 4 = 44,50, średni czas 247,6 / 4 = 61,90. - Blok 5. Raport powstaje strumieniem, bo składa się z jedenastu wierszy budowanych w różnych miejscach. Plik otwiera się raz i zamyka sam na końcu bloku
using. - Blok 6. Odczyt wiersz po wierszu z
EndOfStream. Zwróć uwagę, że raport ma jedenaście wierszy, a nie dziesięć — ostatnieWriteLineteż dopisało przejście do nowego wiersza. - Blok 7. Odczytu nieistniejącego pliku nie da się zabezpieczyć warunkiem w sposób pewny, więc jest
tryz konkretnymcatch. Program nie przerywa się i wypisuje ostatni wiersz.
Sprawdź pięć rzeczy
- Uruchom program dwa razy pod rząd i policz wiersze w pliku. Będzie ich nadal sześć, bo blok 1 nadpisuje. Teraz zamień w bloku 1
WriteAllLinesnaAppendAllLines, uruchom trzy razy i zobacz, jak plik rośnie — to różnica między tymi metodami w jednym doświadczeniu. - Otwórz
dane\wyniki.csvw Notatniku, zepsuj ręcznie jeden wiersz (usuń średnik) i uruchom program. Zobaczysz komunikat „oczekiwano 4 pol, jest 3” z numerem wiersza. Tak właśnie zachowuje się program, który nie ufa danym z pliku. - Zmień w pliku czas
62.5na62,5i uruchom. Wiersz zostanie odrzucony jako „nie jest czasem” — boInvariantCultureprzecinka nie przyjmuje. To pokazuje, po co w ogóle podajemy kulturę. - Usuń
Encoding.UTF8z zapisu raportu i wpisz w nim polskie znaki, a przy odczycie podajEncoding.Unicode. Zobaczysz krzaczki — zapis i odczyt muszą używać tego samego kodowania. - Zamień blok
usingprzyStreamWriterna zwykłe utworzenie obiektu bezClose(). Bardzo prawdopodobnie raport w bloku 6 wyjdzie pusty albo obcięty, bo dane zostały w buforze, a plik nie został zamknięty.
Zestawienie elementów
| Element | Wynik | Działanie i ograniczenia |
|---|---|---|
File.WriteAllText(p, t) | void | Nadpisuje plik podanym napisem; tworzy, jeśli nie istniał. |
File.AppendAllText(p, t) | void | Dopisuje na końcu; tworzy, jeśli nie istniał. |
File.ReadAllText(p) | string | Czyta całość; brak pliku daje FileNotFoundException. |
File.WriteAllLines(p, t[]) | void | Nadpisuje; każdy element w osobnym wierszu. |
File.AppendAllLines(p, t[]) | void | Dopisuje wiersze na końcu pliku. |
File.ReadAllLines(p) | string[] | Czyta plik do tablicy wierszy. |
File.Exists(p) | bool | Do wyboru ścieżki działania, nie do zabezpieczenia odczytu. |
File.Copy(a, b, t) | void | Trzeci argument true pozwala nadpisać cel. |
File.Move(a, b, t) | void | Przenosi albo zmienia nazwę. |
File.Delete(p) | void | Nie trafia do kosza. Brak pliku nie jest błędem. |
Directory.CreateDirectory(f) | DirectoryInfo | Tworzy folder wraz z nadrzędnymi; istniejącego nie rusza. |
Directory.Exists(f) | bool | Czy folder istnieje. |
Path.Combine(a, b) | string | Łączy fragmenty właściwym separatorem systemu. |
Path.GetFileName(p) | string | Nazwa pliku z rozszerzeniem. |
Path.GetExtension(p) | string | Rozszerzenie z kropką. |
Path.GetFullPath(p) | string | Ścieżka bezwzględna dla podanej względnej. |
AppContext.BaseDirectory | string | Folder uruchomionego programu; nie zmienia się w trakcie. |
Directory.GetCurrentDirectory() | string | Katalog bieżący procesu; może być zmieniony. |
new StreamWriter(p, a, e) | strumień | a: false nadpisuje, true dopisuje. Zawsze w bloku using. |
new StreamReader(p, e) | strumień | Odczyt po kawałku; zawsze w bloku using. |
.ReadLine() | string? | Kolejny wiersz albo null po ostatnim. |
.EndOfStream | bool | Czy strumień doszedł do końca pliku. |
using (...) { } | — | Gwarantuje zamknięcie zasobu, także przy wyjątku. |
Encoding.UTF8 | kodowanie | Domyślne i właściwe dla polskich znaków. |
CultureInfo.InvariantCulture | kultura | Kropka jako separator dziesiętny — do zapisu liczb w plikach. |
@"tekst" | string | Napis dosłowny: brak sekwencji ucieczki, wygodny do ścieżek. |
Zanim utkniesz
| Zapis | Problem |
|---|---|
WriteAllText tam, gdzie miało być Append | Kasuje całą dotychczasową zawartość. Bez pytania, bez kosza, bez cofnięcia. |
Szukanie pliku obok Program.cs | Plik ze ścieżką względną powstaje w bin\Debug\net8.0. Wypisz Path.GetFullPath, żeby się upewnić. |
Trzymanie danych w bin\Debug | Ten folder bywa czyszczony przy przebudowie projektu. Buduj ścieżkę świadomie. |
"C:\kurs\dane" z pojedynczymi ukośnikami | Błąd kompilacji — \k i \d to nieznane sekwencje ucieczki. Użyj @"...". |
| Sklejanie ścieżek plusem i ukośnikiem | Psuje się na innym systemie i przy fragmencie kończącym się ukośnikiem. Zawsze Path.Combine. |
| Zapis do folderu, którego nie ma | DirectoryNotFoundException. Wywołaj Directory.CreateDirectory — jest bezpieczne przy każdym uruchomieniu. |
File.Exists jako zabezpieczenie odczytu | Między sprawdzeniem a odczytem plik może zniknąć. Do zabezpieczenia jest try. |
ReadAllText bez try | Brak pliku przerywa program. Przy pierwszym uruchomieniu pliku nigdy nie ma. |
Zapis liczb bez InvariantCulture | Na polskich ustawieniach 62,5 z przecinkiem; plik nie da się odczytać na innym komputerze, a przy separatorze-przecinku rekord rozpada się na dwa pola. |
| Zapis i odczyt różnym kodowaniem | Polskie znaki zamieniają się w krzaczki. Podawaj Encoding.UTF8 w obie strony albo nie podawaj nigdzie. |
Strumień bez bloku using | Plik zostaje otwarty i zablokowany, a część danych nie trafia na dysk. Bez żadnego komunikatu. |
StreamReader gdy wystarczy ReadAllLines | Pięć wierszy kodu zamiast jednego. Strumień jest do dużych plików i wielu zapisów. |
| Ufanie danym z pliku | Plik mógł być poprawiony w Notatniku. Parsuj z walidacją i licz pominięte wiersze. |
| Ciche pomijanie zepsutych wierszy | Statystyka z połowy danych wygląda jak prawdziwa. Zawsze wypisz, ile wierszy odrzucono. |
Split(';') na danych zawierających średnik | Rekord rozpada się na więcej pól. Wybierz separator, którego dane nie zawierają, i sprawdź to przy zapisie. |
Zadania
Napisz program, który zapisuje do pliku notatka.txt trzy wiersze tekstu z polskimi znakami, a potem odczytuje go i wypisuje zawartość. Przed zapisem wypisz wyniki Directory.GetCurrentDirectory(), AppContext.BaseDirectory oraz Path.GetFullPath("notatka.txt") i zapisz je w komentarzu. Odszukaj plik na dysku i zapisz w komentarzu, w jakim folderze naprawdę powstał. Następnie zmień program tak, żeby plik powstawał w podfolderze dane obok programu — użyj AppContext.BaseDirectory, Path.Combine i Directory.CreateDirectory.
Napisz program, który przy każdym uruchomieniu dopisuje do pliku dziennik.txt jeden wiersz w formacie uruchomienie nr N, gdzie N to liczba wierszy już zapisanych w pliku, powiększona o jeden. Jeśli pliku nie ma, program ma go utworzyć i zapisać pierwszy wiersz. Po dopisaniu wypisz całą zawartość pliku i liczbę wierszy. Uruchom program pięć razy i zapisz w komentarzu wynik ostatniego uruchomienia. Następnie zamień AppendAllText na WriteAllText, uruchom trzy razy i zapisz w komentarzu, co się stało i dlaczego.
Napisz klasę Ksiazka z właściwościami Tytul, Autor, Rok i Cena (typ double), konstruktorem oraz metodą string DoWiersza() zwracającą pola rozdzielone średnikiem — liczby zapisz z CultureInfo.InvariantCulture. Utwórz List<Ksiazka> z pięcioma pozycjami, zapisz ją do pliku ksiazki.csv jedną metodą File.WriteAllLines, a potem odczytaj plik do nowej listy i wypisz obie listy obok siebie, żeby sprawdzić, że nic się nie zgubiło. Cenę wypisuj na ekranie w formacie z dwoma miejscami po przecinku. W komentarzu zapisz, jak wygląda jeden wiersz w pliku, i wyjaśnij, dlaczego w pliku jest kropka, a na ekranie przecinek.
Do zadania 3 napisz metodę static Ksiazka? Zparsuj(string wiersz, out string blad), która odrzuca wiersz, gdy: jest pusty albo sam z białych znaków; nie ma dokładnie czterech pól; tytuł po Trim() jest krótszy niż 2 znaki; rok nie jest liczbą całkowitą albo wypada poza zakresem 1450–2030; cena nie daje się wczytać z InvariantCulture, nie jest skończona albo jest ujemna. Przygotuj ręcznie w Notatniku plik ksiazki-brudne.csv z co najmniej ośmioma wierszami, z których połowa jest zepsuta na różne sposoby (brakujący średnik, tekst w miejscu roku, przecinek w cenie, pusty wiersz, rok 3000). Wczytaj plik, wypisz dla każdego odrzuconego wiersza jego numer i konkretny powód, a na końcu podaj, ile pozycji przyjęto i ile pominięto. Policz średnią cenę tylko z przyjętych i pamiętaj o sytuacji, w której nie przyjęto ani jednej.
Do listy z zadania 3 napisz metodę static void ZapiszRaport(string sciezka, List<Ksiazka> lista), która używa StreamWriter w bloku using i zapisuje: nagłówek, linię z myślników, wszystkie pozycje w wyrównanych kolumnach, znowu linię z myślników oraz podsumowanie z liczbą pozycji, sumą i średnią ceną. Następnie napisz metodę static void PokazPlik(string sciezka), która odczytuje plik StreamReader-em wiersz po wierszu i wypisuje go na ekran z numerami wierszy. Wywołaj obie. Potem usuń blok using przy StreamWriter (zostaw samo utworzenie obiektu), uruchom program ponownie i zapisz w komentarzu, co się stało z zawartością pliku i dlaczego. Przywróć using.
Napisz program prowadzący dziennik ocen, który pamięta dane między uruchomieniami. Dane trzymaj w pliku dane/oceny.csv, w wierszach o formacie nazwisko;przedmiot;ocena;data, gdzie data ma postać rrrr-mm-dd. Ścieżkę zbuduj z AppContext.BaseDirectory, Path.Combine i Directory.CreateDirectory. Napisz klasę Wpis z odpowiednimi właściwościami, metodą DoWiersza() budującą wiersz do pliku oraz przesłoniętym ToString() budującym wiersz na ekran — opcja „pokaż wszystkie wpisy” ma podawać do WriteLine sam obiekt, oraz metodę parsującą zwracającą Wpis? z powodem odrzucenia w parametrze out — sprawdź liczbę pól, długość nazwiska, ocenę w zakresie 1–6 oraz poprawność daty metodą DateTime.TryParse. Program po uruchomieniu ma: wczytać plik, jeśli istnieje, i wypisać, ile wpisów przyjęto i ile pominięto (brak pliku obsłuż komunikatem, nie wyjątkiem przerywającym program); pokazać proste menu tekstowe w pętli z opcjami: dodaj wpis (z pełną walidacją każdego pola, pytając do poprawnej odpowiedzi), pokaż wszystkie wpisy, pokaż średnią dla wybranego ucznia, pokaż średnią dla wybranego przedmiotu, zapisz raport do pliku dane/raport.txt i zakończ. Dodanie wpisu ma dopisywać jeden wiersz do pliku CSV metodą AppendAllLines, żeby dane nie zginęły przy zamknięciu programu bez zapisu. Raport twórz StreamWriter-em w bloku using, z zestawieniem po uczniach i po przedmiotach. Wszystkie operacje na plikach zabezpiecz blokami try z konkretnymi typami wyjątków — co najmniej FileNotFoundException, UnauthorizedAccessException i IOException. Sprawdź program, uruchamiając go trzy razy i dodając za każdym razem dwa wpisy. Na końcu pliku źródłowego, w komentarzu, odpowiedz na cztery pytania: dlaczego dodanie wpisu dopisuje do pliku, a nie zapisuje całej listy na końcu programu; dlaczego ocena jest sprawdzana dwa razy — przy wpisywaniu i przy odczycie z pliku; co się stanie, jeśli ktoś ręcznie zepsuje jeden wiersz w pliku; oraz w którym miejscu programu dane przestają być „brudne”.
Co trzeba zapamiętać
- Zmienne żyją do końca programu, plik — do usunięcia. To cała różnica, o którą chodzi w tej lekcji.
- Ścieżka względna wskazuje katalog bieżący, a nie folder z kodem źródłowym; plik powstaje w
bin\Debug\net8.0. - Ścieżki buduj z
AppContext.BaseDirectoryiPath.Combine, nigdy plusem i ukośnikiem. Directory.CreateDirectoryjest bezpieczne przy każdym uruchomieniu — istniejącego folderu nie rusza.- Do ścieżek w kodzie używaj napisu dosłownego
@"...". WriteAllTextiWriteAllLinesnadpisują całość. Do dodawania służyAppend….ReadAllTextiReadAllLinesrzucają wyjątek, gdy pliku nie ma — a przy pierwszym uruchomieniu nigdy go nie ma.File.Existssłuży do wyboru ścieżki działania, a nie do zabezpieczenia odczytu.- Zapisuj i czytaj tym samym kodowaniem; UTF-8 jest właściwym wyborem dla polskich znaków.
- Strumień otwiera plik raz — do dużych plików i do wielu zapisów. Zawsze w bloku
using. usinggwarantuje zamknięcie pliku także przy wyjątku; bez niego dane mogą zostać w buforze.- Liczby w plikach zapisuj i czytaj w
CultureInfo.InvariantCulture— format dla człowieka i format dla pliku to dwie różne rzeczy. - Dane z pliku są danymi z zewnątrz: parsuj z walidacją, odrzucaj z konkretnym powodem i licz pominięte wiersze.
- Operacji na plikach nie da się zabezpieczyć samym warunkiem — tu wyjątki są na miejscu.
Dokumentacja: Microsoft Learn — temat tej lekcji.