Viime blogitekstissä tutustuimme Pythonin tyyppivihjeisiin sekä siihen, miten niitä voidaan käyttää yhdessä dekoraattorien kanssa hyödyntäen Pythonin uusimpia versioita.
Keskityimme tekstissämme ensisijaisesti funktiodekoraattoreihin. Tällä kertaa aiheena ovat erityisesti luokkadekoraattorit. Käsittelimme aiemmassa tekstissä geneeristä ohjelmointia Python tyyppivihjeiden tukemana. Katsotaan ensin, miten luomme Pythonissa yksinkertaisen geneerisen luokkatietorakenteen. Alla on esitetty pinorakenteen toteuttava Stack-luokka:
class Stack[T]: def __init__(self) -> None: self.items: list[T] = [] def push(self, item: T) -> None: self.items.append(item) def pop(self) -> T: return self.items.pop() def empty(self) -> bool: return not self.items
Katsotaan seuraavaksi, miltä koodi näyttäisi tyyppivihjeiden kera:
class Stack[T]: def __init__(self) -> None: self.items: list[T] = [] def push(self, item: T) -> None: self.items.append(item) def pop(self) -> T: return self.items.pop()
Kuten nähdään, uusimpien Python-versioiden ansiosta on helppoa lisätä tyypitys tietorakenteeseen, johon voidaan varastoida mitä tahansa tiettyä tyyppiä edustavia jäseniä. Nyt tämä luokka voidaan initialisoida olioksi, joka tukee jotain vapaavalintaista tyyppiä.
int_stack = Stack[int]() int_stack.push(1) int_stack.push(2) int_stack.pop() # Returns 2 str_stack = Stack[str]() str_stack.push("a") str_stack.push("b") str_stack.pop() # Returns "b"
Kuten kuitenkin olettaa saattaa, seuraava koodi ei läpäise tyyppitarkistinta:
int_stack = Stack[int]() int_stack.push(1) int_stack.push("a") # Error!
Luokkadekoraattorit
Luokkadekoraattorit tarjoavat tehokkaan tavan lisätä luokkaan toiminnallisuutta uudelleenkäytettävällä tavalla sekä välttäen syvien perintähierarkioiden ongelmat. Ne ovat erityisen hyödyllisiä sovelluksen poikkileikkaavien toiminnallisuuksien, kuten lokituksen sekä validaation, toteuttamiseen ilman luokan itsensä muokkaamista.
Lisäksi luokkadekoraattorit mahdollistavat ehdolliset ajonaikaiset muutokset luokkiin esimerkiksi konfiguraatiomuutosten pohjalta.
Alla on klassinen esimerkki luokkadekoraattorista, joka lokittaa kaikki koristellun luokan metodikutsut ja niiden paluuarvot:
from functools import wraps def preserve_method_type(method): if isinstance(method, staticmethod): method = staticmethod(method) elif isinstance(method, classmethod): method = classmethod(method) return method def make_logged_method(method_name, original_method): # Preserve method metadata with wraps @wraps(original_method) def logged_method(*args, **kwargs): print(f"Calling {method_name} with args: {args}, kwargs: {kwargs}") result = original_method(*args, **kwargs) print(f"{method_name} returned: {result}") return result return preserve_method_type(logged_method) def add_method_logging(cls): for attr_name, method in cls.__dict__.items(): # Wrap only user-defined methods if callable(method) and not attr_name.startswith('__'): # Capture each method's name and function reference wrapped_method = make_logged_method(attr_name, method) # Replace the original method with the wrapped one setattr(cls, attr_name, wrapped_method) return cls
Esimerkkikäyttö:
@add_method_logging class MyClass: def add(self, x: int, y: int) -> int: return x + y def multiply(self, x: int, y: int) -> int: return x * y
Yllä add_method_logging
-dekoraattori iteroi läpi kaikki luokan attribuutit ja luo jokaisesta käyttäjän määrittelemästä metodista lokittavan version. Dekoraattorin ansiosta jokainen luokan metodi lokittaa tästedes argumenttinsa ja paluuarvonsa. Lokitus on nyt helppo kääntää myös päälle ja pois lisäämällä dekoraattoriin esimerkiksi konfiguraatiotarkistus.
Lokituksen lisääminen metodeihin toteutetaan erillisessä make_logged_method
-funktiossa, jotta jokaisen alkuperäisen metodin nimi ja referenssi säilytetään oikein. Jos lokituksen lisääminen hoidettaisiin suoraan add_method_logging
-funktion for
-silmukassa, tuloksena olisi bugi Pythonin myöhäisen muuttujasitomisen (late binding) vuoksi: lokittavien metodien sisällä attr_name
ja method
osoittaisivat aina näiden muuttujien viimeisiin arvoihin, jotka silmukassa on kohdattu.
Miten tähän koodiin sitten lisätään tyyppivihjeet? Kaiken aiemmin oppimamme pohjalta se on suhteellisen suoraviivaista:
from collections.abc import Callable from functools import wraps def preserve_method_type[**P, R](method: Callable[P, R]) -> Callable[P, R]: if isinstance(method, staticmethod): method = staticmethod(method) elif isinstance(method, classmethod): method = classmethod(method) return method def make_logged_method[**P, R](method_name: str, original_method: Callable[P, R]) -> Callable[P, R]: @wraps(original_method) def logged_method(*args: P.args, **kwargs: P.kwargs) -> R: print(f"Calling {method_name} with args: {args}, kwargs: {kwargs}") result = original_method(*args, **kwargs) print(f"{method_name} returned: {result}") return result return preserve_method_type(logged_method) def add_method_logging[T](cls: type[T]) -> type[T]: for attr_name, method in cls.__dict__.items(): # Wrap only user-defined methods if callable(method) and not attr_name.startswith('__'): # Capture each method's name and function reference wrapped_method = make_logged_method(attr_name, method) # Replace the original method with the wrapped one setattr(cls, attr_name, wrapped_method) return cls
Ainoa uusi konsepti, joka yllä on esitelty, on type
-tyyppivihje. Sen ansiosta voidaan asettaa vaatimus, että add_method_logging
-funktion argumentti pitää olla mielivaltaista tyyppiä T
edustava luokka, ei objekti. Näin funktiolle voidaan määritellä geneerinen luokkatyyppiparameteri.
Nyt minkä tahansa luokan metodeihin voidaan dekoraattorin ansiosta lisätä lokitus. Kaikki luokan metodien tyyppimäärittelyt myös säilytetään geneerisesti ja tyyppiturvallisesti.
Luokkien laajentaminen
Toinen yleinen käyttötapaus luokkadekoraattoreille on lisätä ominaisuuksia toisesta luokasta toiseen. Alla on esitetty esimerkki luokkadekoraattorista, joka voi ottaa minkä tahansa luokan argumenttinaan ja lisätä sen attribuutit ja metodit mihin tahansa koristeltuun luokkaan.
Tämän lähestymistavan etu verrattna perimiseen on välttää tiukkaa kytköstä (tight coupling) luokkien välillä sekä monimutkaisia luokkahierarkioita erityisesti tilanteessa, jossa sama toiminnallisuus pitää lisätä useampaan luokkaan. Tämä mahdollistaa myös ominaisuuksien lisäämisen perustuen ajonaikaisiin ehtoihin, kuten lokitustasoihin.
def extend_with(extension_class): def extend_class(target_class): # Add each attribute from the extension_class to the target_class for attr_name, attr_value in vars(extension_class).items(): # Skip special methods and private attributes if not attr_name.startswith("__"): setattr(target_class, attr_name, attr_value) return target_class return extend_class # Example extension class class Logger: def log(self, message): print(f"[LOG]: {message}") def error(self, message): print(f"[ERROR]: {message}") # Example target class @extend_with(Logger) class Database: def connect(self): self.log("Connecting to the database...") try: # Simulate database connection logic pass except Exception as e: # Replace with more accurate error type self.error(f"Failed to connect to database, error: {e}") return self.log("Connected to database.") # Usage db = Database() db.connect()
Yllä extend_with
on dekoraattoritehdas (decorator factory), joka ottaa extension_class
-argumentin ja palauttaa dekoraattorin nimeltä extend_class
. Tätä palautettua dekoraattoria käytetään koristamaan puolestaan target_class
-luokka, jolloin se lisää siihen extension_class
-luokan metodit ja attribuutit. Toisin sanoen extend_with(SomeClass)
tuottaa extend_class
-dekoraattorin, jolla on pääsy SomeClass
-luokkaan ulommasta nimiavaruudestaan extension_class
-muuttujan kautta.
Kun extend_with
-dekoraattoritehtaalla koristellaan koodissamme Database
-luokka, annamme tehtaalle argumentiksi Logger
-luokan. Tuloksena syntyvä dekoraattori extend_class
lisää Logger
-luokan metodit ja attribuuttit Database
-luokkaan.
Kuinka tähän koodiin lisätään sitten tyyppivihjeet? Ratkaisu ei ole yksinkertaisin, mutta se näyttää monimutkaisemmalta kuin onkaan:
from typing import Callable def extend_with[T, U](extension_class: type[U]) -> Callable[[type[T]], type[T]]: def extend_class(target_class: type[T]) -> type[T]: # Add each attribute from the extension_class to the target_class for attr_name, attr_value in vars(extension_class).items(): # Skip special methods and private attributes if not attr_name.startswith("__"): setattr(target_class, attr_name, attr_value) return target_class return extend_class class Logger: def log(self, message: str) -> None: print(f"[LOG]: {message}") def error(self, message: str) -> None: print(f"[ERROR]: {message}") @extend_with(Logger) class Database: def connect(self) -> None: self.log("Connecting to the database...") try: # Simulate database connection logic pass except Exception as e: # Replace with more accurate error type self.error(f"Failed to connect to database, error: {e}") return self.log("Connected to database.")
Tässä esimerkissä käytämme geneeristä type
-tyyppivihjettä viittaamaan mihin tahansa mielivaltaiseen luokkaan. Dekoraattoritehdas extend_with
on tyypitetty ottamaan jokin luokka (extension_class
) argumenttinaan sekä palauttamaan Callable
-olio, joka on tässä tapauksessa dekoraattori extend_class
. Parametrin extension_class
tyypiksi on merkitty type[U]
, mikä tarkoittaa, että se voi olla mikä tahansa tuntematon luokka.
Palautettava Callable
-olio ottaa puolestaan argumenttinaan T
-tyyppisen luokan ja palauttaa samantyyppisen luokan. Tätä heijastelee myös extend_class
-dekoraattorin tyypitys: sen argumentti target_class
on tyypin T
luokka samoin kuin sen paluuarvokin. Tällä tavoin extension_class
ja target_class
voivat edustaa eri luokkatyyppejä (U
and T
) samalla, kun varmistamme, että dekoraattori palauttaa aina samantyyppisen luokan kuin target_class
, vain laajennetulla toiminnallisuudella.
Silti kun tyypitys ajetaan tyyppitarkistimen läpi, saamme virheen:
error: "Database" has no attribute "log" [attr-defined]
Syy on suoraviivainen: Pythonin tyyppijärjestelmä ei voi mitenkään tietää, että Database
-luokkaan on lisätty uusia metodeja ajonaikaisesti. Se tunnistaa luokan metodiksi vain alkuperäisen connect
-metodin.
Voimme korjata tämän lisäämällä tynkämetodeja (stub methods) Database
-luokkaan:
@extend_with(Logger) class Database: def log(self, message: str) -> None: ... def error(self, message: str) -> None: ... # ...
Tällä lähestymistavalla esimerkkimme läpäisee tyyppitarkistimen ilman ongelmia. Erillisten tyyppimääritelmien kirjoittaminen jokaiselle laajennettavaan luokkaan lisätylle attribuutille ja metodille voi tietysti tuntua ylimääräiseltä työltä. Asiaa kannattaa kuitenkin miettiä näin: koska lisäämme attribuutteja ja metodeja ajonaikaisesti, staattisella tyyppijärjestelmällä ei voi olla mitään tapaa ottaa niitä automaattisesti huomioon. Kun kirjoitamme tyyppimääritykset eksplisiittisesti, kuromme umpeen aukon dynaamisen ajonaikaisen käytöksen sekä staattisen tyyppitarkistuksen välillä. Tämä varmistaa tyyppiturvallisuuden sekä selkeyttää koodia
Yhteenveto
Kaksiosaisessa blogisarjassamme olemme nyt käsitelleen, miten Python-tyyppivihjeet voidaan lisätä sekä funktio- että luokkadekoraattoreihin. Tällä kertaa keskityimme luokkadekoraattoreihin. Lähdimme liikkeelle geneerisistä luokista sekä demonstroimme myös, miten geneerisillä tyyppiparametreilla voi tyypittää funktioita, jotka ottavat vastaan mielivaltaisia luokkia. Samalla esittelimme esimerkkidekoraattorin, joka lisää toiminnallisuutta jokaiseen koristellun luokan metodiin. Lopuksi tutkimme, miten lisätään mahdollisimman tyyppiturvallinen tyypitys luokkadekoraattoriin, joka laajentaa mitä tahansa luokkaa toisen luokan attribuuteilla ja metodeilla. Tässä yhteydessä käsittelimme myös tynkämetodeja, joiden ansiosta dekoraattorin dynaaminen ajonaikainen käytös voidaan huomioida staattisessa tyyppitarkistuksessa.
Koodiesimerkit löytyvät tämän linkin takaa.
Artikkelin ovat kirjoittaneet Buutin konsultti Casper Van Mourik ja CTO Miikka Salmi.