Aby program był naprawdę użyteczny, musicie umożliwić mu przechowywanie danych także wtedy, gdy nie jest uruchomiony. Wiemy, że jest to możliwe. Tak właśnie przechowywaliśmy wszystkie napisane dotychczas programy — w plikach. Do tego są potrzebne operacje na plikach.
Plikami zarządza system operacyjny komputera. Chcemy zatem, aby C# zwrócił się do systemu o utworzenie plików i ich udostępnienie. Dobra wiadomość jest taka, że choć różne systemy operacyjne nadzorują pliki w różny sposób, to w C# operacje na plikach wykonujemy zawsze tak samo, niezależnie od systemu. Możemy zatem napisać program C#, który bez problemu utworzy plik zarówno na pececie z Windowsem, jak i na komputerze z Uniksem.
9.1. Strumienie i pliki
Aby programy mogły wykonywać operacje na plikach, C# korzysta z tak zwanych strumieni. Strumień stanowi połączenie między programem a zasobem danych. Dane mogą być przekazywane „w górę” bądź „w dół” strumienia, dzięki czemu pliki można zapisywać i odczytywać. Strumień pośredniczy pomiędzy programem a systemem operacyjnym komputera. Za wykonanie odpowiednich czynności odpowiada system — gdy w kodzie zażyczymy sobie użycia strumieni, biblioteka C# przekonwertuje to żądanie na odpowiednie instrukcje dla systemu:
Program C# może zawierać obiekt reprezentujący konkretny strumień, który został utworzony przez programistę i połączony z plikiem. Operacje na pliku będą wykonywane przez program za pośrednictwem metod wywoływanych na obiekcie strumienia.
W C# dostępnych jest szereg rodzajów strumieni, które przydają się do różnych zastosowań. Wszystkich z nich używa się jednak w ten sam sposób. Sami już nawet wiecie jak — klasa Console
, łącząca program C# z użytkownikiem, implementowana jest bowiem jako strumień. Za pomocą metod ReadLine
i WriteLine
możemy przesłać dowolnemu strumieniowi polecenie wczytania lub zapisania danych.
Przyjrzymy się dwóm rodzajom strumieni, które umożliwiają programom operacje na plikach: strumieniom StreamWriter
i StreamReader
.
9.2. Tworzenie strumienia wyjściowego
Obiekt strumienia tworzy się tak samo jak każdy inny obiekt, przy pomocy słowa kluczowego new
. Po utworzeniu strumienia można przekazać mu nazwę pliku do otwarcia.
StreamWriter writer;
writer = new StreamWriter("test.txt");
Zmienna writer
posłuży za oznaczenie strumienia, do którego będziemy zapisywać dane. Gdy strumień StreamWriter
zostanie utworzony, program otworzy zawierający dane wejściowe plik o nazwie test.txt i podłączy do niego strumień. Jeśli procedura ta się nie powiedzie, np. ponieważ system nie jest w stanie dokonać zapisu do pliku bądź nie ma uprawnień, to wywołany zostanie odpowiedni wyjątek.
Warto jednak zauważyć, że powyższy kod zadziała, nawet jeśli będzie już istniał plik o nazwie text.txt. W jego miejsce zostanie po prostu utworzony nowy, pusty plik, co może być niebezpieczne. Przy pomocy powyższych dwóch instrukcji można by zniszczyć całą zawartość istniejącego pliku. Większość programów pyta jednak użytkownika, czy nadpisać istniejący plik. W dalszej części rozdziału dowiemy się, jak dodać takie zapytanie do naszego programu.
9.3. Zapisywanie do strumienia
Po utworzeniu strumienia można zapisać w nim dane za pomocą dostarczanych przez niego metod zapisujących.
writer.WriteLine("witaj, świecie");
Powyższa instrukcja wywołuje na strumieniu metodę WriteLine
, by ten zapisał tekst „witaj, świecie” w pliku test.txt. Dokładnie tą samą techniką posługiwaliśmy się, aby przesłać do konsoli informacje przeznaczone dla użytkownika. Tak naprawdę możemy skorzystać ze wszystkich sposobów wypisywania tekstu, a także sformatować go przy pomocy tych omówionych w sekcji Lepsze wyświetlanie liczb.
Każdy nowo zapisywany w pliku wiersz zostaje dodany na koniec istniejących już wierszy. Gdyby program się zapętlił, zapisując tekst, to mógłby wyczerpać całą pamięć urządzenia. W takim wypadku wywołanie metody WriteLine
zgłosi wyjątek. Porządnie napisany program powinien zadbać o przechwycenie tego typu wyjątków (mogą zostać zgłoszone także podczas otwierania pliku) i odpowiednią obsługę błędu.
9.4. Zamknięcie strumienia
Gdy program zakończy zapisywać dane do strumienia, należy bezwzględnie pamiętać o zamknięciu strumienia za pomocą metody Close
.
writer.Close();
Po wywołaniu metody Close strumień zapisze w pliku oczekującą porcję tekstu, po czym odłączy program od pliku. Wszelkie późniejsze próby zapisania danych do strumienia zakończą się niepowodzeniem i zgłoszeniem wyjątku. Gdy plik zostanie już zamknięty, to dostęp do niego będą mogły uzyskać inne programy na komputerze — po wykonaniu metody Close
plik test.txt będzie można otworzyć za pomocą Notatnika i sprawdzić jego zawartość. Niezamknięcie pliku może mieć nieprzyjemne konsekwencje:
- Działanie programu może zostać zakończone bez prawidłowego zamknięcia pliku. W takim przypadku część zapisanych do pliku danych zostanie utracona.
- Jeśli program zawiera strumień połączony z innym plikiem, inne programy nie będą mogły używać tego pliku. Niemożliwe będzie również przeniesienie pliku ani zmiana jego nazwy.
- Otwarty strumień zużywa niewielką, lecz znaczącą, część zasobów operacyjnych. Jeśli program tworzy wiele strumieni, lecz ich nie zamyka, może to doprowadzić do późniejszych problemów z otwieraniem innych plików i utrudnić operacje na plikach.
Zamknij zatem plik lub licz się z konsekwencjami.
9.5. Strumienie i przestrzenie nazw
Jeśli pospieszycie się z wypróbowaniem powyższych fragmentów kodu, to przekonacie się, że nie działają. Wybaczcie. Przed skorzystaniem z typu StreamWriter
musicie wiedzieć coś jeszcze. Podobnie jak wiele obiektów używanych na potrzeby danych wejściowych i wyjściowych, obiekt ten jest zdefiniowany w przestrzeni nazw System.IO
. Poruszyliśmy zagadnienie przestrzeni nazw wcześniej, gdy rozważaliśmy temat konieczności umieszczania instrukcji using System;
na początku naszych programów C#. Teraz musimy poszerzyć naszą wiedzę w tym zakresie.
Przestrzenie nazw dotyczą znajdowania zasobów. Język C# udostępnia słowa kluczowe i konstrukcje umożliwiające pisanie programów, lecz oprócz nich istnieje wiele dodatkowych zasobów dołączonych do instalacji C#. Obejmują one np. obiekt Console
pozwalający na odczytywanie i pisanie tekstu dla użytkownika. W rzeczywistości instalacja C# zawiera wiele tysięcy zasobów, z których każdy musi być unikatowo zidentyfikowany. Gdyby Waszym zadaniem było katalogowanie olbrzymiej liczby pozycji, docenilibyście możliwość połączenia ich w grupy. Kustosze muzealni robią to cały czas. Umieszczają artefakty rzymskie w jednym pomieszczeniu, a greckie w innym. Projektanci języka C# dodali przestrzenie nazw, które służą programistom do postępowania w ten sam sposób z zasobami.
Przestrzeń nazw to wręcz dosłownie „przestrzeń, w której nazwy mają znaczenie”. Pełna nazwa klasy Console
, za pomocą której pisaliśmy tekst dla użytkownika, to System.Console
. Klasa Console
należy więc do przestrzeni nazw System
. Używanie tej pełnej formy w programach nie jest w istocie błędem:
System.Console.WriteLine("Witaj, świecie");
W powyższym kodzie występuje pełna nazwa zasobu konsoli i wywołanie metody WriteLine
udostępnianej przez ten zasób. Nie musieliśmy jednak zastosować tej formy, ponieważ na początku naszych programów przekazaliśmy kompilatorowi, aby wyszukiwał niewystępujące do tej pory nazwy w przestrzeni nazw System
. Słowo kluczowe using
umożliwia wskazanie miejsca, w którym kompilator ma szukać zasobów.
using System;
Ta instrukcja mówi kompilatorowi, aby wyszukał zasób w przestrzeni nazw System
. Po określeniu przestrzeni nazw w pliku programu nie musimy już używać pełnej nazwy, odwołując się do zasobów z tej przestrzeni. Za każdym razem, gdy kompilator napotka niewidziany wcześniej element, automatycznie wyszuka go we wskazanej przestrzeni nazw. Innymi słowy, gdy kompilator widzi instrukcję:
Console.WriteLine("Witaj, świecie");
to wie, że ma wyszukać ten obiekt w przestrzeni nazw System
, aby mógł wywołać na nim metodę WriteLine
. Jeśli programista błędnie wpisze nazwę klasy:
Consle.WriteLine("Witaj, świecie");
to kompilator przeszuka przestrzeń nazw System
, nie uda mu się znaleźć obiektu o nazwie Console
i wygeneruje błąd kompilacji.
Taki sam błąd zostałby wyświetlony, gdybyśmy spróbowali użyć klasy StreamWriter
bez poinformowania kompilatora, że ma przeszukiwać przestrzeń nazw System.IO
. Innymi słowy, aby korzystać z klas obsługi plików, na samym początku programu należy dodać następującą instrukcję:
using System.IO;
Można umieścić jedną przestrzeń nazw wewnątrz innej (podobnie jak bibliotekarz umieściłby w sali rzymskiej gablotę z wazami i odwoływałby się do nich przy użyciu nazwy Rzymskie.Wazy), tak aby przestrzeń nazw IO znajdowała się wewnątrz przestrzeni System
. Samo użycie przestrzeni nazw nie oznacza jednak korzystania ze wszystkich przestrzeni w niej zdefiniowanych. Należy więc dodać powyższy wiersz, aby mieć dostęp do obiektów obsługi plików i móc wykonywać operacje na plikach.
Przestrzenie nazw to doskonałe rozwiązanie pozwalające mieć pewność, że nazwy utworzonych przez nas elementów nie kolidują z elementami innych programistów. W dalszej części książki przyjrzymy się tworzeniu własnych przestrzeni nazw.
using System;
using System.IO;
class FileWriteDemo
{
public static void Main()
{
StreamWriter writer;
writer = new StreamWriter("test.txt");
writer.WriteLine("witaj, świecie");
writer.Close();
}
}
Przykład. Gotowy kod do zapisywania danych w pliku
9.6. Operacje na plikach — odczytywanie danych z pliku
Odczytywanie danych z pliku jest podobne do zapisywania, ponieważ program utworzy w tym celu strumień. W tym przypadku używany strumień to StreamReader
.
StreamReader reader = new StreamReader("Test.txt");
string line = reader.ReadLine();
Console.WriteLine (line);
reader.Close();
Powyższy program łączy strumień z plikiem Test.txt, odczytuje pierwszy wiersz z tego pliku, wyświetla go na ekranie, po czym zamyka strumień. Jeśli pliku nie będzie można to odnaleźć, to próba jego otwarcia zakończy się niepowodzeniem, a program zgłosi wyjątek.
9.6.1. Wykrywanie końca pliku wejściowego
Wielokrotne wywołania metody ReadLine
zwrócą kolejne wiersze pliku. Jeśli jednak program dotrze do końca pliku, metoda ReadLine
zwróci pusty łańcuch przy każdym wywołaniu. Na szczęście obiekt StreamReader
udostępnia własność o nazwie EndOfStream
, za pomocą której program może określić, kiedy plik dobiegł końca. Gdy własność ta przybiera wartość true
, oznacza to koniec pliku.
StreamReader reader = new StreamReader("Test.txt");
while (reader.EndOfStream == false)
{
string line = reader.ReadLine();
Console.WriteLine(line);
} reader.Close();
Powyższy kod spowoduje otwarcie pliku test.txt i wyświetlenie wszystkich zawartych w nim wierszy w konsoli. Pętla while
zatrzyma program, gdy ten dotrze do końca pliku.
using System;
using System.IO;
class FileWriteandReadDemo
{
public static void Main()
{
StreamWriter writer;
writer = new StreamWriter("test.txt");
writer.WriteLine("witaj, świecie");
writer.Close();
StreamReader reader = new StreamReader("Test.txt");
while (reader.EndOfStream == false)
{
string line = reader.ReadLine();
Console.WriteLine(line);
}
reader.Close();
}
}
Przykład 21 Zapisywanie i odczytywanie danych w pliku
W powyższym przykładowym kodzie najpierw umieszczamy w pliku wiersz tekstu, a następnie otwieramy plik i wyświetlamy jego zawartość na ekranie.
9.6.2. Ścieżki do plików w języku C#
Jeśli macie już pewne doświadczenie w używaniu komputera, to znacie pojęcie folderów (czasem nazywanych katalogami). Służą one do organizowania informacji przechowywanych na komputerze. Każdy utworzony plik jest umieszczany w określonym folderze. Jeśli korzystacie z systemu Windows, to przekonacie się, że kilka folderów jest utworzonych automatycznie. Jeden może służyć do przechowywania dokumentów, inny do przechowywania obrazów, a w jeszcze innym można umieścić muzykę. Wewnątrz nich można tworzyć własne foldery (na przykład Dokumenty\Opowiadania).
Lokalizacja pliku na komputerze jest często nazywana ścieżką do tego pliku. Ścieżkę do pliku można rozłożyć na dwie części: lokalizację folderu i nazwę samego pliku. Jeśli podczas otwierania pliku nie podamy lokalizacji folderu (czyli tak, jak podczas dotychczasowego korzystania z pliku Test.txt), to system założy, że używany plik mieści się w tym samym folderze, co uruchomiony program. Innymi słowy, jeśli uruchamiamy program OdczytPliku.exe z folderu MojeProgramy, to powyższy program uzna, że plik Test.txt również znajduje się w folderze MojeProgramy.
Jeśli chcemy użyć pliku z innego folderu (to dobry pomysł, ponieważ niemal nigdy pliki nie znajdują się w tym samym miejscu, z którego są uruchamiane programy), możemy dodać do nazwy pliku informację o ścieżce:
string path;
path = @"c:\dane\2009\listopad\sprzedaż.txt";
Powyższe instrukcje tworzą zmienną łańcuchową zawierającą ścieżkę do pliku o nazwie sprzedaż.txt. Plik jest przechowywany w folderze listopad, który z kolei mieści się w folderze 2009, a ten w folderze dane na dysku C.
Ukośniki odwrotne (\
) służą w łańcuchu do oddzielenia folderów zawartych w ścieżce do pliku. Zwróćcie uwagę, że określiłem literał łańcuchowy niezawierający znaków sterujących (informuje o tym znak @
na początku literału) — w przeciwnym wypadku umieszczone w łańcuchu ukośniki zostałyby zinterpretowane przez język C# jako początek sekwencji sterującej. Jeśli macie problem z programem nieznajdującym istniejących plików, radzę sprawdzić, czy separatory ścieżki nie są używane jako znaki sterujące.