1. Wprowadzenie
No więc, przekonaliśmy się, że interfejsy to potężne kontrakty. Ale czym właściwie jest programowanie na poziomie interfejsów? To filozofia projektowania, która mówi: "Zależ od abstrakcji, a nie od konkretnych implementacji."
Znowu analogia. Chcesz zrobić kawę. Kupujesz ekspres do kawy. Nie obchodzi cię, czy to konkretny model "Super-Puper-Maszyna V3000" czy "Mega-Automat Barista 5000", prawda? Ważne, że to urządzenie potrafi "robić kawę" – czyli realizuje "kontrakt" ICoffeeMaker. Po prostu wciskasz przycisk "Zrób kawę" i ona to robi. A jak dokładnie to robi – czy używa mielonej kawy, kapsułek czy czegoś innego – jako użytkownika nie bardzo cię to obchodzi.
W kodzie oznacza to, że zamiast wymagać w swoich metodach konkretnego typu Document albo Image, będziesz wymagać IPrintable. Twój kod będzie działał z dowolnym obiektem, który implementuje ten interfejs, nie znając jego konkretnego typu.
Kontrakt interfejsu — to gwarancja, że każda klasa implementująca interfejs na pewno dostarczy definicje wszystkich członków interfejsu (metod, właściwości, zdarzeń). Z punktu widzenia programu każdy obiekt implementujący interfejs wspiera określony „zestaw możliwości” i można na to liczyć, nawet nie wiedząc, jak i co tam w środku jest zrobione.
Załóżmy, że umówiłeś się z kolegą, że zawsze spotykacie się przy czerwonej kanapie w holu. Jak on tam dotrze — windą, pieszo czy biegnąc po ścianach — nie ma znaczenia. Ważne: on tam będzie. No i interfejs to taka „czerwona kanapa” dla kodu.
Przykład
public interface IPrintable
{
void Print();
}
Kontrakt: każdy, kto implementuje IPrintable, musi umieć wydrukować siebie na ekran. Jak — to już szczegóły.
2. Kontrakt jako punkt współpracy między częściami systemu
Jak to działa w praktyce
System może się składać z dziesiątek klas, stworzonych przez różnych ludzi, w różnym czasie, z różnymi celami. Ale jeśli wszystkie trzymają się jednego kontraktu (czyli implementują jeden interfejs), to można ich używać w jednolity sposób.
Przykład z prawdziwej aplikacji: wyobraź sobie bazę klientów. Klasa Customer, klasa Employee, klasa Contractor. Każda przechowuje informacje po swojemu, ale jeśli wszystkie implementują, powiedzmy, interfejs IContactInfo, który wymaga metod GetEmail() i GetPhoneNumber(), to dla zewnętrznego kodu nie ma znaczenia, do jakiego typu należy obiekt — ważne, że można pobrać email i telefon.
public interface IContactInfo
{
string GetEmail();
string GetPhoneNumber();
}
public class Customer : IContactInfo
{
public string Email { get; set; }
public string Phone { get; set; }
public string GetEmail() => Email;
public string GetPhoneNumber() => Phone;
}
// Podobnie dla Employee i Contractor...
Teraz, jeśli musisz wydrukować dane wszystkich, z którymi twoja firma ma kontakt (nie ważne, kto to), po prostu przechodzisz po liście IContactInfo, wywołujesz potrzebne metody — i wszystko działa.
3. Programowanie "na poziomie interfejsów"
Programować na poziomie interfejsów — to znaczy pisać kod, który zależy nie od konkretnych klas, a tylko od interfejsów (czyli kontraktów). Klasa, która implementuje interfejs, może być dowolna, byle spełniała wymagania interfejsu.
Dlaczego to ważne?
- Skalowalność: łatwo dodawać nowe typy, nie zmieniając istniejącego kodu.
- Testowalność: można łatwo podmieniać obiekty na mocki (atrapy) podczas testowania.
- Elastyczność: implementacja może się zmieniać nawet codziennie, interfejs zostaje stabilny.
- Czystość architektury: twoje moduły są słabo powiązane, można je ponownie używać.
Przykład — Znany program: "Konto bankowe"
W poprzednich przykładach aplikacji mieliśmy abstrakcyjną klasę BankAccount z abstrakcyjną metodą Withdraw(), a konkretne typy (SavingsAccount, CheckingAccount) implementowały szczegóły.
Dodajmy teraz interfejs — na przykład do wyświetlania informacji o saldzie:
public interface IBalanceReporter
{
void ReportBalance();
}
public abstract class BankAccount : IBalanceReporter
{
public double Balance { get; set; }
public abstract void Withdraw(double amount);
public void ReportBalance()
{
Console.WriteLine($"Aktualne saldo: {Balance} euro");
}
}
Teraz każdy, kto pracuje z IBalanceReporter, może wywołać ReportBalance() niezależnie od konkretnego typu konta.
4. Ogólna obsługa i polimorfizm przez kontrakt
Przykład: ogólny handler
Jak tylko pracujemy z interfejsem, możemy tworzyć ogólne metody, które nie zależą od typu obiektu:
static void PrintAllBalances(IBalanceReporter[] accounts)
{
foreach (var reporter in accounts)
{
reporter.ReportBalance();
}
}
W tej liście mogą być dowolne obiekty implementujące IBalanceReporter: SavingsAccount, CheckingAccount, nawet jakiś MockAccountForTesting. I żadnej magii, wszystko uczciwie działa.
Schematycznie: co daje kontrakt interfejsu
+-------------------+ implementuje +-------------------+
| BankAccount | <------------------- | IBalanceReporter |
| (SavingsAccount) | |-------------------|
| (CheckingAccount)| | + ReportBalance() |
+-------------------+ +-------------------+
| ^
| |
+-----------+-------------------------+
|
Dowolna inna klasa implementująca kontrakt
5. Kontrakty, logika biznesowa i tworzenie architektury
Kontrakt interfejsu wyraźnie oddziela to, co klasa powinna umieć, od tego, jak to realizuje. Dlatego architekci oprogramowania lubią projektować logikę systemów właśnie przez interfejsy. Często najpierw powstaje interfejs (kontrakt), a jego implementacja pojawia się później.
Przykład z życia: systemy płatności
Portfele elektroniczne, karty, PayPal, kryptowaluty... Każda ma swoje szczegóły, ale jeśli się uogólni i zrobi interfejs IPaymentProvider:
public interface IPaymentProvider
{
void Pay(decimal amount);
bool Refund(decimal amount);
}
Kod, który współpracuje z tym interfejsem, w ogóle nie obchodzi, czy płaci z karty czy z konta. To wygodne i dla architektury, i dla życia: można dodać obsługę nowych systemów płatności bez ruszania reszty kodu.
6. Wydzielenie logiki biznesowej do kontraktu
Kontrakt pozwala wynieść główne reguły biznesowe na poziom interfejsu, a szczegóły (np. sprawdzanie limitu, naliczanie cashbacku itd.) zostawić konkretnym klasom.
Kolejny przykład
public interface ILogger
{
void LogInfo(string message);
void LogError(string message);
}
public class ConsoleLogger : ILogger
{
public void LogInfo(string message) => Console.WriteLine($"INFO: {message}");
public void LogError(string message) => Console.WriteLine($"ERROR: {message}");
}
public class FileLogger : ILogger
{
public void LogInfo(string message) => /* Zapis do pliku */;
public void LogError(string message) => /* Zapis do pliku */;
}
Dalej kod kliencki wygląda tak (nie ważne, z jakim loggerem pracuje):
void DoWork(ILogger logger)
{
logger.LogInfo("Praca się zaczęła.");
// ... jakaś praca ...
logger.LogError("Coś poszło nie tak z pracą.");
}
To właśnie jest programowanie na poziomie interfejsów: kod kliencki zależy tylko od kontraktu (interfejsu), a nie od konkretnej implementacji.
7. Błędy i typowe pułapki
Początkujący często popełniają błąd: ich kod jest mocno powiązany z konkretnymi klasami, a nie z interfejsami. To prowadzi do masy problemów:
- Trudno coś podmienić albo przetestować — trzeba przepisywać dużo kodu.
- Dodawanie nowych typów nie wychodzi bez zmian w wielu miejscach.
- Moduły są mocno „sklejone” ze sobą.
Odwrotnie — jeśli od początku budować system na interfejsach i przekazywać parametry przez nie, można osiągnąć słabe powiązanie i elastyczność.
Główna rada: staraj się myśleć o współpracy między modułami jak o współpracy między kontraktami, a nie między konkretnymi implementacjami.
GO TO FULL VERSION