1. Po co implementować kilka interfejsów?
Kiedy projektujesz prawdziwy system, obiekty często pełnią nie jedną "rolę" — ale kilka naraz. Wyobraź sobie: masz e-booka, którego możesz nie tylko czytać, ale też edytować i nawet zapisywać w chmurze. W terminologii programowania obiektowego (OOP) oznacza to, że obiekt powinien zaimplementować kilka interfejsów:
- IReadable — czytanie zawartości.
- IWritable — edycja zawartości.
- ISyncable — synchronizacja z zewnętrznym storage.
Właśnie tutaj wchodzi do gry możliwość implementacji kilku interfejsów. To jest supermoc C# (i OOP ogólnie), której nie mają klasy: nie możesz dziedziczyć po dwóch lub więcej klasach, ale interfejsów możesz zaimplementować ile chcesz.
Analogicznie do życia
Wyobraź sobie pracownika w firmie. Wojtek może być jednocześnie:
- Programistą (pisze kod)
- Testerem (czasem sprawdza cudzy kod)
- Managerem (planuje sprinty albo dzienną normę kawy)
Te "role" mają zupełnie różne obowiązki. Ale Wojtek spokojnie ogarnia każdy z kontraktów! W programowaniu jest tak samo: klasa implementuje interfejsy i bierze na siebie ich "obowiązki".
2. Składnia implementacji kilku interfejsów
To bardzo proste: przy deklaracji klasy wypisujesz je po przecinku:
public interface IReadable
{
void Read();
}
public interface IWritable
{
void Write(string text);
}
public class Note : IReadable, IWritable
{
private string content = "";
public void Read()
{
Console.WriteLine("Notatka: " + content);
}
public void Write(string text)
{
content = text;
Console.WriteLine("Notatka zaktualizowana!");
}
}
W tym przykładzie klasa Note implementuje oba interfejsy. To znaczy, że musi mieć implementacje obu metod: i Read, i Write.
3. Jak to wygląda na przykładzie "naszej" aplikacji
W naszych poprzednich przykładach rozwijaliśmy prostą aplikację bankową do obsługi kont. Załóżmy teraz: potrzebujemy uniwersalnej klasy dokumentu, który można wydrukować (IPrintable), zapisać do pliku (ISavable) i, być może, wysłać mailem (IEmailable).
Zdefiniujmy interfejsy:
public interface IPrintable
{
void Print();
}
public interface ISavable
{
void Save(string filePath);
}
public interface IEmailable
{
void Email(string toAddress);
}
Klasa, która ogarnia wszystko naraz:
public class Statement : IPrintable, ISavable, IEmailable
{
public string Content { get; set; }
public void Print()
{
Console.WriteLine("Drukuję wyciąg...");
Console.WriteLine(Content);
}
public void Save(string filePath)
{
// Używamy File.WriteAllText ze standardowej biblioteki.
File.WriteAllText(filePath, Content);
Console.WriteLine($"Wyciąg zapisany do pliku: {filePath}");
}
public void Email(string toAddress)
{
Console.WriteLine($"Wyciąg wysłany na maila: {toAddress} (symulacja)");
}
}
Teraz tej klasy możesz używać w kodzie jak chcesz:
var stat = new Statement { Content = "Operacje za miesiąc: +1000 jednostek, -500 kredytów." };
stat.Print();
stat.Save("statement.txt");
stat.Email("boss@bank.corp");
Swoją drogą, w praktyce bardzo wygodnie przekazywać taki obiekt do metod, które wymagają konkretnego interfejsu, nie przejmując się resztą jego możliwości. Na przykład metoda do drukowania może przyjmować parametr IPrintable i nie mieć pojęcia, że w środku jest też "zapisator" i "mailownik".
4. Użycie referencji do interfejsów: gdzie "widać", co mój obiekt potrafi?
Tu zaczyna się magia. Gdy zaimplementujesz kilka interfejsów, możesz traktować obiekt tylko jako jeden z jego kontraktów. Na przykład:
IPrintable printable = new Statement { Content = "Wykład o abstrakcji" };
printable.Print(); // Można tylko drukować
// printable.Save("file.txt"); // Błąd: interfejs IPrintable nic nie wie o Save.
Ale jeśli przełączysz się na inny interfejs:
ISavable savable = printable as ISavable;
if (savable != null)
{
savable.Save("file.txt");
}
To wygodne, gdy przekazujesz obiekt do metody, która przyjmuje referencję do konkretnego interfejsu. Takie podejście zmniejsza zależności i kod staje się bardzo elastyczny: możesz dodawać nowe implementacje bez zmiany starego kodu.
5. Wielokrotna implementacja i takie same metody w różnych interfejsach
A tu robi się ciekawie! Co jeśli dwa interfejsy wymagają metody o tej samej nazwie, ale innym znaczeniu? Na przykład, wyobraź sobie interfejsy dla ekspresu do kawy:
public interface IStartable
{
void Start();
}
public interface IRunnable
{
void Start();
}
Ekspres do kawy może być zarówno "startowalny" (IStartable — zaczyna proces parzenia), jak i "runnable" (IRunnable — zaczyna działać w ogóle).
Domyślna implementacja (zwykła):
public class CoffeeMachine : IStartable, IRunnable
{
public void Start()
{
Console.WriteLine("Ekspres startuje w obu rolach!");
}
}
W tym przypadku jedna implementacja Start() "załatwia" oba interfejsy.
A co jeśli potrzebujesz różnego znaczenia?
Możesz użyć jawnej implementacji interfejsu:
public class CoffeeMachine : IStartable, IRunnable
{
void IStartable.Start()
{
Console.WriteLine("Start: parzenie napoju rozpoczęte!");
}
void IRunnable.Start()
{
Console.WriteLine("Start: maszyna przełączona w tryb pracy.");
}
}
W tym przypadku wywołać konkretną implementację można tylko przez zmienną interfejsową:
CoffeeMachine cm = new CoffeeMachine();
IStartable startable = cm;
startable.Start(); // Start: parzenie napoju rozpoczęte!
IRunnable runnable = cm;
runnable.Start(); // Start: maszyna przełączona w tryb pracy.
// cm.Start(); // Nie skompiluje się! Start niedostępny jako metoda klasy.
Tak przy okazji, taki trik często stosuje się w standardowej bibliotece .NET — na przykład, gdy klasa implementuje kilka podobnych interfejsów z różnych frameworków i każdy wymaga swojego zachowania.
6. Różnica między implementacją kilku interfejsów a dziedziczeniem
| Możliwość | Klasa (dziedziczenie) | Interfejsy |
|---|---|---|
| Liczba typów bazowych | Tylko jeden | Ile chcesz |
| Dziedziczenie kodu | Tak (można bazową implementację) | Nie, tylko sygnatury (oprócz metod domyślnych) |
| Przechowywanie stanu | Tak | Nie |
| Dodanie nowej roli | Nie (albo trudno, przez kompozycję) | Łatwo, implementujesz interfejs |
| Lepsze dla… | "Fizyczne" hierarchie | "Role"/logiczne możliwości |
7. Praktyczne zastosowanie: "hybrydowe" obiekty
Dzięki wielokrotnej implementacji interfejsów możesz robić klasy z unikalnym zestawem "ról", nie myśląc o niepotrzebnym dziedziczeniu.
Na przykład, w naszej aplikacji bankowej — możesz zrobić klasę, która potrafi i przechowywać informacje o sobie, i sprawdzać swoją poprawność, i drukować — wszystko przez różne interfejsy:
public interface IValidatable
{
bool Validate();
}
public class Check : IPrintable, ISavable, IValidatable
{
public string Data { get; set; }
public void Print()
{
Console.WriteLine("Drukowanie czeku: " + Data);
}
public void Save(string filePath)
{
File.WriteAllText(filePath, Data);
Console.WriteLine("Czek zapisany: " + filePath);
}
public bool Validate()
{
return !string.IsNullOrEmpty(Data);
}
}
Takie podejście pozwala budować łatwo rozszerzalne architektury, gdzie klasy łączą role według potrzeb. Jeśli trzeba dodać nowy "obowiązek" — po prostu implementujesz nowy interfejs.
8. Typowe błędy i pułapki
Częsty błąd początkujących: zapomnieć zaimplementować wszystkie człony interfejsu. Kompilator tu nie wybaczy — rzuci błąd i podpowie, czego brakuje. Taką pomyłkę łatwo rozpoznać: "Klasa musi zaimplementować człon interfejsu".
Drugi często spotykany problem — zamieszanie z dostępnością metod. Jeśli zaimplementowałeś metodę jawnie, to jest ona niedostępna przez zmienną klasy — tylko przez interfejs.
Jeszcze jedna typowa sytuacja — refaktoryzacja: wprowadzasz zmiany w interfejsie (np. dodajesz metodę), ale nie aktualizujesz wszystkich implementacji. To prowadzi do błędów kompilacji. Dlatego przy projektowaniu staraj się nie zmieniać interfejsów, gdy już wiele klas ich używa.
GO TO FULL VERSION