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” 🙂

Serwis wykorzystuje pliki cookies. Korzystając ze strony wyrażasz zgodę na wykorzystywanie plików cookies. Więcej informacji

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close