Czy wiesz jak dobrze projektować dziedziczenie w swoim kodzie? Jakie cechy mają najlepsze implementacje dziedziczenia? Jakie są pułapki i jak sobie z nimi radzić? W tym artykule przedstawię Ci trzecią regułę SOLID: Liskov substitution principle.
SOLID to:
- Single Responsibility Principle [SRP]
- Open-Closed Principle [OCP]
- Liskov Substitution Principle [LSP] – Temat tego postu
- Interface Segregation Principle [ISP]
- Dependency Inversion Principle [DIP]
czym jest liskov substitution principle?
Pierwotnym autorem LSP jest Barbara Liskov, która opisała ją w swoim dziele Data Abstraction and Hierarchy[LINK]. Lata później Robert C. Martin zmodyfikował tę teorię i zawarł w zbiorze zasad SOLID.
Reguła ta dotyczy poprawnego stosowania dziedziczenia i głosi, że wszędzie tam gdzie przekazujemy obiekt klasy bazowej, powinniśmy móc przekazać obiekt klasy dziedziczącej po tej klasie. Dlaczego?
Przykładowo funkcja poniżej, która nie jest zgodna z LSP wie o klasach dziedziczących po klasie bazowej przekazanej w parametrze. Przy stworzeniu nowej klasy dziedziczącej będzie trzeba zmodyfikować feralną funkcję. Łamie to zasadę open-closed.
def login(user: BaseUser):
# login user
if isinstance(user, AdminUser):
send_notification(user)
elif isinstance(user, EditorUser):
# other action
Właściwie, gdy w funkcji sprawdzamy czy przekazany obiekt jest obiektem klasy dziedziczącej, to prawdopodobnie łamiemy zasadę LSP.
Warto dodać, że zasada ta nie dotyczy konstruktorów ani metod klas. Zasada Liskov mówi o obiektach klasy, nie o typie samym w sobie. A jeśli obiekt został stworzony to wcześniej musiał być wykonany jego konstruktor.
Przykład naruszenia LSP
Przeanalizujmy ten kawałek kodu:
class NormalFile:
def read(self):
# read file implementation
print('Reading from regular file')
def write(self, input_text):
# write file implementation
print('Writing to regular file')
class ReadonlyFile(NormalFile):
def read(self):
# read readonly file implementation
print('Reading from readonly file')
def write(self):
raise Exception('Can\'t write to readonly file')
normal_file = NormalFile()
readonly_file = ReadonlyFile()
def make_file_operations(fil, input_text):
if not isinstance(fil, ReadonlyFile):
fil.write(input_text)
# some processing stuff
fil.read()
make_file_operations(normal_file, 'tekst')
make_file_operations(readonly_file, 'tekst')
# output:
# Writing to regular file
# Reading from regular file
# Reading from readonly file
Widzimy tutaj pozornie niewinny przykład dziedziczenia. Niestety występują tu conajmniej dwa problemy:
- metoda ‘write’ klasy ReadonlyFile nie przyjmuje tych samych argumentów jak metoda klasy bazowej(File) – naruszenie zasady Liskov
- metody ‘write’ obu klas są niekompatybilne, zachowują się całkiem inaczej. Przy wykonaniu tej metody wymagane jest rozróżnienie jakiego typu jest obiekt, którego używamy(np. tak jak w funkcji make_file_operations). – kolejne naruszenie zasady Liskov.
Aby uczynić powyższy kod zgodny z LSP, musimy ujednolicić listę parametrów przyjmowanych przez metody. Niepotrzebne jest również rzucanie wyjątkiem przy próbie zapisu w klasie ReadonlyFile. Należy wyodrębnić interfejsy np. na “WritableFile” oraz “ReadableFile”. Dzięki czemu klasa ReadonlyFile będzie implementować tylko interfejs do odczytu, nie przejmując się zapisywaniem.
class ReadableFile(ABC):
@abstractmethod
def read(self) -> str: ...
class WritableFile(ABC):
@abstractmethod
def write(self, input_text: str) -> None: ...
class NormalFile(ReadableFile, WritableFile):
def read(self) -> str:
# read file implementation
print('Reading from file')
def write(self, input_text: str) -> None:
# write file implementation
print('Writing to file')
class ReadonlyFile(ReadableFile):
def read(self) -> str:
# read readonly file implementation
print('Reading from readonly file')
wychwyć problemy przy pomocy mypy!
Python jest językiem dynamicznie typowanym i daje użytkownikom swobodę w projektowaniu kodu. Ma to swoje dobre i złe strony. Jednym z minusów jest większe prawdopodobieństwo przekazania obiektu z błędnym typem do funkcji/klasy. Istnieją jednak narzędzia, które pomagają w wykrywaniu takich podstawowych błędów. Na przykład mypy to projekt, który pozwala przeprowadzić statyczną analizę typów. Dzięki tej bibliotece można między innymi namierzyć proste naruszenia LSP.
class NormalFile:
...
def write(self, input_text: str) -> None:
...
class ReadonlyFile(NormalFile):
def write(self) -> None:
...
Mając taki kod, po uruchomieniu mypy na tym pliku powinniśmy dostać taki oto komunikat:
error: Signature of “write” incompatible with supertype “NormalFile”
Błąd jasno sygnalizuje naruszenie zasady Liskov. Funkcja wywołująca musi zadziałać dla obiektu klasy bazowej jak i obiektów z klas po niej dziedziczących. Oczywiście bez błędów, które zapewne pojawiłyby się przy niespójności typów parametrów metody.
podsumowując
W tym artykule opisałem dla Ciebie zasadę podstawiania Barbary Liskov. Okazało się, że jest to kolejna zasada ściśle związana z pozostałymi regułami SOLID.
Warto, abyśmy przestrzegali najczęstszych naruszeń tej zasady: niespójność parametrów metody oraz niekompatybilne implementacje. Szczególnie w pierwszej bardzo pomaga statyczny analizator kodu jak mypy. Automatycznie wyłapie i zwróci uwagę na ewentualny błąd 🙂