Zasady SOLID w Pythonie dla początkujących. Open-closed principle

python open-closed

Zastanawiałeś/aś się skąd przyjęło się przekonanie, że powinno się unikać zmiennych(nie stałych) globalnych? Dlaczego pola klasy powinny być prywatne? Co uczyniło te zasady dobrymi praktykami? W tym wpisie przybliżę Tobie drugą zasadę SOLID – open-closed principle w Pythonie.

SOLID to:

Czym jest zasada open-closed?

Już w 1988. roku Bertrand Meyer opisał tę metodę w swojej książce „Object Oriented Software Construction” (LINK). Mówi ona o tym, że klasy, moduły, funkcje itd. powinny być otwarte na rozszerzenie, ale zamknięte na modyfikację.

Oczywiście chcemy żeby kod pisany przez nas był rozszerzalny i przystosowywał się do nowych wymagań. Innymi słowy, powinniśmy móc dodawać nowe funkcjonalności systemowe, bez potrzeby modyfikowania istniejącego już kodu. Funkcjonalności powinniśmy dodawać tylko poprzez pisanie nowego kodu.

Jeśli z chcemy dodać nową rzecz do aplikacji i żeby to osiągnąć musimy modyfikować „stary”, istniejący już kod, to całkiem prawdopodobne, że nie został on najlepiej napisany. W idealnym przypadku nowe zachowania po prostu się dopisuje.

Dlaczego tak? Otóż nie chcemy aby przy dodawaniu nowego „ficzera” konieczna była zmiana w wielu zależnych od siebie modułach. Każda taka zmiana niesie ze sobą ryzyko popełnienia błędu, przez co programy i systemy nie spełniające reguły open-closed są często nieprzewidywalne (w złym tego słowa znaczeniu).

Przykład zasady open-closed w pythonie

Wyobraźmy sobie sytuację, że posiadamy prosty kod obliczający podatek dla zawodu artysty, prawnika oraz nauczyciela:

class Profession(Enum):
    ARTIST = auto()
    LAWYER = auto()
    TEACHER = auto()

@dataclass
class TaxPayer:
    profession: str
    salary: float


def calculate_tax(tax_payer):
    if tax_payer.profession == Profession.ARTIST:
        return tax_payer.salary * 0.25
    elif tax_payer.profession == Profession.LAWYER:
        return tax_payer.salary * 0.34
    elif tax_payer.profession == Profession.TEACHER:
        return tax_payer.salary * 0.15

Wszystko działa ok. Za jakiś czas dowiadujemy się, że będziemy musieli dodać kolejne profesje. Można pomyśleć, że wystarczy dodać kolejnego ifa do funkcji liczącej i problem z głowy.

Rozwiązanie zachowa się poprawnie, ale nie jest zgodne z zasadą open-closed. Aby dodać nową funkcjonalność zmodyfikowaliśmy istniejącą implementację. Nie jest ona więc otwarta na rozszerzenie. Co jeśli następnie dostalibyśmy wymaganie, aby liczyć podatek dla np. murarza, programisty itd.? Wymagałoby to ciągłego modyfikowania funkcji calculate_tax.

To co chcemy zrobić to odpowiednia abstrakcja profesji, dzięki której będziemy mogli dodawać w „czysty” sposób nowe zawody.

@dataclass
class TaxPayer(ABC):
    salary: float

    @abstractmethod
    def calculate_tax(self):
        pass


class Artist(TaxPayer):
    profession = Profession.ARTIST

    def calculate_tax(self):
        return self.salary * 0.25


class Lawyer(TaxPayer):
    profession = Profession.LAWYER

    def calculate_tax(self):
        return self.salary * 0.34


class Teacher(TaxPayer):
    profession = Profession.TEACHER

    def calculate_tax(self):
        return self.salary * 0.15

artist = Artist(1000)
artist.calculate_tax()  # 250.0

Dzięki zastosowaniu prostej abstrakcji i wymaganiu, aby każdy zawód implementował własną metodę obliczenia podatku udało się osiągnąć elastyczną i otwartą na rozszerzenia implementację.

jak pisać kod zgodny z tą regułą?

W rzeczywistości nawet doświadczonym programistom ciężko jest przewidzieć jakie wymagania biznesowe pojawią się w przyszłości. Aby być w stanie precyzyjniej określać abstrakcje na przyszłe rozszerzenia potrzeba zarówno doświadczenia, jak i czasu. Czasu na obserwację zmian w projekcie. Na ich podstawie możemy zauważyć, które części naszej aplikacji są podatne na modyfikacje i uczynić je zgodne z regułą open-closed.

zasada Open-closed vs zasada Single responsibility

Czy reguła open-closed nie stoi w sprzeczności do single responsibility? Ta pierwsza mówi o zamykaniu klas, metod, modułów na modyfikację podczas gdy SRP mówi, że każda z nich musi posiadać tylko jeden powód do zmiany. Hmm, czy to się nawzajem nie wyklucza?

Otóż nie 🙂 SRP dopuszcza, aby klasa miała tylko jeden powód do zmiany, spowodowany zmianą wymagań biznesowych, istniejącej już implementacji. Na przykład zmienia się algorytm obliczania płac klasy EmployeePaymentCalculator z poprzedniego postu o SRP. Takiej zmiany nie zmieniamy poprzez rozszerzenie, ale modyfikujemy istniejący kod. Każda inna zmiana powinna być wykonana poprzez rozszerzenie, a nie modyfikację.

dlaczego więc unikać zmiennych globalnych?

We wstępie wspomniałem, że źródłem powszechnego przekonania aby unikać zmiennych globalnych, jest właśnie open-closed principle. Dlaczego? Nie da się zamknąć na zmiany żadnego modułu, który jest zależny od zmiennej globalnej, którą może zmodyfikować dowolny inny moduł. Niesie to za sobą duże ryzyko wkradnięcia się błędu.

Analogicznie, pola klas powinny być prywatne i osiągalne tylko przez metody tej klasy. To również pochodzi od reguły open-closed. Gdy zmienimy pole klasy, to również musimy zmienić wszystkie metody, które od niej zależą. W programowaniu obiektowym przyjęto, że żadna metoda klasy nigdy nie jest zamknięta na zmiany pól tej samej klasy. Natomiast każda inna klasa(wliczając w to klasy dziedziczące) jest zamknięta na zmiany pól innej klasy. To jest właśnie enkapsulacja 🙂


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