Obiekty kontenerowe, czyli jak pisać kod, który czyta się jak prozę


Poznaj obiekty kontenerowe i zobaczy jak na prawdę pisać czysty kod w Python!


Grady Booch powiedział kiedyś, że czysty kod czyta się jak dobrze napisaną prozę. Dodał, że czysty kod jest pełen trafnych abstrakcji. Trudno się z nim nie zgodzić. A dodatkowo Python wspiera takie podejście. Spróbujmy zatem napisać kod, który faktycznie czyta się jak prozę! A użyjemy do tego obiektów kontenerowych w python.

Czysty kod w Python - dataclasses i type hints

Adnotacje, dataclasses oraz mechanizm type hints w Python w znacznym stopniu pozwalają na pisanie czystego kodu. Nie jest to ostateczny krok, ale jednak pierwszy bardzo ważny element. Spróbujmy zaimplementować mechanizm mówiący, czy użytkownik ma dostęp do jakiegoś zasobu. A logika jest prosta, bo mamy klasę "subskrybcji", która może zawierać jedno, lub wiele "kont". Jeśli identyfikator "konta" pokrywa się z identyfikatorem "użytkownika", to to znaczy, że dany użytkownik powinien mieć przydzielony dostęp. Zaimplementujmy strukturę tych klas właśnie przy użyciu dataclass .

@dataclasses.dataclass
class Account:
    id: int
    name: str

@dataclasses.dataclass
class Subscription:
    accounts: list[Account]

@dataclasses.dataclass
class Person:
    id: int
    name: str

A teraz zaimplementujmy funkcję, która sprawdzi czy użytkownik powinien mieć przydzielony dostęp

def has_permission(person: Person, subscriptions: list[Subscription]) -> bool:
    for subscription in subscriptions:
        for account in subscription.accounts:
            if person.id == account.id:
                return True
    return False

Czysty kod, czyli idiomatyczny kod

Ktoś tutaj mógłby zauważyć, że taka struktura rozmija się z Pythonicznym podejściem, bo można to robić znacznie lepiej. Uprośćmy zatem ten kod usuwając niepotrzebną pętlę

def has_permission(person: Person, subscriptions: list[Subscription]) -> bool:
    for subscription in subscriptions:
        if person.id in (account.id for account in subscription.accounts):
            return True
    return False

Każdy, kto przeczytał mój pierwszy wpis wie, że można pozbyć się też drugiej pętli. Sprawdźmy to!

def has_permission(person: Person, subscriptions: list[Subscription]) -> bool:
    return person.id in (account.id for subscription in subscriptions for account in subscription.accounts)

I teraz bardzo prosto możemy wykonać tę funkcję, żeby sprawdzić czy działa to zgodnie z oczekiwaniami:

dawid = Person(1, "ddeby")
deby = Person(10, "ddeby")
account1 = Account(1, "ddeby")
account2 = Account(2, "Dawid")
account3 = Account(3, "Deby")
account4 = Account(4, "Dawid.Deby")
subscriptions = [
    Subscription([account1, account2]),
    Subscription([account3, account4]),
]
has_permission(person1, subscriptions)  # True
has_permission(person2, subscriptions)  # False

Czysty kod czyta się jak prozę

Powyższy kod faktycznie jest idiomatyczny. Powyższy kod faktycznie wykorzystuje potencjał języka. Jednak tego kodu nie można określić jako taki, który czyta się jak prozę. I zdecydowanie takiego kodu nie można pokazać osobie nietechnicznej, żeby oceniła jego poprawność. Sam mam problem z czytaniem zagnieżdżonych pętli. Na szczęście nie mamy tu żadnych warunków, więc jeszcze jest to do utrzymania. Muszę przyznać, że da się to zrobić znacznie prościej. Mianowicie tak jak w poniższym listingu, czyli:

person1 in subscriptions  # True
person2 in subscriptions  # False

Czysty kod poprzez obiekty kontenerowe

Jeśli wcześniej nie znałeś takiego podejścia to przyznaj, że Cię to zaintrygowało. A żeby to osiągnąć musisz dodać jedynie nową klasę, która implementuję magiczną metodę __contains__ . Zróbmy to:

class Subscriptions:
    def __init__(self, data: list) -> None:
        self._data = data

    def __contains__(self, item):
        return item.id in (account.id for subscription in self._data for account in subscription.accounts)

A stworzenie takiej klasy nie wymaga znaczącego narzutu, w porównaniu do zwykłej listy

subscriptions = Subscriptions([
    Subscription([account1, account2]),
    Subscription([account3, account4]),
])

Klasa jest bardzo prosta, a ma kilka zalet

  • chowamy złożoność sprawdzenia tej logiki pod warstwą abstrakcji, co jest zgodne z paradygmatem obiektowym
  • uprościliśmy sposób samego sprawdzenia - korzystamy ze słówka kluczowego "in", co zdecydowanie uprszacza korzystanie z naszej klasy
  • ukrywamy wewnętrzną strukturę kolekcji danych, dzięki czemu możemy w przyszłości zmienić zdanie co do tej kolekcji
  • jawnie wykazujemy intencję, co pozwoli na lepszą komunikację pomiędzy programistami

To wszystko jest spójne z tym co mówi Grady na temat czystego kodu.

Czysty kod jest pełen trafnych abstrakcji

To sformułowanie najbardziej oddaje ideę obiektów kotenerownych. Możemy użyć abstrakcji, żeby ukryć nawet operacje matematyczne. Kiedyś implementowałem uproszczoną aplikację do wizualizacji wnętrza. Nie jestem z tego dumny, a sama wizualizacja polegała na układaniu figur obok siebie. Musieliśmy sprawdzać czy dana figura znajduje się w innej, czy też nie, żeby na przykład ocenić czy wazon można postawić na stoliku.

Najprostsza możliwa implementacja takiego sprawdzenia może wyglądać następująco

sqrt(pow(c1.x-c2.x,2)+pow(c1.y-c2.y,2))<=abs(c1.r-c2.r)

Jednak dla osób, które nie miały doczynienia z operacjami matematycznymi ten zapis nie musi być oczywisty. Dlatego warto schować tę logikę poprzez utworzenie naszego obiektu kontenerowego

@dataclasses.dataclass
class Circle:
    x: int
    y: int
    r: int

    def __contains__(self, circle: Cirlce):
        return sqrt(pow(c1.x-c2.x,2)+pow(c1.y-c2.y,2))<=abs(c1.r-c2.r)

I teraz taki zapis pozwoli w przyszłości sprawdzać całość zdecydowanie łatwiej

circle1 = Circle(10, 10, 10)
circle2 = Circle(5, 5, 20)

circle1 in circle2

Inne przykłady obiektów kontenerowych

To nie jest tak, że tej konstrukcji możemy użyć tylko do sprawdzenia czy jakiś element znajduje się na liście. Ta konstrukcja może być użyta znacznie szerzej, a niżej tylko kilka przykładów

# Czy pionek znajduje się na planszy
pawn in Board()

# Czy mamy wystarczająco gotówki
Money(10) in Wallet(100)

# Czy użytkownik jest na liście klientów
user in clients

Podsumowanie

Pthon oferuje bardzo dużo metod magicznych, __contains__ jest tylko jedną z nich, ale jak widać pozwala tak pisać kod, żeby faktycznie dało się go czytać jak prozę. Dlatego zachęcam Cię do przejrzenia kodu twojej aplikacji, bo być może jesteś w stanie sporo uprościć taką prostą zmianą.

May 09, 2023

Najnowsze wpisy

Zobacz wszystkie