"Poszukujemy następującej właściwości podstawiania: Jeżeli dla każdego obiektu o1 typu S istnieje obiekt o2 typu T taki, że dla wszystkich programów P zdefiniowanych w kategoriach T zachowanie P pozostanie niezmienione, gdy o1 zostanie podstawione za o2, to S jest podtypem T."
W sumie na tym mógłbym zakończyć ten artykuł, bo już chyba wszystko jest jasne. Prawda? No chyba nie do końca :)
W ostatecznej wersji treść zasady podstawienia Liskov brzmi tak:
"Funkcje, które używają wskaźników lub referencji do klas bazowych, muszą być w stanie używać również obiektów klas dziedziczących po klasach bazowych, bez dokładnej znajomości tych obiektów".
Krócej mówiąc, w miejsce typu bazowego, możesz podstawić dowolny typ klasy pochodnej i nie powinieneś utracić poprawnego działania.
SOLID - Liskov Substitution Principle (LSP) - Wszystko Co Powinieneś Wiedzieć o Zasadzie Podstawienia Liskov
Zasada podstawienia Liskov jest powiązana z zasadą omawianą w poprzednim artykule, czyli zasadzie otwarte-zamknięte, ponieważ dzięki możliwości zastępowania podtypów, mamy możliwość rozbudowy klas bez konieczności ich modyfikowania. Aby dziedziczenie było dobre, klasy pochodne nie powinny nadpisywać metod klas bazowych. Natomiast można je rozszerzyć, poprzez wywołanie metody z klasy bazowej, czyli klasa pochodna powinna rozszerzać klasę bazową bez wpływania na jej działanie.
Zasada LSP dotyczy prawidłowo zaprojektowanego dziedziczenia. Jeżeli tworzymy klasę pochodną, to musimy być również w stanie użyć jej zamiast klasy bazowej. W przeciwnym przypadku oznacza to, że dziedziczenie zostało zaimplementowane nieprawidłowo.
Zacznijmy od popularnego przykładu, który bardzo dobrze pokazuje naruszenie zasady LSP, powstał już nawet słynny mem na ten temat. Myślę, że ten przykład najlepiej zilustruje Ci, czym jest zasada LSP.
Czyli jeżeli coś wygląda jak kaczka, kwacze jak kaczka, ale potrzebuję baterii, to prawdopodobnie masz złą abstrakcję :)
public interface IDuck
{
void Swim();
bool IsSwimming { get; }
}
public class OrganicDuck : IDuck
{
private bool _isSwimming = false;
public void Swim()
{
Console.WriteLine("OrganicDuck swims");
_isSwimming = true;
}
public bool IsSwimming { get { return _isSwimming; } }
}
public class ElectricDuck : IDuck
{
private bool _isSwimming;
public void Swim()
{
if (!IsTurnedOn)
return;
Console.WriteLine("ElectricDuck swims");
_isSwimming = true;
}
public bool IsTurnedOn { get; set; }
public bool IsSwimming { get { return _isSwimming; } }
}
public class Program
{
static void Main()
{
var ducks = new List<IDuck>();
IDuck organicDuck = new OrganicDuck();
IDuck electricDuck = new ElectricDuck();
ducks.Add(organicDuck);
ducks.Add(electricDuck);
MakeDuckSwim(ducks); //OrganicDuck swims
}
private static void MakeDuckSwim(IEnumerable<IDuck> ducks)
{
foreach (var duck in ducks)
duck.Swim();
}
}
Jak widzisz obie kaczki implementują interfejs IDuck. Zawierają metody Swim, lecz po wywołaniu tej metody kaczka elektryczna nie pływa, ponieważ nie została wcześniej włączona. Jeżeli chciałbyś "naprawić" ten kod, tak aby obie kaczki pływały, musiałbyś zmodyfikować metodę MakeDuckSwim i zrobić osobną logikę w tej metodzie, gdy kaczka jest typu ElectricDuck.
private static void MakeDuckSwim(IEnumerable<IDuck> ducks)
{
foreach (var duck in ducks)
{
if (duck is ElectricDuck)
((ElectricDuck)duck).TurnOn();
duck.Swim();
}
}
Taka zmiana oczywiście też jest zła i narusza zasadę LSP oraz OCP (klasa nie jest zamknięta na modyfikacje). Podsumowując, zdecydowanie mamy w tym wypadku zaprojektowaną złą abstrakcję.
Jak mówi definicja funkcje, które używają referencji do wskaźników klas bazowych, powinny również mieć możliwość użycia funkcji klas pochodnych bez znajomości tego obiektu. Czyli można powiedzieć, że zasada podstawień Liskov między innymi sprowadza się do zakazu zadawania pytania o typ obiektu.
Dodatkowo, aby zasada LSP była zachowana muszą być spełnione również poniższe warunki:
#1 Kowariancja typów zwracanych w podtypie.
#2 Kontrawariancja argumentów metody w podtypie.
#3 Metody podtypu nie powinny rzucać żadnych nowych wyjątków, oprócz sytuacji, gdy nowe wyjątki są podtypami zgłaszanych metod nadtypu.
#4 W podtypie nie można wzmocnić warunków wstępnych.
#5 Warunki podrzędne nie mogą być mniej restrykcyjne w podtypie.
Omówmy każdy z tych warunków na przykładzie.
#1 Kowariancja typów zwracanych w podtypie.
Kowariancja opisuje relacje między klasami. Przyjrzyj się. proszę poniższemu przykładowi:
public class Vehicle
{
}
public class Car : Vehicle
{
}
public class Audi : Car
{
}
public class Program
{
private static Car GetCar()
{
return new Car();
}
static void Main()
{
Vehicle vehicle = GetCar();
Car car = GetCar();
Audi audi = GetCar(); //Błąd kompilacji
}
}
Przedstawiłem prostą relację dziedziczenia. Klasa Vehicle jest klasą bazową i dziedziczą po niej klasa Car oraz klasa Audi. Następnie w metodzie Main próbuje przypisać do każdego typu, obiekt typu Car, na szczęście kompilator na to nie pozwala, ponieważ do typu Audi, próbujemy przypisać typ bardziej szczegółowy. Kowariancja jest konwersją z typu bardziej szczegółowego do typu bardziej ogólnego. Czyli dla typu pochodnego możemy zawsze przypisać typ obiektu lub typ pochodny, ale nie bazowy.
#2 Kontrawariancja argumentów metody w podtypie.
Kontrawariancja jest relacją odwrotną do kowariancji, a zatem pozwala na konwersję z typu bardziej ogólnego na typ bardziej szczegółowy.
public class Program
{
private static void TurnOn(Car car)
{
}
static void Main()
{
TurnOn(new Vehicle()); //Błąd kompilacji
TurnOn(new Car());
TurnOn(new Audi());
}
}
Jak widzisz, metoda TurnOn oczekuje parametru typu Car, próbując przekazać jako argument typ Vehicle dostajemy błąd kompilacji. Nie można zatem przekazać typu bardziej ogólnego niż typ Car. Czyli podobnie jak w kowariancji, przed naruszeniem tej zasady programistów C# ostrzega kompilator.
#3 Metody podtypu nie powinny rzucać żadnych nowych wyjątków, oprócz sytuacji, gdy nowe wyjątki są podtypami zgłaszanych metod nadtypu.
public class Vehicle
{
public virtual void TurnOn()
{
throw new IndexOutOfRangeException();
}
}
public class Car : Vehicle
{
public override void TurnOn()
{
throw new DivideByZeroException();
}
}
public class Program
{
public static void TurnOnVehicle(Vehicle vehicle)
{
try
{
vehicle.TurnOn();
}
catch (IndexOutOfRangeException)
{
}
}
static void Main()
{
var vehicles = new List<Vehicle>
{
new Vehicle(),
new Car()
};
foreach (var vehicle in vehicles)
{
TurnOnVehicle(vehicle); //Unhandled exception
}
}
}
Powyższy kod naruszą tę zasadę, ponieważ typ Car rzuci wyjątek w metodzie Main, którego nie spodziewa się typ bazowy. Dopuszczalne w tej sytuacji byłoby rzucenie w klasie Car wyjątku, który w tym przypadku dziedziczyłby po IndexOutOfRangeException.
#4 W podtypie nie można wzmocnić warunków wstępnych.
public class Vehicle
{
public virtual void TurnOn(int temp)
{
if (temp < -20)
return;
//logic
}
}
public class Car : Vehicle
{
public override void TurnOn(int temp)
{
if (temp < -5)
return;
//logic
}
}
Klasa pochodna jest bardziej restrykcyjna niż jej typ bazowy. Wymaga, aby argument temp była wyższy niż -5, gdzie klasa bazowa wymaga, aby argument był tylko wyższy niż -20. Typ pochodny w tym przypadku Car musi obsługiwać taki sam zakres danych lub szerszy, na pewno nie mniejszy. Jest to kolejne naruszenie zasady LSP.
#5 Warunki końcowe nie mogą być mniej restrykcyjne w podtypie.
public class Vehicle
{
public int Temp { get; set; }
public virtual int GetTemp()
{
//logic
Temp = -1;
if (Temp < -100)
throw new Exception("Sensor damaged.");
return Temp;
}
}
public class Car : Vehicle
{
public override int GetTemp()
{
//logic
Temp = -200;
return Temp;
}
}
Klasa bazowa rzuci wyjątek, gdy Temp < -100 i również przynajmniej taki warunek powinien być w klasie pochodnej. Obecnie klasa Car nie sprawdza wcale właściwości Temp, przez co jest mniej restrykcyjna niż klasa bazowa Vehicle. Jest to naruszenie reguły LSP.
PODSUMOWANIE
Zasada LSP jest początkowo dość trudna do zrozumienia i programiści często mylą ją z innymi zasadami SOLID: OCP (open-closed principle - omawiana w poprzednim artykule) oraz ISP (interface segregation principle - napiszę o niej w następnym artykule). Implementując w poprawny sposób zasadę podstawienia Liskov, nie powinniśmy się posługiwać żadnym konstrukcjami warunkowymi, aby wymusić poprawne działanie, Obiekt pochodny musi z logicznego punktu widzenia być szczególnym przypadkiem obiektu bazowego. Musisz zawsze pamiętać, że możesz podstawić dowolny obiekt pochodny w miejsce obiektu bazowego i nie możesz zadawać pytania o to, jakiej klasy jest obiekt.
Poprzedni artykuł - SOLID - Open-Closed Principle (OCP) - Wszystko Co Powinieneś Wiedzieć o Zasadzie Otwarte-Zamknięte.
Następny artykuł - SOLID - Interface Segregation Principle (ISP) - Wszystko Co Powinieneś Wiedzieć o Zasadzie Segregacji Interfejsów .