Rozdział 7. Tablice i kolekcje.

Adam Boduch

Omówiliśmy już sporą część tego, co oferuje język C#. Powiedzieliśmy sobie o najważniejszym — programowaniu obiektowym, które może przysporzyć najwięcej kłopotów początkującemu programiście. Nie zaprezentowałem do tej pory bardzo ważnego elementu wielu języków programowania, a mianowicie obsługi tablic.

Jest to bardzo wygodna funkcja języka programowania; przekonasz się o tym podczas pisania przykładowej aplikacji podsumowującej dotychczasową wiedzę o języku C#. Będzie to znana i lubiana gra — kółko i krzyżyk. O tym jednak pod koniec tego rozdziału. Nie przedłużając, spieszę z wyjaśnieniem, czym są tablice…

1 Czym są tablice
     1.1 Deklarowanie tablic
     1.2 Indeks
     1.3 Inicjalizacja danych
2 Tablice wielowymiarowe
3 Pętla foreach
     3.4 Pętla foreach a tablice wielowymiarowe
4 Tablice tablic
5 Tablice struktur
6 Parametr args w metodzie Main()
7 Klasa System.Array
     7.5 Metody klasy
          7.5.1 BinarySearch()
          7.5.2 Clear()
          7.5.3 Clone()
          7.5.4 Copy()
          7.5.5 Find()
          7.5.6 FindAll()
          7.5.7 FindLast()
          7.5.8 GetLength()
          7.5.9 GetLowerBound(), GetUpperBound()
          7.5.10 GetValue()
          7.5.11 Initialize()
          7.5.12 IndexOf()
          7.5.13 Resize()
          7.5.14 SetValue()
     7.6 Słowo kluczowe params
8 Przykład — gra kółko i krzyżyk
     8.7 Zasady gry
     8.8 Specyfikacja klasy
          8.8.15 Ustawienia gracza
          8.8.16 Zarys klasy
     8.9 Ustawienie symbolu na planszy
     8.10 Sprawdzenie wygranej
     8.11 Interfejs aplikacji
          8.11.17 Menu do gry
               8.11.17.1 Sterowanie menu
          8.11.18 Kod źródłowy modułu głównego
          8.11.19 Ćwiczenie dodatkowe
9 Mechanizm indeksowania
     9.12 Indeksy łańcuchowe
10 Kolekcje
     10.13 Interfejsy System.Collections
          10.13.20 IEnumerable
          10.13.21 ICollection
          10.13.22 IList
          10.13.23 IDictionary
          10.13.24 IEnumerator
11 Stosy
12 Kolejki
     12.14 Klasa ArrayList
13 Listy
     13.15 Typy generyczne
          13.15.25 Tworzenie typów generycznych
          13.15.26 Metody generyczne
     13.16 Korzystanie z list
14 Słowniki
     14.17 Przykładowy program
15 Podsumowanie

Czym są tablice

Wyobraź sobie, że w swojej aplikacji musisz przechować wiele zmiennych tego samego typu. Dla przykładu, niech będą to dni tygodnia typu string. Proste? Owszem, wystarczy zadeklarować siedem zmiennych typu string:

string pon, wt, śr, czw, pt, so, nd;

Teraz do tych zmiennych należy przypisać wartość:

pon = "Poniedziałek";
wt = "Wtorek"; 
// itd

Teraz wyobraź sobie sytuację, w której musisz zadeklarować 12 zmiennych oznaczających nazwy miesięcy. Nieco uciążliwe? Owszem. Do tego celu najlepiej użyć tablic, które służą do grupowania wielu elementów tego samego typu. Osobiście z tablic korzystam bardzo często, jest to znakomity, czytelny sposób na przechowywanie dużej ilości danych.

Przejdźmy jednak do rzeczy. W C# istnieje możliwość deklaracji zmiennej, która przechowywać będzie wiele danych. Tak w skrócie i uproszczeniu możemy powiedzieć o tablicach.

Deklarowanie tablic

Tablice deklaruje się podobnie jak zwykłe zmienne. Jedyną różnicą jest zastosowanie nawiasów kwadratowych:

typ[] Nazwa;

W miejsce typ należy podać typ danych elementów tablicowych (np. int, string), a w miejsce nazwa — nazwę zmiennej tablicowej. Przykładowo:

int[] Foo;

W tym miejscu zadeklarowaliśmy tablicę Foo, która może przechowywać elementy typu int. Przed użyciem takiej tablicy należy zadeklarować, z ilu elementów ma się ona składać. W tym celu korzystamy ze znanego nam operatora new:

Foo = new int[5];

Taka konstrukcja oznacza zadeklarowanie w pamięci komputera miejsca dla pięciu elementów tablicy Foo. Przypisywanie danych do poszczególnych elementów odbywa się również przy pomocy symboli nawiasów kwadratowych:

int[] Foo;
Foo = new int[5];

Foo[0] = 100;
Foo[1] = 1000;
Foo[2] = 10000;
Foo[3] = 100000;
Foo[4] = 1000000;

Console.WriteLine(Foo[4]);

Możliwy jest również skrótowy zapis deklaracji tablic, podobny do tego znanego z tworzenia obiektów:
int[] Foo = new int[5];

Indeks

Tablica składa się z elementów. Każdemu z nich przypisany jest tzw. indeks, dzięki któremu odwołujemy się do konkretnego elementu tablicy. Ów indeks ma postać liczby i wpisujemy go w nawiasach kwadratowych, tak jak to zaprezentowano w poprzednim przykładzie. Spójrz na kolejny przykład:

char[] Foo = new char[5];

Foo[0] = 'H';
Foo[1] = 'e';
Foo[2] = 'l';
Foo[3] = 'l';
Foo[4] = 'o';

Indeksy numerowane są od zera do N – 1, gdzie N to ilość elementów tablicy. Aby lepiej to zrozumieć, spójrz na tabelę 7.1.

Tabela 7.1. Prezentacja zależności indeksów elementów

Indeks01234
WartośćHello

Uwaga! Należy uważać, aby nie odwołać się do elementu, który nie istnieje! Jeżeli zadeklarowaliśmy tablicę 5-elementową i odwołujemy się do szóstego elementu (poprzez indeks nr 5), kompilator C# nie zareaguje! Błąd zostanie wyświetlony dopiero po uruchomieniu programu.

Inicjalizacja danych

Po utworzeniu tablicy każdemu elementowi przypisywana jest domyślna wartość. Np. w przypadku typu int jest to cyfra 0. Programista po zadeklarowaniu takiej tablicy ma możliwość przypisania wartości dla konkretnego elementu.

Istnieje możliwość przypisania wartości dla konkretnego elementu już przy deklarowaniu tablicy. Należy wówczas wypisać wartości w klamrach:

char[] Foo = new char[5] {'H', 'e', 'l', 'l', 'o'};

Console.WriteLine(Foo[4]);

Język C# dopuszcza uproszczony zapis takiego kodu — wystarczy pominąć ilość elementów tablicy:

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

Kompilator oblicza rozmiar takiej tablicy po ilości elementów uporządkowanych pomiędzy klamrami.

Tablice wielowymiarowe

C# umożliwia także deklarowanie tzw. tablic wielowymiarowych. Przykładowo, poniższy kod tworzy tablicę 7x2 (7 kolumn i 2 wiersze):

string[,] Foo = new string[7, 2];

Zasada deklarowania tablic wielowymiarowych jest prosta. W nawiasie kwadratowym wpisujemy znak przecinka (,), natomiast podczas inicjalizacji musimy podać wymiar tablicy (ilość elementów należy również rozdzielić znakiem średnika). Podczas przypisywania danych do elementów należy podać dokładny indeks:

Foo[0, 0] = "Pn";
Foo[1, 0] = "Wt";
Foo[2, 0] = "Śr";
Foo[3, 0] = "Czw";
Foo[4, 0] = "Pt";
Foo[5, 0] = "So";
Foo[6, 0] = "Nd";

Foo[0, 1] = "Mon";
Foo[1, 1] = "Tue";
Foo[2, 1] = "Wed";
Foo[3, 1] = "Thu";
Foo[4, 1] = "Fri";
Foo[5, 1] = "Sat";
Foo[6, 1] = "Sun";

Język C# nie ogranicza nas w ilości wymiarów. Możemy więc wprowadzić do naszej tablicy kolejny wymiar. Poniższy fragment prezentuje deklarację tablicy 2x4x2:

string[, ,] Foo = new string[2, 4, 2];

Inicjalizacja danych tablicy wielowymiarowej jest analogiczna do standardowej tablicy:

int[,] Foo = new int[3, 3] { { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 } };

Zauważ jednak, że poszczególne elementy zawarte w klamrach są rozdzielone znakiem przecinka. Oczywiście istnieje możliwość skrótowego zapisu:

int[,] Foo = new int[,] { { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 } };

lub:

int[,] Foo = { { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 } };

Pętla foreach

Podczas omawiania zagadnienia pętli nie wspomniałem o jednej ważnej pętli służącej do operowania na tablicach. Ponieważ tematyka tablic w rozdziale 3. nie była omawiania, pragnę wspomnieć o tej pętli właśnie tutaj.

Pętla ta, znana programistom PHP, Perl czy też Delphi, dla .NET jako parametru wymaga tablicy. Spójrz na poniższy fragment kodu:

string[] Foo = new string[7];

Foo[0] = "Pn";
Foo[1] = "Wt";
Foo[2] = "Śr";
Foo[3] = "Czw";
Foo[4] = "Pt";
Foo[5] = "So";
Foo[6] = "Nd";

foreach (string Bar in Foo)
{
    Console.WriteLine(Bar);
}

Uruchomienie takiej aplikacji spowoduje wyświetlenie, jeden pod drugim, kolejnych elementów tablicy. Po każdej iteracji kolejny element tablicy przypisywany jest do zmiennej Bar. Tutaj ważna uwaga. Zmienna Bar nie może być zadeklarowana lub użyta we wcześniejszych fragmentach kodu. Np. poniższa konstrukcja jest błędna:

string Bar = "Test";
foreach (string Bar in Foo)
{
    Console.WriteLine(Bar);
}

Przy próbie kompilacji wyświetlony zostanie błąd: A local variable named 'Bar' cannot be declared in this scope because it would give a different meaning to 'Bar', which is already used in a 'parent or current' scope to denote something else.

Identyczny rezultat jak ten pokazany przed chwilą można osiągnąć, stosując pętlę for:

for (int i = 0; i < Foo.Length; i++)
{
    Console.WriteLine(Foo[i]);
}

Konstrukcja Foo.Length zwraca rozmiar tablicy.

Zasadniczo wygodniejszym i czytelniejszym sposobem jest użycie pętli foreach, która w końcu została stworzona po to, by operować na tablicach. Jednakże użycie pętli for ma jedną przewagę nad foreach — można w niej modyfikować wartości elementów. Spójrz na poniższy przykład:

for (int i = 0; i < Foo.Length; i++)
{
    Foo[i] = "Foo";
    Console.WriteLine(Foo[i]);
}

Identycznego efektu nie uzyskamy, stosując pętlę foreach:

foreach (string Bar in Foo)
{
    Bar = "Foo";
}

W tym momencie kompilator zasygnalizuje błąd: Cannot assign to 'Bar' because it is a 'foreach iteration variable'.

Pętla foreach a tablice wielowymiarowe

Pętla foreach z powodzeniem działa na tablicach wielowymiarowych. W takim wypadku kolejność iteracji jest następująca: najpierw przekazana zostanie wartość elementu [0, 1], następnie [0, 2] itd. Krótki kod prezentujący takie działanie:

string[,] Foo = new string[7, 2];

Foo[0, 0] = "Pn";
Foo[1, 0] = "Wt";
Foo[2, 0] = "Śr";
Foo[3, 0] = "Czw";
Foo[4, 0] = "Pt";
Foo[5, 0] = "So";
Foo[6, 0] = "Nd";

Foo[0, 1] = "Mon";
Foo[1, 1] = "Tue";
Foo[2, 1] = "Wed";
Foo[3, 1] = "Thu";
Foo[4, 1] = "Fri";
Foo[5, 1] = "Sat";
Foo[6, 1] = "Sun";

foreach (string Bar in Foo)
{
    Console.WriteLine(Bar);
} 

Kolejność wyświetlania danych na konsoli będzie następująca:

Pon
Mon
Wt
Tue
...

W przypadku tablic wielowymiarowych konstrukcja Tablica.Length zwraca liczbę wszystkich elementów w tablicy. W prezentowanym przykładzie będzie to 7x2, czyli 14.

Działanie pętli for na tablicach wielowymiarowych jest nieco inne. Przykładowo, poniższa pętla spowoduje wyświetlenie jedynie polskich dni tygodnia:

for (int i = 0; i < Foo.Length / 2; i++)
{
   Console.WriteLine(Foo[i, 0]);
}

Tablice tablic

Mechanizm tablic jest w języku C# bardzo rozbudowany. Umożliwia nawet tworzenie tablic, które zawierają kolejne tablice. Poniższy kod prezentuje deklarację takiej tablicy:

int[][] Foo = new int[2][]; 

Ten zapis oznacza, iż tablica Foo zawierać będzie kolejne dwie tablice o nieokreślonej jeszcze liczbie elementów. Te dwie kolejne tablice również muszą zostać utworzone:

Foo[0] = new int[50];
Foo[1] = new int[1000];

Przypisywanie danych do takich tablic wygląda podobnie jak w przypadku tablic wielowymiarowych:

// przypisanie wartości do elementu 26. tablicy nr 1
Foo[0][25] = 100;
// przypisanie wartości do elementu 1000. tablicy drugiej
Foo[1][999] = 1;

Sprawa inicjalizacji tablic prezentuje się podobnie, jak to zostało zaprezentowane w trakcie omawiania bardziej podstawowych elementów. Przypisanie wartości do elementów w tablicach tego typu charakteryzuje się dość specyficzną składnią:

int[][] Foo = new int[][]
{
    new int[] {1, 2}, // zwróć uwagę na brak średnika!
    new int[] {1, 2, 3}
}; // zwróć uwagę na obecność średnika!

Console.WriteLine(Foo[0][1]);

Moim zdaniem przejrzystszy jest skrótowy zapis powyższego kodu, również akceptowany przez kompilator C#:

int[][] Foo = 
{
    new int[] {1, 2},
    new int[] {1, 2, 3}
};

Tablice struktur

O strukturach i wyliczeniach powiedzieliśmy sobie w rozdziale 5. W niektórych przypadkach przydatna okazuje się możliwość deklarowania tablic struktur lub typów wyliczeniowych. Jest to sprawa dość prosta, jeżeli znasz już podstawy użycia tablic, bowiem zamiast typu dotychczas używanego (czyli int, string, char itp.) należy użyć wcześniej zadeklarowanej struktury:

public struct Bar
{
    public string Name;
    public byte Age;
}

class Program
{
    static void Main(string[] args)
    {
        Bar[] BarArr = new Bar[2];

        BarArr[0].Name = "Janusz Kowalski";
        BarArr[0].Age = 52;

        BarArr[1].Name = "Piotr Nowak";
        BarArr[1].Age = 18;
    }
}

Parametr args w metodzie Main()

Gdy w rozdziale trzecim omawiałem podstawowe elementy programu C#, wspomniałem oczywiście o metodzie Main(), lecz pominąłem znaczenie parametru args. Zrobiłem to celowo, aby nie wprowadzać zamętu, gdyż tematyka tablic czy nawet typów danych nie była wówczas poruszana. Parametr args typu tablicowego zawiera ewentualne parametry przekazane do naszej aplikacji z linii poleceń. Czyli uruchamiając program z poziomu linii komend, mogę napisać:

MojaAplikacja.exe Parametr1 Parametr2

Zarówno Parametr1, jak i Parametr2 zostaną przekazane do aplikacji, każdy zostanie przypisany do odrębnego elementu tablicy. Napiszmy dla treningu prosty program. Jego zadanie będzie banalne: sortowanie argumentów przekazanych do programu.

Właściwie najtrudniejszą rzeczą w programie jest sama konwersja danych z łańcucha string na wartość całkowitą int. Samo sortowanie tablicy realizuje metoda Sort() klasy Array. Całość programu prezentuje listing 7.1.

Listing 7.1. Pobieranie i sortowanie argumentów programu

using System;

namespace FooConsole
{
    class Program
    {
        static void Main(string[] args)
        {
            if (args.Length == 0)
            {
                Console.WriteLine("Brak argumentów programu!");
                return;
            }
            int[] ArrInt = new int[args.Length];
            int count = 0;            

            foreach (string element in args)
            {
                ArrInt[count] = Int32.Parse(element);
                ++count;
            }

            Array.Sort(ArrInt);
                        
            for (int i = 0; i < ArrInt.Length; i++)
            {
                Console.WriteLine("{0} ", ArrInt[i]);
            }
        }
    }
}

Argumenty przekazane do aplikacji konwertujemy, a następnie zapisujemy w nowo utworzonej tablicy ArrInt. Zwróć uwagę, że do konwersji danych ze string na int użyłem metody Parse() z klasy Int32. To również jest dopuszczalny sposób, równie dobrze mogłem także użyć klasy Convert.

Posortowaną tablicę prezentuję na konsoli, wyświetlając w pętli kolejne jej elementy.

Element {0} wykorzystany w łańcuchu w metodzie WriteLine() zostanie zastąpiony wartością zmiennej przekazanej w tej samej metodzie. Jest to czytelny i prosty sposób formatowania łańcuchów — np.:

string sName = "Adam";
string sLocation = "Wrocławiu";
Console.WriteLine("Mam na imię {0} i mieszkam we {1}", sName,  sLocation);

Klasa System.Array

Chyba oswoiłeś się już z myślą, że całe środowisko .NET oparte jest na klasach, strukturach i wyliczeniach? Nie inaczej jest w przypadku tablic. Każda tablica w języku C# dziedziczy po klasie System.Array, która dostarcza podstawowych mechanizmów do manipulacji na elementach tablicy. To dzięki metodom tej klasy możemy pobrać ilość elementów w tablicy, posortować ją czy przeszukać. Kilka najbliższych stron zostanie przeznaczonych na opisanie podstawowych elementów tej klasy.

Jeżeli chodzi o właściwości klasy, to najważniejszą jest Length, która zwraca aktualną liczbę elementów tablicy. Ta sama klasa udostępnia również właściwość LongLength, która zwraca 64-bitową wartość określającą rozmiar wszystkich elementów w przypadku tablic wielowymiarowych.

Warto również wspomnieć o właściwości Rank, która zwraca liczbę wymiarów danej tablicy:

int[,] Foo = new int[3, 2] { { 1, 2 }, { 1, 2 }, { 1, 2 } };

Console.WriteLine(Foo.Rank); // tablica dwuwymiarowa (wyświetli 2)

Metody klasy

W trakcie omawiania klasy System.Array należy wspomnieć o paru metodach, które mogą Ci się przydać przy okazji operowania na tablicach.

BinarySearch()

Używając algorytmu przeszukiwania binarnego, przeglądam elementy tablicy, aby znaleźć żądaną wartość. Pierwszym parametrem tej metody musi być nazwa tablicy, na której będzie ona operować. Drugim parametrem — szukany element. Oto przykład użycia tej metody:

string[] Foo = new string[] { "Pn", "Wt", "Śr", "Czw", "Pt" };
Console.WriteLine(Array.BinarySearch(Foo, "Śr"));

Metoda zwraca numer indeksu, pod jakim znajduje się szukany element, lub –1, jeżeli nic nie zostało znalezione. W zaprezentowanym przykładzie metoda zwróci wartość 2.

Clear()

Metoda umożliwia wyczyszczenie tablicy. W rzeczywistości ustawia każdemu elementowi wartość 0 lub null w zależności od jego typu. Metoda przyjmuje trzy parametry. Pierwszym jest nazwa tablicy, drugim — numer indeksu, od którego ma rozpocząć czyszczenie, a trzecim — zasięg tego procesu. Dzięki metodzie Clear() można bowiem wyczyścić określone elementy z tablicy. Poniższy przykład prezentuje czyszczenie całej zawartości:

Array.Clear(Foo, 0, Foo.Length);

Metoda Clear() nie zmienia rozmiaru czyszczonej tablicy. Jeżeli czyścimy tablicę, która ma — powiedzmy — 5 elementów, to po przeprowadzeniu tej operacji nadal będzie ich miała tyle samo.

Do elementów wyczyszczonej tablicy ponownie możemy przypisywać jakieś wartości.

Słowo kluczowe null w języku C# oznacza wartość pustą.

Clone()

Metoda Clone() zwraca kopię tablicy, z której została wywołana — np.:

Bar = Foo.Clone();

Od tej pory Bar będzie posiadała takie same elementy co Foo. Ponieważ metoda Clone() zwraca dane w postaci typu object, należy dokonać rzutowania na właściwy typ. Tzn. jeżeli mamy tablicę typu string, należy na niego dokonać odpowiedniego rzutowania, co prezentuje poniższy przykład:

string[] Foo = new string[] { "Pn", "Wt", "Śr", "Czw", "Pt" };
// tworzenie kopii
string[] Bar = (string[])Foo.Clone();

foreach (string element in Bar)
{
    Console.WriteLine(element);
}

Copy()

Być może lepszym sposobem na utworzenie kopii tablicy będzie zastosowanie metody Copy(). Umożliwia ona dodatkowo określenie rozmiarów kopiowania, tj. ile elementów zostanie skopiowanych. Oto przykład:

string[] Foo = new string[] { "Pn", "Wt", "Śr", "Czw", "Pt" };
string[] Bar = new string[Foo.Length];
// tworzenie kopii
Array.Copy(Foo, Bar, Foo.Length);

Pierwszym parametrem tej metody musi być tablica źródłowa (kopiowana), drugim — tablica, do której skopiowane zostaną elementy. Trzeci parametr to oczywiście ilość kopiowanych elementów.

Find()

Metoda umożliwia przeszukanie całej tablicy w celu znalezienia danego elementu. Kwalifikacja danego elementu jako znaleziony lub też nie odbywa się przy pomocy zewnętrznej metody. Oto przykładowy program:

{
    Point[] points = { 
        new Point(10, 20),
        new Point(100, 200),
        new Point(400, 500)
    };

    Point first = Array.Find(points, pointFind);

    Console.WriteLine("Found: {0}, {1}", first.X, first.Y);
    Console.Read();
}

private static bool pointFind(Point point)
{
    if (point.X % 2 == 0)
    {
        return true;
    }
    else
    {
        return false;
    }
}

Na samym początku zadeklarowałem tablicę struktur Point, które będą przeszukiwane. Program wyszukuje elementów tablicy, w której element X struktury Point jest liczbą parzystą. Po znalezieniu pierwszego metoda pointFind() zwraca true i kończy swe działanie.

Struktura Point zadeklarowana jest w przestrzeni nazw System.Drawing. Nie zapomnij zadeklarować jej użycia przy pomocy słowa using oraz dołączyć odpowiedniego podzespołu (System.Drawing.dll).

FindAll()

Jak sama nazwa wskazuje, metoda FindAll() wyszukuje wszystkie elementy, które spełniają dane kryteria poszukiwań. Oto jak powinien wyglądać program z poprzedniego listingu, jeśli ma wyszukiwać wszystkie elementy:

static void Main(string[] args)
{
    Point[] points = { 
        new Point(10, 20),
        new Point(100, 200),
        new Point(400, 500)
    };

    Point[] find = Array.FindAll(points, pointFind);

    foreach (Point element in find)
    {
        Console.WriteLine("Found: {0}, {1}", element.X, element.Y);
    }
    Console.Read();
}

private static bool pointFind(Point point)
{
    if (point.X % 2 == 0)
    {
        return true;
    }
    else
    {
        return false;
    }
}

FindLast()

Metoda ta działa podobnie jak Find(). Jedyna różnica jest taka, że FindLast() szuka ostatniego wystąpienia danego elementu; nie kończy pracy, gdy znajdzie pierwszy element pasujący do kryteriów.

GetLength()

Zwraca ilość elementów w tablicy. Umożliwia działanie na tablicach wielowymiarowych. W parametrze tej metody należy podać numer wymiaru, którego ilość elementów ma być pobrana. Jeżeli mamy do czynienia z tablicą jednowymiarową, w parametrze wypisujemy cyfrę 0.

Klasa System.Array udostępnia również metodę GetLongLength(), która działa analogicznie do GetLength(), z tą różnicą, iż zwraca dane w postaci liczby typu long.

GetLowerBound(), GetUpperBound()

Metoda GetLowerBound() zwraca numer najmniejszego indeksu w tablicy. W przeważającej części przypadków będzie to po prostu cyfra 0. Metoda GetUpperBound() zwraca natomiast największy indeks danej tablicy. Obie metody mogą działać na tablicach wielowymiarowych; wówczas należy w parametrze podać indeks wymiaru. Przykładowe użycie:

int[] Foo = { 1, 2, 3, 4, 5, 6 };

Console.WriteLine("Najmniejszy indeks: {0}, największy: {1}",
        Foo.GetLowerBound(0), Foo.GetUpperBound(0));

GetValue()

Prawdopodobnie nie będziesz zmuszony do częstego korzystania z tej metody, zwraca ona bowiem wartość danego elementu tablicy. W parametrze tej metody musisz podać indeks elementu, tak więc jej działanie jest równoznaczne z konstrukcją:

Tablica[1]; // zwraca element znajdujący się pod indeksem 1

Chcąc wykorzystać tę metodę, kod możemy zapisać następująco:

int[] Foo = { 1, 2, 3, 4, 5, 6 };

Console.WriteLine(Foo.GetValue(1));

Initialize()

We wcześniejszych fragmentach tego rozdziału pisałem o inicjalizacji tablicy. Metoda Initialize() może to ułatwić. Jej użycie powoduje przypisanie każdemu elementowi pustej wartości (czyli może to być cyfra 0 lub np. wartość null). Jej użycie jest bardzo proste, nie wymaga podawania żadnych argumentów:

Foo.Initialize();

IndexOf()

Przydatna metoda. Zwraca numer indeksu na podstawie podanej wartości elementu. Przykład użycia:

string[] Foo = { "Pn", "Wt", "Śr", "Czw", "Pt", "So", "Nd" };

Console.WriteLine(Array.IndexOf(Foo, "Pt"));

W powyższym przykładzie na konsoli zostanie wyświetlona cyfra 4, gdyż pod tym numerem kryje się element Pt.

Resize()

Metoda Resize() przydaje się wówczas, gdy musimy zmienić rozmiar danej tablicy. W pierwszym jej parametrze musimy podać nazwę tablicy poprzedzoną słowem kluczowym ref oznaczającym referencję. Drugim parametrem musi być nowy rozmiar tablicy:

string[] Foo = { "Pn", "Wt", "Śr", "Czw", "Pt", "So", "Nd" };
Array.Resize(ref Foo, Foo.Length + 5);

SetValue()

Metoda SetValue() umożliwia nadanie wartości dla danego elementu tablicy. Prawdopodobnie nie będziesz korzystał z niej zbyt często, gdyż to samo działanie można zrealizować przy pomocy operatora przypisania. Gdybyś jednak miał wątpliwości, co do jej użycia, poniżej prezentuję przykład:

Foo.SetValue("Weekend", 9);

Taki zapis oznacza przypisanie wartości Weekend pod indeks nr 9.

Słowo kluczowe params

Mechanizm tablic języka C# nie umożliwia tworzenia tablic dynamicznych, tj. o zmiennym rozmiarze. Zmiana rozmiaru tablic (ilości elementów) jest nieco problematyczna, podobnie jak usuwanie elementów. Warto jednak wspomnieć o słowie kluczowym params, używanym w połączeniu z tablicami. Konkretnie z tablicowymi parametrami metod:

static void Foo(params string[] args)
{
}

Słowo params, które poprzedza właściwą deklarację parametru, mówi o tym, iż liczba elementów przekazywanych do metody będzie zmienna. Oto przykład takiego programu:

using System;

namespace FooApp
{
    class Program
    {
        static void Foo(params string[] args)
        {
            for (int i = 0; i < args.Length; i++)
            {
                Console.Write(args[i] + " ");
            }
            Console.WriteLine();
        }
        static void Main(string[] args)
        {
            Foo("Adam", "Paulina");

            Foo("Adam", "Paulina", "Marta");

            Console.Read();            
        }
    }
}

Jak widzisz, możliwe jest przekazanie dowolnej liczby parametrów do metody Foo(). Każdy parametr będzie kolejnym elementem tablicy i jest to przydatna cecha języka C#.

Możliwe jest przekazywanie parametrów różnego typu. Nagłówek metody musi wyglądać wówczas tak:

static void Foo(params object[] args)

Taką metodę można wywołać np. tak:

Foo("Adam", 10, 12.2);

Przykład — gra kółko i krzyżyk

Wydaje mi się, że już dość powiedziałem o klasach, obiektach i tablicach, nie prezentując żadnego przykładu wymagającego napisania więcej niż 100 linii kodu. Poświęćmy więc trochę czasu na napisanie aplikacji, która będzie wykorzystywała tablice. Niech to będzie popularna gra — kółko i krzyżyk. Na samym początku napiszemy „silnik” aplikacji, który będzie zawarty w osobnym module, w klasie — nazwijmy ją — GomokuEngine (niekiedy gra kółko i krzyżyk jest właśnie tak nazywana). Aby lepiej zaprezentować pewną cechę klas, najpierw napiszemy interfejs konsolowy, który będzie wykorzystywał nasz silnik, a dopiero później, w dalszej części książki, skorzystamy z WinForms.

Zasady gry

Wydaje mi się, że większość Czytelników zna zasady gry kółko i krzyżyk, lecz na wszelki wypadek je przypomnę. W najpopularniejszym wydaniu gra odbywa się na planszy 3x3. Gracze na przemian umieszczają w polach swoje znaki, dążąc do zajęcia trzech pól w jednej linii. Gracz ma przyporządkowany znak krzyżyka (X) lub kółka (O). Jedno pole może być zajęte przez jednego gracza i nie zmienia się przez cały przebieg gry (rysunek 7.1).

csharp7.1.jpg
Rysunek 7.1. Prezentacja gry kółko i krzyżyk

Specyfikacja klasy

Zacznijmy od utworzenia nowego modułu w naszym projekcie. Na pasku narzędziowym znajdź przycisk Add New Item (Ctrl+Shift+A) i wybierz pozycję Code File.

Przede wszystkim należy zadeklarować nowy typ wyliczeniowy identyfikujący znak umieszczony na planszy do gry:

public enum FieldType {ftCircle = 1, ftCross = 10};

Nie przypadkowo wartościom typu wyliczeniowego nadałem indeksy 1 oraz 10. Te liczby zostaną wykorzystane w algorytmie obliczania, który z graczy wygrał (lub czy w ogóle ktoś wygrał). Jeżeli mamy już typ wyliczeniowy, należy zadeklarować tablicę, która będzie kluczowa w całej grze. Będzie bowiem przechowywać stan gry i zawartość planszy jednocześnie:

private FieldType[,] FField = new FieldType[3, 3];

Do tego musimy skorzystać z tablicy wielowymiarowej 3x3.

Jeżeli więc użytkownik będzie chciał wykonać jakiś ruch, należy określić współrzędne pola, na którym zostanie postawiony znak:

FField[1, 1] = FieldType.ftCircle; // np...

Teraz warto zastanowić się, jakie zapisy powinny w tej klasie znaleźć się w sekcji publicznej, aby gotowa gra spełniała oczekiwania użytkowników. A jakie są oczekiwania? Funkcjonalność gry nie musi być duża, wystarczy funkcja, która na podstawie współrzędnych X i Y umieści w tablicy odpowiednie pole (krzyżyk lub kółko). Trzeba oczywiście zapewnić dostęp do tej tablicy, czyli klasę tę należy zadeklarować w sekcji public. Dodatkowo przydałyby się właściwości, dzięki którym będzie można określić imiona graczy. Co poza tym? Na pewno będziemy chcieli wiedzieć, czy gra została zakończona i kto wygrał. Potrzebne będą jeszcze dwie metody — Start() oraz NewGame(). Pierwsza rozpoczyna grę, druga resetuje ustawienia (liczbę zwycięstw poszczególnych graczy).

Ustawienia gracza

Gracze będą identyfikowani za pomocą nazw. Na samym początku gry użytkownicy będą mogli wpisać swoje imiona. Proponuję więc pójść dalej i utworzyć nową strukturę — Player, która będzie zawierać informację o graczu:

// struktura opisująca gracza
public struct Player
{
    // nazwa gracza
    public string Name;
    // ilość zwycięstw
    public int Winnings;
    // reprezentujący go symbol
    public FieldType Type;
}

W strukturze znajduje się nazwa użytkownika, symbol, jakim się on posługuje (pole Type), czyli kółko (ftCircle) lub krzyżyk (ftCross), oraz liczba zwycięstw (Winnings). Skoro graczy będzie dwóch, to można w sekcji private klasy utworzyć tablicę dwuelementową:

private Player[] FPlayer = new Player[2];

Zarys klasy

Powiedzieliśmy już o informacji o graczach oraz planszy do gry. Ważne jest także, aby zawrzeć w klasie właściwość zwracającą aktywnego użytkownika, tj. takiego, który w danej chwili ma wykonać ruch. Należy również zadeklarować właściwość Winner typu bool, która będzie przypisywać wartość true, jeżeli któryś z graczy wygrał. Będzie to informacja, aby zakończyć grę. W swojej klasie skorzystałem z właściwości, z których przeważająca cześć jest tylko do odczytu.
Na podstawie tego, co powiedziałem, listę pól oraz właściwości można zapisać tak, jak to zostało przedstawione na listingu 7.2.

Listing 7.2. Zarys klasy GomokuEngine

using System;

// typ pola (kółko — circle lub krzyżyk (cross))
public enum FieldType {ftCircle = 1, ftCross = 10};

// struktura opisująca gracza
public struct Player
{
    // nazwa gracza
    public string Name;
    // ilość zwycięstw
    public int Winnings;
    // reprezentujący go symbol
    public FieldType Type;
}

class GomokuEngine
{
    // tablica 3x3 reprezentująca pole gry
    private FieldType[,] FField = new FieldType[3, 3];
    // zmienna oznaczająca zwycięstwo któregoś gracza (true)
    private bool FWinner;
    // ID gracza, który teraz wykonuje ruch
    private int FActive;
    // tablica graczy (tylko dwóch graczy)
    private Player[] FPlayer = new Player[2];

    /* METODY PRYWATNE */
    private string GetPlayer1()
    {
        return FPlayer[0].Name;
    }

    private void SetPlayer1(string Name)
    {
        FPlayer[0].Name = Name;
    }

    private string GetPlayer2()
    {
        return FPlayer[1].Name;
    }

    private void SetPlayer2(string Name)
    {
        FPlayer[1].Name = Name;
    }

    private Player GetActive()
    {
        return FPlayer[this.FActive];
    }

    // właściwość Winner
    public bool Winner
    {
        get
        {
            return FWinner;
        }
    }
    // właściwość zwraca aktywnego gracza
    public Player Active
    {
        get
        {
            return GetActive();
        }
    }
    // zwraca informacje o graczu nr 1
    public string Player1
    {
        get
        {
            return GetPlayer1();
        }
        set
        {
            SetPlayer1(value);
        }
    }
    // właściwość zwraca informacje o graczu nr 2
    public string Player2
    {
        get
        {
            return GetPlayer2();
        }
        set
        {
            SetPlayer2(value);
        }
    }
    // właściwość tylko do odczytu, zwraca informacje o polu bitwy ;-)
    public FieldType[,] Field
    {
        get
        {
            return FField;
        }
    }
}

W klasie nie ma pól typu public — zadeklarowałem same właściwości oraz metody. Dodatkowo większość właściwości jest tylko do odczytu — wartości można przypisać jedynie dwóm właściwościom, Player1 oraz Player2 (imiona graczy).

Zacznijmy od rzeczy najważniejszej, czyli od pola FPlayer. Jest to dwuelementowa tablica typu Player przechowująca informacje dotyczące graczy. Pole FActive typu int przechowuje informację, który użytkownik aktualnie wykonuje ruch.

Dla użytkownika naszej klasy zapewne ważna będzie właściwość Active, która reprezentuje danego gracza. Owa właściwość typu Player jest tylko do odczytu, zwracane przez nią dane są odczytywane za pomocą metody GetActive():

private Player GetActive()
{
    return FPlayer[FActive];
}

Informacje o użytkowniku są zwracane na podstawie pola FActive.

Ustawienie symbolu na planszy

Jedną z ważniejszych funkcji klasy jest m.in. metoda Set(), która na podstawie współrzędnych X i Y umieszcza odpowiedni symbol w tablicy FField. Druga ważna metoda klasy to CheckWinner(), która po każdym ruchu użytkownika sprawdza, czy grę można zakończyć. W tym celu należy opracować odpowiedni algorytm, który jest najtrudniejszą częścią programu, ale tym zajmiemy się później.

Najpierw przyjrzyjmy się metodzie Set():

// Metoda służy do ustawiania symbolu na danym polu
public bool Set(int X, int Y)
{
    // ponieważ indeks tablic rozpoczyna się od zera, należy zmniejszyć 
    // wartości współrzędnych, bo user podaje współrzędne numerowane od 1
    --X;
    --Y;

    // sprawdzenie, czy pole nie jest zajęte
    if (FField[X, Y] > 0)
    {
        Console.WriteLine("To pole nie jest puste!");
        return false;
    }
    // sprawdzamy, czy użytkownik podał prawidłowe współrzędne
    if ((X > 3) || (Y > 3))
    {
        Console.WriteLine("Nieprawidłowe wartości X lub/i Y");
        return false;
    }
    // ustawienie znaku na danym polu
    FField[X, Y] = GetActive().Type;
    // sprawdzenie, czy należy zakończyć grę
    CheckWinner();

    // jeżeli nikt nie wygrał — zamiana graczy
    if (!Winner)
    {
        FActive = (FActive == 0 ? 1 : 0);
    }
    return true;
}

Należy wziąć pod uwagę fakt, iż użytkownik podając współrzędne, będzie podawał wartości z przedziału 1 – 3. Ponieważ elementy w tablicy są indeksowane od 0, należy podane wartości pomniejszyć o 1. Następnie przeprowadzamy proces tzw. walidacji danych, czyli sprawdzenia ich poprawności. Użytkownik nie może postawić znaku na już zajętym polu. Nie może również podać współrzędnych większych niż 3.
Jeżeli jednak wszystko pójdzie dobrze, w tablicy FField, w odpowiednim miejscu stawiamy odpowiedni znak. Ów znak odczytujemy przy pomocy metody GetActive(). Ostatnim krokiem jest sprawdzenie, czy nie należy zakończyć gry (metoda CheckWinner()). Jeżeli nie trzeba jej zakończyć, zamieniamy aktywnego gracza.

W powyższym kodzie brakuje sprawdzania, czy użytkownik nie podał współrzędnych mniejszych od cyfry 1. To jest zadanie dla Ciebie. Mam nadzieję, że poradzisz sobie z tym problemem.

Jeżeli w metodzie Set() proces walidacji zostanie przeprowadzony bez problemów, zwraca ona wartość true. W przeciwnym wypadku — false.

Sprawdzenie wygranej

Metoda CheckWinner() ma sprawdzać, czy któryś z graczy wygrał, tj. czy zapełnił trzy pola w jednej linii. Zastanówmy się, ile może być kombinacji wygranych w grze kółko i krzyżyk. Skoro pól jest dziewięć, można naliczyć osiem wygranych kombinacji (trzy w poziomie, trzy w pionie i dwie na ukos). Spójrzmy na rysunek 7.2.

csharp7.2.jpg
Rysunek 7.2. Plansza do gry kółko i krzyżyk

Wyobraźmy sobie, że plansza na rysunku 7.2 odwzorowuje tablicę FFields w klasie GomokuEngine. Warto zauważyć, że pole FFields wskazuje na tablicę FieldType, a elementy tablicy są w rzeczywistości liczbami. Kółko odpowiada cyfrze 1, a krzyżyk liczbie 10, tak jak to zostało przedstawione na rysunku 7.2. Jak więc sprawdzić, czy gracz zakończył grę zwycięstwem? Wystarczy zsumować wartości pól w jednej linii. Przykładowo: na planszy znalazły się trzy znaki krzyżyka ustawione obok siebie w linii poziomej (tak jak to zaprezentowano na rysunku 7.2). Po zsumowaniu wartości tych elementów tablicy otrzymamy liczbę 30. Gdyby zamiast krzyżyków umieścić kółka, otrzymamy cyfrę 3. Oto dwie metody, które odpowiadają za sprawdzenie zwycięscy gry:

// metoda sprawdza, czy gracz nr 1 lub 2 wygrał grę
private void Sum(int Value)
{
    if (Value == 3 || Value == 30)
    {
        FPlayer[FActive].Winnings++;
        FWinner = true;
    }
}

// algorytm sprawdza, czy któryś z graczy wygrał grę
private void CheckWinner()
{
    for (int i = 0; i < 3; i++)
    {
        Sum((int)FField[i, 0] + (int)FField[i, 1] + (int)FField[i, 2]);
        Sum((int)FField[0, i] + (int)FField[1, i] + (int)FField[2, i]);
    }

    Sum((int)FField[0, 0] + (int)FField[1, 1] + (int)FField[2, 2]);
    Sum((int)FField[0, 2] + (int)FField[1, 0] + (int)FField[2, 0]);
}    

Metoda CheckWinner() sprawdza wszystkie kombinacje w taki sposób, że sumuje po trzy pola z tablicy i przekazuje zsumowaną wartość do metody Sum(). Metoda Sum() sprawdza, czy przekazana wartość (parametr Value) równa się liczbie 3 lub 30. Jeżeli tak jest, można zakończyć grę i ogłosić zwycięzcę (wartość pola FWinner zmieniamy na true).

Pełny kod źródłowy modułu, w którym znajduje się klasa GomokuEngine, prezentuje listing 7.3.

Listing 7.3. Kod źródłowy modułu

/************************************************************
 *             Gomoku (Kółko i krzyżyk)                   *
 *           Copyright (c) Adam Boduch 2006                 *
 *              E-mail: [email protected]                     *
 *              http://4programmers.net                     *
 *                                                          *
 * *********************************************************/
using System;

// typ pola (kółko — circle lub krzyżyk (cross))
public enum FieldType {ftCircle = 1, ftCross = 10};

// struktura opisująca gracza
public struct Player
{
    // nazwa gracza
    public string Name;
    // ilość zwycięstw
    public int Winnings;
    // reprezentujący go symbol 
    public FieldType Type;
}

class GomokuEngine
{
    // tablica 3x3 reprezentująca pole gry
    private FieldType[,] FField = new FieldType[3, 3];
    // zmienna oznaczająca zwycięstwo któregoś gracza (true)
    private bool FWinner;
    // ID gracza, który teraz wykonuje ruch
    private int FActive;
    // tablica graczy (tylko dwóch graczy)
    private Player[] FPlayer = new Player[2];

    /* METODY PRYWATNE */
    private string GetPlayer1()
    {
        return FPlayer[0].Name;
    }

    private void SetPlayer1(string Name)
    {
        FPlayer[0].Name = Name;
    }

    private string GetPlayer2()
    {
        return FPlayer[1].Name;
    }

    private void SetPlayer2(string Name)
    {
        FPlayer[1].Name = Name;
    }

    private Player GetActive()
    {
        return FPlayer[FActive];
    }

    // właściwość Winner
    public bool Winner
    {
        get
        {
            return FWinner;
        }
    }
    // właściwość zwraca aktywnego gracza
    public Player Active
    {
        get
        {
            return GetActive();
        }
    }
    // zwraca informacje o graczu nr 1
    public string Player1
    {
        get
        {
            return GetPlayer1();
        }
        set
        {
            SetPlayer1(value);
        }
    }
    // właściwość zwraca informacje o graczu nr 2
    public string Player2
    {
        get
        {
            return GetPlayer2();
        }
        set
        {
            SetPlayer2(value);
        }
    }
    // właściwość tylko do odczytu, zwraca informacje o polu bitwy ;-)
    public FieldType[,] Field
    {
        get
        {
            return FField;
        }
    }

    // metoda sprawdza, czy gracz nr 1 lub 2 wygrał grę
    private void Sum(int Value)
    {
        if (Value == 3 || Value == 30)
        {
            FPlayer[FActive].Winnings++;
            FWinner = true;
        }
    }

    // algorytm sprawdza, czy któryś z graczy wygrał grę
    private void CheckWinner()
    {
        for (int i = 0; i < 3; i++)
        {
            Sum((int)FField[i, 0] + (int)FField[i, 1] + (int)FField[i, 2]);
            Sum((int)FField[0, i] + (int)FField[1, i] + (int)FField[2, i]);
        }

        Sum((int)FField[0, 0] + (int)FField[1, 1] + (int)FField[2, 2]);
        Sum((int)FField[0, 2] + (int)FField[1, 0] + (int)FField[2, 0]);
    }    

    /* rozpoczyna właściwą grę */
    public void Start()
    {
        // przyporządkowanie symbolu danemu graczowi
        FPlayer[0].Type = FieldType.ftCircle;
        FPlayer[1].Type = FieldType.ftCross;

        FWinner = false;
        // czyszczenie tablicy 
        System.Array.Clear(FField, 0, FField.Length);
    }

    // nowa gra — ilość zwycięstw zostaje wyzerowana
    public void NewGame()
    {
        FPlayer[0].Winnings = 0;
        FPlayer[1].Winnings = 0;
    }

    // Metoda służy do ustawiania symbolu na danym polu
    public bool Set(int X, int Y)
    {
        // ponieważ indeks tablic rozpoczyna się od zera, należy zmniejszyć 
        // wartości współrzędnych, bo user podaje współrzędne numerowane od 1
        --X;
        --Y;

        // sprawdzenie, czy pole nie jest zajęte
        if (FField[X, Y] > 0)
        {
            Console.WriteLine("To pole nie jest puste!");
            return false;
        }
        // sprawdzamy, czy użytkownik podał prawidłowe współrzędne
        if ((X > 3) || (Y > 3))
        {
            Console.WriteLine("Nieprawidłowe wartości X lub/i Y");
            return false;
        }
        // ustawienie znaku na danym polu
        FField[X, Y] = GetActive().Type;
        // sprawdzenie, czy należy zakończyć grę
        CheckWinner();

        // jeżeli nikt nie wygrał — zamiana graczy
        if (!Winner)
        {
            FActive = (FActive == 0 ? 1 : 0);
        }
        return true;
    }
}

Interfejs aplikacji

Na samym początku wykorzystamy utworzoną klasę w aplikacji konsolowej. Aby użytkownik mógł zapełnić określone pole, musi podać jego współrzędną X i Y. Następnie aplikacja wyświetla aktualny stan gry (rysunek 7.3).

csharp7.3.jpg
Rysunek 7.3. Gra kółkoi krzyżyk w trybie konsoli

Kod źródłowy aplikacji korzystającej z wcześniej stworzonego modułu jest dość prosty. Podstawą gry jest menu, dzięki któremu użytkownik steruje pracą programu.

Menu do gry

Menu jest dość proste. Zawiera kilka opcji, które można wybrać przy pomocy klawiatury. Operacje „rysowania” menu zawarłem w osobnej metodzie — Menu():

static void Menu()
{
    Console.Clear();
    Console.WriteLine("------- Kółko i krzyżyk ------");
    Console.WriteLine("------------------------------");
    Console.WriteLine(" 1 -- Start gry               ");
    Console.WriteLine(" 2 -- Opcje                   ");
    Console.WriteLine(" 3 -- O programie             ");
    Console.WriteLine(" Esc -- Zakończenie           ");
    Console.WriteLine("------------------------------");
}

Wyjaśnienia wymaga właściwie tylko pierwsza linia z ciała tej metody: Console.Clear(). Ponieważ ta metoda będzie wywoływana wiele razy w naszym programie, należy wyczyścić zawartość okna konsoli.

Sterowanie menu

Użytkownik będzie mógł sterować menu za pomocą klawiszy klawiatury. Trzeba więc pobrać informacje na temat wciśniętego klawisza. Umożliwia to metoda ReadKey() z klasy Console, zwracająca informacje w postaci typu ConsoleKeyInfo:

ConsoleKeyInfo Key;   
Key = Console.ReadKey(true);

W zależności od wciśniętego klawisza wykonujemy odpowiednie metody naszego programu:

switch (Key.KeyChar)
{
    case '1':

        Start();
        break;

    case '2':

        Option();
        break;

    case '3':

        About();
        break;

    case '4':
        break;
}

Warunkiem zakończenia programu jest naciśnięcie klawisza Esc, więc wyświetlanie menu należy kontynuować w pętli. Oto cały kod metody Main():

static void Main(string[] args)
{
    // utworzenie nowej instancji klasy
    GomokuObj = new GomokuEngine();

    ConsoleKeyInfo Key;       

    do
    {
        Menu();
        // pobranie wciśniętego klawisza
        Key = Console.ReadKey(true);

        // w zależności od wciśniętego klawisza, wykonujemy określoną metodę
        switch (Key.KeyChar)
        {
            case '1':

                Start();
                break;

            case '2':

                Option();
                break;

            case '3':

                About();
                break;

            case '4':
                break;
        }
    }
    while (Key.Key != ConsoleKey.Escape);        
}

Kod źródłowy modułu głównego

Najważniejsza w naszym programie jest metoda Start(). To ona zawiera główny kod aplikacji, który odpowiada za pobieranie współrzędnych i wywoływanie metody Set() z klasy GomokuEngine. Ona odpowiada również za rysowanie planszy do gry. Pełny kod źródłowy aplikacji przedstawiony jest na listingu 7.4.

Listing 7.4. Kod aplikacji Gomoku

/************************************************************
 *             Gomoku (Kółko i krzyżyk)                   *
 *           Copyright (c) Adam Boduch 2006                 *
 *              E-mail: [email protected]                     *
 *              http://4programmers.net                     *
 *                                                          *
 * *********************************************************/
using System;

class Program
{
    // instancja klasy
    static GomokuEngine GomokuObj;

    // metoda wyświetla informacje o autorze
    static void About()
    {
        Console.WriteLine(
        "   Kółko i Krzyżyk v. 1.0          \n" +
        "   Copyrigh (c) Adam Boduch 2006   \n" +
        "   E-mail: [email protected]         \n");
        Console.ReadLine();
    }

    // metoda wyświetla menu gry
    static void Menu()
    {
        Console.Clear();
        Console.WriteLine("------- Kółko i krzyżyk ------");
        Console.WriteLine("------------------------------");
        Console.WriteLine(" 1 -- Start gry               ");
        Console.WriteLine(" 2 -- Opcje                   ");
        Console.WriteLine(" 3 -- O programie             ");
        Console.WriteLine(" Esc -- Zakończenie           ");
        Console.WriteLine("------------------------------");
    }

    // główna metoda — rozpoczęcie gry
    static void Start()
    {
        // jeżeli użytkownik nie podał imion, przekierowujemy go do metody Option
        if (GomokuObj.Player1 == null || GomokuObj.Player2 == null)
        {
            Option();
        }
        // inicjalizacja gry
        GomokuObj.Start();
        // licznik tur
        int Counter = 1;

        Console.WriteLine();

        int X, Y;
        char C;

        // gra będzie kontynuowana, dopóki ktoś nie wygra 
        // jednym z warunków zakończenia jest również remis
        while (GomokuObj.Winner == false)
        {
            Console.WriteLine("Tura nr. " + Convert.ToString(Counter) + ", Gracz: " + GomokuObj.Active.Name);
            
            // pobranie współrzędnych pola
            Console.Write("Podaj współrzędną Y: ");
            X = Convert.ToInt32(Console.ReadLine());
            Console.Write("Podaj współrzędną X: ");
            Y = Convert.ToInt32(Console.ReadLine());

            // jeżeli nie można umieścić znaku na polu, nie wykonujemy dalszego kodu
            // przechodzimy do kolejnej iteracji
            if (!GomokuObj.Set(X, Y))
            {
                continue;
            }
        
            Counter++;

            // jeżeli jest to 9 ruch, widocznie jest remis — przerywamy pętlę
            if (Counter == 9)
            {
                break;
            }

            // poniższe pętle mają za zadanie rysowanie planszy do gry
            for (int i = 0; i < 3; i++)
            {
                for (int j = 0; j < 3; j++)
                {
                    switch (GomokuObj.Field[i, j])
                    {
                        case FieldType.ftCross:

                            C = 'X';
                            break;

                        case FieldType.ftCircle:

                            C = 'O';
                            break;

                        default:
                            C = '_';
                            break;
                    }
                    Console.Write(" {0} |", C);
                }
                Console.WriteLine();
            }

        }

        if (GomokuObj.Winner)
        {
            Console.WriteLine("Gratulacje, wygrał gracz " + GomokuObj.Active.Name);
        }
        else
        {
            Console.WriteLine("Remis!");
        }
        Console.WriteLine("Naciśnij Enter, aby powrócić do menu");
        Console.ReadLine();
    }

    // wyświetlanie opcji do gry, czyli możliwości wpisania imion
    static void Option()
    {
        Console.Write("Podaj imię pierwszego gracza: ");
        GomokuObj.Player1 = Console.ReadLine();

        Console.Write("Podaj imię drugiego gracza: ");
        GomokuObj.Player2 = Console.ReadLine();
    }

    static void Main(string[] args)
    {
        // utworzenie nowej instancji klasy
        GomokuObj = new GomokuEngine();

        ConsoleKeyInfo Key;       

        do
        {
            Menu();
            // pobranie wciśniętego klawisza
            Key = Console.ReadKey(true);

            // w zależności od wciśniętego klawisza, wykonujemy określoną metodę
            switch (Key.KeyChar)
            {
                case '1':

                    Start();
                    break;

                case '2':

                    Option();
                    break;

                case '3':

                    About();
                    break;

                case '4':
                    break;
            }
        }
        while (Key.Key != ConsoleKey.Escape);        
    }
}

Ćwiczenie dodatkowe

W klasie GomokuEngine zaimplementowałem metodę NewGame(), lecz nigdzie w programie nie ma możliwości rozpoczęcia gry od nowa, czyli wyzerowania licznika wygranych. Ba, licznik wygranych nie jest nawet nigdzie w programie prezentowany. To zadanie dla Ciebie!

Mechanizm indeksowania

Skoro powiedzieliśmy sobie o tablicach, grzechem byłoby nie wspomnieć o tzw. mechanizmie indeksowania, funkcji języka C#, która pozwala odwoływać się do obiektu tak jak do zwykłych tablic (przy pomocy symboli [ ]). Jest to ciekawy element języka C#, nieco bardziej zaawansowany, lecz myślę, że warto o nim wspomnieć.

O indeksatorach możesz myśleć jak o tablicach połączonych z możliwością deklarowania właściwości klas. Wyobraź sobie, że przy pomocy symboli [ oraz ] możesz odwoływać się do pewnych elementów klasy:

TranslateList MyList = new TranslateList();
MyList[0] = "Foo";

Dodatkowo możesz kontrolować proces pobierania oraz przypisywania takich elementów.

Indeksatory w klasie deklaruje się podobnie jak właściwości, z tą różnicą, że zamiast nazwy właściwości stosujemy słowo kluczowe this:

class TranslateList
{
    public string this[string index]
    {
        get
        {
        }
        set
        {
        }
    }
}

Jak widzisz, składnia jest dość charakterystyczna:

*Indeksatory deklarujemy z użyciem słowa kluczowego this.
*Indeksator musi posiadać typ zwrotny.
*Indeksator musi posiadać akcesor get i/lub set.
*W jednej klasie może być wiele indeksatorów, pod warunkiem że będą miały różne parametry.

Zaprezentuję teraz prosty przykład wykorzystania indeksatorów. Spójrz na poniższą klasę:

class TranslateList
{
    private string[,] DayList;
    private int LangId;

    public TranslateList(string lang)
    {
        DayList = new string[,] 
        {
            {"Pn", "Wt", "Śr", "Czw", "Pt", "So", "Nd"},
            {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
        };

        LangId = lang == "pl" ? 0 : 1;
    }
    public string this[int index]
    {
        get
        {
            return DayList[LangId, index];
        }
    }
}

W konstruktorze klasy wypełniamy danymi dwuwymiarową tablicę, w której znajdują się oznaczenia dni tygodnia w języku polskim oraz angielskim. W indeksatorze istnieje możliwość odczytania nazwy danego dnia w zależności od wartości pola LangId. Teraz przy pomocy nawiasów kwadratowych możemy odwołać się do poszczególnych elementów:

TranslateList MyList = new TranslateList("en");

for (int i = 0; i < 7; i++)
{
    Console.Write("{0} ", MyList[i]);
}

W zaprezentowanym przeze mnie przykładzie nie ma możliwości przypisania danych do indeksatora. Kompilator zaprotestuje, wyświetlając komunikat o błędzie: Property or indexer cannot be assigned to -- it is read only.

Indeksy łańcuchowe

Tablice asocjacyjne, znane zapewne wielu programistom PHP, nie są niestety dostępne w języku C#. Zamiast tego możemy jednak skorzystać z indeksatorów, które dopuszczają użycie łańcuchów w nazwach indeksu:

Console.WriteLine(MyList["Pn"]);
MyList["Pn"] = "Monday";
Console.WriteLine(MyList["Pn"]);

Oczywiście musimy jeszcze odpowiednio oprogramować nasz indeksator:

class TranslateList
{
    private string[] ShortDay;
    private string[] LongDay;

    public TranslateList()
    {
        ShortDay = new string[] {"Pn", "Wt", "Śr", "Czw", "Pt", "So", "Nd"};
        LongDay = new string[] {"Poniedziałek", "Wtorek", "Środa", "Czwartek", "Piątek", "Sobota", "Niedziela"};         
    }
    public string this[string index]
    {
        get
        {
            int IndexOf = System.Array.IndexOf(ShortDay, index);
            return LongDay[IndexOf];
        }
        set
        {
            int IndexOf = System.Array.IndexOf(ShortDay, index);
            LongDay[IndexOf] = value;
        }
    }
}

Takie rozwiązanie jest jednak nieco problematyczne. Lepiej w takich przypadkach używać list i słowników, o których będzie mowa w tym rozdziale.

Podsumowując: mechanizm indeksowania można traktować jako rozbudowany system tablic, ponieważ dają one większą kontrolę nad przypisywaniem i odczytywaniem danych.

Indeksatory nie mogą być opatrzone modyfikatorem dostępu static.

Kolekcje

Pisałem już o tablicach, zgłębiłem temat mechanizmu indeksowania, lecz omawiając tablice języka C#, nie sposób nie wspomnieć jeszcze o mechanizmie kolekcji. Tablice jako takie obecne są w większości języków wysokiego poziomu. Niekiedy mechanizm korzystania z tablic jest naprawdę rozbudowany (jak np. w języku PHP), a niekiedy może okazać się niewystarczający (jak w języku C#). Tablice w języku C# mają wiele ograniczeń, które możemy łatwo ominąć, stosując mechanizm kolekcji. Mówiąc o ograniczeniach, mam na myśli brak możliwości nadawania określonych indeksów dla elementów tablicy (jeżeli nie chcemy, aby elementy były indeksowane od 0) czy trudności w usuwaniu lub dodawaniu kolejnych elementów tablicy. Deklarując tablicę w C#, musimy podać z góry jej rozmiar lub nadać jej elementy.

Kolekcje stanowią bardziej zaawansowany mechanizm przechowywania zbioru różnych obiektów. Odpowiednie klasy dostarczają bardziej zaawansowane metody służące do operacji na zbiorze elementów kolekcji. W przestrzeni nazw System.Collections znajduje się wiele klas służących do operowania na zbiorach danych. W dalszej części tego rozdziału omówimy kilka z nich.

Interfejsy System.Collections

W przestrzeni nazw System.Collections zadeklarowanych jest wiele klas służących do operowania na zbiorach danych, a ich zależność jest dość duża, co może spowodować pewną dezorientację. Powróćmy jeszcze na chwilę do klasy System.Array. Mimo iż należy ona do przestrzeni nazw System, implementuje metody interfejsów ICloneable, IList, ICollection oraz IEnumerable! Możemy więc o niej powiedzieć, iż jest prostą klasą wykorzystującą mechanizm kolekcji!

Można wyróżnić 8 interfejsów zdefiniowanych wewnątrz przestrzeni System.Collections: IEnumerable, ICollection, IList, IDictionary, IEnumerator, IComparer, IDictionaryEnumerator, IHashCodeProvider. W tabeli 7.2 znajduje się krótki opis każdej z nich.

Tabela 7.2. Opis interfejsów z przestrzeni System.Collections

InterfejsOpis
IEnumerableUdostępnia interfejs, który umożliwia przeglądanie elementów kolekcji w jednym kierunku.
ICollectionDziedziczy metody IEnumerable. Definiuje rozmiar kolekcji.
IListDefiniuje kolekcję, której elementy są dostępne za pośrednictwem indeksów.
IDictionaryDefiniuje kolekcję, w której elementy są dostępne za pośrednictwem kluczy.
IEnumerator Definiuje metody umożliwiające przeglądanie kolejnych elementów kolekcji.
IDictionaryEnumeratorDefiniuje właściwości umożliwiające pobieranie kluczy i wartości słowników (obiektów klas Dictionary).
IComparerDefiniuje metodę służącą do porównywania dwóch obiektów.
IHashCodeProviderDefiniuje metodę zwracającą unikalny kod danego obiektu.

Żaden z tych interfejsów nie może oczywiście działać samodzielnie. One jedynie definiują metody, które są następnie implementowane w klasach reprezentujących. Tym zajmiemy się w dalszej części rozdziału. Teraz omówię pokrótce kilka najważniejszych interfejsów.

IEnumerable

Bazowy interfejs. Definiuje właściwie tylko jedną metodę — GetEnumerator(). Zwraca ona egzemplarz klasy, który implementuje interfejs IEnumerable. I to właściwie wszystko, co znajduje się w tym interfejsie i wymaga omówienia.

ICollection

Interfejs, który dziedziczy po IEnumerable. Definiuje przydatną właściwość, z której będziesz korzystał nie raz. Jest to właściwość Count, która zwraca ilość elementów w kolekcji. Definiuje również metodę CopyTo(), która umożliwia skopiowanie obiektu zawartego w danej kolekcji do tablicy (System.Array). Metoda ta posiada dwa parametry. Pierwszy z nich to nazwa tablicy, do której zostaną skopiowane dane. Drugi to indeks (liczony od zera), od którego rozpocznie się kopiowanie.

IList

Interfejs IList przejmuje właściwości oraz metody po interfejsach ICollection oraz IEnumerable. Jest to bazowy interfejs dla tzw. list. Definiuje metody oraz właściwości umożliwiające dodawanie i kasowanie elementów listy. Na razie przyjrzyj się metodom i właściwościom zdefiniowanym w interfejsie IList (tabela 7.3.). Przykłady wykorzystania klas implementujących ten interfejs zaprezentuję w dalszej części książki.

Tabela 7.3. Metody oraz właściwości zdefiniowane w interfejsie IList

Właściwość/MetodaOpis
IsFixedSizeWłaściwość określa, czy lista ma stały rozmiar.
IsReadOnlyWłaściwość określa, czy dana lista zwraca swoje wartości jedynie do odczytu.
ItemWłaściwość umożliwia pobranie lub ustawienie elementów pod danym indeksem.
Add()Metoda umożliwia dodanie elementów do listy.
Clear()Usuwa elementy z danej listy.
Contains()Sprawdza, czy lista zawiera daną wartość.
IndexOf()Zwraca indeks danej wartości.
Insert()Umożliwia wstawienie elementu pod wskazaną pozycję.
Remove()Usuwa pierwsze wystąpienie danego elementu.
RemoveAt()Usuwa element określony danym indeksem.

IDictionary

Interfejs definiujący metody oraz właściwości umożliwiające korzystanie z tzw. słowników. Słowniki umożliwiają przechowywanie danych, do których dostęp uzyskuje się, podając odpowiedni klucz — nie indeks, jak to jest w tablicach oraz listach. Tym zagadnieniem zajmiemy się nieco później.

Interfejs, podobnie jak IList, definiuje właściwości IsFixedSize, IsReadOnly i Items oraz metody Add(), Clear(), Contains(), Remove(). Dodatkowo posiada dwie właściwości:

*Keys — właściwość zwraca kolekcję składającą się z kluczy danego słownika.
*Values — właściwość zwraca kolekcję składającą się z wartości danego słownika.

IEnumerator

Interfejs IEnumerator definiuje właściwość Current zwracającą bieżący element kolekcji. Dodatkowo definiuje metody MoveNext() oraz Reset(). Pierwsza z nich służy do przechodzenia do kolejnego elementu kolekcji, a druga — do przejścia do pierwszego elementu.

Stosy

Po tym przydługim wstępie czas przejść do rzeczy bardziej praktycznych, czyli obsługi kolejek. Na początek chciałbym wspomnieć o klasie Stack (ang. stos), która służy do przechowywania danych zgodnie z zasadą FILO (ang. First-in Last-out), co oznacza: pierwszy wchodzi, ostatni wychodzi.

Wyobraź sobie stos talerzy — jeden leży na drugim. Aby wyciągnąć ten na samym dole, musisz najpierw ściągnąć te leżące na nim, prawda? Identycznie działa klasa Stack. Możesz swobodnie dodawać kolejne elementy do kolekcji, lecz nie ma sposobu na usunięcie tego dodanego na samym początku.

Spójrz na listing 7.5, który prezentuje przykład użycia klasy Stack oraz wyświetlania zawartości danego stosu.

Listing 7.5. Przykład wykorzystania klasy Stack

using System;
using System.Collections;

namespace FooConsole
{
    class Program
    {
        static void Main(string[] args)
        {
            Stack MyStack = new Stack();

            for (int i = 0; i < 20; i++)
            {
                MyStack.Push("Pozycja nr " + i);
            }
            Console.WriteLine("Ostatni element: " + MyStack.Peek());
            Console.WriteLine("Usunięty element: " + MyStack.Pop());
            Console.WriteLine();

            IEnumerator MyEnum = MyStack.GetEnumerator();

            while (MyEnum.MoveNext())
            {
                Console.WriteLine(MyEnum.Current.ToString());
            }
            Console.Read();            
        }
    }
}

Przed użyciem klasy Stack należy wywołać konstruktor klasy. Można w nim określić przewidywalną ilość elementów lub wywołać konstruktor bez parametrów, tak jak ja to zrobiłem. Wówczas zostanie nadana domyślna ilość elementów, czyli 10. W razie potrzeby ta wartość zostanie automatycznie zwiększona, więc nie musisz się bać, że wystąpi błąd (takie ryzyko istniało przy okazji korzystania z tablic).

Korzystając z metody Push(), umieszczam na stosie kolejne elementy. Metoda Peek() służy do pobrania ostatniego elementu stosu, natomiast Pop() zwraca ostatni element, po czym go usuwa.

Zwróć uwagę na sposób wyświetlania elementów z kolekcji. W tym celu zadeklarowałem zmienną wskazującą na interfejs IEnumerator. Użyłem również metody GetEnumerator(), która zwraca implementowany obiekt. Dzięki temu, posługując się odpowiednimi metodami, jestem w stanie pobrać kolejne elementy ze stosu i je wyświetlić.

Klasa Stack implementuje interfejsy ICollection, IEnumerable, ICloneable. Pamiętaj więc o tym, iż zawiera metody oraz właściwości zdefiniowane w tych interfejsach.

Ze względu na to, że klasa Stack nie umożliwia usuwania dowolnego elementu stosu, pewnie nie będziesz z niej często korzystał. Właściwie zależy to tylko od Twoich potrzeb. Jeżeli istnieje konieczność umieszczenia elementów w formie stosu, wydaje mi się, że zastosowanie klasy Stack będzie dobrym rozwiązaniem. Jeżeli musisz mieć możliwość usuwania dowolnego elementu, zapewne będziesz zmuszony skorzystać z list.

Kolejki

Klasa Queue działa podobnie jak Stack. Różny jest jednak sposób ich działania. Klasa Queue działa zgodnie z zasadą FIFO (ang. First-in First-out), co można przetłumaczyć jako: pierwszy wchodzi, pierwszy wychodzi. Aby lepiej zrozumieć zasadę działania tej klasy, wyobraź sobie zwykłą kolejkę sklepową. Ten, kto stoi w niej ostatni, obsługiwany jest jako ostatni, prawda? Pierwsza osoba w kolejce wychodzi ze sklepu jako pierwsza. Klasa Queue również nie posiada metody umożliwiającej usunięcie dowolnego elementu. Zamiast tego istnieje możliwość umieszczenia danego elementu jako ostatniego w kolejce, co realizuje metoda Enqueue(). Jeżeli mówimy o usuwaniu, to realizuje to metoda Dequeue(), która usuwa pierwszy element w kolejce. Podsumowując: jeżeli chcemy usunąć ostatni element kolejki, najpierw musimy usunąć wszystkie pozostałe utworzone wcześniej. Na listingu 7.6 znajduje się kod źródłowy zmodyfikowanego programu z listingu 7.5.

Listing 7.6. Program prezentujący użycie klasy Queue

using System;
using System.Collections;

namespace FooConsole
{
    class Program
    {
        static void Main(string[] args)
        {
            Queue MyQueue = new Queue();

            for (int i = 0; i < 20; i++)
            {
                MyQueue.Enqueue("Pozycja nr " + i);
            }
            Console.WriteLine("Pierwszy element: " + MyQueue.Peek());
            Console.WriteLine("Usunięty element: " + MyQueue.Dequeue());
            Console.WriteLine();

            IEnumerator MyEnum = MyQueue.GetEnumerator();

            while (MyEnum.MoveNext())
            {
                Console.WriteLine(MyEnum.Current.ToString());
            }
            Console.Read();            
        }
    }
}

Rysunek 7.4 prezentuje program w trakcie działania.

csharp7.4.jpg
Rysunek 7.4. Program prezentujący działanie klasy Queue

Klasa Queue implementuje te same interfejsy, co klasa Stack.

Klasa ArrayList

ArrayList stanowi prawdopodobnie najlepszą alternatywę pomiędzy klasą Stack oraz Queue. Posiada ona również metody obecne w klasie Array, więc operowanie nią jest podobne do obsługi zwykłych tablic. Implementuje interfejsy IList, ICollection, IEnumerable, ICloneable, więc miej na uwadze to, iż zawiera właściwości i metody, o których wspominałem kilka stron wcześniej, przy okazji omawiania tych interfejsów.

Listy

Opisałem już, czym charakteryzują się kolejki oraz stosy. Chciałbym teraz pójść nieco dalej i zająć się tematyką list oraz typami generycznymi. Zostawmy na razie możliwości oferowane przez przestrzeń nazw System.Collections i pójdźmy dalej. Zajmijmy się możliwościami oferowanymi przez .NET 2.0, z którego zapewne teraz korzystasz, a konkretnie klasami znajdującymi się w przestrzeni System.Collections.Generic.

Typy generyczne

Podczas omawiania klas nie wspomniałem o jednej właściwości klas szeroko wykorzystywanej przy kolekcjach. Jest to nowość w środowisku .NET Framework 2.0 (poprzednia wersja 1.1 nie posiadała możliwości wykorzystania typów generycznych), wzorowana na technologii templates z języka C++.
Spójrz na poniższy kod prezentujący, w jaki sposób możemy dodać elementy do kolekcji typu ArrayList i wyświetlić je:

ArrayList Foo = new ArrayList();

Foo.Add("Adam");
Foo.Add("Marta");
Foo.Add(100);
Foo.Add(2.34);

for (int i = 0; i < Foo.Count; i++)
{
    Console.WriteLine("Indeks: {0} wartość: {1}", i, Foo[i]);
}

Przy pomocy Add() dodajemy do listy kolejne elementy, raz typu string, później liczbę stałoprzecinkową i wreszcie — liczbę rzeczywistą. Jest to absolutnie dopuszczalne, gdyż parametr metody Add() jest typu object, a jak wiadomo — wszystkie typy .NET Framework dziedziczą po tej klasie.

Przy każdym wywołaniu metody Add() program musi wykonać pakowanie (ang. boxing) typów, a przy wyświetlaniu — odpakowywanie. Przy dużej ilości danych trwa to dość długo.

O technice pakowania oraz odpakowywania pisałem w rozdziale 5.

Dlatego jeżeli zamierzasz umieścić w kolekcji dane tego samego typu, lepiej wykorzystać klasy oferowane przez przestrzeń nazw System.Collection.Generic. Znajdują się tam klasy, odpowiedniki już wspomnianych Stack oraz Queue, które działają szybciej na danych tego samego typu. Nie ma w przestrzeni nazw System.Collection.Generic klasy ArrayList. Zamiast tego możemy jednak wykorzystać klasę List, która jest właściwie generycznym odpowiednikiem ArrayList. Listing 7.7 prezentuje prosty przykład użycia klasy List<T>:

Listing 7.7. Przykład użycia klasy List<T>

using System;
using System.Collections.Generic;

namespace FooConsole
{
    class Program
    {
        static void Main(string[] args)
        {
            List<int> Foo = new List<int>();

            Foo.Add(10);
            Foo.Add(100);
            Foo.Add(10000);
            
            for (int i = 0; i < Foo.Count; i++)
            {
                Console.WriteLine("Indeks: {0} wartość: {1}", i, Foo[i]);
            }
            Console.Read();

        }
    }
}

Przy deklarowaniu i utworzeniu obiektu klasy List<T> musiałem podać typ danych (int), na jakim chcemy operować. Oczywiście istnieje możliwość operowania na dowolnym typie danych — wówczas w miejsce T należy podać jego nazwę.

Tworzenie typów generycznych

Istnieje możliwość tworzenia własnych klas generycznych. Jedyne, co musimy zrobić, to na końcu nazwy dodać frazę <T>:

class Generic<T>
{
}

Teraz przy tworzeniu egzemplarza klasy kompilator będzie wymagał, aby podać również typ danych, na których ma ona operować — np.:

Generic<string> MyGeneric = new Generic<string>();

Przyjęło się, że przy deklarowaniu typów generycznych stosujemy frazę <T>. Kompilator nie wymusza jednak takiego nazewnictwa, więc równie dobrze możemy napisać: class Generic<code><Type></code> {}.

Po zadeklarowaniu takiej klasy w jej obrębie typ T będzie oznaczał typ danych podany podczas jej tworzenia. Można go dowolnie wykorzystywać. Np.:

class Generic<T>
{
    public void Add(T X)
    {
        Console.WriteLine("{0}", X);
    }
}

Obsługa takiej klasy wiąże się z odpowiednim utworzeniem obiektu:

Generic<string> MyGeneric = new Generic<string>();
MyGeneric.Add("Hello World!");

Metody generyczne

Istnieje również możliwość deklarowania metod generycznych. Ich tworzenie wygląda bardzo podobnie:

static void Foo<T>(T Bar)
{
    Console.WriteLine("{0}", Bar);
}

Wywołując taką metodę, możesz, aczkolwiek nie musisz, podawać typu danych — np.:

Foo("Adam"); // dobrze
Foo<int>(12); // dobrze

Kompilator domyśli się typu na podstawie przekazanych parametrów.

Korzystanie z list

Klasa List<T> posiada spore możliwości — implementując interfejsy IList<T>, ICollection<T>, IEnumerable<T>, IList, ICollection, IEnumerable, umożliwia dodawanie, usuwanie dowolnych pozycji list. Nie będę prezentował spisu wszystkich metod oraz właściwości, gdyż to zostało właściwie powiedziane już wcześniej, przy okazji omawiania tablic oraz interfejsów. Zaprezentuję za to prostą aplikację przedstawiającą użycie list.

Aplikacja będzie dość prosta. Po uruchomieniu użytkownik będzie mógł dodać do listy nazwę województwa wraz z jego stolicą (rysunek 7.5).

csharp7.5.jpg
Rysunek 7.5. Aplikacja w trakcie działania

Użytkownik dodatkowo będzie mógł mieć możliwość usunięcia zaznaczonej pozycji oraz wyczyszczenia całej listy. W liście będą przechowywane dane odnośnie do województw oraz ich stolic. W tym celu zadeklarowałem odpowiednią strukturę:

public struct Location
{
    public string Province;
    public string Capital;
} 

Listing 7.8 zawiera kod źródłowy głównego formularza programu.

Listing 7.8. Kod źródłowy formularza

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

namespace ListApp
{
    // struktura przechowywana w liście
    public struct Location
    {
        public string Province;
        public string Capital;
    }

    public partial class MainForm : Form
    {
        private List<Location> MyList;

        public MainForm()
        {
            InitializeComponent();
        }

        // metoda uaktualniająca stan komponentu ListBox zgodnie ze stanem faktycznym
        private void UpdateList()
        {
            lbLocation.Items.Clear();

            for (int i = 0; i < MyList.Count; i++)
            {
                lbLocation.Items.Add(
                    String.Format("{0,-10} {1,10}", MyList[i].Province, MyList[i].Capital
                ));
            }
        }

        private void MainForm_Load(object sender, EventArgs e)
        {
            // utworzenie instancji klasy w momencie załadowania formularza
            MyList = new List<Location>();
            lbLocation.Items.Clear();
        }

        private void btnClear_Click(object sender, EventArgs e)
        {
            MyList.Clear();
            UpdateList();
        }

        private void btnDelete_Click(object sender, EventArgs e)
        {
            // usunięcie zaznaczonej pozycji
            MyList.RemoveAt(lbLocation.SelectedIndex);
            UpdateList();
        }

        private void btnAdd_Click(object sender, EventArgs e)
        {
            Location MyLocation = new Location();

            MyLocation.Province = textProvince.Text;
            MyLocation.Capital = textCapitol.Text;

            textProvince.Text = textCapitol.Text = "";

            MyList.Add(MyLocation);
            UpdateList();
        }
    }
}

Myślę, że aplikacja jest dość prosta. Za wyświetlanie wszystkich pozycji w komponencie typu ListBox odpowiada metoda UpdateList(). W pętli dodaje ona kolejne pozycje do komponentu ListBox po uprzednim jego wyczyszczeniu. Odpowiednie procedury zdarzeniowe komponentów Button służą do dodawania nowej pozycji, usuwania zaznaczonej oraz czyszczenia wszystkich dodanych rekordów.

Po naciśnięciu przycisku służącego do dodawania nowych wpisów tworzona jest instancja struktury, do której przypisujemy dane wpisane w kontrolkach typu TextBox. Następnie przy pomocy metody Add() dodajemy nowy wpis do kolejki.

Słowniki

Programistom PHP zapewne znany jest mechanizm tablic, w którym zamiast indeksu używany jest tzw. klucz. Wiesz już, że indeks jest unikalną wartością identyfikującą dany element w tablicy/liście. Omawiając mechanizm indeksowania, pokazałem, w jaki sposób zrobić indeks, który mógłby przyjmować wartości łańcuchowe. W przypadku słowników zamiast indeksu jest klucz (wartość typu string), który również musi posiadać unikalną wartość w obrębie całej kolekcji. Właściwie to, czy będzie on typu string, czy jakiegokolwiek innego, zależy już tylko od nas. To samo dotyczy wartości elementów słownika. Najczęściej słowniki służą mimo wszystko do przechowywania wartości tekstowych, gdzie klucz i wartość są typu string.

Klasa Dictionary zadeklarowana jest w przestrzeni nazw System.Collections.Generic w sposób następujący:

public class Dictionary<TKey, TValue> : IDictionary<TKey, TValue>, ICollection<KeyValuePair<TKey, TValue>>, IEnumerable<KeyValuePair<TKey, TValue>>, IDictionary, ICollection, IEnumerable, ISerializable, IDeserializationCallback { } 

Jak więc widzisz, ilość interfejsów, które są w klasie implementowane, jest imponująca. Deklarując instancję klasy, należy podać zarówno typ klucza, jak i wartości — np.:

Dictionary<string, string> MyDictionary;

Korzystanie ze słowników jest bardzo podobne do używania zwykłych list. Różnica jest taka, iż słowniki dają nam możliwość określenia typu klucza. Tak więc dla powyższej deklaracji, dane do słownika możemy przypisywać w ten sposób:

MyDictionary["Klucz"] = "Wartość"; 

Jeżeli chcemy korzystać ze słowników tak jak z list, należy utworzyć następującą zmienną wskazującą na klasę:

Dictionary<int, string> MyDictionary;

Wówczas kluczem takiego słownika jest liczba stałoprzecinkowa, a wartością — ciąg znaków typu string.

Przykładowy program

Aby zaprezentować możliwości działania słowników, napisałem prosty program umożliwiający tłumaczenie tekstu na podstawie słówek wpisanych do słownika. Działanie jest proste: przy pomocy metody Replace() program zamienia określony tekst znajdujący się w komponencie RichTextBox na wyrazy pobierane ze słownika.

Program napisałem na podstawie biblioteki wizualnej Windows Forms. Aplikacja została podzielona na dwie zakładki przy użyciu komponentu TabControl (rysunki 7.6 oraz 7.7).

csharp7.6.jpg
Rysunek 7.6. Zakładka umożliwiająca dodanie danych do słownika

csharp7.7.jpg
Rysunek 7.7. Zakładka umożliwiająca zamianę tekstu

Na pierwszej zakładce, w komponencie ListView wyświetlana jest zawartość słownika. Lewa kolumna zawiera klucze słownika, a prawa — ich wartości. Program umożliwia modyfikowanie, usuwanie oraz czyszczenie słownika.

Na prawej zakładce istnieje możliwość wprowadzenia tekstu. Po naciśnięciu przycisku program odszukuje wyrazy określone w kluczach słownika i zastępuje je wartościami tych kluczy.

Jeżeli chodzi o zastępowanie tekstu, to realizuje to metoda Replace() klasy System.String. Omówienie metod służących do operowania na tekście, znajdziesz w rozdziale 9. W pętli musimy pobierać dane ze słownika, i nie tylko wartości elementów, ale również wartości kluczy. Realizuje to poniższy kod:

// utworzenie instancji klasy
Dictionary<string, string>.Enumerator Enumerator = MyDictionary.GetEnumerator();

while (Enumerator.MoveNext())
{
    // pobierz kolejny klucz i wartość
    KeyValuePair<string, string> Value = Enumerator.Current;

    // zastąp tekst
    richTextBox.Text = richTextBox.Text.Replace(Value.Key, Value.Value);
}

Aby zrealizować to zadanie, musimy zadeklarować zmienną wskazującą na strukturę Dictionary<TKey, TValue>.Enumerator, do której przypiszemy wartość zwracaną przez metodę GetEnumerator(). Pobranie kolejnych kluczy i wartości następuje w pętli przy pomocy struktury KeyValuePair.

Dodanie kolejnego elementu do słownika realizuje metoda Add(), która posiada dwa parametry — nazwę klucza oraz wartość. Chcąc usunąć dany element, należy skorzystać z metody Remove() poprzez podanie nazwy klucza.

Podsumowanie

Być może na tym etapie nauki języka nie dostrzegasz zalet stosowania tablic czy kolekcji. Być może nie widzisz również potrzeby stosowania tego typu rozwiązań we własnych aplikacjach. Możesz mi wierzyć lub nie, ale z czasem, gdy Twoje programy będą coraz bardziej zaawansowane, zaistnieje konieczność wykorzystania tablic. Jako że tablice w C# nie dają takich korzyści jak w innych językach (m.in. z powodu trudności w deklarowaniu rozmiaru w trakcie działania programu), być może będziesz zmuszony do wykorzystania kolekcji. Wówczas możesz sięgnąć po ten rozdział i przeanalizować prezentowane tutaj przykłady

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

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

3 komentarzy

Mam pytanie, skoro ArrayList dziedziczy po IList, ICollection, IEnumerable, ICloneable. To jaki jest sens aby znowu Interfejs ICollection dziedziczył po IEnumerable, a interfejs IList po ICollection, IEnumerable? Dodatkowo, jak to jest ze interfejs dziedziczy po innym i w swoim ciele nie ma definicji metod interfejsu bazowego?

Adamie, GetEnumerator() zwraca instancję klasy Enumerator (implementującej IEnumerator<T>, IDisposable, IEnumerator), a nie IEnumerable (vide "Zwraca ona egzemplarz klasy, który implementuje interfejs IEnumerable"). Klasa Enumerator/interfejs IEnumerator ma dwie interesujące rzeczy - pole Current i metodę MoveNext(), które mógłbyś opisać.

W procedurze sprawdzającej czy, któryś z graczy wygrał jest błąd. Nie jest wykrywana poprawnie "linia" {(1,3), (2,2),(3,1)}.

Procedura CheckWinner powinna wyglądać tak:

private void CheckWinner()
{
    for (int i = 0; i < 3; i++)
    {
        Sum((int)FField[i, 0] + (int)FField[i, 1] + (int)FField[i, 2]);
        Sum((int)FField[0, i] + (int)FField[1, i] + (int)FField[2, i]);
    }

    Sum((int)FField[0, 0] + (int)FField[1, 1] + (int)FField[2, 2]);
    Sum((int)FField[0, 2] + (int)FField[1, 1] + (int)FField[2, 0]);
}