Tyyppivihjeet ovat Pythonin ominaisuus, jotka mahdollistavat odotettujen datatyyppien määrittelyn koodissa esiintyville tietotyypeille, muuttujille, argumenteille sekä paluuarvoille. Dekoraattorit puolestaan sallivat funktioiden, metodien ja luokkien käytöksen muokkaamisen ja laajentamisen ilman niiden varsinaiseen koodin koskemista. Olemme kirjoittaneet perusteellisesti aiemminkin Python-dekoraattoreista:
Making Your Life Easier with Python Decorators
Python Decorators: Overcoming Common Misconceptions
Tässä blogitekstissä tutkimme, kuinka voimme yhdistää nämä kaksi konseptia tehokkaasti käyttämällä Pythonin uusimpia ominaisuuksia. Blogisarjan ensimmäisessä osassa esittelemme, miten tyyppivihjeitä lisätään funktiodekoraattoreihin, kun taas seuraavassa osassa keskitymme luokkadekoraattoreihin. Kaikki esimerkkimme on testattu Python 3.13:lla sekä mypy 1.13.0:lla.
Tyyppivihjeiden hyödyt
Python on kaikkinensa dynaaminen ja ns. ankkatyyppitetty (duck-typed) ohjelmointikieli. Se ei siis pakota tiukkoja tyyppisääntöjä kääntäessä vaan tarkistaa tyypit ajon aikana. Ankkatyypitys tarkoittaa erityisesti, että objektin tyyppi määräytyy sen käytöksen perusteella – eli siltä pohjalta mitä metodeja ja attribuutteja se tukee – kuin niinkään sen luokan pohjalta. Kuten sanonta kuuluu: “Jos se näyttää ankalta, kaakattaa kuin ankka, se on ankka.”
Tämä joustavuus tekee Pythonin kanssa työskentelystä helppoa, minkä vuoksi monet ihmiset pitävät kielestä, erityisesti pienemmissä projekteissa. Kuitenkin suuria järjestelmiä rakentaessa Python-koodista voi tulla nopeasti vaikeasti seurattavaa ja debugattavaa ilman staatisen kielen tiukkoja rajoitteita. Tästä ongelmaa tyyppivihjeet ratkovat.
PEP 484 -kehitysehdotuksessa esitellyt tyyppivihjeet parantavat koodin luettavuutta, helpottavat aikaista bugien havaitsemista sekä sujuvoittavat ohjelmointityötä IDE:ien, linttereiden sekä mypyn kaltaisten staattisten tyyppitarkastimien kanssa. Tyyppivihjeiden oikeellisuutta ei pakoteta ajonaikaisesti, mutta edellä mainittuja työkaluilla on mahdollista tarkistaa ja varmistaa, että koodi noudattaa odotettuja tyyppejä.
Yksinkertainen esimerkki Python-tyyppivihjeistä:
def calculate_total(price: float, tax_rate: float) -> float: return price * (1 + tax_rate)
Argumenttien price
– ja tax_rate
tyypit esitellään kaksoispisteen jälkeen. Funktion paluuarvon tyyppi puolestaan määritellään nuolen (->) jälkeen. Tämän funktion tapauksessa kaikki tyypit ovat liukulukuja (float
).
Tyyppivihjeillä on myös itsedokumentoiva tarkoitus. Alla on validaatiofunktio, joka validoi joukon tuotehintoja. Se ottaa hinnat sanakirjana ja varmistaa, että jokaisella tuotteella sanakirjassa on positiivinen hinta. Ilman tyyppivihjeitä sisääntulevan datan muoto pitäisi päätellä funktiokoodista.
class ValidationError(Exception): pass def validate_prices(prices): for item_id, price in prices.items(): if price < 0: raise ValidationError(f"Invalid price for {item_id}: Price cannot be negative.")
Tyyppivihjeiden myötä prices-argumentin sisältö on välittömästi selvä lukijalle:
def validate_prices(prices: dict[str, int | float]) -> None: for item_id, price in prices.items(): if price <= 0: raise ValidationError(f"Invalid price for {item_id}: Price cannot be negative.")
Sen lisäksi, että tyyppivihjeet kertovat sanakirja-avaimien olevan merkkijonoja, ne dokumentoivat, että arvot voivat puolestaan olla kokonais- tai liukulukuja.
Katsotaan seuraavaksi, miten tyyppivihjeet auttavat korjaamaan bugeja ajoissa käyttämällä alla olevaa funktiota esimerkkinä.
def register_user(user): username = user["username"] email = user["email"] age = user["age"] # Register the user here
Tämän funktion tapauksessa voimme kohdata monenlaisia ajonaikaisia virheitä, kuten epäyhteensopivia tyyppejä, puuttuvia vaadittuja kenttiä tai odottamattomia tietorakenteita. Korjataan asia tyyppivihjeitä hyödyntäen:
from typing import TypedDict class User(TypedDict): username: str email: str age: int def register_user(user: User) -> None: username = user["username"] email = user["email"] age = user["age"] # Register the user here user = User( username="mickey28", email="mickey.mouse@gmail.com", age=95 ) register_user(user)
Nyt jos käytämme mypyä projektissamme, se raportoi edellä mainitun kaltaisista virheistä. Lisäämällä mypy-tarkistuksen osaksi projektin CI/CD-putkea varmistammekin, että sellaisia bugeja ei pääse yhtä helposti tuotantoon. Kaiken lisäksi koodi dokumentoi nyt selkeästi user
-argumentin oletetun muodon.
Geneerinen lähestymistapa
Tyyppivihjeet tukevat myös geneeristä ohjelmointia (generics). Se sallii sellaisten funktioiden ja luokkien kirjoittamisen, jotka pystyvät käsittelemään useita eri tyyppejä. Geneerinen ohjelmointi on erityisen kätevää, kun koodin pitäisi toimia minkä tahansa tyypin kanssa ilman, että tyyppiturvallisuutta uhrataan.
Alla on yksinkertainen esimerkki kahdesta funktiosta. Ensimmäisen funktion tyyppivihjeet kertovat sen hyväksyvän vain listan kokonaislukuja ja palauttavan kokonaisluvun. Toinen funktio on geneerinen ja se sallii listan minkä tahansa mielivaltaisen tyypin elementtejä. Se myös palauttaa olion, joka edustaa tuota samaa tyyppiä.
# Non-generic function def get_first_int(elements: list[int]) -> int: return elements[0] # Generic function def get_first[T](elements: list[T]) -> T: return elements[0] print(get_first_int([1, 2, 3])) # Works print(get_first([1, 2, 3])) # Works print(get_first_int(["a", "b", "c"])) # mypy error print(get_first(["a", "b", "c"])) # Works
T
on esimerkissämme tyyppimuuttuja (TypeVar
), joka edustaa jotakin tuntematonta tyyppiä. Mikä tahansa muukin muuttujanimi kävisi, kuten U
tai MyType
, mutta T
:tä käytetään yleisenä käytäntönä, kun tuntemattomia tyyppejä on koodissa vain yksi.
Geneerinen ohjelmointi on hyvin hyödyllistä muun muassa yleiskäyttöisten utiliteettifunktioiden ja -tietorakenteiden yhteydessä. Seuraavassa monimutkaisemmassa esimerkissä käytetään Callable
-tyyppivihjettä. Se edustaa mitä tahansa funktiota (tai kutsuttavaa oliota), jolla on tietyntyyppiset parametrit ja paluuarvo.
from collections.abc import Callable def filter_map[T, U](elements: list[T], condition: Callable[[T], bool], transform: Callable[[T], U]) -> list[U]: result: list[U] = [] for element in elements: if condition(element): result.append(transform(element)) return result # Example usage numbers = [1, 2, 3, 4, 5, 6] even_doubled = filter_map(numbers, lambda x: x % 2 == 0, lambda x: x * 2) print(even_doubled) # Output: [4, 8, 12]
Ylläoleva filter_map
-funktio muuntaa listan olioita listaksi (potentiaalisesti) toisentyyppisiä olioita hyödyntäen sille annettua transformaatiofunktiota. Vain ne oliot muunnetaan, jotka läpäisevät annetun ehtofunktion asettaman ehdon. Muut oliot alkuperäisestä listasta hylätään.
Kun tämä funktio ajetaan mypyn läpi, tyyppivihjeet varmistavat, että sille annetut funktioargumentit, condition
ja transform
, vastaavat niille asetettuja tyyppivaatimuksia. Geneerisen condition
-funktion kuuluu hyväksyä tuntematonta T
-tyyppiä edustava argumentti ja palauttaa totuusarvo, kun taas transform
-funktion kuuluu ottaa myös T
-tyyppiä edustava olio ja palauttaa jotain toista tyyppiä U
edustava olio.
Kun jokin olio merkitään Callable
-tyyppiseksi, sen parametrien tyypit määritellään hakasulkujen sisällä. Koska sekä condition
että transform
ottavat vastaan vain yhden parametrin, joka edustaa tyyppiä T
, käytetään niiden tapauksessa merkintää [T]
.
Entä dekoraattorit?
Dekoraattorit ovat funktioita, jotka ottavat toisen funktion argumenttinaan ja palauttavat uuden funktion. Ne ovat pohjimmiltaan wrappereita, jotka voivat lisätä toiminnallisuutta olemassaolevaan funktioon, metodiin tai luokkaan siististi ja uudelleenkäytettävästi. Dekoraattorit ovat erityisen hyödyllisiä läpileikkaavien tehtävien, kuten lokittamisen, pääsyoikeuksien, validoinnin, toteuttamiseen. Alla on esimerkki Python-dekoraattorista, joka mittaa funktion suorittamisajan:
import time def measure_execution_time(func): def wrapper(*args, **kwargs): start_time = time.time() result = func(*args, **kwargs) execution_time = time.time() - start_time print(f"{func.__name__} executed in {execution_time:.4f} s") return result return wrapper @measure_execution_time def slow_function(): time.sleep(1) slow_function()
Koodin pitäisi tulostaa jotain tämänkaltaista:
slow_function executed in 1.0002 seconds
Nyt kun tiedämme, että tyyppivihjeet voivat olla varsin hyödyllisiä, miten ne toimivat yhdessä dekoraattorien kanssa? Käytetään edellistä dekoraattoria esimerkkinä:
import time from collections.abc import Callable from functools import wraps def measure_execution_time[**P, R](func: Callable[P, R]) -> Callable[P, R]: # Preserve function metadata with wraps @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: start_time = time.time() result = func(*args, **kwargs) execution_time = time.time() - start_time print(f"{func.__name__} executed in {execution_time:.4f} s") return result return wrapper @measure_execution_time def slow_function() -> None: time.sleep(1)
Notaatio **P
saattaa tässä tapauksessa aiheuttaa hämmennystä. P
on parametrispesifikaatio (ParamSpec
). **P
-syntaksi kertoo Pythonille, että P
sisältää funktion kaikkien parametrien tyypit, niin paikkasidonnaisten kuin avainsana-argumenttienkin.
Koska P
esittää automaattisesti koko func
-funktion parametrilistaa, hakasulut sen ympärillä ovat tarpeettomat, kun käytetään Callable
-tyyppivihjettä. R
taas on vain normaali geneerinen tyyppimuuttuja, joka edustaa func
-funktion paluutyyppiä.
Parametrispesifikaatiosyntaksi mahdollistaa sellaisten funktioiden tyyppiturvallisen tyypittämisen, jotka kutsuvat toisia funktioita samoilla mielivaltaisilla parametreillä. Nyt voimme viitata sekä wrapper
– että func
-funktioiden paikkasidonnaisiin argumentteihin P.args
-merkinnällä sekä avainsana-argumentteihin P.kwargs
-merkinnällä.
Argumenttien välittäminen
Hyvin yleinen käyttötapaus dekoraattoreille on välittää argumentteja dekoraattorin käärimälle funktiolle. Tällaisia argumentteja ovat muun muassa erilaiset kontekstioliot. Seuraavassa esimerkissä dekoraattori välittää UserContext
-olion mille tahansa kääritylle funktiolle automaattisesti, jotta koodarin ei tarvitse välittää sitä itse joka kerta, kun hän kutsuu funktiota. Esimerkissä oletetaan, että koodissa on käytettävissä get_user_context
-funktio voidaan importata context
-moduulista ja että se palauttaa seuraavantyyppisen olion:
from typing import TypedDict class UserContext(TypedDict): user_id: int username: str role: UserRole # This is just some enum
Itse dekoraattori:
from functools import wraps def add_user_context(func): @wraps(func) def wrapper(*args, **kwargs): return func(get_user_context(), *args, **kwargs) return wrapper
Esimerkkikäyttöä:
@add_user_context def greet_user(user_context): return f"Hello, User {user_context['username']}. Your role is {user_context['role']}." @add_user_context def process_order(user_context, quantity): return f"Processing order {order_id} of quantity {quantity} for user {user_context['user_id']}." print(greet_user()) print(process_order(10, 5)) print(process_order(order_id=10, quantity=5))
Äkkiseltään voisi olettaa, että tämän dekoraattorin voisi tyypittää samoin kuin aiemmin:
from collections.abc import Callable from functools import wraps def add_user_context[**P, R](func: Callable[P, R]) -> Callable[P, R]: @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return func(get_user_context(), *args, **kwargs) return wrapper
Jos nyt ajamme koodin käärittyjen funktioiden kanssa, saamme seuraavan virheen:
error: Argument 1 has incompatible type "UserContext"; expected "P.args" [arg-type]
Tämä johtuu siitä, että ensimmäisessä return
-lauseessa välitimme func
-funktiolle odottamattoman UserContext
-olion ennen *args
-argumentteja. Funktion func
tyyppimäärittelyssä parametreiksi on määritelty vain P
, joka kuvastaa kaikkia *args-
ja **kwargs
-argumenttien tyyppejä. Meidän täytyykin muokata määrittelyä Callable[P, R]
ottamaan UserContext
-olio huomioon.
from collections.abc import Callable from functools import wraps from typing import Concatenate def add_user_context[**P, R](func: Callable[Concatenate[UserContext, P], R]) -> Callable[P, R]: @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return func(get_user_context(), *args, **kwargs) return wrapper
Concatenate
-operaattoria voidaan käyttää Callable
– ja ParamSpec
-määritysten yhteydessä tyyppimäärittelemään sellainen dekoraattori, joka lisää, poistaa tai muuntaa toisen funktion parametreja. Tässä tapauksessa add_user_context
-dekoraattori vaatii Callable
-argumentin func
, joka ottaa ensimmäisenä argumenttinaan UserContext
-olion sekä listan joitain muita parametreja P
. Lisäksi func
palauttaa tyypin R
olion. Dekoraattori itse puolestaan palauttaa uuden Callable
-olion, joka ottaa vastaan vain parametrit P
(ilman UserContext
-oliota) ja palauttaa tyypin R
olion. Nyt esimerkki on tyyppiturvallinen ja läpäisee tyyppitarkistimen.
Ratkaisua voidaan vielä selkeyttää käyttämällä tyyppi-aliasta func
-argumentin tyypille.
from collections.abc import Callable from functools import wraps from typing import Concatenate type CallableExpectingUserContext[**P, R] = Callable[Concatenate[UserContext, P], R] def add_user_context[**P, R](func: CallableExpectingUserContext[P, R]) -> Callable[P, R]: @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return func(get_user_context(), *args, **kwargs) return wrapper
Näin tyyppi-alias CallableWithUserContext
dokumentoi hieman monimutkaisen Concatenate
-hökötyksen tarkoituksen ja lyhentää add_user_context
-funktion tyyppimäärittelyä.
Yhteenveto
Tässä blogitekstissä käsiteltiin Pythonin tyyppivihjeiden hyötyjä sekä osoitettiin, kuinka niitä voi näppärästi lisätä funktiodekoraattoreihin käyttämällä hyväksi uusimpien Python-versioiden ominaisuuksia. Seuraavassa sarjan osassa keskitymme luokkadekoraattorien tyypittämiseen sekä haasteisiin, joita niiden yhteydessä kohdataan.
Koodiesimerkit löytyvät täältä.
Artikkelin ovat kirjoittaneet Buutin konsultti Casper Van Mourik ja CTO Miikka Salmi.