1. Klasyfikacja kluczowych interfejsów
Jeśli myślisz, że interfejsy to zabawka dla architektów i "czystego kodu", a w prawdziwych projektach można się bez nich obejść, muszę cię zaskoczyć: każdy poważny projekt w C# po uszy siedzi w interfejsach. Dlaczego? Bo praktycznie każda część standardowej biblioteki .NET jest zrobiona przez interfejsy! Bez nich nie ogarniesz kolekcji, nie przeczytasz pliku, nawet nie przefiltrujesz kolekcji z LINQ.
Interfejsy to fundament polimorfizmu i rozszerzalności w .NET i to one decydują, jak wszystkie "klocki" frameworka łączą się ze sobą.
Standardowa biblioteka .NET jest dosłownie napakowana interfejsami. Żeby się w tym nie utopić, proponuję taką klasyfikację (oczywiście niepełną — życia nie starczy, żeby ogarnąć wszystko!):
| Kategoria | Interfejsy | Po co są? |
|---|---|---|
| Kolekcje | , , , , |
Iteracja, modyfikacja, dostęp przez indeks, praca z parami klucz-wartość |
| Praca z zasobami | |
Zwalnianie zasobów (pliki, połączenia, strumienie) |
| Porównywanie | , , |
Sortowanie, porównywanie, unikalność obiektów |
| Serializacja | |
Zamiana obiektów na strumień bajtów (i odwrotnie) |
| LINQ i zapytania | , |
Wsparcie dla złożonych zapytań (np. do bazy danych) |
| Asynchroniczność | , |
Asynchroniczna iteracja i czyszczenie zasobów |
| Zdarzenia i powiadomienia | , |
Reakcja na zmiany właściwości/kolekcji |
| Data i czas | |
Customowe formatowanie stringów |
| Struktury danych | , |
Głębokie porównywanie kolekcji i krotek |
| Strumienie/wejście-wyjście | , , , |
Praca ze strumieniami, powiadomienia push/pull |
Ważne! Wiele z powyższych interfejsów ma typy-parametry: List<string>. Zwykle używa się ich w kolekcjach i dla interfejsów kolekcji Int[] → List<int>. Szczegóły ogarniemy za parę poziomów, jak będziemy poznawać kolekcje.
2. Interfejsy kolekcji
IEnumerable i IEnumerator — twój bilet do foreach
Prawie każda kolekcja w .NET implementuje interfejs IEnumerable albo nawet jego generyczną wersję IEnumerable<T>. To właśnie ten interfejs pozwala ci używać magicznej składni foreach:
List<int> numbers = new List<int> { 1, 2, 3 };
foreach (int n in numbers) // działa, bo List<int> implementuje IEnumerable<int>
{
Console.WriteLine(n);
}
Tak wygląda ten interfejs w minimalistycznej wersji:
public interface IEnumerable
{
IEnumerator GetEnumerator();
}
IEnumerator — to interfejs samego "iteratora", któremu powierzono chodzenie po twojej kolekcji:
public interface IEnumerator
{
bool MoveNext();
object Current { get; }
void Reset();
}
W prawdziwych zadaniach bardzo często będziesz przekazywać parametry i zwracać wartości typu IEnumerable<T>. Na przykład metoda, która zwraca wszystkie parzyste liczby z tablicy:
public IEnumerable<int> GetEvenNumbers(int[] array)
{
foreach (var x in array)
if (x % 2 == 0) yield return x;
}
Tak, słowo kluczowe yield robi magię — implementuje interfejs za ciebie, ale o tym więcej później.
ICollection i IList — kolekcje z dostępem przez indeks i modyfikacją
Jeśli potrzebujesz nie tylko iteracji, ale też możliwości dodawania/usuwania elementów albo dostępu przez indeks, potrzebujesz bardziej wyspecjalizowanych interfejsów:
- ICollection<T> — dodaje metody Add, Remove oraz właściwość Count.
- IList<T> — rozszerza listę możliwych operacji, w tym dostęp przez indeks (this[int index]).
public void PrintFirstItem(IList<string> list)
{
if (list.Count > 0)
Console.WriteLine(list[0]);
}
List<T> implementuje i IEnumerable<T>, i ICollection<T>, i IList<T>. To mega wygodne — możesz pracować z nim jak z prostą listą do iteracji albo używać wszystkich bajerów.
IDictionary<TKey, TValue> — pary "klucz-wartość"
Jeśli lubisz pracować ze słownikami (a to się zdarza bardzo często!), używaj interfejsu IDictionary<TKey, TValue>. Gwarantuje on, że możesz po kluczu dostać wartość i odwrotnie.
public void PrintAllPairs(IDictionary<string, int> ages)
{
foreach (var pair in ages)
Console.WriteLine($"{pair.Key}: {pair.Value}");
}
Tutaj ages może być czymkolwiek, nawet Dictionary<string, int> albo jakimś SortedDictionary<string, int> — ważne, że implementują ten interfejs!
3. Interfejs IDisposable: poprawna praca z zasobami
Cały input-output, praca z plikami, połączeniami sieciowymi, bazami danych w .NET opiera się na interfejsie IDisposable. Ten interfejs definiuje mega ważny kontrakt: jeśli obiekt ma niezarządzane zasoby, trzeba go "posprzątać" po użyciu. Tak, to ten gość, który umożliwił składnię using:
using (StreamReader reader = new StreamReader("file.txt"))
{
// Pracujemy z plikiem, a po using plik na pewno się zamknie!
string line = reader.ReadLine();
}
Jak wygląda sam interfejs? Śmiesznie prosto:
public interface IDisposable
{
void Dispose();
}
Ale każda "poważna" biblioteka go implementuje! Więcej o dobrych praktykach pracy z tym interfejsem znajdziesz w oficjalnej dokumentacji.
4. Interfejsy do porównywania i unikalności
IComparable<T> i IComparer<T>
Jeśli chcesz, żeby twoja kolekcja obiektów mogła być posortowana, obiekty muszą umieć się porównywać między sobą. Do tego jest interfejs IComparable<T>:
public class Student : IComparable<Student>
{
public string Name { get; set; }
public int Score { get; set; }
public int CompareTo(Student? other)
{
// Sortowanie malejąco po punktach
if (other == null) return 1;
return other.Score.CompareTo(this.Score);
}
}
// Teraz możesz sortować studentów:
var students = new List<Student> { ... };
students.Sort();
Więcej w dokumentacji Microsoft.
IComparer<T> pozwala zdefiniować porównanie poza klasą — np. czasem chcesz sortować studentów po imieniu, a czasem po punktach:
public class NameComparer : IComparer<Student>
{
public int Compare(Student? x, Student? y)
{
return string.Compare(x?.Name, y?.Name);
}
}
IEquatable<T> — porównanie równości
Chcesz, żeby twój obiekt działał w HashSet<T> (czyli był "unikalny" dla kolekcji)? Zaimplementuj IEquatable<T>:
public class Person : IEquatable<Person>
{
public string Name { get; set; }
public bool Equals(Person? other)
{
if (other == null) return false;
return this.Name == other.Name;
}
}
5. Interfejsy do zdarzeń i powiadomień
Chciałeś kiedyś, żeby twoja aplikacja "wiedziała", że w obiekcie zmieniły się właściwości? Na przykład chcesz, żeby interfejs sam się odświeżał, jak użytkownik zmienia nazwisko? Właśnie do tego jest interfejs INotifyPropertyChanged. Jest mega popularny w aplikacjach z GUI (np. WPF albo Xamarin).
Sygnatura interfejsu jest prosta:
public interface INotifyPropertyChanged
{
event PropertyChangedEventHandler? PropertyChanged;
}
Przykład implementacji:
public class User : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private string name;
public string Name
{
get => name;
set
{
if (name != value)
{
name = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
}
}
}
}
Teraz każde powiązanie UI z tym obiektem będzie się automatycznie odświeżać przy zmianie Name. Jeśli cię to zainteresuje — czytaj oficjalną dokumentację.
6. Inne przydatne interfejsy
Interfejs IFormattable: elastyczne formatowanie
Kiedy chcesz, żeby twój obiekt mógł się ładnie i różnie wyświetlać jako string (np. z różną liczbą miejsc po przecinku), zaimplementuj interfejs IFormattable:
public class Temperature : IFormattable
{
public double Celsius { get; }
public Temperature(double celsius) => Celsius = celsius;
public string ToString(string? format, IFormatProvider? formatProvider)
{
// Nie komplikujmy, pokażemy 'C' albo 'F' zależnie od formatu
if (format == "F")
return $"{Celsius * 9 / 5 + 32} F";
return $"{Celsius} C";
}
}
Teraz możesz wołać temp.ToString("F", null) albo temp.ToString("C", null). Dlatego DateTime, double, decimal i inne wbudowane typy wspierają elastyczne formatowanie.
Interfejsy do asynchroniczności
Z pojawieniem się asynchroniczności w C# pojawiły się nowe interfejsy do pracy w świecie async. Na przykład, jeśli twój obiekt asynchronicznie zwalnia zasoby — zaimplementuj IAsyncDisposable:
public interface IAsyncDisposable
{
ValueTask DisposeAsync();
}
I teraz zamiast using piszesz await using!
Z asynchronicznymi enumeracjami (await foreach) działa interfejs IAsyncEnumerable<T>, który pozwala iterować po elementach bez blokowania głównego wątku. Używa się go np. przy czytaniu dużych plików na raty albo przy pracy ze strumieniami danych z internetu. Więcej na poziomie 58 :P
Interfejsy serializacji: ISerializable
Jeśli twoim zadaniem jest zamiana obiektów na strumień bajtów do zapisu/przesyłania (np. przez sieć), .NET daje interfejs ISerializable. Dziś używa się go rzadziej, bo są wygodniejsze mechanizmy (np. przez atrybuty i gotowe serializery), ale warto wspomnieć. Oto przykład sygnatury:
public interface ISerializable
{
void GetObjectData(SerializationInfo info, StreamingContext context);
}
7. Zastosowanie interfejsów w praktyce: prawdziwa aplikacja
Załóżmy, że klepiesz konsolową apkę do ewidencji książek w bibliotece (no bo ktoś musi!). Chcesz, żeby można było wyświetlać listy książek, sortować je, filtrować, ładować i zapisywać dane. Dzięki znajomości interfejsów .NET możesz:
- Zwracać różne typy kolekcji przez IEnumerable<Book>, żeby użytkownik nie musiał się zastanawiać, czy to tablica czy lista.
- Dodać sortowanie po różnych kryteriach: implementujesz IComparable<Book> do sortowania po tytule i osobny IComparer<Book> po autorze.
- Zapewnić efektywne zwalnianie zasobów przy pracy z plikami przez IDisposable.
- Zorganizować filtrowanie książek po gatunku lub autorze przez LINQ, który działa na wszystkim, co implementuje IEnumerable<T>.
- Skalować aplikację — np. zamienić plikowe repozytorium na bazę danych, jeśli twoje klasy operują przez interfejsy (IBookStorage), a nie konkretne implementacje.
8. Typowe błędy i niuanse
Jeden z najczęstszych błędów początkujących — używanie konkretnych implementacji ("List") zamiast interfejsów ("IEnumerable", "IList") przy deklarowaniu zmiennych i argumentów metod. PAMIĘTAJ: jeśli "programujesz na interfejsach", twój kod będzie elastyczny i łatwy do rozbudowy.
Czasem trzeba uważać, jaki poziom interfejsu wybrać — np. jeśli piszesz metodę, której wystarczy tylko iteracja, użyj IEnumerable<T>, a nie IList<T>, żeby nie narzucać niepotrzebnych ograniczeń.
Jeszcze jeden niuans: jeśli klasa ma dużo interfejsów i mają one powtarzające się metody lub właściwości, trzeba użyć jawnej implementacji interfejsu (explicit implementation), bo inaczej kompilator się pogubi.
GO TO FULL VERSION