Rozdział 12. Pliki i obsługa strumieni.

Adam Boduch

Czym jest plik? Tego chyba nie trzeba wyjaśniać żadnemu użytkownikowi komputera. Istnieje kilka rodzajów plików: tekstowe, binarne, typowane itp. Pliki są wykorzystywane przez programy do pobierania lub przechowywania informacji. Mogą też zawierać binarne fragmenty programu. Ten rozdział będzie poświęcony plikom i ich obsłudze w C#. Zaczniemy od rzeczy najprostszych, przechodząc do coraz bardziej zaawansowanych aspektów.

Biblioteka klas środowiska .NET Framework zawiera szereg klas umożliwiających obsługę plików oraz katalogów. Klasy te znajdują się w przestrzeni nazw System.IO.

IO to potoczne określenie operacji wejścia-wyjścia (ang. input-output).

1 Czym są strumienie
2 Klasy przestrzeni System.IO
3 Operacje na katalogach
     3.1 Tworzenie i usuwanie katalogów
     3.2 Kopiowanie i przenoszenie
     3.3 Odczytywanie informacji o katalogu
4 Obsługa plików
     4.4 Tworzenie i usuwanie plików
     4.5 Kopiowanie i przenoszenie plików
     4.6 Odczytywanie informacji o pliku
5 Strumienie
     5.7 Obsługa plików tekstowych
     5.8 Operacje na danych binarnych
6 Serializacja
     6.9 Formaty zapisu danych
     6.10 Przykład serializacji
7 Podsumowanie

Czym są strumienie

Strumienie są specjalną formą wymiany i transportu danych, obsługiwaną przez klasy przestrzeni System.IO. To określenie może nie jest zbyt precyzyjne, ale zaraz postaram się wyjaśnić to szczegółowo.
Dzięki strumieniom można w prosty sposób operować na danych znajdujących się w pamięci komputera, w plikach itp. Przykładowo, strumień może być plikiem, pamięcią operacyjną lub współdzielonym zasobem sieciowym.

Klasy przestrzeni System.IO

Do operowania na plikach i katalogach możemy wykorzystać klasy opisane w tabeli 12.1. Niektóre klasy zawierają metody statyczne, więc nie jest konieczne tworzenie ich instancji.

Tabela 12.1. Podstawowe klasy operowania na plikach i katalogach

Klasa Opis
`Directory` Udostępnia metody służące do operowania na katalogach (przenoszenie, kopiowanie).
`File` Klasa udostępnia podstawowe mechanizmy pozwalające na tworzenie, usuwanie oraz przenoszenie plików.
`Path`Klasa służy do przetwarzania informacji o ścieżkach katalogów oraz plików.
`DirectoryInfo`Ma działanie podobne do klasy Directory. Jeżeli dokonujemy wielu działań na katalogach, jest to optymalna klasa, gdyż jej metody nie wykonują tzw. testów bezpieczeństwa.
`FileInfo`Ma działanie podobne do klasy File. Jeżeli dokonujemy wielu działań na plikach, jest to optymalna klasa, gdyż jej metody nie wykonują testów bezpieczeństwa.
`FileSystemInfo`Klasa bazowa dla klas DirectoryInfo oraz FileInfo.

Operacje na katalogach

Do operowania na katalogach wykorzystujemy klasę Directory lub DirectoryInfo. Directory może być wygodnym sposobem prostego operowania na katalogach, gdyż nie wymaga tworzenia egzemplarza klasy. Jeżeli jednak musimy wykonać wiele operacji na katalogach, wydajniejszym sposobem będzie skorzystanie z klasy DirectoryInfo.

Tworzenie i usuwanie katalogów

Jeżeli chcemy utworzyć nowy katalog, należy skorzystać z metody CreateDirectory() z klasy Directory:

if (!Directory.Exists("C:\\Foo"))
{
    Directory.CreateDirectory("C:\\Foo");
}

Zwróć uwagę, że przed utworzeniem katalogu następuje sprawdzenie, czy przypadkiem on już nie istnieje (metoda Exists()). Nie jest to jednak konieczne, gdyż w przypadku gdy tworzony katalog istnieje na dysku, żaden wyjątek nie zostanie wygenerowany.

Przy pomocy metody CreateDirectory() możemy utworzyć katalog, nawet wówczas gdy nie istnieje katalog macierzysty. Np.:

Directory.CreateDirectory("C:\\Foo\\Bar");

Ścieżka C:\Foo\Bar zostanie utworzona, nawet gdy na dysku nie ma katalogu Foo.

Pamiętaj o tym, aby w trakcie podawania ścieżek w łańcuchu korzystać z podwójnych backslashów (\). Podobnie jak w językach C/C++, kompilator po znaku \ oczekuje symbolu specjalnego, takiego jak np. \n, który oznacza nową linię.

Alternatywny kod korzystający z klasy DirectoryInfo, również tworzący nowy katalog, wygląda następująco:

DirectoryInfo dInfo = new DirectoryInfo("C:\\Foo");

if (!dInfo.Exists)
{
    dInfo.Create();
}

Jak widzisz, w konstruktorze klasy DirectoryInfo musimy podać ścieżkę do katalogu, na jakim będziemy operowali. Za tworzenie nowego folderu odpowiada metoda Create(), natomiast właściwość Exists zwraca wartość true, jeżeli ścieżka przekazana w konstruktorze istnieje.

Jeżeli chcemy usunąć dany katalog, możemy skorzystać z metody Delete() klasy DirectoryInfo:

if (dInfo.Exists)
{
     dInfo.Delete();                
}

Klasa DirectoryInfo udostępnia przeciążoną metodę Delete(). Jej druga wersja może posiadać parametr typu bool, który określa, czy katalog będzie usuwany nawet wówczas, gdy posiada inne pliki i foldery (wartość true).

Kopiowanie i przenoszenie

Podczas tworzenia aplikacji przetwarzających z katalogami może zaistnieć konieczność skopiowania i (lub) przeniesienia pewnych folderów. Okazuje się, że klasy Directory i DirectoryInfo udostępniają tylko metody do przenoszenia katalogów. Zaprezentuję również technikę umożliwiającą kopiowanie całych katalogów, gdyż — nie wiedzieć czemu — takiej możliwości brakuje w bibliotece klas FCL.

Przenoszenie katalogów jest proste. Klasa Directory udostępnia metodę Move(), która przyjmuje dwa parametry — katalog źródłowy oraz docelowy:

Directory.Move("C:\\Foo", "C:\\Bar");

Przenoszenie jest proste. Należy jednak zastanowić się nad metodą kopiowania katalogów, którą trzeba napisać samodzielnie. Metoda, którą za chwilę zaprezentuję, wykorzystuje elementy nieomówione do tej pory — omówię je w dalszej części książki.

Metoda, która realizuje kopiowanie katalogu, przedstawiona została poniżej:

private void CopyDir(string SourceDir, string TargetDir)
{
    string[] Files;
    FileAttributes Attr;
    string srcFile;

    // sprawdzenie, czy na końcu ścieżki znajduje się separator \ 
    if (!SourceDir.EndsWith(Path.DirectorySeparatorChar.ToString()))
    {
        SourceDir += Path.DirectorySeparatorChar;
    }
    if (!TargetDir.EndsWith(Path.DirectorySeparatorChar.ToString()))
    {
        TargetDir += Path.DirectorySeparatorChar;
    }
    // pobranie listy plików z katalogu
    Files = Directory.GetFileSystemEntries(SourceDir);
    // utworzenie katalogu docelowego
    Directory.CreateDirectory(TargetDir);

    for (int i = 0; i < Files.Length; i++)
    {
        // pobranie atrybutu pliku
        Attr = File.GetAttributes(Files[i]);
        // pobranie nazwy pliku
        srcFile = Path.GetFileName(Files[i]);

        // warunek sprawdza, czy plik jest katalogiem 
        if (FileAttributes.Directory == Attr)
        {
            if (!Directory.Exists(TargetDir + srcFile))
            {
                Directory.CreateDirectory(TargetDir + srcFile);
            }
            // wywołanie rekurencyjne
            CopyDir(Files[i], TargetDir + srcFile);
        }
        else
        {
            // skopiowanie pliku z katalogu
            File.Copy(Files[i], TargetDir + srcFile);
        }
    }
}

Prywatna metoda ma dwa parametry — ścieżkę katalogu źródłowego oraz docelowego.

Pierwsze dwie instrukcje warunkowe sprawdzają, czy na końcu ścieżki znajduje się znak ukośnika (\ w systemie Windows lub / w Linuksie). W kolejnych wierszach kodu pobieramy listę katalogów i plików znajdujących się w danym folderze. Dalej w pętli są pobierane atrybuty pliku, gdyż trzeba sprawdzić, czy kopiowany plik nie jest katalogiem. Jeżeli jest, stosujemy rekurencję i kopiujemy zawartość podkatalogu.
Jeżeli nie — po prostu kopiujemy plik z katalogu źródłowego do docelowego.

Metoda rekurencyjna to taka, która wywołuje samą siebie.

Wykorzystanie takiej metody jest bardzo proste. Możemy pobrać od użytkownika ścieżki katalogu źródłowego oraz docelowego i wywołać metodę:

CopyDir(edtSrc.Text, edtDst.Text);

Odczytywanie informacji o katalogu

Klasa DirectoryInfo posiada kilka użytecznych metod służących do odczytu informacji o katalogach. Opis najważniejszych właściwości znajduje się w tabeli 12.2.

Tabela 12.2. Właściwości klasy DirectoryInfo

WłaściwośćOpis
AttributesAtrybuty katalogu
CreationTimeCzas utworzenia katalogu
CreationTimeUtcCzas utworzenia katalogu w formacie UTC (ang. Coordinal Universal Time — UTC)
FullNamePełna ścieżka do katalogu
LastAccessTimeCzas ostatniego dostępu do katalogu
LastAccessTimeUtcCzas ostatniego dostępu do katalogu w formacie UTC
LastWriteTimeCzas ostatniego zapisu do katalogu
LastWriteTimeUtcCzas ostatniego zapisu do katalogu
NameZwraca nazwę katalogu
FullNameZwraca pełną ścieżkę do katalogu
ParentZwraca macierzysty katalog

Listing 12.1 prezentuje przykład odczytu informacji o katalogu, w którym znajduje się uruchamiany program (rysunek 12.1).

Listing 12.1. Przykład odczytu informacji o katalogu

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
using System.IO;

namespace WindowsApplication1
{
    public partial class MainForm : Form
    {
        public MainForm()
        {
            InitializeComponent();
        }

        private void btnLoad_Click(object sender, EventArgs e)
        {
            DirectoryInfo dirInfo = new DirectoryInfo(Application.StartupPath);
            
            lbDirectory.Items.Add(
                "Pełna ścieżka: " + dirInfo.FullName);
            lbDirectory.Items.Add(
                "Katalog macierzysty: " + dirInfo.Parent);
            lbDirectory.Items.Add(
                "Data utworzenia: " + dirInfo.CreationTime.ToShortDateString());
            lbDirectory.Items.Add(
                "Data ostatniego dostępu: " + dirInfo.LastAccessTime.ToShortDateString());
            lbDirectory.Items.Add(
                "Data zapisu: " + dirInfo.LastWriteTime.ToShortDateString());
            lbDirectory.Items.Add(
                "Atrybuty: " + dirInfo.Attributes.ToString());
        }
    }
}

csharp12.1.jpg
Rysunek 12.1. Przykład odczytu informacji o katalogu

Metody zwracające czas ostatniej modyfikacji katalogu (ewentualnie dostępu) zwracają informacje o dacie i czasie, w formacie DateTime. Ta klasa posiada metodę ToShortDateString(), która służy do łańcuchowego prezentowania daty w krótkim formacie.

Obsługa plików

Tak jak omówione w poprzednim podrozdziale klasy Directory i DirectoryInfo służą do obsługi katalogów, tak File oraz FileInfo służą do obsługi plików. Z klasy File skorzystałem już wcześniej, podczas prezentowania sposobu na skopiowanie katalogu. Teraz chciałbym skupić się na podstawowych operacjach, jakich dokonujemy na plikach.

Tworzenie i usuwanie plików

Chyba najprostszym sposobem na utworzenie nowego pliku jest użycie metody CreateText() z klasy File:

if (!File.Exists("C:\\foo.txt"))
{
    File.CreateText("C:\\foo.txt");
}

Tworzy ona nowy plik gotowy do zapisu tekstu z kodowaniem UTF-8. Oczywiście parametrem musi być ścieżka do tworzonego pliku.

Metoda Exists() zwraca informację, czy plik, którego ścieżka przekazana jest w parametrze, istnieje (wówczas zwraca wartość true).

Alternatywnym rozwiązaniem jest użycie klasy FileInfo:

FileInfo F = new FileInfo("C:\\foo.txt");

if (!F.Exists)
{
    F.CreateText();
}

Oczywiście utworzony w ten sposób plik będzie pusty. Ani klasa File, ani FileInfo nie oferuje metod służących do zapisu lub odczytu danych z pliku. W tym celu można skorzystać z klasy StreamWriter, której obiekt jest zwracany przez metodę CreateText():

if (!File.Exists("C:\\foo.txt"))
{
    StreamWriter sw = File.CreateText("C:\\foo.txt");

    sw.WriteLine("Hello World!");
    sw.Close();
}

W tym przykładzie metoda WriteLine() zapisuje nową linię do pliku tekstowego. Taki plik należy koniecznie zamknąć, korzystając z metody Close().

Usuwanie plików jest równie proste jak usuwanie katalogów. Służy do tego metoda Delete():

File.Delete("C:\\foo.txt");

Wersja klasy FileInfo:

FileInfo F = new FileInfo("C:\\foo.txt");
F.Delete();

Kopiowanie i przenoszenie plików

Poprzednio w rozdziale używałem już metody Copy() z klasy File. Oczywiście, jak się domyślasz, realizuje ona kopiowanie pliku. W parametrach metody należy podać ścieżkę źródłową oraz docelową:

string srcPath = "C:\\foo.txt";
string dstPath = "C:\\bar.txt";

if (!File.Exists(dstPath))
{
    File.Copy(srcPath, dstPath);
}

Przenoszenie realizuje metoda Move():

string srcPath = "C:\\foo.txt";
string dstPath = "C:\\bar.txt";

if (!File.Exists(dstPath))
{
    File.Move(srcPath, dstPath);
}
Dobrą praktyką jest sprawdzanie (przy pomocy metody <kbd>Exists()</kbd>), czy pod ścieżką docelową nie znajduje się jakiś plik. 

Odczytywanie informacji o pliku

Podobnie jak klasa DirectoryInfo udostępnia informacje o katalogu, do odczytania informacji o plikach możemy użyć klasy FileInfo.

Kilka z nich omówiłem w tabeli 12.3.

Tabela 12.3. Właściwości klasy FileInfo

WłaściwośćOpis
NameNazwa pliku
LengthRozmiar pliku (ilość bajtów)
DirectoryNameNazwa katalogu, w którym znajduje się plik
Extension Rozszerzenie pliku
AttributesAtrybuty pliku
CreationTimeCzas utworzenia pliku
LastAccessTimeCzas ostatniego dostępu do pliku
LastWriteTimeCzas zapisu do pliku

Rysunek 12.2 prezentuje aplikację, która odczytuje informacje o pliku i dodaje je kolejno do komponentu typu ListBox.

csharp12.2.jpg
Rysunek 12.2. Odczytywanie informacji o pliku

Listing 12.2 zawiera kod źródłowy programu zaprezentowanego na rysunku 12.2.

Listing 12.2. Analizowanie informacji o pliku

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
using System.IO;

namespace WinForms
{
    public partial class MainForm : Form
    {
        public MainForm()
        {
            InitializeComponent();
        }

        private void button1_Click(object sender, EventArgs e)
        {
            if (!File.Exists("C:\\Foo.txt"))
            {
                File.CreateText("C:\\Foo.txt");
            }

            FileInfo F = new FileInfo("C:\\Foo.txt");

            lbFile.Items.Add(
                "Nazwa pliku: " + F.Name);
            lbFile.Items.Add(
                "Rozmiar pliku: " + F.Length);
            lbFile.Items.Add(
                "Nazwa katalogu: " + F.DirectoryName);
            lbFile.Items.Add(
                "Rozszerzenie: " + F.Extension);
            lbFile.Items.Add(
                "Atrybuty: " + F.Attributes.ToString());
            lbFile.Items.Add(
                "Czas utworzenia pliku: " + F.CreationTime.ToShortDateString());
            lbFile.Items.Add(
                "Czas dostępu: " + F.LastAccessTime.ToShortDateString());
            lbFile.Items.Add(
                "Czas ostatniej modyfikacji: " + F.LastWriteTime.ToShortDateString());

            File.Delete("C:\\Foo.txt");            
        }
    }
}

Strumienie

O strumieniach możesz myśleć jako o ciągach danych. W środowisku .NET Framework dostępnych jest wiele klas obsługujących strumienie, w zależności od medium przechowującego te dane. I tak do obsługi strumieni plików wykorzystujemy klasę FileStream, natomiast klasa MemoryStream reprezentuje strumienie znajdujące się w pamięci operacyjnej. Pomimo tych różnic wykorzystywanie tych klas jest niemalże identyczne.

Wspomniałem o klasach reprezentujących dane. Jednakże do odczytywania i zapisywania danych do strumieni używamy odrębnych klas — StreamReader oraz StreamWriter. W przypadku danych binarnych są to odpowiednio klasy BinaryWriter i BinaryReader. Możliwości stosowania tych klas w praktyce zostaną zilustrowane przykładami w kolejnych punktach.

Obsługa plików tekstowych

Podstawowe operacje na plikach tekstowych, jakie wykonujemy, programując, to: zapisywanie, odczytywanie oraz — ewentualnie — przemieszczanie się w pliku tekstowym.

Zacznijmy od utworzenia egzemplarza klasy FileStream. Klasa posiada wiele przeciążonych konstruktorów. Jeden z nich wymaga podania trzech parametrów:

*ścieżki do pliku,
*trybu otwarcia pliku,
*trybu dostępu do pliku.

Typowe utworzenie nowego egzemplarza może wyglądać tak:

FileStream fs = new FileStream("C:\\Foo.txt", 
   FileMode.OpenOrCreate, FileAccess.ReadWrite);

W drugim i trzecim parametrze określony został tryb otwarcia oraz dostępu do pliku. Są to typy wyliczeniowe, których wartości opisałem w tabeli 12.4 oraz 12.5.

Tabela 12.4. Wartości typu wyliczeniowego FileMode

WartośćOpis
`Append`Tworzy lub otwiera plik i przechodzi na jego koniec. Wymaga utworzenia obiektu z parametrem FileAccess.Write.
`Create`Tworzy plik, a w razie gdy on już istnieje, zastępuje jego dotychczasową zawartość.
`CreateNew`Tworzy plik, a w razie gdy on już istnieje, generuje odpowiedni `wyjątek`.
`Open`Otwiera nowy plik do odczytu. Jeżeli plik nie istnieje, generowany jest wyjątek.
`OpenOrCreate`Otwiera plik, a jeżeli ten nie istnieje — tworzy nowy.
`Truncate`Otwiera plik i czyści jego zawartość.

Tabela 12.5. Wartości typu wyliczeniowego FileAccess

WartośćOpis
`Read`Dane mogą być jedynie odczytywane.
`Write`Dane mogą być tylko zapisywane.
`ReadWrite`Dane mogą być zarówno zapisywane, jak i odczytywane.

Powyższy kod tworzący obiekt klasy FileStream próbuje otworzyć plik. Jeżeli ten nie istnieje — tworzy nowy. Plik jest otwierany zarówno do odczytu, jak i do zapisu.

Aby zapisać wartość w pliku tekstowym, należy utworzyć również egzemplarz klasy StreamWriter. W parametrze konstruktora tej klasy należy przekazać obiekt klasy FileStream (patrz listing 12.3).

Listing 12.3. Przykład tworzenia oraz zapisania wartości do pliku

using System;
using System.IO;

namespace ConsoleApp
{
    class Program
    {
        static void Main(string[] args)
        {
            FileStream fs = new FileStream("C:\\Foo.txt", 
                FileMode.OpenOrCreate, FileAccess.ReadWrite);

            try
            {
                StreamWriter sw = new StreamWriter(fs);

                sw.WriteLine("Hello World!");
                sw.WriteLine("Bye!");
                sw.Close();
            }
            catch (Exception e)
            {
                Console.WriteLine(e.ToString());
            }
        }
    }
}

Podobnie jak metoda WriteLine() z klasy Console wyświetla nową linię w oknie konsoli, tak metoda WriteLine() z klasy StreamWriter zapisuje nową linię w pliku tekstowym. Należy wspomnieć o tym, że wywołanie metody WriteLine() nie równa się bezpośredniemu zapisaniu do pliku, lecz do bufora pamięci. Należy pamiętać o wywołaniu metody Close() lub Flush(), która czyści bufor.

Aby odczytać zawartość pliku tekstowego, możemy skorzystać z klasy StreamReader. Jej użycie jest podobne jak w przypadku wcześniej wspominanej klasy StreamWriter. Listing 12.4 prezentuje przykład załadowania do komponentu typu RichtextBox zawartości pliku tekstowego wskazanego przez użytkownika.

Listing 12.4. Przykład załadowania zawartości pliku

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Text;
using System.Windows.Forms;
using System.IO;

namespace WinForms
{
    public partial class MainForm : Form
    {
        public MainForm()
        {
            InitializeComponent();
        }

        private void MainForm_Load(object sender, EventArgs e)
        {
            openDialog.ShowDialog();

            FileStream fs = new FileStream(openDialog.FileName,
                FileMode.Open, FileAccess.Read);

            try
            {
                StreamReader sr = new StreamReader(fs);

                richText.Text = sr.ReadToEnd();
                sr.Close();
            }
            catch (Exception ex)
            {
                MessageBox.Show(ex.ToString());
            }                
        }
    }
}

Do operacji odczytu zawartości pliku służy metoda ReadToEnd(). Odczytuje ona zawartość całego pliku, zwracając ją w formie łańcucha string. Tak więc przy większych plikach użycie tej metody może wpłynąć na zawartość pamięci, jaka jest używana przez naszą aplikację. W przypadku większych plików można skorzystać z metody ReadLine(), która odczytuje kolejne linie pliku tekstowego. Tę metodę należy odtwarzać w pętli:

while (!sr.EndOfStream)
{
    richText.Text += sr.ReadLine();
}

Właściwość EndOfStream zwraca true, jeżeli napotkano koniec pliku. W przypadku strumieni istnieje pojęcie bieżącej pozycji wewnątrz reprezentowanego bloku danych, z której dane są odczytywane lub na której są zapisywane. Może to być albo początek strumienia, albo koniec, albo wybrany punkt pomiędzy jego początkiem a końcem.

Do przesuwania wewnętrznej pozycji strumienia służy metoda Seek(), w której możemy podać ilość bajtów, o jaką zostanie przesunięty wskaźnik:

sr.BaseStream.Seek(10, SeekOrigin.Begin);

Pozycja odczytu-zapisu w strumieniu jest reprezentowana za pomocą wartości typu wyliczeniowego SeekOrigin (patrz tabela 12.6).

Tabela 12.6. Wartości typu wyliczeniowego SeekOrigin

WartośćOpis
`Begin`Reprezentuje początek strumienia.
`Current`Reprezentuje bieżącą pozycję w strumieniu.
`End`Reprezentuje koniec strumienia.

Czyli np. wywołanie:

se.BaseStream.Seek(0, SeekOrigin.End);

oznacza ustawienie wskaźnika na końcu pliku.

Rozwiązaniem alternatywnym do zaprezentowanej przed chwilą właściwości EndOfStream jest metoda Peek(). Zwraca ona numer kolejnego znaku (typ int), ale nie zmienia dotychczasowego położenia wskaźnika. Jeżeli wartość zwrócona przez tę metodę jest równa -1, oznacza to, że mamy do czynienia z końcem pliku:

while (sr.Peek() != -1)
{
// kod 
}

Klasa StreamReader udostępnia również metodę ReadBlock(), która umożliwia odczytanie konkretnego fragmentu pliku. Pierwszy parametr musi wskazywać na zmienną tablicową (typu char), do której zostanie przypisana odczytana wartość. Kolejny parametr oznacza wartość początkową, od której rozpocznie się kopiowanie znaków, a ostatni — ilość znaków do skopiowania. Np.:

char[] buff = new char[100];
sr.ReadBlock(buff, 0, 100);

Operacje na danych binarnych

Praca z danymi binarnymi jest bardzo podobna do pracy ze zwykłymi plikami tekstowymi. Różnica jest taka, iż w tych drugich do odczytu oraz zapisu stosujemy klasy BinaryRead oraz BinaryWrite.

Przy pomocy klasy BinaryWrite możemy zapisać do pliku dane wielu rodzajów, począwszy od łańcuchów tekstowych, na liczbach i tablicach typu char skończywszy. Wszystko dzięki przeciążonej metodzie Write().

Oto przykład:

char[] MyChar = { 'H', 'e', 'l', 'l', 'o' };

FileStream fs = new FileStream("C:\\Data.dat",
    FileMode.OpenOrCreate, FileAccess.ReadWrite);

BinaryWriter bw = new BinaryWriter(fs);

bw.Write("Hello World!");
bw.Write(12.23);
bw.Write(true);
bw.Write(MyChar);
bw.Close();

Konstruktor klasy BinaryWriter jako parametru również wymaga obiektu klasy FileStream.

Do odczytu danych z pliku binarnego musimy użyć odpowiednich metod, których nazwa tworzona jest ze słów Read oraz typu danych. Np. za odczyt danych typu string odpowiada metoda ReadString(). Poniżej zaprezentowałem kod odpowiadający za załadowanie i wyświetlenie danych binarnych:

FileStream fs = new FileStream("C:\\Data.dat",
    FileMode.OpenOrCreate, FileAccess.ReadWrite);

BinaryReader bw = new BinaryReader(fs);

Console.WriteLine("String: {0}", bw.ReadString());
Console.WriteLine("Double: {0}", bw.ReadDouble());
Console.WriteLine("Boolean: {0}", bw.ReadBoolean());
Console.WriteLine("Char array: {0}", bw.ReadChars(5));

Console.Read();

Metoda ReadChars(), w odróżnieniu od pozostałych, jako parametru wymaga ilości znaków, które zostaną odczytane.

Serializacja

Serializacja jest procesem umożliwiającym zapisywanie informacji o obiektach klas w plikach tekstowych lub binarnych. Ściślej mówiąc, jest procesem przekształcania klas w strumień bajtów, który może być utrwalany na nośniku danych, przesyłany do innego procesu lub nawet na inny komputer. Procesem odwrotnym jest deserializacja (ang. deserialization), która polega na odczytaniu strumienia bajtów z pliku lub zdalnego komputera i zrekonstruowaniu zawartej w nim klasy włącznie z jej zapisanym stanem.
Dzięki serializacji możemy w każdej chwili zapisać stan, w jakim znajduje się obiekt, wraz z wartościami jego elementów. Kiedy obiekt jest serializowany, środowisko uruchomieniowe CLR wewnętrznie buduje graf obiektów. Umożliwia on temu środowisku obsługę utrwalanych obiektów wraz ze wszystkimi obiektami, które są z nimi powiązane.

Seralizowana klasa musi być opatrzona atrybutem [Serializable]. Serializowane są wszystkie elementy klasy, pod warunkiem że nie zostały opatrzone atrybutem [NonSerialized]:

[Serializable]
class Foo
{
    int i;
    string s;
    [NonSerialized]
    double d;
} 

Formaty zapisu danych

Platforma .NET Framework udostępnia dwa formaty zapisu serializowanych danych — binarny i SOAP. Należy pamiętać, że platforma .NET Framework nie ogranicza liczby tych formatów — w razie potrzeby możemy więc tworzyć własne formaty. Aby zapisać klasy w formacie binarnym, musimy użyć klasy BinaryFormatter, należącej do przestrzeni nazw System.Runtime.Serialization.Formatters.Binary. Użycie formatu SOAP wymaga wykorzystania klasy SoapFormatter, należącej do przestrzeni nazw System.Runtime.Serialization.Formatters.Soap.

SOAP — ang. Simple Object Access Protocol — jest językiem opartym na XML, wykorzystywanym przez usługi sieciowe do wywoływania procedur. Protokół SOAP określa format przesyłanych danych, nazwy parametrów itp. Język XML zostanie omówiony w kolejnym rozdziale książki.

Klasa SoapFormatter nie jest zalecana jako format przechowywanych danych. W wersji 2.0 biblioteki klas FCL ta klasa może być nieobecna.

Przykład serializacji

Listing 12.5 zawiera przykładowy kod źródłowy, który dokonuje procesu serializacji, a następnie deserializacji danych. Serializowane dane są zapisywane na dysku C: pod nazwą data.dat.

Listing 12.5. Przykład serializacji

using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

namespace ConsoleApp
{
    public enum GenreEnum { Fish, Mammal };
    
    [Serializable]
    public struct Animal
    {
        public string Name;
        public int Age;
        public GenreEnum Genre;
    }

    class Program
    {
        static void Main(string[] args)
        {
            // deklaracja tablicy struktur
            Animal[] MyPet = new Animal[2];

            // wypełnienie tablicy struktur
            MyPet[0].Name = "Pies";
            MyPet[0].Age = 10;
            MyPet[0].Genre = GenreEnum. Mammal;

            MyPet[1].Name = "Ryba";
            MyPet[1].Age = 1;
            MyPet[1].Genre = GenreEnum.Fish;

            FileStream MyStream;

            // utworzenie pliku, który będzie zawierał strumienie danych
            MyStream = new FileStream("C:\\data.dat", 
                FileMode.Create);


            BinaryFormatter MyFormatter = new BinaryFormatter();
            // próba serializacji
            MyFormatter.Serialize(MyStream, MyPet);
            // zamknięcie strumienia
            MyStream.Close();

            // ponowne otwarcie strumienia 
            MyStream = new FileStream("C:\\data.dat",
                FileMode.Open);
            // deserializacja 
            MyPet = (Animal[])MyFormatter.Deserialize(MyStream);
            
            foreach (Animal Pet in MyPet)
            {
                Console.WriteLine("{0} {1} {2}",
                    Pet.Name, Pet.Age, Pet.Genre
                );
            }
            Console.Read();             
        }
    }
}

Serializowanymi danymi jest struktura Animal. Struktura służy do przechowywania informacji o zwierzętach. W programie zadeklarowałem dwuelementową tablicę typu Animal, którą wypełniam danymi.

Następnie tworzona jest instancja klasy FileStream oraz BinaryFormatter. Serializowania dokonuje metoda Serialize(). W tym samym programie dokonujemy również deserializacji przy pomocy metody Deserialize(). Ponieważ metoda ta zwraca dane typu object, konieczne jest rzutowanie typów na tablicę Animal, którą wyświetlamy w oknie konsoli.

Podsumowanie

Obsługa plików we własnej aplikacji wciąż, z uwagi na większą popularność systemów bazodanowych, jest rzadziej wykorzystywana. Do przechowywania danych dotyczących aplikacji używa się baz danych czy plików XML (będzie o nich mowa w kolejnym rozdziale). Strumienie są jednak wykorzystywane dosyć często w bibliotece klas FCL w wielu klasach, dlatego warto znać choćby ich podstawową obsługę. Niekiedy może się również nadarzyć okazja do użycia plików w formie bazy danych — do przechowywania informacji. Wówczas możesz powrócić do tego rozdziału i przypomnieć sobie sposoby obsługi różnych typów strumieni.

[[C_Sharp/Wprowadzenie|Spis treści]]

[[C_Sharp/Wprowadzenie/Prawa autorskie|©]] Helion 2006. Autor: Adam Boduch. Zabrania się rozpowszechniania tego tekstu bez zgody autora.

1 komentarz

Super artykuł :)