Jednolinijkowce w Python - nie dajmy się zwariować


Jak pisać czytelne jednolinijkowe instrukcje i nie dać się zwariować! Zapoznaj się z bardzo ciekawą składnią i zobacz czego warto unikać.


Wiele języków pozwala na budowanie jednolinijkowych instrukcji. Nie mogło ich zabraknąć także w moim ulubionym Pythonie. Sam najczęściej używam takich warunków i pętli czyli One Line For i One Line If. Poza nimi są również instrukcje przypisania czy lambdy, które są tematem spornym, jednak bardzo często bez nich kod jest dużo bardziej rozbudowany przez co mniej czytelny. Są to instrukcje, które bardzo często ułatwiają pracę, ale należy z nimi uważać, bo można wpaść w pewną pułapkę. Pułapkę tę chciałbym Wam w tym wpisie przedstawić.

Jakiś czas temu z jednym ze współpracowników, który jest jednocześnie moim przyjacielem, mieliśmy żywą dyskusję przy przeglądzie kodu. Chodziło właśnie o jednolinijkową pętlę. Ten konkretny przypadek był w miarę czytelny, jednak z uwagi na to, że lubimy często dyskutować szybko zeszliśmy na inne przypadki, które widzieliśmy w projektach, nad którymi pracowaliśmy. Bez wątpienia dobre wykorzystanie takich instrukcji znacząco ułatwia kod. Jednak użyte niepoprawnie sprawiają, że ich czytanie jest żmudne i męczące. Największą ich zaletą jest fakt iż można je zagnieżdżać i używać wzajemnie. Paradoksalnie jest to ich największa wada o czym postaram się Was przekonać kilkoma przykładami.

Na pierwszy ogień weźmy instrukcję przypisania. Musimy do danej zmiennej przypisać nazwę artykuły lub jakąś domyślną wartość, jeżeli ten jest pusty. Standardowe rozwiązanie może wyglądać w następujący sposób:

def get_title(article, default='Domyślny tytuł'):
    if article.title:
        return article.title
    else:
        return default

Można to jednak uprościć do następującej struktury:

def get_title(article, default='Domyślny tytuł'):
    return article.title or default

Jak widać taka struktura jest równie czytelna a kod skrócił się o ponad połowę.

Spróbujmy teraz przejść do instrukcji warunkowej. Za przykład weźmy sytuację, w której musimy do zmiennej przypisać jakąś wartość w zależności od tego czy artykuł jest aktywny. Ponownie standardowe rozwiązanie może wyglądać następująco:

def get_published_message(article):
    if article.active:
        return 'Zapraszam do lektury'
    else:
        return 'Niestety artykuł jest nieaktywny.'

Ponownie spróbujmy przedstawić to w uproszczonej postaci:

def get_published_message(article):
    return 'Zapraszam do lektury' if article.active else 'Niestety artykuł jest nieaktywny'

Skoro proste przypadki mamy za sobą spróbujmy iść za ciosem i zbudujmy nieco bardziej rozbudowaną logikę. A więc spróbujmy poprawić pierwszą metodę. Klient zauważył, że jeżeli artykuł nie ma tytułu to wyświetlamy domyślną wartość i zamiast tego zlecił wyświetlać tytuł kategorii. Taki zapis w tradycyjnej postaci mógłby wyglądać następująco (dla ułatwienia przyjmijmy, że artykuł zawsze na kategorię):

def get_title(article, default='Domyślny tytuł'):
    if article.title:
        return article.title
    elif article.category.title:
        return article.category.title
    else:
        return default

Ponownie spróbujmy uprościć ten zapis:

def get_title(article, default='Domyślny tytuł'):
    return article.title if article.title else article.category.title or default

Ponownie znacznie uprościliśmy kod nie zmniejszając jego czytelności. Takie przypadki można mnożyć w nieskończoność, jednak sama idea pozostaje ta sama. Widzimy zatem, że jednolinijkowa instrukcja warunkowa jest bardzo przydatna i nie zmniejsza czytelności kodu.

A co z wspomnianymi wcześniej pętlami? Gdybym miał oceniać to wydaje mi się, że są to najlepsze rozwiązania do przepisywania list i innych podobnych struktur z jakimiś ograniczeniami. Wyobraźmy sobie sytuację, w której mamy listę kilku posortowanych liczb naturalnych i musimy wypisać tylko parzyste. Bez znajomości jednolinijkowe pętli kod mógłby wyglądać następująco:

def even_numbers(numbers):
    even_numbers = []
    for num in numbers:
        if not num % 2:
            even_numbers.append(num)
    return even_numbers

Można to jednak zdecydowanie uprościć co widać poniżej:

def even_numbers(numbers):
    return [num for num in numbers if not num % 2]

Spotkaliście się kiedyś z zagadnieniem, w którym musieliście wybrać z listy słów tylko te które spełniają określone warunki? Na przykład te które mają minimum 7 znaków lub te które mają maksymalnie 10 liter? Jeżeli tak to zapewne wyglądało to tak:

def long_words(words):
    words = []
    for word in words:
        if len(word) > 7:
           words.append(word)
    return words

Analogicznie jak w poprzednich przypadkach to również można uprościć:

def long_words(words):
    return [word for word in words if len(word) > 7]

Innym częstym zastosowaniem jest wypisanie kluczy lub wartości słownika czy innej struktury danych. Za przykład weźmy sytuację w której mamy listę komentarzy. Komentarz jest słownikiem zawierającym dane autora i treść. Poniżej struktura listy kilku komentarzy:

comments = [
    {'author': 'Jan Kowalski', 'content': 'Ten wpis jest niesamowity!'},
    {'author': 'Jan Nowak', 'content': 'Nie dowiedziałem się niczego nowego, ale i tak daję plusa za pomysłowość'},
    {'author': 'Jan Zieliśnki', 'content': 'Żenada. Weź się za coś innego.'},
    {'author': 'Jan Kowalski', 'content': 'Ciekawe. Gdybym tylko wiedział o tym wcześniej'},
    {'author': 'Jan Kowalski', 'content': 'Mógłbyś popracować nad stylem pisania ;)'},
]

I teraz spróbujmy wypisać wszystkie komentarze, których autorem był: "Jan Kowalski". Ponownie dla porównania spróbujmy standardowe podejście:

def get_comments(comments):
    contents = []
    for comment in comments:
        if comment['author']=='Jan Kowalski':
            contents.append(comment['content'])
    return contents

Wiem, że część może się oburzyć, że użyłem stałej w metodzie i że Jan Kowalski powinien być przekazany jako zmienna albo nazwa funkcji powinna być poprawiona. Jednak nie to było celem. Celem było pokazanie jak działa instrukcje if. Teraz zbudujmy to samo przy użyciu naszej ulubionej jednolinijkowe instrukcji:

def get_comments(comments):
    return [comment['content'] for comment in comments if comment['author']=='Jan Kowalski']

Nie ogranicza się to tylko do list. Można w bardzo łatwy sposób przepisywać i budować np nowe słowniki. Sytuacja, którą często wykorzystywałem to przepisanie słownika do nowego z pominięciem pustych wartości:

def non_empty_dict(data):
    return {k:v for k,v in data.items() if v}

A co z zagnieżdżeniem? Tu również można je stosować i sam często je stosowałem. Wyobraźmy sobie bardziej rozbudowany przykład z parzystymi liczbami. Tym razem zamiast listy kilku liczb mamy listę list z liczbami. Taka struktura przedstawiona jest poniżej:

numbers = [
    [11, 12, 13, 14, 15, 16, 17, 18, 19, 20],
    [21, 22, 23, 24, 25, 26, 27, 28, 29, 30],
    [31, 32, 33, 34, 35, 36, 37, 38, 39, 40],
    [41, 42, 43, 44, 45, 46, 47, 48, 49, 50],
    [51, 52, 53, 54, 55, 56, 57, 58, 59, 60],
]

Jak z takiej struktury w formie jednej listy zebrać wszystkie liczby podzielne przez 2? Rozbudujmy wcześniejszą funkcję:

def even_numbers(numbers):
    even_numbers = []
    for row in numbers:
        for num in row:
            if not num % 2:
                even_numbers.append(num)
    return even_numbers

Skrócony zapis z zagnieżdżonymi pętlami może wyglądać tak jak poniżej:

def even_numbers(numbers):
    return [num for row in numbers for num in row if not num % 2]

I tu powoli dochodzimy do sytuacji, w której jednoliniowa instrukcja ułatwia działanie kodu jednak staje się mało czytelna. Ten konkretny przypadek jest jeszcze do zaakceptowania, ponieważ warunek jest tylko jeden i jest stosunkowo łatwy. Widać natomiast, że przy zagnieżdżaniu pętli ich czytanie jest utrudnione. Dlatego właśnie gorąco zachęcam Was do eksperymentowania, jednak wszystko z umiarem. Zdarzało mi się czasem popaść w stan, w którym wszystko robiłbym One Line If i One Line For, bo wtedy były to dla mnie najlepsze instrukcje ;). Jednak po kilku miesiącach sam miałem problemy z rozczytaniem takiego kodu. Nie wspomnę już o rozczytywaniu podobnych fragmentów pisanych przez innych programistów. Jak ze wszystkim, musimy po prostu uważać :).

Przedstawiłem już kilka przykładów, które ułatwiają szybkie pisanie aplikacji a z negatywów podałem tylko jeden przykład, który w dodatku jest wątpliwy, bo gdy ktoś pracował z takimi strukturami to szybko by to rozgryzł. Czy zatem te instrukcje nie mają wad? Otóż mają i pora teraz na kilka słów goryczy.
Po pierwsze debugowanie. Przechodzenie przez kolejne iteracje w normalnej pętli pozwala łatwiej zlokalizować błąd. W instrukcjach zbudowanych w jednej linijce nie punktu zaczepienia i niestety trzeba uzbroić się w cierpliwość.
Po drugie testy i procentowe pokrycie kodu tymi testami. Instrukcje skrócone pozwalają sztucznie zwiększać pokrycie kodu testami niekoniecznie sprawdzając wszystkie przypadki. Nie mówiąc już o przypadkach brzegowych. Widząc kod rozdzielony na wiele linii intuicyjnie tworzę do tego odpowiednie przypadki a nawet jeżeli o jakimś zapomnę to raporcie z pokrycia widzę, że coś przeoczyłem. Oczywiście nie jest to wymierna metoda, bo 100% pokrycia nie oznacza, że aplikacja jest w pełni przetestowana, ale zawsze zmniejsza ryzyko wystąpienia błędu.
I jeszcze jedna bardzo ważna uwaga. Mianowicie długie nazwy zmiennych. To częsty argument, który zmniejsza czytelność kodu a w połączeniu z takimi strukturami danych powoduje, że nie chce się na taki kod patrzeć. Spróbujmy zatem przepisać powyższy przykład na inne nazwy.

def even_numbers(list_of_even_numberbers):
    return [even_number for row_with_even_numberbers in list_of_even_numberbers for even_number in row_with_even_numberbers if not even_number % 2]

Wiem, że ten przykład jest nieco przerysowany, ale uwierzcie, że bardzo często spotykałem się z dłuższymi nazwami. No i na koniec coś co budziło we mnie największe zdziwienie to łamanie jednilijkowego fora ;). Tak, dobrze czytacie. Poniżej zaprezentuję przykłady wzorowane na tych, które miałem przyjemność recenzować:

def even_numbers(numbers):
    return [
        num for num in numbers
        if not num % 2
    ]

A to mój absolutny faworyt. Z góry przepraszam, bo mogło to wyglądać nieco inaczej, ale pamiętam, że mieliśmy w projekcie jednego programistę, który za punkt honoru wziął sobie, żeby z jego ręki nie wyszedł kod którego szerokość przekracza 80 znaków. W połączeniu z długimi nazw zmiennych powstało coś na wzór tego:

def even_list_of_even_numberbers(list_of_even_numberbers):
    return [
        even_number for row_with_even_numberbers in
        list_of_even_numberbers for even_number in row_with_even_numberbers
            if not even_number % 2
    ]

Na koniec chciałbym podać jeszcze jeden argument, który przemawia za standardowymi pętlami. W momencie kiedy przepisujemy coś do naszej listy mamy ją zdefiniowaną i możemy sprawdzić czy element znajduje się na liście. Nie możemy tego zrobić w przypadku jednoliniowej pętli. Może łatwiej będzie to zrozumieć na przykładzie. Pamiętacie jeszcze listę komentarzy? Teraz potrzebujemy wypisać wszystkich autorów komentarzy.

def get_authors(comments):
    return [comment['author'] for comment in comments]

Zadanie wydaje się proste. Na początku sam skorzystałbym z tej jednolinijkowej wersji przedstawionej wyżej. Ale co w przypadku gdy mamy wypisać tych autorów bez powtórzeń? Możemy pokusić się o rzutowanie tego wszystkiego na zbiór, ale wtedy tracimy kolejność. I ostatecznie po wielu próbach, bez znajomości zewnętrznych narzędzi najprawdopodobniej skończylibyśmy z kodem wyglądającym mniej więcej tak jak ten poniżej:

def get_authors(comments):
    authors = []
    for comment in comments:
        if not comment['author'] in authors:
            authors.append(comment['author'])
    return authors

Jako ciekawostkę zamieszczam kilka ciekawych zastosowań takich instrukcji na które natknąłem się przygotowując się do tego wpisu. Będą tu zarówno sprytne, bardzo proste instrukcje jak również spore kawałki kodu które czasami mogą wyglądać jako przerost formy nad treścią. Nazwy zmiennych celowo będą krótkie. Sami oceńcie stopień ich zaawansowania do wymiernych korzyści:

  • Wypisanie n liczb pierwszych:
    • z = lambda n : [x for x in range(2, n + 1) if len([i for i in range(2, x) if x%i == 0]) == 0]
      z(300)
  • QuickSort - czyli algorytm szybkiego sortowania. Oparty jest na metodzie dziel i zwyciężaj:
    • qsort = lambda l : l if len(l)<=1 else qsort([x for x in l[1:] if x < l[0]]) + [l[0]] + qsort([x for x in l[1:] if x >= l[0]])
      qsort([1,2,5,7,8,2,6,8])
      
  • Tabliczka mnożenia:
    • table = lambda size: [[col*row for col in range(1, size)] for row in range(1, size)]
      table(11)
  • Sprawdzenie czy wyraz jest palindromem, czyli czytany normalnie i od tyłu brzmi jednakowo:
    • p = lambda x:x == x[::-1]
      p('ala')

Mam nadzieję, że tym dość krótkim wpisem skłoniłem Was do refleksji. Pamiętajcie, że wszystko da się zrobić na wiele różnych sposobów i nie ma rozwiązania idealnego. Wiadomo, pierwsze zetknięcie się z takimi nowinkami potrafi zawrócić w głowie jednak czas i kolejne linijki kodu uczą pokory. Pomocne są również recenzje kodu, zarówno te kiedy ktoś recenzuje nasze aplikacje jak również nasze. Nie bójcie się przeglądać cudzych fragmentów nawet jeżeli programista, który je pisał jest bardziej doświadczony od Was. Starsi programiści również wpadają w pułapki, o których dziś opowiedziałem. Celowo pominąłem bibliotekę itertools , która w dobrych rękach pomaga stworzyć prawdziwe cuda. Może i na to przyjdzie czas.

Tymczasem, jeśli dobrnąłeś aż tutaj to dziękuję za uwagę. Jest to jeden z pierwszych wpisów także w razie uwag czy sugestii jestem otwarty. Wpisujcie proszę w komentarzach, co mogę usprawnić, aby kolejne wpisy były jeszcze bardziej wartościowe.

Wykorzystany kod napisany został w Python2.7.
Sty 01, 2019

Najnowsze wpisy

Zobacz wszystkie