Przejdź do treści
School IT / Przestrzeń do nauki
← C# od podstawLekcja 36 z 58

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.

C# File Path StreamWriter using CSV UTF-8 90 min
CEL LEKCJI

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.Combine i utworzysz folder metodą Directory.CreateDirectory.
  • Dobierzesz właściwą metodę klasy File i odróżnisz nadpisanie od dopisania.
  • Zapiszesz i odczytasz polskie znaki, podając to samo kodowanie w obie strony.
  • Użyjesz StreamWriter i StreamReader w bloku using i 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 catch dla 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.

TEORIA

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 daneJak długo żyjąPrzykład
zmienna, tablica, List<T>do zakończenia programuwszystko z lekcji 01–35
plik na dyskudo usunięcia plikuta lekcja
baza danychdo usunięcia rekorduosobne zajęcia
usługa w siecizależy od serwerakurs aplikacji webowych

Plik tekstowy to najprostsza trwała pamięć, jaką ma program. Nie wymaga serwera, instalacji ani konfiguracji — wystarczy jedna metoda:

Program.cs
File.WriteAllText("notatka.txt", "Turniej retro, 12 maja");

string tresc = File.ReadAllText("notatka.txt");
Console.WriteLine(tresc);
wynik w konsoli
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;

TEORIA

Ś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:

Program.cs
Console.WriteLine(Directory.GetCurrentDirectory());
Console.WriteLine(AppContext.BaseDirectory);
przykładowy wynik na Windowsie
C:\kurs\Turniej\bin\Debug\net8.0
C:\kurs\Turniej\bin\Debug\net8.0\
ZapisCo dajeUwaga
Directory.GetCurrentDirectory()katalog bieżący procesuMożna go zmienić w trakcie działania, a niektóre środowiska ustawiają go inaczej.
AppContext.BaseDirectoryfolder, z którego uruchomiono programNie 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):

Program.cs
// 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

Program.cs
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

Program.cs
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

Program.cs
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.

ELEMENTY WBUDOWANE

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.

MetodaCo robiGdy plik nie istnieje
File.WriteAllText(p, t)zapisuje napis, nadpisując całośćtworzy
File.AppendAllText(p, t)dopisuje napis na końcutworzy
File.ReadAllText(p)czyta całość jako jeden napiswyjątek
File.WriteAllLines(p, t[])zapisuje tablicę wierszy, nadpisująctworzy
File.AppendAllLines(p, t[])dopisuje wiersze na końcutworzy
File.ReadAllLines(p)czyta plik do tablicy wierszywyją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:

Program.cs
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));
wynik w konsoli
drugi
trzeci

Cały tekst czy tablica wierszy

Program.cs
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}");
}
ZadanieUżyj
zapisać jedną notatkę albo cały raportWriteAllText
zapisać listę rekordów, po jednym na wierszWriteAllLines
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ściReadAllText
przeczytać plik o rozmiarze setek megabajtówżadna z nich — strumień, patrz dalej

Sprawdzanie, kopiowanie, usuwanie

Program.cs
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.

TEORIA

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.

Program.cs
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.

KodowanieKiedy je spotkaszPolskie znaki
UTF-8domyślne w .NET, w internecie, w nowoczesnych edytorachpoprawne — tego używaj
UTF-8 z BOMpliki z Notatnika Windows i starszego Excelapoprawne, ale pierwszy wiersz ma niewidoczny znak
windows-1250stare pliki polskie, Excel w wersjach polskichpoprawne tylko przy jawnym podaniu kodowania
UTF-16 (Encoding.Unicode)rzadko; niektóre eksporty z Windowsapoprawne przy jawnym podaniu
ASCIIstare systemypolskich 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.

TEORIA

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

Program.cs
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

Program.cs
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:

zapis skrócony
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

ryzykowne
// 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();
poprawnie
// 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.

ZadanieMetody FileStrumień
mały plik, jedno wczytanietakniepotrzebnie rozwlekle
mały plik, jeden zapistakniepotrzebnie rozwlekle
tysiąc dopisań w pętlitysiąc otwarć plikutak — jedno otwarcie
plik większy od pamięcinie da siętak — wiersz po wierszu
zatrzymanie po znalezieniu wierszaczyta całość i taktak — break i koniec
formatowany raport z wielu częścisklejanie napisutak — kolejne WriteLine
TEORIA

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

Program.cs
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));
}
Program.cs
Wynik w = new Wynik("Kasia Nowak", "3TP", 51, 62.5);
Console.WriteLine(DoWiersza(w));
wynik w konsoli
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:

Program.cs
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:

Program.cs
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.

TEORIA

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ątekPrzyczynaCo zrobić
FileNotFoundExceptionodczyt pliku, którego nie mazacząć od pustych danych i powiedzieć o tym użytkownikowi
DirectoryNotFoundExceptionścieżka wskazuje nieistniejący folderDirectory.CreateDirectory przed zapisem
UnauthorizedAccessExceptionbrak uprawnień albo plik tylko do odczytuwybrać inny folder — zwykle dokumenty użytkownika
IOExceptionplik otwarty w innym programie, dysk pełny, zerwane połączeniepoprosić o zamknięcie pliku, zwolnić miejsce
PathTooLongExceptionścieżka dłuższa niż limit systemuskrócić nazwy folderów
ArgumentExceptionw nazwie pliku jest niedozwolony znaksprawdzić 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:

Program.cs
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.

PRZYKŁAD

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 — pełny plik
// 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";
    }
}
wynik w konsoli
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.BaseDirectory plus Path.Combine plus Directory.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. WriteAllLines nadpisuje plik od zera, AppendAllLines dopisuje na końcu. Gdyby w bloku 2 było Write zamiast Append, 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 Zparsuj zwraca Wynik?, a sprawdzenie to if (w is null). Nie ma tu ani jednego wykrzyknika wyciszającego ostrzeżenie — kompilator sam widzi, że za tym continue obiekt istnieje.
  • Czas w pliku ma kropkę (62.5), a na ekranie przecinek (62,50). To nie niekonsekwencja: do pliku zapisujemy w InvariantCulture, żeby plik dał się odczytać na każdym komputerze, a człowiekowi pokazujemy format jego systemu.
  • Blok 4. Sprawdzenie wyniki.Count == 0 stoi 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ęć — ostatnie WriteLine też 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 try z konkretnym catch. Program nie przerywa się i wypisuje ostatni wiersz.

Sprawdź pięć rzeczy

  1. 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 WriteAllLines na AppendAllLines, uruchom trzy razy i zobacz, jak plik rośnie — to różnica między tymi metodami w jednym doświadczeniu.
  2. Otwórz dane\wyniki.csv w 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.
  3. Zmień w pliku czas 62.5 na 62,5 i uruchom. Wiersz zostanie odrzucony jako „nie jest czasem” — bo InvariantCulture przecinka nie przyjmuje. To pokazuje, po co w ogóle podajemy kulturę.
  4. Usuń Encoding.UTF8 z zapisu raportu i wpisz w nim polskie znaki, a przy odczycie podaj Encoding.Unicode. Zobaczysz krzaczki — zapis i odczyt muszą używać tego samego kodowania.
  5. Zamień blok using przy StreamWriter na zwykłe utworzenie obiektu bez Close(). Bardzo prawdopodobnie raport w bloku 6 wyjdzie pusty albo obcięty, bo dane zostały w buforze, a plik nie został zamknięty.
ELEMENTY WBUDOWANE

Zestawienie elementów

ElementWynikDziałanie i ograniczenia
File.WriteAllText(p, t)voidNadpisuje plik podanym napisem; tworzy, jeśli nie istniał.
File.AppendAllText(p, t)voidDopisuje na końcu; tworzy, jeśli nie istniał.
File.ReadAllText(p)stringCzyta całość; brak pliku daje FileNotFoundException.
File.WriteAllLines(p, t[])voidNadpisuje; każdy element w osobnym wierszu.
File.AppendAllLines(p, t[])voidDopisuje wiersze na końcu pliku.
File.ReadAllLines(p)string[]Czyta plik do tablicy wierszy.
File.Exists(p)boolDo wyboru ścieżki działania, nie do zabezpieczenia odczytu.
File.Copy(a, b, t)voidTrzeci argument true pozwala nadpisać cel.
File.Move(a, b, t)voidPrzenosi albo zmienia nazwę.
File.Delete(p)voidNie trafia do kosza. Brak pliku nie jest błędem.
Directory.CreateDirectory(f)DirectoryInfoTworzy folder wraz z nadrzędnymi; istniejącego nie rusza.
Directory.Exists(f)boolCzy folder istnieje.
Path.Combine(a, b)stringŁączy fragmenty właściwym separatorem systemu.
Path.GetFileName(p)stringNazwa pliku z rozszerzeniem.
Path.GetExtension(p)stringRozszerzenie z kropką.
Path.GetFullPath(p)stringŚcieżka bezwzględna dla podanej względnej.
AppContext.BaseDirectorystringFolder uruchomionego programu; nie zmienia się w trakcie.
Directory.GetCurrentDirectory()stringKatalog 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.
.EndOfStreamboolCzy strumień doszedł do końca pliku.
using (...) { }—Gwarantuje zamknięcie zasobu, także przy wyjątku.
Encoding.UTF8kodowanieDomyślne i właściwe dla polskich znaków.
CultureInfo.InvariantCulturekulturaKropka jako separator dziesiętny — do zapisu liczb w plikach.
@"tekst"stringNapis dosłowny: brak sekwencji ucieczki, wygodny do ścieżek.
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
WriteAllText tam, gdzie miało być AppendKasuje całą dotychczasową zawartość. Bez pytania, bez kosza, bez cofnięcia.
Szukanie pliku obok Program.csPlik ze ścieżką względną powstaje w bin\Debug\net8.0. Wypisz Path.GetFullPath, żeby się upewnić.
Trzymanie danych w bin\DebugTen folder bywa czyszczony przy przebudowie projektu. Buduj ścieżkę świadomie.
"C:\kurs\dane" z pojedynczymi ukośnikamiBłąd kompilacji — \k i \d to nieznane sekwencje ucieczki. Użyj @"...".
Sklejanie ścieżek plusem i ukośnikiemPsuje się na innym systemie i przy fragmencie kończącym się ukośnikiem. Zawsze Path.Combine.
Zapis do folderu, którego nie maDirectoryNotFoundException. Wywołaj Directory.CreateDirectory — jest bezpieczne przy każdym uruchomieniu.
File.Exists jako zabezpieczenie odczytuMiędzy sprawdzeniem a odczytem plik może zniknąć. Do zabezpieczenia jest try.
ReadAllText bez tryBrak pliku przerywa program. Przy pierwszym uruchomieniu pliku nigdy nie ma.
Zapis liczb bez InvariantCultureNa 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 kodowaniemPolskie znaki zamieniają się w krzaczki. Podawaj Encoding.UTF8 w obie strony albo nie podawaj nigdzie.
Strumień bez bloku usingPlik zostaje otwarty i zablokowany, a część danych nie trafia na dysk. Bez żadnego komunikatu.
StreamReader gdy wystarczy ReadAllLinesPięć wierszy kodu zamiast jednego. Strumień jest do dużych plików i wielu zapisów.
Ufanie danym z plikuPlik mógł być poprawiony w Notatniku. Parsuj z walidacją i licz pominięte wiersze.
Ciche pomijanie zepsutych wierszyStatystyka z połowy danych wygląda jak prawdziwa. Zawsze wypisz, ile wierszy odrzucono.
Split(';') na danych zawierających średnikRekord rozpada się na więcej pól. Wybierz separator, którego dane nie zawierają, i sprawdź to przy zapisie.
ZADANIA

Zadania

ZAD 1Pierwszy plik i jego miejsce★☆☆

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.

ZAD 2Nadpisanie kontra dopisanie★☆☆

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.

ZAD 3Zapis i odczyt listy obiektów★★☆

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.

ZAD 4Odczyt z walidacją★★☆

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.

ZAD 5Strumienie i raport★★☆

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.

ZAD 6Dziennik ocen z trwałą pamięcią — pełny program★★★

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”.

PODSUMOWANIE

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.BaseDirectory i Path.Combine, nigdy plusem i ukośnikiem.
  • Directory.CreateDirectory jest bezpieczne przy każdym uruchomieniu — istniejącego folderu nie rusza.
  • Do ścieżek w kodzie używaj napisu dosłownego @"...".
  • WriteAllText i WriteAllLines nadpisują całość. Do dodawania służy Append….
  • ReadAllText i ReadAllLines rzucają wyjątek, gdy pliku nie ma — a przy pierwszym uruchomieniu nigdy go nie ma.
  • File.Exists sł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.
  • using gwarantuje 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.

Postęp zapisuje się w tej przeglądarce.