Krok Do Pythonicznego Kodu – Comprehension

  • by

W społeczności Pythonowej istnieje takie określenie kodu, że jest on Pythoniczny. Oznacza to, że wykorzystuje on możliwości i cechy charakterystyczne tego języka. Jednym z takich elementów są tak zwane złożenia (ang. comprehension), nazywane też wyrażeniami (np. wyrażenia listowe ang. list comprehension). Ich rodzaje oraz sposoby użycia powinien znać każdy fan Pythona!

Comprehension

Aby zrozumieć, jak wygląda implementacja złożeń, oraz jak zastąpić zwykłą pętlę tą konstrukcją, przejdźmy do przykładu. Stworzymy trzy funkcje, które w tym pomogą. Pierwsza to map_func, której zadaniem mogłoby być przekształcenie naszych danych w pewien sposób. Kolejna to filter_func, której zadaniem byłoby określenie, czy dana zmienna powinna się znaleźć w nowej kolekcji, czy nie. Funkcja ta zwraca bool, ponieważ oczekujemy konkretnej odpowiedzi: True albo False. Ostatnia funkcja to iterable, która zwraca nam obiekt iterowalny – po jego elementach będzie tworzyć pętlę.

from typing import Iterable, Any


def map_func(obj) -> Any:
    return obj


def filter_func(obj) -> bool:
    return bool(obj)


def iterable() -> Iterable:
    return range(-2, 5)

Gdy mamy już zdefiniowane takie elementy, to możemy podstawić je pod standardową konstrukcję pythonową. Nie korzystając ze złożeń, musielibyśmy najpierw utworzyć pustą kolekcję, a następnie poprzez pętle sprawdzać kolejne elementy i dodawać tylko te, które są poprawne (z naszego punktu widzenia).

result = []
for obj in iterable():
    if filter_func(obj):
        result.append(map_func(obj))

Złożenia Pythonowe bardzo uproszczą nam ten kod. Cała logika mieści się nam w jednej linii kodu. Zamykamy ją w odpowiednich nawiasach (w zależności od typu złożenia). Wynik operacji będzie automatycznie dodawany do listy. Poniższe to przykład list comprehension.

result = [map_func(obj) for obj in iterable() if filter_func(obj)]

print(result)
[-2, -1, 1, 2, 3, 4]

Aby dokładniej przyjrzeć się, jak zmieniła się nasza logika, przeformatujmy nasze złożenie. Po pierwsze nie definiujemy już pustej kolekcji. Po drugie zmieniła się trochę kolejność czytania kodu. Poprzednie rozwiązanie mogliśmy czytać jako:

Dla każdego obiektu w obiekcie iterowalnym, jeżeli spełnia on pewne założenie, to przekonwertuj go i dodaj do kolekcji.

Nowe rozwiązanie będziemy czytać jako:

Przekonwertuj i dodaj do nowej kolekcji każdy obiekt w obiekcie iterowalnym, jeżeli spełnia on pewne założenia.

result = [
    map_func(obj) 
    for obj in iterable() 
    if filter_func(obj)
]

Dlaczego korzystamy w takim razie z tej konstrukcji w naszym kodzie? Jest to czytelna i szybka metoda przekształcania, filtrowania i tworzenia nowych kolekcji, na podstawie już istniejących.

Aby nie było wątpliwości co do powyższych przykładów. Gdyby nie chęć pokazania przykładu z opisami poszczególnych elementów, nasze złożenie wyglądałoby w tym przypadku następująco:

result = [num for num in range(-2, 5) if num]

Przypadek użycia

Aby uatrakcyjnić nasze przykłady, skupmy się na jednym przypadku użycia. Użytkownik na wejściu do naszej aplikacji przekazuje string liczb oddzielonych białym znakiem. Naszym celem jest odpowiednio przekształcić to wejście w pewną kolekcję. Aby kod był czytelniejszy, utworzymy zmienną input_iter, która będzie kolekcją kolejnych liczb przekazanych przez użytkownika, wciąż w formacie str.

user_input = "123 52 43 -11 1 9 4 0 23 1 1 -2"
input_iter = user_input.split()

List comprehension

Najprostszym i najczęściej spotykanym przykładem jest list comprehension. Poniższy przykład pokazuje, w jaki sposób przekształcić naszą kolekcję liczb w formacie str do int. Jest to dość prosty zabieg, więc widzimy zdecydowanie, dlaczego developerzy tak bardzo lubią tę konsturkcję 🙂

numbers = []
for number in input_iter:
    numbers.append(int(number))

numbers = [int(number) for number in input_iter]
print(numbers)
[123, 52, 43, -11, 1, 9, 4, 0, 23, 1, 1, -2]

Dict comprehension

Gdy potrzebujemy przekształcić kolekcję na słownik, z pomocą przychodzi dict comprehension. Nasz nowy przykład pokazuje mapowanie liczb jako kluczy do wartości, którą jest bool definiujący czy przed liczbą stoi znak. Jak widać, różne typy złożeń definiujemy poprzez zmianę nawiasów.

numbers_sign = {}
for number in input_iter:
    numbers_sign[int(number)] = number[0] == "-"

numbers_sign = {int(number): number[0] == "-" for number in input_iter}
print(numbers_sign)
{123: False, 52: False, 43: False, -11: True, 1: False, 9: False, 4: False, 0: False, 23: False, -2: True}

Set comprehension

W sytuacji, gdy zachodzi potrzeba utworzenia kolekcji unikalnych elementów, przydać się może set comprehension. Dzięki niemu dość szybko stworzymy kolekcje unikalnych liczb podanych przez użytkownika.

unique_numbers = set()
for number in input_iter:
    unique_numbers.add(int(number))

unique_numbers = {int(number) for number in input_iter}
print(unique_numbers)
{0, 1, 4, 9, 43, 52, -11, 23, 123, -2}

If

Złożenia oferują nam także możliwość filtrowania danych w kolekcjach. Aby to zrobić, w list comprehension należy na końcu po pętli dodać if z konkretnym predykatem. Na nasze potrzeby chcemy utworzyć listę wyłącznie dodatnich liczb.

positive_numbers = []
for number in input_iter:
    if int(number) >= 0:
        positive_numbers.append(int(number))

positive_numbers = [int(number) for number in input_iter if int(number) >= 0]
print(positive_numbers)
[123, 52, 43, 1, 9, 4, 0, 23, 1, 1]

If else

Nie zawsze poprzez if pragniemy odfiltrować dane. Zdarza się, że w różnych przypadkach chcemy otrzymać inną wartość. Wtedy możemy skorzystać z if else w formie tzw. conditional expression. W tym przypadku każdą ujemną wartość będziemy przekształcać na dodatnią.

positive_numbers = []
for number in input_iter:
    if int(number) >= 0:
        positive_numbers.append(int(number))
    else:
        positive_numbers.append(0 - int(number))

positive_numbers = [
    int(number) if int(number) >= 0 else 0 - int(number) for number in input_iter
]
print(positive_numbers)
[123, 52, 43, 11, 1, 9, 4, 0, 23, 1, 1, 2]

For x,y

Tak jak w prostych pętlach, tak i tutaj możemy iterować po kilku elementach poprzez rozpakowanie tupli. W ten sposób możemy, chociażby iterować po kluczach i wartościach słowników, lub odpowiednio do naszych potrzeb rozpakowywać dane.

last_digits = []
for *digits, last_digit in input_iter:
    last_digits.append(last_digit)

last_digits = [last_digit for *digits, last_digit in input_iter]
print(last_digits)
['3', '2', '3', '1', '1', '9', '4', '0', '3', '1', '1', '2']

Nested

Złożenia nie są ograniczone. Dzięki temu możemy pozwolić także na zagnieżdżone pętle w złożeniach! Poniższy przykład utworzy kolekcje unikalnych cyfr użytych przez użytkownika!

unique_numbers = set()
for number in input_iter:
    for char in number:
        if char != '-':
            unique_numbers.add(int(char))

unique_numbers = {int(char) for number in input_iter for char in number if char != "-"}
print(unique_numbers)
{0, 1, 2, 3, 4, 5, 9}

Generator expression

Jak wiemy, nie zawsze tworzenie kolekcji ma sens. Gdy chcemy przetworzyć dane, aby je zaraz wykorzystać, dużo lepszym rozwiązaniem jest utworzenie generatora. Jednak tworzenie zwykłego generatora oznacza tworzenie funkcji, która poprzez yield zwracać będzie kolejne wartości.

def numbers(user_input):
    for number in user_input:
        yield int(number)


print(numbers)
numbers_sum = sum(numbers(input_iter))
<function numbers at 0x7fd5b9b661f0>

W tym wypadku możemy taką konstrukcję zastąpić poprzez tzw. generator expression które niczym nie różni się od możliwości, które zaprezentowane zostały powyżej przy innych złożeniach. Taka konstrukcja wymaga zamknięcia naszego list comprehension nawiasami okrągłymi, jak przy definicji tuple.

numbers = (int(number) for number in input_iter)
print(numbers)
numbers_sum = sum(numbers)

<generator object <genexpr> at 0x7fd5bb77e9e0>

Jeżeli powyższe rozwiązanie nie do końca Ci się nie podoba, to bardzo dobrze! Ponieważ gen expr można wstawiać bezpośrednio jako argument, pod tym względem jego zachowanie można porównać z funkcją anonimową lambda.

numbers_sum = sum(int(number) for number in input_iter)

Jest pewien haczyk. Generator pozwoli nam przejść po danych wyłącznie jeden raz. Miejmy to na uwadze, ponieważ jest to ważny wyznacznik tego, czy warto z niego skorzystać w danej sytuacji.

Nested comprehension

Jeżeli powyższe przykłady nie rozwiązują do końca problemu, to może pewna wariacja to zrobi. Pomyślmy, że potrzebujemy zrobić pewną operację na danych, a następnie te dane przetworzyć. Możemy do tego skorzystać z zagnieżdżenia się jednego złożenia w drugim. W poniższym przykładzie chcemy przechowywać tuple sąsiadów liczb przekazanych przez użytkownika.

number_neighbours = []
for number in input_iter:
    number = int(number)
    result = (number - 1, number, number + 1)
    number_neighbours.append(result)

number_neighbours = [
    (int(number) - 1, int(number), int(number) + 1) for number in input_iter
]

print(number_neighbours)
[(122, 123, 124), (51, 52, 53), (42, 43, 44), (-12, -11, -10), (0, 1, 2), (8, 9, 10), (3, 4, 5), (-1, 0, 1), (22, 23, 24), (0, 1, 2), (0, 1, 2), (-3, -2, -1)]

To przekształcanie potrójne tej samej wartości odbiera trochę sens, i nasz kod wydaje się nie być wcale lepszy. Jednak jest na to rada, i jest nią zagnieżdżenie. Zagnieżdżmy gen expr, które przekształcać będzie nasze liczby w formacie str na int przed pobraniem kolejnej wartości.

number_neighbours = [
    (number - 1, number, number + 1) for number in (int(n) for n in input_iter)
]

I teraz spójrzmy, jak byśmy zrobili tę samą konstrukcję, nie korzystając ze złożeń:

def numbers(iterable):
    for number in iterable:
        yield int(number)


number_neighbours = []
for number in numbers(input_iter):
    number_neighbours.append((number - 1, number, number + 1))

Albo bez użycia generatora tak:

numbers = []
for number in input_iter:
    numbers.append(int(number))

number_neighbours = []
for number in numbers:
    number_neighbours.append((number - 1, number, number + 1))

Jak pokazują przykłady, poznana tutaj konstrukcja daje dużą elastyczność przy pisaniu kodu. W dodatku zdecydowanie upraszcza kod, który musielibyśmy napisać w to miejsce.

Use cases

Skoro już wiemy, jak to wszystko działa, spróbujmy z tego skorzystać w innym przypadku. Dużo ciekawiej jest, gdy operujemy na słownikach i potrzebujemy wyciągnąć na ich podstawie pewne informacje. Do przykładów skorzystamy z prostego słownika, który zawiera kilku użytkowników:

from pprint import pprint

users = [
    {
        "id": 1,
        "username": "jbuzzing0",
        "email": "bschulkins0@youku.com",
        "ip_address": "171.228.113.240",
        "is_active": True,
        "is_admin": True,
    },
    {
        "id": 2,
        "username": "afettiplace1",
        "email": "epellatt1@deviantart.com",
        "ip_address": "239.233.89.230",
        "is_active": True,
        "is_admin": False,
    },
    {
        "id": 3,
        "username": "rsparry2",
        "email": "kocarrol2@smugmug.com",
        "ip_address": "58.241.139.102",
        "is_active": False,
        "is_admin": True,
    },
    {
        "id": 4,
        "username": "fbrinkman3",
        "email": "cradnedge3@springer.com",
        "ip_address": "190.242.241.245",
        "is_active": True,
        "is_admin": False,
    },
    {
        "id": 5,
        "username": "gperren4",
        "email": "hsictornes4@pbs.org",
        "ip_address": "226.186.181.230",
        "is_active": True,
        "is_admin": False,
    },
]

W pierwszej kolejności chcemy sprawdzić, czy na naszej liście użytkowników jest jakiś aktywny administrator. Robimy to poprzezset comprehension i sprawdzamy, czy w kolekcji, którą otrzymaliśmy, jest wartość True.

any_admin = any({user["is_admin"] and user["is_active"] for user in users})
pprint(any_admin)
True

Skoro już wiemy, że wśród użytkowników istnieje jakiś administrator, stwórzmy ich listę. Nowa lista będzie zawierać tylko tych użytkowników, których wartość spod klucza is_admin jest równa True.

admins = [user for user in users if user["is_admin"]]
print(admins)
[{'email': 'bschulkins0@youku.com',
  'id': 1,
  'ip_address': '171.228.113.240',
  'is_active': True,
  'is_admin': True,
  'username': 'jbuzzing0'},
 {'email': 'kocarrol2@smugmug.com',
  'id': 3,
  'ip_address': '58.241.139.102',
  'is_active': False,
  'is_admin': True,
  'username': 'rsparry2'}]

Co jeżeli potrzebujemy obojętnie jakiego administratora? Zamiast tworzyć ich listę, tworzymy prosty generator, który zwróci nam pierwszego administratora. Dzięki temu otrzymamy do niego dostęp szybciej, niż w przypadku gdybyśmy tworzyli najpierw całą listę, aby następnie chcieli pobrać tylko jej pierwszy element.

first_admin = next(user for user in users if user["is_admin"] and user["is_active"])
pprint(first_admin)
{'email': 'bschulkins0@youku.com',
 'id': 1,
 'ip_address': '171.228.113.240',
 'is_active': True,
 'is_admin': True,
 'username': 'jbuzzing0'}

Może potrzebujemy adresy email wszystkich użytkowników, aby przesłać im wiadomości? Wystarczy dla każdego użytkownika wyciągnąć wartość spod odpowiedniego klucza

emails = [user['email'] for user in users]
pprint(emails)
['bschulkins0@youku.com',
 'epellatt1@deviantart.com',
 'kocarrol2@smugmug.com',
 'cradnedge3@springer.com',
 'hsictornes4@pbs.org']

Timing

Skoro już wiemy, z czym mamy do czynienia, i jak z tego korzystać – dowiedzmy się czy komputer też wolałby comprehension. Użyjemy do tego biblioteki timeit. Użyjemy do tego celu prostej pętli z predykatem.

from timeit import timeit


func = """
arr = []
for i in range(1000):
    if(i%3):
        arr.append(i)
"""

time = timeit(func, number=10000)
print(f"{'standard':>14} {time}")


func = """
arr = [i for i in range(1000) if i%3]
"""

time = timeit(func, number=10000)
print(f"{'comprehension':>14} {time}")
      standard 1.2301481989998138
 comprehension 0.8710038739955053

Wynik to ostateczny powód, dlaczego warto się tej konstrukcji nauczyć. W tym wypadku jest ona szybsza od standardowej funkcji aż o 33%!

Kiedy Nie Stosować?

Złożeń nie należy stosować w kilku sytuacjach:

  • Gdy robimy pętle po obiektach, które nas nie interesują. Może to być pętla, która wysyłać będzie wiadomości z listy, a metoda do wysyłania będzie zwracała None. Stosujemy to tylko wtedy gdy chcemy utworzyć nową kolekcję. Więc prostą zasadą, jeżeli ktoś zrobi taką konstrukcję, ale nie przypisze jej do żadnej wartości, to należy ją przepisać na prostą pętlę.
  • Gdy nasza aktualna implementacja jest skomplikowana, a zapisanie jej poprzez złożenie tylko utrudni zrozumienie kodu. Jest to fajne wyzwanie na napisanie jak najmniejszej ilości linii kodu, ale zdecydowanie antywzorzec w kodzie produkcyjnym. Pamiętajmy, że w Pythonie stawiamy na czytelność. Przykład takiego zakazanego kodu poniżej.
def fibonacci(n):
    fib = [1, 1]
    return [sum(fib[-2:]) + int(str(fib.append(sum(fib[-2:]))).replace('None', '0')) for i in range(n)][-3] if n>=3 else 1

Podsumowanie

I to (chyba?) tyle. Przyjrzeliśmy się dokładnie wszystkim formą złożeń, jakie spotkać możemy w Pythonie, przyjrzeliśmy się pewnym przypadkom użycia, a na koniec udowodniliśmy, że jest to rozwiązanie szybsze!

Najlepszą praktyką, jest stosowanie złożeń wszędzie tam, gdzie potrzebujemy utworzyć kolekcje, lub gdy na podstawie jednej kolekcji chcemy utworzyć nową. Gdy chcemy przetworzyć jakieś argumenty lub je odfiltrować. Nie powinniśmy natomiast złożeń stosować także wtedy, gdy nasze rozwiązanie straci przez to na czytelności. Pamiętajmy też o generatorach, gdy kolekcja, którą tworzymy, zostanie wykorzystana tylko raz.

W ramach nauki polecam także wykorzystać plugin do flake8, który zwróci uwagę dodatkowo na złożenia w kodzie, jest to flake8-comprehensions.