İçeriğe geç
Educora
İleri18 dk17 / 42

Bağlam yöneticileri ve with deyimi

`with` aslında ne yapar: `__enter__` ve `__exit__`, `__exit__` içinde istisnaları ele almak, `contextlib.contextmanager` ile yönetici yazmak ve hazır araçlar `suppress`, `redirect_stdout`, `ExitStack`.

Kendini test et
Bu derste öğreneceklerin
  • with deyiminin __enter__ ve __exit__ metotlarını nasıl çağırdığını açıklamak
  • İstisnaları doğru ele alan bir bağlam yöneticisi sınıfı yazmak
  • @contextmanager ile üreteç tabanlı bir yönetici oluşturmak
  • suppress, redirect_stdout ve ExitStack gibi hazır yöneticileri kullanmak

Bir program bir dosya, veritabanı bağlantısı ya da ağ soketi açar ve yolun yarısında bir şeyler ters gider: bir istisna oluşur ya da fonksiyon erkenden return eder. Kaynak serbest bırakılmazsa dosyalar kilitli kalır, bağlantılar birikir ve veriler diske hiç ulaşmayabilir. with open(...) yazımıyla dosyalar dersinde tanıştın; şimdi içinde ne olduğunu görelim ve önce hazırlanıp sonra mutlaka temizlenmesi gereken her kaynak için kendi bağlam yöneticilerimizi (context manager) yazalım.

with aslında ne yapar

Bağlam yöneticisi, iki metodu olan bir nesnedir. with manager as x: yazımı manager.__enter__() metodunu çağırır ve döndürdüğü değeri x'e bağlar. Sonra blok çalışır. Ardından ne olursa olsun (blok normal biterse, bir return, break ya da istisna olursa) Python manager.__exit__(exc_type, exc, tb) metodunu çağırır. Dosya nesnesi kendi bağlam yöneticisidir ve __exit__ metodu dosyayı kapatır:

Python
with open('notes.txt', 'w', encoding='utf-8') as f:
    f.write('first line\n')
    print('inside:', f.closed)
print('after:', f.closed)

try:
    with open('notes.txt', encoding='utf-8') as f:
        text = f.read()
        raise ValueError('something went wrong')
except ValueError as e:
    print('error:', e)
print('closed even after the error:', f.closed)
▸ Beklenen çıktı
inside: False
after: True
error: something went wrong
closed even after the error: True

Python bir with deyimini kabaca aşağıdaki koda dönüştürür. Dikkat: __exit__ istisnayı alır ve yayılmaya devam edip etmeyeceğine kendisi karar verir:

Python
manager = open('notes.txt', encoding='utf-8')
f = manager.__enter__()
try:
    text = f.read()
except BaseException as e:
    if not manager.__exit__(type(e), e, e.__traceback__):
        raise
else:
    manager.__exit__(None, None, None)
Basitleştirilmiş sürüm: gerçek mekanizma birkaç nadir durumu da hesaba katar.

Bu, aksi hâlde her yerde elle yazman gereken try/finally kalıbının aynısıdır. Bağlam yöneticisi hazırlık ve temizlik mantığını yeniden kullanılabilir tek bir nesnede toplar; kaynağı kullanan kod kısa kalır ve temizliği “unutamaz”.

Kendi bağlam yöneticisi sınıfın

__exit__'in üç argümanı istisnayı tanımlar: türü, istisna nesnesi ve çağrı izi (traceback). Blok normal bittiyse üçü de None olur. Dönüş değeri önemlidir: doğru (true) bir değer istisnayı bastırır, False ya da None ise yayılmasına izin verir. Aşağıdaki yönetici, blok içinde bir şey ters giderse sözlüğü eski hâline getirir; tıpkı küçük bir veritabanı işlemi (transaction) gibi:

Python
class Transaction:
    def __init__(self, account):
        self.account = account

    def __enter__(self):
        self.backup = dict(self.account)
        return self.account

    def __exit__(self, exc_type, exc, tb):
        if exc_type is not None:
            self.account.clear()
            self.account.update(self.backup)
        return False

wallet = {'Aysel': 100, 'Murad': 50}
for amount in (30, 500):
    try:
        with Transaction(wallet) as w:
            w['Aysel'] -= amount
            w['Murad'] += amount
            if w['Aysel'] < 0:
                raise ValueError('not enough money')
    except ValueError as e:
        print('cancelled:', e)
    print(wallet)
▸ Beklenen çıktı
{'Aysel': 70, 'Murad': 80}
cancelled: not enough money
{'Aysel': 70, 'Murad': 80}

İlk transfer (30) gerçekleşti. İkincisi yarıda kesildi: hata fark edildiğinde 500 zaten Aysel'den düşülüp Murad'a eklenmişti, ama __exit__ yedeği geri yükledi. Çağıran kod istisnayı yine de görür, çünkü __exit__ False döndürdü.

contextlib.contextmanager: üreteçten yönetici

Her yönetici için sınıf yazmak yorucudur. **@contextmanager** dekoratörü bir üreteç fonksiyonunu bağlam yöneticisine dönüştürür: yield'den önceki kod __enter__ rolünü üstlenir, yield'in verdiği değer as'e gider, yield'den sonraki kod ise __exit__ rolünü üstlenir. Blokta bir istisna oluşursa üretecin içinde tam yield satırında yeniden fırlatılır; bu yüzden temizlik kodu finally içinde olmalıdır:

Python
from contextlib import contextmanager

@contextmanager
def tag(name):
    print(f'<{name}>')
    try:
        yield name
    finally:
        print(f'</{name}>')

with tag('ul'):
    for item in ['tea', 'plov']:
        with tag('li') as t:
            print(f'  {item} (inside {t})')
▸ Beklenen çıktı
<ul>
<li>
  tea (inside li)
</li>
<li>
  plov (inside li)
</li>
</ul>

Çıktı sırayı gösteriyor: her li, iç içe parantezler gibi ul'un içinde açılıp kapanır. Yöneticilerden giriş sırasının tersiyle çıkılır: son giren ilk çıkar.

Örnek: bir bloğun süresini ölçen yönetici

with bloğunun içindeki kodun kaç milisaniye sürdüğünü yazdıran bir timer(label) bağlam yöneticisi yaz. Blokta istisna oluşsa bile süre yazdırılmalıdır.

Çözümü göster
yield'den önce başlangıç zamanını kaydet: start = time.perf_counter().
perf_counter(), aralık ölçmek için tasarlanmış hassas bir saattir.
yield'i try içine koy ve time.perf_counter() - start farkını finally içinde hesapla; böylece süre bir hatadan sonra da yazdırılır.
Bloğun bir değere ihtiyacı yok; bu yüzden yalnızca yield yazmak yeterlidir ve as kullanılmaz.
Python
import time
from contextlib import contextmanager

@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f'{label}: {elapsed * 1000:.1f} ms')

with timer('sum of squares'):
    total = sum(n * n for n in range(1_000_000))
print(total)
Milisaniye sayısı bilgisayara bağlıdır; bu yüzden her seferinde biraz farklı olur.

Standart kütüphanedeki yararlı yöneticiler

contextlib modülünde birkaç hazır yönetici bulunur. suppress(Error) belirtilen istisnaları yok sayar, redirect_stdout(buffer) ise print çıktısını geçici olarak başka bir akışa yönlendirir:

Python
from contextlib import suppress, redirect_stdout
import io
import os

with suppress(FileNotFoundError):
    os.remove('no-such-file.txt')
print('no crash')

buffer = io.StringIO()
with redirect_stdout(buffer):
    print('this goes into the buffer')
print('captured:', buffer.getvalue().strip())
▸ Beklenen çıktı
no crash
captured: this goes into the buffer

Kaynakların sayısı ancak program çalışırken belli oluyorsa **ExitStack** kullan: istediğin sayıda yöneticiyi toplar ve bloğun sonunda hepsinden ters sırayla çıkar; üçüncü dosya açılamasa bile:

Python
from contextlib import ExitStack

names = ['a.txt', 'b.txt', 'c.txt']
for i, name in enumerate(names):
    with open(name, 'w', encoding='utf-8') as f:
        f.write(f'file {i}\n')

with ExitStack() as stack:
    files = [stack.enter_context(open(n, encoding='utf-8')) for n in names]
    print([f.readline().strip() for f in files])
print(all(f.closed for f in files))
▸ Beklenen çıktı
['file 0', 'file 1', 'file 2']
True

Başka birçok nesne de with ile çalışır. Bir şeyin geçici olarak kurulduğunu gördüğünde bir bağlam yöneticisi ara:

NesneÇıkışta ne olur
open(...)dosya kapatılır
threading.Lock()kilit serbest bırakılır
decimal.localcontext()önceki duyarlık geri yüklenir
tempfile.TemporaryDirectory()klasör içeriğiyle birlikte silinir
unittest.mock.patch(...)asıl nesne yerine konur
sqlite3.connect(...)işlem onaylanır ya da geri alınır (bağlantı kapatılmaz!)
Python
from decimal import Decimal, localcontext

print(Decimal(1) / Decimal(7))
with localcontext() as ctx:
    ctx.prec = 5
    print(Decimal(1) / Decimal(7))
print(Decimal(1) / Decimal(7))
▸ Beklenen çıktı
0.1428571428571428571428571429
0.14286
0.1428571428571428571428571429
Bloğun içinde duyarlık 5 basamaktır; çıkışta varsayılan 28 basamak kendiliğinden geri gelir.
Alıştırma

section(title) bağlam yöneticisini tamamla: girişte == title ==, çıkışta -- end of title -- yazdırsın. Çıkış satırı blokta bir istisna oluştuğunda da yazdırılmalı, istisna ise çağıran koda ulaşmalıdır.

Alıştırma · Python
from contextlib import contextmanager

@contextmanager
def section(title):
    # print '== title ==' on entry and '-- end of title --' on exit, even after an error
    yield

with section('Report'):
    print('all good')

try:
    with section('Import'):
        raise ValueError('bad file')
except ValueError as e:
    print('error:', e)
▸ Beklenen çıktı
== Report ==
all good
-- end of Report --
== Import ==
-- end of Import --
error: bad file
Alıştırma

Ignore(*exceptions) sınıfının __exit__ metodunu düzelt: bloktaki istisna verilen tiplerden biriyse onu bastırsın; aksi hâlde (ve istisna yoksa) False döndürsün.

Alıştırma · Python
class Ignore:
    def __init__(self, *exceptions):
        self.exceptions = exceptions

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        # return True only if the exception is one of self.exceptions
        return False

with Ignore(ZeroDivisionError):
    print(1 / 0)
print('still running')

with Ignore(KeyError, IndexError):
    [][5]
print('done')
▸ Beklenen çıktı
still running
done

Önemli noktalar

  • with m as x, m.__enter__()'ı çağırır (sonuç x'e gider) ve sonunda m.__exit__() her zaman çağrılır.
  • __exit__(exc_type, exc, tb) istisnanın bilgilerini alır; doğru bir değer döndürmek istisnayı bastırır.
  • @contextmanager'da yield'den önceki kod giriş, sonraki kod çıkıştır; temizlik finally içinde olmalıdır.
  • Birden fazla yöneticiden ters sırayla çıkılır; değişken sayıda yönetici için ExitStack vardır.
  • suppress, redirect_stdout, localcontext ve TemporaryDirectory hazır bağlam yöneticileridir.

Kendini test et

10 soru. Her doğru cevap XP kazandırır.

1 / 10
__exit__ metodu ne zaman çağrılır?