Jak pokazałem we wcześniejszych artykułach dotyczących SOLID, w wielu przypadkach rozwoju projektu bardzo ważną rolę odgrywają interfejsy.
Trzecia zasada SOLID brzmi pozornie prosto: jeśli jakaś część programu działa z klasą bazową, powinna działać równie dobrze z każdą jej podklasą. W praktyce jednak jest to jedna z najczęściej łamanych zasad, często nieświadomie. Wystarczy jeden niespodziewany wyjątek albo jedna metoda zwracająca dane innego typu niż obiecuje kontrakt – i program zaczyna zachowywać się nieprzewidywalnie.
Wyobraźmy sobie przykład, gdzie mamy zbudować strukturę obsługującą różne typy płatności, zacznijmy od bazowej klasy Payment:
<?php
// Klasa bazowa – ogólna płatność
class Payment
{
public function processPayment(float $amount): string
{
return "Przetwarzanie płatności: {$amount} PLN";
}
public function refund(float $amount): string
{
return "Zwrot środków: {$amount} PLN";
}
}
Klasa ta ma trzy podklasy, mianowicie CardPayment, BlikPayment oraz CryptoPayment. Rozpiszmy te klasy i zaznaczmy w kodzie nasze wątpliwości
<?php
// Płatność kartą – działa zgodnie z kontraktem
class CardPayment extends Payment
{
public function processPayment(float $amount): string
{
return "Płatność kartą: {$amount} PLN";
}
public function refund(float $amount): string
{
return "Zwrot na kartę: {$amount} PLN";
}
}
// ❌ Płatność BLIK – nie obsługuje zwrotów
class BlikPayment extends Payment
{
public function processPayment(float $amount): string
{
return "Płatność BLIK: {$amount} PLN";
}
// ❌ NARUSZENIE LSP: metoda istnieje w klasie bazowej,
// ale klasa potomna rzuca wyjątek zamiast ją obsłużyć
// Kod korzystający z Payment nigdy nie spodziewa się wyjątku
public function refund(float $amount): string
{
throw new \Exception("BLIK nie obsługuje zwrotów!");
}
}
// ❌ Płatność kryptowalutą – zmienia znaczenie kwoty
class CryptoPayment extends Payment
{
public function processPayment(float $amount): string
{
// ❌ NARUSZENIE LSP: klasa bazowa operuje na PLN,
// a ta klasa po cichu zmienia znaczenie parametru $amount
// Wywołujący nie ma pojęcia, że $amount to nagle BTC, nie PLN
return "Płatność kryptowalutą: {$amount} BTC";
}
public function refund(float $amount): string
{
// ❌ Zwrot zajmuje "do 30 dni" –
// zupełnie inne zachowanie niż sugeruje klasa bazowa
return "Zwrot kryptowaluty może potrwać do 30 dni: {$amount} BTC";
}
}
Na końcu mamy klasę Shop, która wywołuje obiekty płatności:
class Shop
{
public function checkout(Payment $payment, float $amount): string
{
// ❌ Ten kod zakłada, że każda płatność obsługuje zwroty –
// podstawienie BlikPayment spowoduje nieoczekiwany wyjątek
$result = $payment->processPayment($amount);
$refund = $payment->refund($amount);
return "{$result} | {$refund}";
}
}
O ile klasa CardPayment zachowuje się zgodnie z kontraktem, o tyle CryptoPayment zmienia zupełnie wartość kwoty (użytkownik nie wie, czy wyświetla mu się kwota w złotówkach czy bitcoinach), zaś BLIK nie obsługuje zwrotów płatności. Po wywołaniu klasy obiektu klasy Shop mamy:
// Użycie
$shop = new Shop();
// Działa poprawnie
echo $shop->checkout(new CardPayment(), 100.00);
// ❌ Wyrzuca wyjątek – LSP złamane
echo $shop->checkout(new BlikPayment(), 100.00);
// ❌ Kwota nagle w BTC zamiast PLN – LSP złamane
echo $shop->checkout(new CryptoPayment(), 100.00);
Jak widać powyżej, pierwsza metoda checkout zadziała poprawnie, druga rzuci wyjątkiem przy wewnętrznej metodzie refund, zaś trzecia nie zwróci błędu, lecz zwróci nieprawidłową wartość. Zatem mamy tutaj wyraźne naruszenie zasady Liskov, ponieważ zamienienie klasy bazowej jej podklasami powoduje błędne działanie programu.
Jak temu zaradzić? Optymalnym rozwiązaniem byłoby „podzielenie” klasy Payment na dwie mniejsze klasy, jedna poświęcona płatności, a druga zwrotowi zapłaconej kwoty. Jednak mamy pewien problem, dziedziczenie wielokrotne nie jest wspierane w większości powszechnie używanych języków, dlatego lepszym wyjściem będzie ponowne zastosowanie interfejsów. Stwórzmy zatem dwa mniejsze interfejsy:
<?php
// Interfejs bazowy definiuje kontrakt –
// tylko te operacje, które KAŻDA płatność musi obsługiwać
interface PaymentInterface
{
public function processPayment(float $amountInPln): string;
}
// Osobny interfejs dla płatności obsługujących zwroty –
// nie każda płatność musi go implementować
interface RefundableInterface
{
public function refund(float $amountInPln): string;
}
A teraz pokażmy implementujące je klasy (płatność kartą i BLIKiem):
<?php
// ✅ Płatność kartą obsługuje oba interfejsy
class CardPayment implements PaymentInterface, RefundableInterface
{
public function processPayment(float $amountInPln): string
{
return "Płatność kartą: {$amountInPln} PLN";
}
public function refund(float $amountInPln): string
{
return "Zwrot na kartę: {$amountInPln} PLN";
}
}
// ✅ BLIK implementuje tylko to, co naprawdę obsługuje –
// nikt nie spodziewa się zwrotu, bo interfejs go nie obiecuje
class BlikPayment implements PaymentInterface
{
public function processPayment(float $amountInPln): string
{
return "Płatność BLIK: {$amountInPln} PLN";
}
}
Dla bitcoina stwórzmy osobny interfejs, rozszerzający PaymentInterface:
interface CryptoPaymentInterface extends PaymentInterface
{
public function getAmountInBtc(float $amountInPln): float;
}
class CryptoPayment implements CryptoPaymentInterface, RefundableInterface
{
private float $btcRate = 200000.00; // 1 BTC = 200 000 PLN
public function processPayment(float $amountInPln): string
{
$btc = $this->getAmountInBtc($amountInPln);
return "Płatność kryptowalutą: {$btc} BTC ({$amountInPln} PLN)";
}
public function getAmountInBtc(float $amountInPln): float
{
return round($amountInPln / $this->btcRate, 8);
}
public function refund(float $amountInPln): string
{
$btc = $this->getAmountInBtc($amountInPln);
return "Zwrot kryptowaluty: {$btc} BTC ({$amountInPln} PLN)";
}
}
A oto zmodyfikowana klasa Shop:
// ✅ Sklep operuje na kontrakcie – może przyjąć każdą płatność
class Shop
{
public function checkout(PaymentInterface $payment, float $amount): string
{
return $payment->processPayment($amount);
}
// ✅ Zwrot jest osobną metodą wymagającą osobnego interfejsu –
// nie każda płatność musi go obsługiwać
public function refund(RefundableInterface $payment, float $amount): string
{
return $payment->refund($amount);
}
}
Wywołajmy ostatecznie nasz sklep:
// Użycie
$shop = new Shop();
// ✅ Wszystkie płatności działają poprawnie w checkout()
echo $shop->checkout(new CardPayment(), 100.00);
// "Płatność kartą: 100 PLN"
echo $shop->checkout(new BlikPayment(), 100.00);
// "Płatność BLIK: 100 PLN"
echo $shop->checkout(new CryptoPayment(), 100.00);
// "Płatność kryptowalutą: 0.0000005 BTC (100 PLN)"
// ✅ Zwrot możliwy tylko tam, gdzie interfejs to gwarantuje
echo $shop->refund(new CardPayment(), 100.00);
// "Zwrot na kartę: 100 PLN"
// ✅ PHP nie pozwoli przekazać BlikPayment do metody refund()
echo $shop->refund(new BlikPayment(), 100.00);
// Błąd: BlikPayment nie implementuje RefundableInterface
W ten sposób niezależnie od tego, ile sposobów płatności, implementujących dane interfejsy wprowadzimy, nasz program zawsze będzie działał prawidłowo.
Jak widzieliśmy, rozwiązaniem problemów z LSP często jest rozbicie jednego dużego kontraktu na kilka mniejszych, bardziej precyzyjnych interfejsów. To podejście jest na tyle uniwersalne, że doczekało się własnej zasady w SOLID – Interface Segregation Principle, czyli literki I, o której opowiemy sobie w następnym artykule.

