Gdy projekt w C# zaczyna rosnąć, szybko pojawia się problem zależności między klasami. Interfejs pomaga oddzielić to, co obiekt potrafi, od tego, jak dokładnie realizuje swoją pracę. Pokażę, jak definiować i implementować interfejsy, wykorzystywać je w polimorfizmie, testach oraz wstrzykiwaniu zależności, a także kiedy lepiej wybrać klasę abstrakcyjną.
Interfejs porządkuje współpracę między elementami aplikacji
- Interfejs opisuje zestaw operacji, które typ musi udostępniać.
- Jedna klasa może implementować wiele interfejsów, ale dziedziczyć tylko po jednej klasie bazowej.
- Największą korzyścią jest luźne powiązanie kodu i łatwiejsza wymiana implementacji.
- Interfejsy świetnie współpracują z polimorfizmem, testami i dependency injection.
- Od C# 8 interfejs może zawierać także domyślną implementację, choć nie zawsze jest to najlepszy wybór.
Czym jest interfejs w C#
Interfejs traktuję jako kontrakt. Nie opisuje on konkretnego obiektu, lecz określa, jakie operacje musi udostępniać każdy typ, który ten kontrakt przyjmie. Dzięki temu kod korzystający z interfejsu nie musi znać szczegółów implementacji.
public interface INotifier
{
void Send(string message);
}
Powyższy przykład mówi tylko tyle, że typ zgodny z INotifier ma udostępniać metodę Send. Nie wiemy jeszcze, czy wiadomość trafi do e-maila, komunikatora czy logu aplikacji. Ta niewiedza jest tutaj zaletą, ponieważ odbiorca zależy od możliwości, a nie od konkretnej klasy.
Interfejs może definiować między innymi metody, właściwości, zdarzenia i indeksatory. Nie przechowuje zwykłego stanu obiektu tak jak klasa, więc nie znajdziemy w nim klasycznych pól instancji ani konstruktora.
Jak zdefiniować i zaimplementować interfejs
Klasa implementuje interfejs, umieszczając jego nazwę po dwukropku. Musi wtedy dostarczyć wszystkie wymagane elementy, chyba że korzysta z domyślnej implementacji dostępnej w nowoczesnych wersjach C#.
public class EmailNotifier : INotifier
{
public void Send(string message)
{
Console.WriteLine($"Wysyłam e-mail: {message}");
}
}
Obiekt można przechowywać zarówno jako konkretną klasę, jak i jako typ interfejsu. Druga forma zwykle lepiej pokazuje intencję kodu.
INotifier notifier = new EmailNotifier();
notifier.Send("Raport jest gotowy");
Jeżeli klasa ma obsługiwać kilka niezależnych kontraktów, wymieniamy je po przecinku.
public class SmartPrinter : IPrintable, IScannable
{
public void Print() { }
public void Scan() { }
}
To jedna z praktycznych przewag interfejsów nad dziedziczeniem klas. Jedna klasa może implementować wiele interfejsów, dzięki czemu może łączyć różne role bez tworzenia sztucznej hierarchii obiektów.
Jawna implementacja interfejsu
Czasem dwie implementowane umowy zawierają metody o tej samej nazwie. Wtedy można zastosować jawną implementację, czyli przypisać metodę bezpośrednio do konkretnego interfejsu.
public interface IReadable
{
string GetValue();
}
public interface IWritable
{
string GetValue();
}
public class Document : IReadable, IWritable
{
string IReadable.GetValue() => "Wartość do odczytu";
string IWritable.GetValue() => "Wartość do zapisu";
}
Takie metody nie są dostępne bezpośrednio przez zmienną typu Document. Trzeba rzutować obiekt na właściwy interfejs. Stosuję to oszczędnie, ponieważ zwiększa precyzję, ale może też utrudnić odkrywanie API w edytorze.
Dlaczego interfejsy pomagają pisać elastyczny kod
Największa wartość interfejsów ujawnia się wtedy, gdy jedna część aplikacji korzysta z wielu możliwych implementacji. Przykładowo serwis zamówień może potrzebować płatności, ale nie powinien być przywiązany do konkretnego operatora.
public interface IPaymentGateway
{
bool Pay(decimal amount);
}
public class OrderService
{
private readonly IPaymentGateway paymentGateway;
public OrderService(IPaymentGateway paymentGateway)
{
this.paymentGateway = paymentGateway;
}
public bool Complete(decimal amount)
{
return paymentGateway.Pay(amount);
}
}
Do produkcji można przekazać implementację obsługującą prawdziwe płatności, a do testu prosty obiekt udający operatora. To właśnie wstrzykiwanie zależności, czyli przekazywanie wymaganych obiektów z zewnątrz zamiast tworzenia ich wewnątrz klasy.
Interfejsy są też naturalnym narzędziem polimorfizmu. Metoda może przyjmować dowolny typ, który spełnia określony kontrakt.
public static void NotifyUser(INotifier notifier, string message)
{
notifier.Send(message);
}
Metoda NotifyUser zadziała dla powiadomień e-mail, SMS i komunikatów w aplikacji. Nie trzeba jej zmieniać przy dodawaniu kolejnych kanałów, o ile każdy z nich implementuje INotifier. W dużych projektach taki podział często wyraźnie ogranicza liczbę modyfikowanych plików.
Interfejsy w testach jednostkowych
Załóżmy, że klasa pobiera dane z zewnętrznego API. Bez abstrakcji test może zależeć od sieci, czasu odpowiedzi i aktualnego stanu serwera. Interfejs pozwala podstawić kontrolowaną implementację, która zwraca ustalone dane.
public interface IUserRepository
{
User? FindById(int id);
}
public class FakeUserRepository : IUserRepository
{
public User? FindById(int id)
{
return new User { Id = id, Name = "Testowy użytkownik" };
}
}
Nie oznacza to, że każdą klasę trzeba obudować interfejsem. Tworzenie abstrakcji dla prostego typu używanego tylko w jednym miejscu bywa przerostem formy nad treścią. Interfejs ma sens, gdy istnieje realna zmienność, granica systemu albo potrzeba niezależnego testowania.
Interfejs a klasa abstrakcyjna
Te konstrukcje bywają mylone, bo obie pomagają projektować wspólne zachowanie. Różnica polega na tym, że klasa abstrakcyjna może przechowywać stan i dostarczać gotową bazową implementację, natomiast interfejs przede wszystkim opisuje możliwości typu.
| Cecha | Interfejs | Klasa abstrakcyjna |
|---|---|---|
| Dziedziczenie | Klasa może implementować wiele interfejsów | Klasa może dziedziczyć po jednej klasie bazowej |
| Stan obiektu | Zwykle nie przechowuje pól instancji | Może zawierać pola, właściwości i konstruktor |
| Cel | Opisuje kontrakt lub rolę | Udostępnia wspólną bazę dla spokrewnionych klas |
| Typowe użycie | Wymiana implementacji, testy, warstwy aplikacji | Wspólna logika i kontrolowana hierarchia dziedziczenia |
Wybieram interfejs, gdy chcę powiedzieć: „ten typ potrafi wykonywać określoną czynność”. Klasę abstrakcyjną wybieram wtedy, gdy typy mają wspólne pochodzenie i rzeczywiście współdzielą kod lub stan. Nie każda wspólna metoda uzasadnia klasę bazową.
Przykładowo IPersistable może pasować do użytkownika, faktury i raportu, nawet jeśli te obiekty nie mają wspólnej natury. Z kolei Animal jako klasa abstrakcyjna ma sens, jeśli wszystkie klasy pochodne dzielą stan i zachowania właściwe zwierzętom.
Nowoczesne możliwości i typowe pułapki
Od C# 8 interfejs może zawierać domyślną implementację. Pozwala to dodać zachowanie bez natychmiastowego zmuszania wszystkich istniejących implementacji do jego napisania.
public interface ILoggable
{
void Log(string message);
void LogError(string message)
{
Log($"ERROR: {message}");
}
}
To przydatne przy rozwijaniu publicznych bibliotek, ale nie traktowałbym tej funkcji jako zamiennika klasy bazowej. Gdy interfejs zaczyna gromadzić dużą ilość logiki, przestaje być prostym kontraktem i trudniej zrozumieć, za co właściwie odpowiada.
Warto znać także statyczne abstrakcyjne elementy interfejsów. Są wykorzystywane między innymi w generycznych algorytmach, które muszą wykonywać operacje na typie, a nie na konkretnej instancji. To rozwiązanie zaawansowane i zwykle nie jest potrzebne w pierwszych projektach.
Przeczytaj również: Swift - Kiedy naprawdę ma sens? Poznaj podstawy!
Błędy, które często widzę w kodzie
- Interfejs dla każdej klasy bez realnej potrzeby. Zwiększa liczbę plików i poziom abstrakcji.
- Interfejs o nazwie opisującej implementację, na przykład
MyServiceInterface, zamiast nazwy roli, takiej jakIUserRepository. - Jeden ogromny interfejs zawierający kilkanaście niezwiązanych metod. Lepsze są mniejsze kontrakty zgodne z zasadą segregacji interfejsów.
- Zmiana istniejącego interfejsu bez sprawdzenia wszystkich implementacji. Dodanie nowego wymaganego członka może wywołać błędy kompilacji w wielu projektach.
- Używanie interfejsu tylko po to, by ukryć źle zaprojektowaną klasę. Sama abstrakcja nie naprawi niejasnych odpowiedzialności.
Najprostsza reguła brzmi: interfejs powinien być mały, spójny i opisany językiem możliwości. Jeśli implementujący typ musi dodawać puste metody albo rzucać wyjątki „nieobsługiwane”, kontrakt prawdopodobnie jest zbyt szeroki.
Jak podejść do interfejsów w praktycznym projekcie
Na początku identyfikuję granice, które mogą się zmieniać. Może to być baza danych, zewnętrzne API, system płatności albo mechanizm wysyłania powiadomień. W tych miejscach interfejs zazwyczaj przynosi więcej korzyści niż w prostych klasach domenowych.
Potem nazywam kontrakt zgodnie z jego rolą i umieszczam go blisko warstwy, która go potrzebuje. Nie projektuję od razu dziesięciu przyszłych implementacji. Najpierw definiuję najmniejszy kontrakt potrzebny dzisiaj, a rozszerzam go dopiero wtedy, gdy pojawia się konkretna potrzeba.
Na koniec sprawdzam kod z perspektywy użytkownika interfejsu. Jeżeli może on wykonać tylko sensowne operacje, a implementację da się łatwo podmienić w teście lub konfiguracji aplikacji, abstrakcja spełnia swoje zadanie.
Interfejs w C# nie jest ozdobnikiem ani obowiązkowym elementem każdej klasy. To narzędzie do budowania czytelnych granic. Użyty we właściwym miejscu ułatwia rozwój aplikacji, testowanie i wymianę technologii, ale używany automatycznie może tylko zaciemnić prosty kod.