Praca w IT

Pythonic way. Jak pisać czysty kod w Pythonie?

Wiele mówi się o czystym i przejrzystym kodzie. Takim, który się łatwo czyta, modyfikuje i testuje. Każdy programista ze stażem zetknął się z tym pojęciem przynajmniej kilka razy w trakcie swojej kariery. Jednak wykorzystywane przez nas frameworki, narzędzia, języki czy biblioteki czasami wymuszają pewne konwencje. Czy to dobrze? Jak zwykle… to zależy. Dzisiaj postaram się przybliżyć Wam moje przemyślenia dotyczące czystego i pythonowego kodu.

Pythonic way

Zacznijmy od definicji, co to w ogóle znaczy pythonic way lub pythonowy kod. Sprawa jest prosta: chcemy, aby kod napisany w Pythonie był pisany jak kod w Pythonie. Z wykorzystaniem mechanizmów, które daje sam język i zgodnie z jego założeniami.

Możliwe, że mieliście taką sytuację, w której na widok kodu w języku X pomyśleliście: “Tak się tego nie pisze w tym języku, on/ona na pewno kiedyś pisał/a w języku Y. To widać!”

Dokładnie o to chodzi, taki zestaw dobrych praktyk, stylu i zasad, w jaki sposób pisać w danym języku. Nie ma jednak jednego prostego sposobu na to, w jaki sposób pisać kod, aby spełniał on założenia tego wpisu.

Najłatwiej będzie pokazać kilka przykładów, które – mam nadzieję – zbliżą Was do zrozumienia, co moim zdaniem znaczy pythonic way. Zacznijmy od zasad.

Zen of Python

Najbardziej pythonowy zestaw zasad, jaki znam to Zen of Python. Napisany przez Tima Petersa w imieniu Guido Van Rossuma (twórcy Pythona).

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren’t special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one– and preferably only one –obvious way to do it.
Although that way may not be obvious at first unless you’re Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it’s a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea — let’s do more of those!

Czytając ten krótki tekst można zauważyć, jakie cechy i zasady powinien mieć pythonowy kod. Powinien być:

  • Ładny, a nie brzydki,
  • Zgodny z twierdzeniem, że wyrażone wprost (rozwiązanie) jest lepsze niż domniemane,
  • Prosty,
  • Posiadający mało zagnieżdżeń,
  • Czytelny.

W trakcie rozwoju języka developerzy wielokrotnie odwoływali się do tych zasad, podejmując decyzje, w jakim kierunku ma pójść Python. Więc skoro dla Core Developerów jest to istotne, to myślę, że warto wpisać import this w konsolę i poświęcić na to trochę czasu.

Przejdźmy do przykładu.

Prosta pętla

Ci, którzy tworzą swoje pierwsze skrypty w Pythonie, a posiadają już doświadczenie w językach z rodziny C, często piszą taki kod.

names = ["Kasia", "Marek", "Ania", "Bartosz"]
for i in range(len(names)):
   name = names[i]
   print("Hi " + name + "!")

Ten kod jest jasny i działa. Mamy listę, wiemy, ile ma elementów i po indeksie wyciągamy z niej kolejne imiona.

Ale to nie jest zbyt pythonowy kod. W języku węży iteruje się po elementach listy. Nic więcej nie trzeba robić.

names = ["Kasia", "Marek", "Ania", "Bartosz"]
for name in names:
   print(f"Hi {name}!")

Taki kod jest krótszy i czytelniejszy dla pythonowców. Oczywiście przykład jest prosty, ale osobiście często się z nim spotykałem, obserwując młodych adeptów Pythona.

Łatwiej prosić o przebaczenie niż o zgodę

Pamiętajcie, że tu mówimy tylko o Pythonie! W prywatnym życiu może nie bierzcie sobie tej porady do serca. Świetnym przykładem, jak wykorzystać tę zasadę jest zliczanie liczby słów z listy do słownika. Możemy za każdym razem pytać się, czy jest bezpiecznie i dopiero wtedy zliczać słowa.

LUB

Po prostu to zrobić. Jak się coś nie uda, to będziemy to wiedzieli. Od tego mamy wyjątki.

words = ["word1", "word2", "word1", "word3", "word99", ...]
counter = {}

for word in words:  # Solution 1
   if word in counter:  # Check for every word
       counter[word] += 1
   else:
       counter[word] = 1

for word in words:  # Solution 2
   try:
       counter[word] += 1
   except KeyError:  # Go here only for NEW words.
       counter[word] = 1

W pierwszym rozwiązaniu posiadamy instrukcję IF sprawdzającą, czy mamy dane słowo w słowniku. Jeżeli nie, to w sekcji ELSE przypisywana jest wartość 1. Można tu zauważyć, że tyle, ile będzie iteracji pętli, tyle razy sprawdzane jest, czy dane słowo już znajduje się w słowniku. Nawet jeżeli przez cały czas będzie się powtarzał ten sam wyraz.

Drugie rozwiązanie zawsze próbuje wykonać +1 dla każdego słowa. A co w przypadku, gdy nie ma takiego klucza w słowniku? Będzie wyjątek KeyError. Ale to nic nie szkodzi, od tego mamy mechanizm try / except. Łapiemy wyjątek i wtedy inicjalizujemy wartość 1 dla nowego słowa.

Zauważcie, że w drugim przypadku liczba operacji jest dużo mniejsza niż w pierwszym. Nie wykonujemy za każdym razem sprawdzenia, a dodatkowe operacje wykonają się tylko wtedy, gdy spróbujemy zliczyć słowo po raz pierwszy.

Biblioteki standardowe

Jedną z niesamowitych zalet Pythona jest społeczność i mnogość bibliotek utworzonych dookoła niego. Większość podstawowych problemów jest rozwiązana i zapisana w bibliotekach standardowych – link.

Nie inaczej jest z problemem zliczania słów. Możemy do tego wykorzystać defaultdict z collections. W takim przypadku nie musimy się martwić o błąd KeyError, ponieważ z defaultdict z automatu (w przypadku, gdy nie ma klucza) podstawi domyślną wartość. Kod wyglądałby tak:

words = ["word1", "word2", "word1", "word3", "word99", ...]
counter_default = collections.defaultdict(int)
for word in words:  # Solution 3
   counter_default[word] += 1

Ładniej? Krócej? Moim zdaniem tak. Do tego mamy mniej wcięć i zagnieżdżeń. Szybciej się ten kod czyta i jest bardziej przejrzysty.

Natomiast zliczanie elementów ze zbioru nie jest zbyt wyrafinowanym problemem. Można powiedzieć, że występuje dość często. A co za tym idzie, możliwe, że ktoś już ten problem rozwiązał.

words = ["word1", "word2", "word1", "word3", "word99", ...]
counter = collections.Counter(words)

Python jest znany z tego, że można w nim zrobić całkiem skomplikowane rzeczy prosto i szybko. Wykorzystanie Counter z paczki collections jest tego dobrym przykładem.

Często na rozmowach kwalifikacyjnych występują tego typu zadania. Dobrze jest znać wtedy gotowe sposoby w bibliotekach standardowych.

Polecam zajrzeć do:

  • collections,
  • itertools,
  • functools,
  • dataclasses,
  • math,
  • datetime,
  • csv,
  • random.

Wszystko jest obiektem

Jest to jedna z rzeczy, do której trzeba się przyzwyczaić. I nie oszukujmy się, to koncept czasami trudny do “załapania”. Ale kiedy się to zrozumie, możliwości są prawie nieograniczone.

Trochę enigmatyczne? Spieszę z przykładem.

class Student:
   def __init__(self, name):
       self.name = name

class SoftwareDeveloper:
   def __init__(self, name):
       self.name = name

for class_definition in [Student, SoftwareDeveloper]:
   class_instance = class_definition("Franek")
   print(f"Hi {class_instance.name}")

W powyższym przykładzie stworzyłem dwie klasy: Student i SoftwareDeveloper. Do stworzenia instancji klasy potrzebne jest tylko imię.

W pętli iterujemy po liście z deklaracjami klas. Nie jest to obiekt klasy (instancja klasy), tylko deklaracja. Korzystając z tego, że wszystko jest obiektem, możemy przypisać definicję klasy do zmiennej class_definition.

W trakcie pierwszej iteracji po pętli możecie wyobrazić sobie, że zamiast class_definition wpiszecie klasę Student. Więc dla pierwszego elementu kod mógłby wyglądać tak:

class_instance = Student("Franek")

Analogiczna sytuacja ma miejsce dla klasy SoftwareDeveloper. Zdaję sobie sprawę, że kod nie wygląda zbyt imponująco i o co w zasadzie tyle zachodu. Już wyjaśniam.

Załóżmy, że lista z deklaracjami klas jest przekazywana w jakiejś zmiennej, a nie wpisana na sztywno w pętli for. Imiona też się zmieniają, dodatkowo dla każdej instancji stworzonej wykonujemy kolejne operacje.

Taki kod jest generyczny, możemy w zasadzie przekazać dowolną klasę, która przyjmuje 1 parametr name.

Wzorce projektowe

Wzorce projektowe (design patterns) to ogólnie znane rozwiązania typowych problemów, które możemy napotkać podczas pisania kodu. Znając wykorzystany wzorzec, łatwiej jest czytać kod i pozwala to na szybsze jego zrozumienie.

Same wzorce projektowe nie są związane z konkretnym językiem programowania. W różnych językach implementacja może się znacznie różnić. Natomiast ogólna zasada wzorca jest podobna.

Na początku artykułu zaznaczałem, że w Pythonie powinniśmy pisać po pythonowemu. W przypadku wzorców należy na to zwrócić szczególną uwagę. Możemy przepisać implementację Fabryki, widząc kod C# do Pythona. Ale czy o to chodziło? Z pewnością ten kod nie będzie korzystał ze wszystkich mechanizmów, jakie posiada Python, a moim zdaniem głównie o to chodzi.

Refaktoryzacja

Rozważmy następujący problem. Mamy listę osób z informacjami o ich imieniu, wieku i kierunku studiów. Na podstawie studiowanego kierunku wiemy, jaki zawód osoba zdobędzie po studiach.

people_info = [
   {"name": "Zenon", "age": 21, "field_of_study": "Mechanika"},
   {"name": "Asia", "age": 22, "field_of_study": "Stomatologia"},
   {"name": "Dariusz", "age": 24, "field_of_study": "Informatyka"},
   {"name": "Martyna", "age": 21, "field_of_study": "Ekonomia"},
]

Przygotowałem już definicje klas dla kilku profesji.

class Profession:
   def __init__(self, name: str, age: int, field_of_study: str):
       self.name = name
       self.age = age
       self.field_of_study = field_of_study

   def __str__(self) -> str:
       return f"{self.name} ma {self.age} lat. Nie wie kim będzie po studniach."


class Mechanic(Profession):
   def __str__(self) -> str:
       return f"{self.name} ma {self.age} lat. Po studiach będzie Mechanikiem."


class Dentist(Profession):
   def __str__(self) -> str:
       return f"{self.name} ma {self.age} lat. Po studiach będzie Dentystą."


class Programmer(Profession):
   def __str__(self) -> str:
       return f"{self.name} ma {self.age} lat. Po studiach będzie Informatykiem."

Oczywiście nie mam klas dla wszystkich profesji, więc jakoś sobie z tym trzeba będzie poradzić. Oto moje pierwsze podejście do rozwiązania problemu:

people = []
for person_info in people_info:
   fos = person_info["field_of_study"]
   if fos == "Mechanika":
       person = Mechanic(**person_info)
   elif fos == "Stomatologia":
       person = Dentist(**person_info)
   elif fos == "Informatyka":
       person = Programmer(**person_info)
   else:
       print(f"{fos} - nie znam takiego kierunku! ;(")
       continue
   people.append(person)
print(people)

Najbardziej problematyczna w tym kodzie będzie jego rozbudowa. Wyobraźcie sobie jeszcze 100 różnych kierunków studiów… i dla każdego kierunku kolejny IF.

Wykorzystując kilka mechanizmów dostępnych w języku, zrobiłem refaktor. Sprawdźcie efekty.

STUDY_PROFESSION_MAPPING = {
   "Mechanika": Mechanic,
   "Informatyka": Programmer,
   "Stomatologia": Dentist
}

people = []
for person_info in people_info:
   try:
       person = STUDY_PROFESSION_MAPPING[person_info["field_of_study"]](**person_info)
       people.append(person)
   except KeyError:
       # If the field_of_study isn't in the dict.
       print(f"{person_info['field_of_study']} - nie znam takiego kierunku! ;(")
print(people)

Dodałem słownik z mapowaniem kierunku na odpowiedni zawód. Dzięki temu kod w pętli bardzo się uprościł. Żeby łatwiej było Wam analizować ten kod, to warto dodać, że w środku pętli wykorzystałem wzorzec fabryki.

W słowniku STUDY_PROFESSION_MAPPING przechowuję informację o tym, jaką klasę trzeba wybrać. W sekcji try na podstawie kierunku osoby wybierana jest odpowiednia klasa i następnie wywoływany jest konstruktor (**person_info).

Podsumowanie

Pythonic way to nie do końca końcowy efekt. To proces, w którym szukamy najlepszych sposobów na implementację naszych pomysłów w prosty i przejrzysty sposób, korzystając ze wszystkich dobrodziejstw języka.

W załączonym kodzie starałem się przemycić pewne zagadnienia, z których często korzystam:

  • error handling – łapanie wyjątków (try / except),
  • continue – przejście do następnego elementu w pętli,
  • type hints – wskazówki typów zmiennych,
  • rozpakowywanie danych (tuple unpacking – **person_info),
  • wzorzec fabryki (implementacja wzorca strategii wygląda podobnie).

Dzięki za doczytanie do końca! Chciałbym zaznaczyć, że zdecydowanie ten temat jest dużo szerszy niż to, co opisałem. Mam nadzieję, że choć trochę przybliżyłem Wam tę ideę i podczas następnej sesji kodowania czy Code review, zwrócicie uwagę także na te aspekty.

Zdjęcie główne artykułu pochodzi z unsplash.com.

Programista, miłośnik i ewangelista Pythona. W projektach często łączył światy data science, machine learning i web development, tak aby wszystko współgrało, jak dobrze naoliwiona maszyna. Po godzinach zapalony słuchacz podcastów i wieczny uczeń. Cały czas rozwija się, żeby móc lepiej rozwijać innych.

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/pythonic-way-jak-pisac-czysty-kod-w-pythonie/" order_type="social" width="100%" count_of_comments="8" ]