W kilku wcześniejszych notatkach opisane zostały tablice, sposób ich przeszukiwania oraz tablice wielowymiarowe. Poznaliśmy również typy ogólne. Wynikiem połączenia obu tematów są kolekcje.
Zacznijmy od klasy List<T>
, będącej kolekcją zmiennej liczby elementów typu T. Pomimo udostępnionego indeksatora umożliwiającego pobieranie elementów za pomocą indeksu, podobnie jak w przypadku tablic, kolekcja List<T>
jest czymś innym niż tablica T[]
. Korzystanie z indeksatorów może przypominać odwołanie do elementu tablicy, ale nim nie jest. Indeksator jest właściwością zwracającą kopię wartości, próba modyfikacji wartości spowoduje modyfikacje kopii, a nie elementu listy. Język C# nie pozwala na takie działanie, w przypadku tablic operacja jest dozwolona. Spójrzmy na przykład poniżej (klasa Point
pochodzi z przestrzeni nazw System.Drawing).
var pointList = new List<Point>() { new Point(10, 20), new Point(20, 40) };
pointList[0].X = 20;
var points = new Point[] { new Point(10, 20), new Point(20, 40) };
points[0].X = 20;
Próba wykonania pointList[0].X = 20;
spowoduje błąd.
To, co wyróżnia listę typu List<T>
od tablicy, jest możliwość zmiany wielkości. Udostępniona została metoda Add umożliwiająca dodanie nowego elementu na końcu listy oraz metoda Remove pozwalająca na usunięcie pierwszego elementu o podanej wartości. Mamy również możliwość dodania kilku elementów korzystając z AddRange lub wstawienia elementu/ów w konkretne miejsce listy za pomocą metod Insert lub InsertRange. Metody RemoveAt oraz RemoveRange pozwalają usunąć konkretny element lub wskazany zakres. Wywołanie dowolnej z wymienionych metod powoduje zmianę rozmiaru listy, a nierzadko konieczność przesunięcia elementów. Przykład poniżej prezentuje sposób użycia wcześniej wymienionych metod. Warto zwrócić uwagę na właściwość Count, której nazwa różni się od analogicznej właściwości Length udostępnionej w przypadku tablic.
var numbers = new List<int>() { 10, 2 };
numbers.Add(16);
Console.WriteLine($"numbers: {string.Join(", ", numbers)}");
Console.WriteLine($"Count: {numbers.Count}");
numbers.AddRange(new int[] { 10, 88 });
numbers.Remove(10);
numbers.Insert(1, 7);
Console.WriteLine($"numbers: {string.Join(", ", numbers)}");
Console.WriteLine($"Count: {numbers.Count}");
Kod powyżej korzysta z konstruktora umożliwiającego przekazanie elementów inicjalizujących listę. Kompilator przekształca taki konstruktor, do postaci użycia metody Add dla każdego elementu. Metoda Join typu string
tworzy ciąg znaków na podstawie przekazanej tablicy, gdzie poszczególne elementy są rozdzielone określonym separatorem.
Rozmiar listy podlega ciągłej zmianie w zależności od wykonywanych operacji. Oprócz konstruktora domyślnego oraz umożliwiającego przekazanie wartości początkowych, dostępny jest konstruktor umożliwiający określenie początkowego rozmiaru listy. Pozwala to na określenie potrzebnego obszaru pamięci. W sytuacji, gdy początkowo zadeklarowany rozmiar okaże się niewystarczający, nie stanie się nic złego, uruchomiony zostanie mechanizm rozszerzający listę. Pamiętajmy jednak, że zadeklarowanie listy o dużym rozmiarze początkowym będzie wiązało się z rezerwacją odpowiednio dużego obszaru pamięci potrzebnego na przechowanie wszystkich elementów. W celu odzyskania niewykorzystanej pamięci możemy użyć metody TrimExcess. Listy podobnie jak tablice udostępniają zestaw metod IndexOf, LastIndexOf, Find, FindLast, FindAll, Sort oraz BinarySearch umożliwiających ich przeszukiwanie.
.NET Framework definiuje kilka interfejsów wykorzystywanych w kolekcjach. Są to: IList<T>
, ICollection<T>
oraz IEnumerable<T>
. Każdy z interfejsów zapewnia inny zestaw metod, tak aby dostosować kod do potrzeb. Niektóre z kolekcji umożliwiają dostęp do dowolnego elementu, inne implementują wyłącznie dostęp sekwencyjny. Najbardziej ogólny interfejs IEnumerable<T>
wymaga, aby klasy implementujące, implementowały również interfejs IEnumerable
. Wynika to z konieczności zachowania zgodności pomiędzy wersjami .NET Framework. W wersji 1.0 udostępniony został interfejs IEnumerable
, ale typy ogólne pojawiły się dopiero w wersji 2.0. To dlatego oba interfejsy wymagają implementacji metod zwracających enumerator. Klasa ConfigFiles w przykładzie poniżej implementuje interfejs IEnumerable
, udostępniając nazwy plików przechowujących konfigurację.
using System;
using System.Collections;
namespace HelloCollections
{
class Program
{
static void Main(string[] args)
{
var configFiles = new ConfigFiles();
IEnumerator enumerator = configFiles.GetEnumerator();
while (enumerator.MoveNext())
Console.WriteLine(enumerator.Current);
Console.ReadLine();
}
public class ConfigFiles : IEnumerable
{
private string[] files = new string[] { "web.config", "app.config", "nLog.config" };
public IEnumerator GetEnumerator()
{
return files.GetEnumerator();
}
}
}
}
Użycie interfejsu IEnumerable<T>
wiąże się z wywołaniem metody GetEnumerator, która dostarcza enumerator, który z kolei wykorzystywany jest do pobrania elementu. Interfejs IEnumerator
oprócz samego elementu dostarczonego przez właściwość Current, udostępnia metodę MoveNext, zwracającą prawdę, pod warunkiem, że w kolekcji znajduje się kolejny element oraz metodę Reset pozwalającą przewinąć enumerator do pierwszego elementu. W sytuacji zastosowania pętli while
należy skorzystać z opisanych metod. W przypadku pętli foreach
język C# wykonuje za nas część tej pracy. Klasa ConfigFiles udostępnia enumerator dostarczony przez tablicę files implementującą interfejs IEnumerable
. Przykład poniżej oprócz interfejsu IEnumerable
implementuje również IEnumerator
, a przejście przez enumerator wykonuje zarówno pętla foreach
jak i while
.
static void Main(string[] args)
{
var configFiles = new ConfigFiles();
foreach(var file in configFiles)
Console.WriteLine(file);
while(configFiles.MoveNext())
Console.WriteLine(configFiles.Current);
Console.ReadLine();
}
public class ConfigFiles : IEnumerable, IEnumerator
{
private string[] Files { get; set; } = new string[] { "web.config", "app.config", "nLog.config"};
private int Index { get; set; } = -1;
public object Current
{
get
{
return Files[Index];
}
}
public bool MoveNext()
{
Index++;
return Index < Files.Length;
}
public void Reset()
{
Index = -1;
}
public IEnumerator GetEnumerator()
{
return this;
}
}
Jeżeli zdecydowaliście się na uruchomienie powyższego przykładu, być może zastanawiacie się, dlaczego pomimo zastosowania dwóch pętli lista elementów została wyświetlona tylko raz? Jeżeli tak, wróć do opisu składowych interfejsu IEnumerator
lub rozwiń rozwiązanie.
var configFiles = new ConfigFiles();
configFiles.Reset();
foreach (var file in configFiles)
Console.WriteLine(file);
configFiles.Reset();
while (configFiles.MoveNext())
Console.WriteLine(configFiles.Current);
Console.ReadLine();
Choć interfejs IEnumerable
jest powszechnie wykorzystywany, to ogranicza się do udostępnienia kolejnych elementów. Brakuje możliwości sprawdzenia rozmiaru kolekcji, nie wspominając o jej modyfikacji. Interfejs ICollection<T>
wymaga implementacji zarówno IEnumerable<T>
, jak i IEnumerable
. W przypadku użycia interfejsu IEnumerable<T>
, który z kolei wymaga implementacji IDisposable
, metoda Dispose interfejsu IDisposable
, odpowiada za prawidłowe działanie w wielu przypadkach użycia enumeratora (Microsoft udostępnia ciekawy przykład z zastosowaniem tej metody). Dodatkowo metoda IEnumerator GetEnumerator()
musi zostać przysłonięta, a w jej miejsce pojawia się IEnumerator<T> GetEnumerator()
. Sam interfejs ICollection<T>
wymaga od nas implementacji metod: Count, IsReadOnly, Add, Remove, Clear, Contains oraz CopyTo. Przykład poniżej rozszerza dotychczasową klasę ConfigFiles o możliwości zarządzania kolekcją. Zachęcam do przeprowadzenia testów klasy ConfigFiles.
public class ConfigFiles : ICollection<string>, IEnumerator<string>
{
public ConfigFiles()
{
Init();
}
private const int MaxCount = 10;
private string[] Files { get; set; } = new string[MaxCount];
private int Index { get; set; } = -1;
private void Init()
{
Add("web.config");
Add("app.config");
Add("nLog.config");
}
#region ICollection
public int Count { get; private set; } = 0;
public bool IsReadOnly { get { return Count >= MaxCount; } }
public void Add(string item)
{
if (IsReadOnly)
return;
Files[Count] = item;
Count++;
}
public bool Remove(string item)
{
int posItem = Array.IndexOf(Files, item);
if (posItem > -1)
{
Array.Copy(Files, posItem +1, Files, posItem, Count - (posItem + 1));
Files[Count - 1] = null;
Count--;
return true;
}
return false;
}
public void Clear()
{
Array.Clear(Files, 0, MaxCount);
Count = 0;
Init();
}
public bool Contains(string item)
{
return Array.IndexOf(Files, item) > -1;
}
public void CopyTo(string[] array, int arrayIndex)
{
Array.Copy(Files, 0, array, arrayIndex, Count);
}
public IEnumerator<string> GetEnumerator()
{
Reset(); // Reset index before iteration
return this;
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
#endregion
#region IEnumerator
public string Current
{
get
{
return Files[Index];
}
}
object IEnumerator.Current
{
get
{
return Current;
}
}
public void Dispose()
{
// Implement if needed for releasing resources
}
public bool MoveNext()
{
Index++;
return Index < Count;
}
public void Reset()
{
Index = -1;
}
#endregion
}
Ostatnim z wymienionych interfejsów, wykorzystywany w kolekcjach sekwencyjnych jest IList<T>
, poniżej zamieszczam jego strukturę. Zastosowanie tego interfejsu, wymaga implementacji ICollection<T>
, a zatem i IEnumerable<T>
.
public interface IList<T> : ICollection<T>, IEnumerable<T>, IEnumerable
{
T this[int index] { get; set; }
int IndexOf(T item);
void Insert(int index, T item);
void RemoveAt(int index);
}
Interfejsy ICollection
oraz IList
, choć podobne z nazwy do opisanych ogólnych interfejsów, nie są ze sobą bezpośrednio powiązane. W przeciwieństwie do swoich ogólnych imienników powstały w wersji 1.0, przez co nie korzystają z argumentu typu, a typu object
. Dodatkowo oba interfejsy wymagają udostępnienie właściwości SyncRoot, która w teorii miała usprawnić działanie wielowątkowe. W praktyce okazało się zgoła inaczej.
Wszystkie opisane interfejsy implementuje tablica, o stałym, niezmiennym rozmiarze. W tym momencie powinna zapalić się nam żarówka. W jaki sposób ukryte zostały wszystkie te opisane metody? Zastosowana została jawna implementacja interfejsu, tak aby ukryć metody umożliwiające zmianę rozmiaru tablicy. Możliwe natomiast jest wykorzystanie interfejsu IList<T>
, w celu dostępu do np. metody Add tak jak w przykładzie poniżej (próba wykonania metody spowoduje zgłoszenie wyjątku).
IList<string> files = new[] { "web.config", "app.config", "nLog.config" };
files.Add("test.config");
.NET Framework 4.5 wprowadza interfejs IReadOnlyList<T>
, który podobnie jak IList<T>
implementuje IEnumerable<T>
, lecz nie wymaga ICollection<T>
. Zamiast niego wymagany jest IReadOnlyCollection<T>
, który definiuje właściwość Count
oraz indeksator. Zastosowanie IReadOnlyList<T>
rozwiązuje problem kolekcji przeznaczonych tylko do odczytu.
Zaprezentowane interfejsy oczywiście nie wyczerpują tematu kolekcji, stanowią wstęp do bardziej zaawansowanych zastosowań. Być może po przeczytaniu tej notatki nasuwa się pytanie, jakiego użyć typu pisząc własną klasę pod kolekcje? Odpowiedź jest prosta. Takiego, aby zaspokoić potrzeby, bez nadmiarowej implementacji. Jeżeli IEnumerable<T>
jest wystarczające, to nie ma sensu dodawać kolejnych interfejsów. Stosowanie interfejsów zamiast konkretnego typu ogólnego najczęściej jest lepszym rozwiązaniem.