Zasady SOLID w Pythonie dla początkujących. Liskov substitution principle

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:

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 🙂


Bądź na bieżąco z blogiem!

Bądź na bieżąco z kolejnymi metariałami publikowanymi na tym blogu i innymi użytecznymi (moim zdaniem) materiałami zapisując się do newslettera 🙂

Jako prezent otrzymasz ode mnie mini e-book „Jak zacząć działać w Open Source i rozwijać swoje umiejętności” 🙂