Uważaj na get z domyślną wartością w słowniku


Jak bezpiecznie pobierać dane ze słownika. Kompleksowe wyjasnienie błędnego podejścia do nadużycia metody get, zwłaszcza z domyślną wartością.


W jednym z wpisów pisałem, że pobieranie wartości ze słownika po get jest lepsze niż pobieranie przez nawiasy kwadratowe. Ten sposób pozwala na zwrócenie konkretnej wartości jeśli danego klucza nie ma słowniku. Czy to jest idealne rozwiązanie? Dziś odpowiemy na to pytanie.

Przypomnienie - jak pobierać dane ze słownika

W ramach szybkiego przypomnienia, dane ze słownika pobierać można w zasadize na dwa sposoby.

person = {
    "name": "ddeby",
    "age": "26"
}
name = person["name"]  # Standardowy sposób
name = person.get("name")  # Pobieranie przez get

Problem pojawia się jesli chcemy pobrać wartość spod klucza, którego nie ma w słowniku. Pisałem o tym wcześniej, więc tylko krótko możliwości. Niżej 4, które mozna spotkać najczęściej. Nazwałem je roboczo defensywny if , agresywny try , sprytny get oraz dziwny get

Proste pobieranie wartości ze słownika

profession = person["profession"] if "profession" in person else "brak"  # defensywny if

try:
    profession = person["profession"]  # agresywny try
except KeyError:
    profession = "brak"

Pobieranie danych przez metodę get w słowniku

profession = person.get("profession", "brak")  # sprytny get z domyślną wartością
profession = person.get("profession") or "brak"  # dziwny get z operatorem or

Dziś skupimy się na dwóch ostatnich i poznamy jedną aczkolwiek ważną różnicę między nimi.

Od jakiegoś czasu pracuje nad projektem, w którym musiałem parsować ceny. Chciałbym Was teraz przeprowadzić przez podobną ścieżkę myślową, którą Ja miałem. Nie jest to idealnie ten sam przypadek ale jest bardzo zbliżony.

Struktura danych

Dane, który dostałam wyglądały mniej więcej tak jak na przykładzie poniżej

product = {
    "name": "Ubongo gra planszowa",
    "link": "sample_link",
    "prices":[
        {
            "value": 100,
            "currency": "pln"
        },
        {
            "value": 40,
            "currency": "usd"
        }
    ]
}

Musiałem wyciągnąć średnią cenę dla projektu. Najczęściej projekt posiadał 1, 2 lub 3 ceny. Jako, że lubię jednolinkowce, a te do takich zastosowań są niemal idealne to od razu napisałem szybkie wyciągnięcie danych do listy.

Naiwne wyciągnięcie danych

prices = [price["value"] for price in product["prices"]]

Osoby, które nie czują sie mocne w jednolinijkowych strukturach odsyłam do wpisu, w którym wyjasniam to kopleksowo. Pobieramy listę cen spod klucza prices , następnie iterujemy po nich i z każdego elementu pobieramy wartość spod klucza value .

Get zamiast KeyError

Oczywiście znałem już problem KeyError i wiedziałem, że może poja dlatego spróbowałem to zrobić nieco lepiej.

prices = [price["value"] for price in product.get("prices", [])]

Get z domyślną wartością

I tu pojawiło się pierwsze zaskoczenie, bo okazało się, że jeśli produkt nie jest jeszcze w sprzedaży to potrafi wysłać cenę bez wartości.

product = {
    "name": "Robinson Crusoe: Przygoda Na Przeklętej Wyspie",
    "link": "sample_link",
    "prices":[
        {
            "currency": "pln"
        },
    ]
}

Musiałem zatem dodać poprawkę. Niewiele myśląc dodałem najszybszy możliwy fix, czyli get, również w zagnieżdżonym słowniku.

prices = [price.get("value", 0) for price in product.get("prices", [])]

Uważaj na domyślną wartość w słowniku

Skrypt do pobierania cen działał poprawnie aż nagle dostaję informację o błędzie. W teorii wszystko powinno działać, ale to jest praktyka i w praktyce wyszło coś zupełnie innego. Mylnie przyjąłem, że dane zostały wcześniej poprawnie zwalidowane. Może inaczej, dane były zwalidowane ale z błędnymi założeniami. Przyjąłem, że wartość nie jest obowiązkowa i to był błąd. Dlaczego? Zobaczcie jakie dostałem dane

product = {
    "name": "Ubongo gra planszowa",
    "link": "sample_link",
    "prices":[
        {
            "value": None,
            "currency": "pln"
        },
    ]
}

Początkowo ciężko było mi namierzyć błąd, bo pobranie danych działało poprawnie. I skrypt wyłożył się dopiero na etapie liczenia średniej ceny.

avg = sum(prices) / len(prices) if len (prices) else 0  # TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'

Niby wszystko było w porządku. Ten kod przeszedł, ale wyłożył się na późniejszym etapie, bo do listy liczb wpadło coś, co liczbą nie było. I tu właśnie ujawnia się odpowiedź na cały wpis, bo get z domyślną wartością zwróci tę wartośc tylko jeśli danego klucza nie ma słowniku. Przekonałem się o tym boleśnie, dlatego przestrzegam i proszę - uważaj z pobieraniem takich danych. Poprawa wyglądała mniej więcej tak

prices = [price.get("value") or 0 for price in product.get("prices", [])]

Dogrywka z domyślną wartością

Czy to już wszystko? Oczywiście, że nie. Nie minęła godzina i znowu spotkałem ten sam błąd. Tym razem było to jeszcze głupsze. Okazało się, że jeśli dany produkt został wycofany, to zamiast pustej listy pod kluczem prices siedzi None . Przykład takiego produktu możemy zobaczyć niżej

product = {
    "name": "Cry Havoc",
    "link": "sample_link",
    "prices": None,
}

Klucz był, zatem drugi parametr z domyślną wartością nie został zwrócony.

Zdesperowany dograłem ostatnią poprawkę i po wszystkim wyszedł mi taki potwór

prices = [price.get("value") or 0 for price in product.get("prices") or []]

Nie da się tego inaczej określić niż potworem, bo czytelność takiego pobierania w słowniku spadła diametralnie. Gdybym tylko poszedł po rozum do głowy już po pierwszym alarmie i dopisał testy na wszystkie przypadki albo nie szedł na łatwiznę i zrobił to zgodnie z TDD to nie miałbym takich problemów. Dlateg uważaj na metodę get z domyślną wartością w słowniku, bo nigdy nie wiesz czy API nie zwróci Ci czegoś dziwnego.

Nie tylko Ja błądziłem

Początkowo chciałem napisać wpis o ogólnym zastosowaniu słowników, bo moim zdaniem to rewelacyjny sposób przechowywania danych i podczas przeszukiwania zauważyłem, że ktoś miał podobny problem co Ja z cenami. Dlatego właśnie postanowiłem wydzielić to do osobnego wpisu. Prześledźmy zatem podobny tok myślowy ale dla danych, który były w tamtym artykule.

product = {
    "name": "Ubongo gra planszowa",
    "link": "sample_link",
    "offer": {
        {
            "value": "100",
            "currency": "pln"
        },
    }
}

Proste pobranie

Struktura była o tyle czytelniejsza, że była zwrócona jedna, najniższa cena. Podobnie jak wcześniej możemy to zrobić prosto i naiwnie

price = product["offer"]["value"]

Get jest bezpieczniejszy

Po wcześniejszej historii wiemy, że get jest bezpieczniejszy. Zabezpieczmy się od razu na taki przypadek.

price = product.get("offer", {}).get("value")

Get z wartością domyślną jest przydatny

Podobnie jak wcześniej, wartość ceny może być pusta. Najłatwiej załatać to poprzez wartość domyślną

price = product.get("offer", {}).get("value", 0)

Wartość domyślna, kiedy klucz występuje w słowniku

Zapomnieliśmy o przykładzie, kiedy klucz z ceną występuje ale jest tam wartość None . Obsłużymy zatem i to

price = product.get("offer", {}).get("value") or 0

Ponownie błąd z domyślną wartoścą w słowniku

I na koniec zostaje perełka, czyli ten sam przykład co wcześniej. Oferta jest, ale pusta. Rozwiązanie wydaje się proste, zamiast wartości domyślnej przyjmijmy te None i korzystając z or zwróćmy pusty słownik jako alternatywę. I tu pojawia się problem. Jak zrobić to sensownie, żeby to jakoś wyglądało, bo taki zapis jest bardzo nieczytelny. Jest też trudny do odczytania i debugowania. Zresztą zobaczacie sami

(get("offer") or {}).get("value") or 0

Czytelność na pierwszym miejscu

Idąc w myśl idei, że znacznie więcej kodu się czyta niż pisze poprawiłem to tak, żeby działało tak samo ale było czytelniejsze

offer = product.get("offer") or {}
price = offer.get("value") or 0


Wyjaśnienie

Dla osób, które dalej czują się zagubione postaram się zrobić krótkie wyjasnienie.
Get w słowniku pobiera wartość o podanym kluczu. Jeśli danego klucza nie ma w słowniku, to zwraca None. Metoda ta potrafi przyjąć drugi parametr, który może nadpisać domyślnie zwracaną wartość. Problem pojawia się, jeśli klucz jest w słowniku ale w środku jest własnie None. często nie jest to problem, ale w konkretnych sytuacjach może okazać się problematyczne. I tutaj pojawia się kolejna bardzo ciekawa cecha Python, czyli skorzystanie z operatora OR. Przy takiej konstrukcji jeśli pierwsza wartość będzie boolowskim fałszem, czyli np. 0, None, lub właśnie False, to sięgniemy do drugiej wartości. I to właśnie połączenie tych dwóch zachowań daje takie ciekawe rezultaty.

# Operator or
0 or 1  # 1
0 or "Dawid"  # "Dawid"
1 or "Dawid"  # 1
None or "Dawid"  # "Dawid"

# Pobieranie ze słownika
person = {"name": "Dawid"}
person["name"]  # Dawid
person.get("name")  # Dawid
person.get("age")  # None
person.get("age", "brak")  # "brak"

# Połączenie
person.get("age") or "brak"

Podsumowanie

Chciałbym, żeby ten wpis nauczył Was czterech rzeczy. Po pierwsze jeśli ktoś mówi Wam, że coś jest banalne i można zrobić to w godzinę, to zawsze bierzcie na to poprawkę. Po drugie pisanie testów nie jest takie straszne i pisząc zgodnie z TDD możecie uniknąć wielu nieprzewidzianych sytuacji. Po trzecie nigdy nie ufaj w pełni w dane z zewnętrznego źródła. Nigdy nie wiesz, czy na wyjściu danych dostawca przewidział wszystkie przypadki. Wyłap przynajmniej te najbardziej krytyczne. I w końcu najważniejsze, każdą niespodziewaną sytuację i awarię analizujcie. Rozumiem, że czasem każda minuta się liczy i wrzucenie poprawki musi być natychmiastowe, jednak po wrzuceniu takiej poprawki dopiszcie testy na brakujące przypadki, sprawdźcie czy dane zostały na wcześniejszym etapie zwalidowane. Zaoszczędzi Wam to wiele nerwów w przyszłości.

Nie jest tak, że domyślna wartość w słowniku jest zła, powiem nawet że jest bardzo przydatna. Trzeba po prostu korzystać z niej świadomie.

Kwi 15, 2020

Najnowsze wpisy

Zobacz wszystkie