Python context managers: with sakiniai

Kas tie context managers ir kodėl jie iš viso reikalingi

Programuojant Python’u, nuolat tenka dirbti su resursais – failais, tinklo jungtimis, duomenų bazių sesijomis. Problema ta, kad šiuos resursus reikia ne tik atidaryti, bet ir tinkamai uždaryti. Užmiršai uždaryti failą? Sveiki sulaukę atminties nutekėjimų ir keistų klaidų, kurias sunku atkartoti.

Context managers – tai Python’o mechanizmas, kuris leidžia automatiškai valdyti resursus. Jie užtikrina, kad nepriklausomai nuo to, kas vyksta kodo bloke (net jei įvyksta klaida), resursai bus tinkamai atlaisvinami. Skamba abstrakčiai? Pažiūrėkime į konkretų pavyzdį.

Senoviniu būdu failą atidaryti ir uždaryti atrodytų maždaug taip:


file = open('duomenys.txt', 'r')
try:
content = file.read()
# darykite ką nors su content
finally:
file.close()

Veikia, bet šlykštu. Daug bereikalingo kodo, lengva suklysti. O dabar su context manager:


with open('duomenys.txt', 'r') as file:
content = file.read()
# darykite ką nors su content

Failas automatiškai užsidarys, kai išeisime iš with bloko. Net jei viduje įvyks klaida, failas vis tiek bus uždarytas. Štai čia ir slypi context managers magija.

Kaip veikia with sakinys po gaubtu

Kai Python’as mato with sakinį, jis iš tikrųjų atlieka gana aiškią operacijų seką. Pirmiausia iškviečiamas objekto __enter__() metodas – tai įvyksta prieš pradedant vykdyti kodo bloką. Šis metodas gali grąžinti reikšmę, kuri bus priskirta kintamajam po as žodžio.

Kai kodas bloke baigiasi (normaliai arba dėl klaidos), Python’as iškviečia __exit__() metodą. Šis metodas gauna tris argumentus: išimties tipą, išimties reikšmę ir traceback objektą. Jei klaidos nebuvo, visi trys bus None.

Štai paprastas pavyzdys, kaip tai atrodo iš vidaus:


class ManoContextManager:
def __enter__(self):
print("Įeiname į kontekstą")
return self

def __exit__(self, exc_type, exc_value, traceback):
print("Išeiname iš konteksto")
if exc_type is not None:
print(f"Įvyko klaida: {exc_value}")
return False # False reiškia, kad klaida nebus nutildyta

with ManoContextManager():
print("Esame kontekste")
# raise Exception("Kažkas nutiko!")

Svarbu suprasti, kad __exit__() metodas visada bus iškviečiamas. Tai garantija, kurią Python’as duoda. Net jei jūsų kodas sprogtų į šipulius, valymo darbai bus atlikti.

Praktiniai panaudojimo scenarijai

Failai – tai tik ledkalnio viršūnė. Context managers puikiai tinka daugeliui situacijų, kur reikia užtikrinti „setup” ir „cleanup” operacijas.

Duomenų bazių transakcijos – klasikinis pavyzdys. Norite, kad arba visi pakeitimai būtų išsaugoti, arba jei kas nors nepavyksta, viskas būtų atšaukta:


with database.transaction():
database.insert_user(user_data)
database.update_statistics()
# Jei čia įvyks klaida, visa transakcija bus atšaukta

Laikini failo pakeitimai. Kartais reikia laikinai pakeisti esamą failą, o paskui grąžinti originalą:


from contextlib import contextmanager
import shutil
import os

@contextmanager
def temporary_file_replacement(filepath):
backup = filepath + '.backup'
shutil.copy2(filepath, backup)
try:
yield filepath
except:
shutil.copy2(backup, filepath)
raise
finally:
os.remove(backup)

with temporary_file_replacement('config.ini') as config:
# Modifikuokite failą
# Jei kas nors nepavyks, originalas bus atstatytas
pass

Laikmačiai ir profiliavimas. Norite išmatuoti, kiek laiko užtrunka kodo blokas?


import time

class Timer:
def __enter__(self):
self.start = time.time()
return self

def __exit__(self, *args):
self.end = time.time()
self.duration = self.end - self.start
print(f"Užtruko: {self.duration:.4f} sekundžių")

with Timer():
# Kažkoks lėtas kodas
time.sleep(2)

Contextlib biblioteka – jūsų naujas geriausias draugas

Python’o standartinė biblioteka turi contextlib modulį, kuris daro context managers kūrimą dar paprastesnį. Svarbiausia žvaigždė čia – @contextmanager dekoratorius.

Vietoj to, kad rašytumėte klasę su __enter__ ir __exit__ metodais, galite tiesiog parašyti generatorių:


from contextlib import contextmanager

@contextmanager
def managed_resource():
print("Atidarome resursą")
resource = acquire_resource()
try:
yield resource
finally:
print("Uždarome resursą")
release_resource(resource)

with managed_resource() as res:
# Naudokite resursą
pass

Esmė ta, kad yield žodis veikia kaip riba tarp „setup” ir „cleanup” fazių. Viskas prieš yield vyksta įeinant į kontekstą, o kas po finally – išeinant.

contextlib.suppress – dar vienas naudingas įrankis, kai norite ignoruoti konkrečias išimtis:


from contextlib import suppress

with suppress(FileNotFoundError):
os.remove('neegzistuojantis_failas.txt')
# Jei failo nėra, tiesiog tęsiame toliau

Arba contextlib.closing, kai objektas turi close() metodą, bet nėra context manager:


from contextlib import closing
from urllib.request import urlopen

with closing(urlopen('http://example.com')) as page:
content = page.read()

Keli context managers viename sakinyje

Python leidžia naudoti kelis context managers viename with sakinyje. Tai ypač patogu, kai reikia dirbti su keliais failais ar resursais vienu metu:


with open('input.txt', 'r') as infile, open('output.txt', 'w') as outfile:
for line in infile:
outfile.write(line.upper())

Svarbu žinoti, kad context managers bus įjungti iš kairės į dešinę, o išjungti – atvirkščia tvarka (kaip stack’as). Tai logiška, nes vėliau atidaryti resursai dažnai priklauso nuo anksčiau atidarytų.

Nuo Python 3.10 galite naudoti ir skliaustus, jei turite daug context managers:


with (
open('file1.txt') as f1,
open('file2.txt') as f2,
open('file3.txt') as f3,
):
# Dirbkite su failais
pass

Asinchroniniai context managers

Jei dirbate su async/await sintakse, Python’as turi ir asinchroninius context managers. Jie veikia panašiai, tik naudoja async with sintaksę ir __aenter__ bei __aexit__ metodus:


class AsyncResource:
async def __aenter__(self):
print("Asinchroniškai atidarome")
await asyncio.sleep(1)
return self

async def __aexit__(self, exc_type, exc_val, exc_tb):
print("Asinchroniškai uždarome")
await asyncio.sleep(1)

async def main():
async with AsyncResource() as resource:
print("Naudojame resursą")

asyncio.run(main())

Tai ypač naudinga dirbant su asinchroninėmis duomenų bazių jungtimis, HTTP klientais ar kitais I/O resursais, kur norite išvengti blokavimo.

contextlib biblioteka taip pat palaiko asinchroninius context managers per @asynccontextmanager dekoratorių:


from contextlib import asynccontextmanager

@asynccontextmanager
async def async_managed():
await setup()
try:
yield
finally:
await cleanup()

Dažniausios klaidos ir kaip jų išvengti

Viena iš dažniausių klaidų – manyti, kad __exit__ metodas automatiškai nuslopins išimtis. Pagal nutylėjimą, jei __exit__ grąžina False arba None, išimtis bus perduota toliau. Jei norite ją nuslopinti, turite grąžinti True:


def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is ValueError:
print("Sugavome ValueError, ignoruojame")
return True # Nuslopina išimtį
return False # Kitos išimtys bus perduotos toliau

Kita problema – bandymas naudoti context manager rezultatą už jo ribų:


with open('failas.txt') as f:
content = f.read()

# Čia failas jau uždarytas!
# f.read() # Tai sukels ValueError: I/O operation on closed file

Jei reikia duomenų už context manager ribų, išsaugokite juos kintamajame viduje bloko.

Dar viena subtili klaida – užmiršti, kad yield dekoruotame context manager’yje turi būti tik vienas:


@contextmanager
def wrong_manager():
yield 1
yield 2 # Klaida! Tik vienas yield leidžiamas

Kada kurti savo context managers ir kada ne

Ne viskas turi būti context manager. Jei jūsų objektas tiesiog atlieka paprastą operaciją be jokio „cleanup”, context manager greičiausiai perteklinis.

Kurkite context manager, kai:
– Reikia garantuoti resursų atlaisvinimą
– Turite aiškią „setup” ir „teardown” logiką
– Norite užtikrinti, kad tam tikros operacijos visada įvyktų kartu
– Reikia laikino būsenos pakeitimo (pvz., laikinai pakeisti working directory)

Nekurkite context manager, kai:
– Objektas tiesiog atlieka paprastą funkciją
– Nėra jokių resursų, kuriuos reikėtų valyti
– Logika per daug sudėtinga ir context manager tik apsunkina supratimą

Pavyzdžiui, štai geras context manager panaudojimas – laikinas direktorijos pakeitimas:


import os
from contextlib import contextmanager

@contextmanager
def change_dir(path):
old_dir = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(old_dir)

with change_dir('/tmp'):
# Darome operacijas /tmp direktorijoje
pass
# Automatiškai grįžtame į seną direktoriją

O štai blogas pavyzdys – kai context manager nieko neduoda:


# Neverta daryti
@contextmanager
def print_hello():
yield
print("Hello")

# Geriau tiesiog:
def print_hello():
print("Hello")

Ką reikia žinoti apie with sakinius realiam gyvenimui

Context managers – tai ne tik elegantiškas būdas rašyti kodą, bet ir praktiškas įrankis, kuris padeda išvengti subtilių klaidų. Failai, duomenų bazių jungtys, lock’ai daugiagijėse programose – visur, kur reikia garantuoti, kad resursai bus tinkamai valdomi, with sakinys yra jūsų draugas.

Pradėkite naudoti context managers ten, kur jau naudojate try/finally blokus. Dažniausiai tai reiškia, kad galite supaprastinti kodą. Jei rašote biblioteką ar framework’ą, pagalvokite, ar jūsų objektai neturėtų būti context managers – tai labai pagerina API patogumą.

Nepamirškite contextlib modulio – jis dažnai leidžia išspręsti problemą per kelias eilutes, vietoj to, kad rašytumėte visą klasę. Ypač @contextmanager dekoratorius yra nepakeičiamas greito prototipavimo metu.

Ir paskutinis patarimas – skaitydami svetimą kodą, atkreipkite dėmesį į tai, kaip naudojami context managers. Tai dažnai atskleidžia, kaip autorius galvojo apie resursų valdymą ir klaidas. Geras kodas naudoja context managers nuosekliai ir prasmingai, o ne tiesiog todėl, kad „taip reikia”.

Daugiau

Elasticsearch Logstash pipeline: logų apdorojimas