Ana içeriğe geç
Kesit Python
Orta · Pythonic kod

Kapsam ve kapanışlar (closures) derinlemesine

LEGB kuralını, global ile nonlocal farkını, kapanışın değişkeni nasıl yakaladığını, döngüdeki geç bağlama tuzağını, fabrika fonksiyonlarını, __closure__ hücrelerini ve functools.partial ile ilişkisini iş senaryolarıyla derinlemesine öğrenirsin.

Uygulamada aç, 19 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Neden önemli?

Anadolu Market'in kampanya ekibi her hafta yeni kural tanımlıyor: "500 TL üstüne yüzde 10", "Ankara'ya kargo 30 TL indirimli", "ilk siparişe 50 TL". Ilgaz Bank'ta ise her kart için farklı günlük harcama limiti var. Bu kuralları tek tek if bloklarıyla yazarsan kod büyüdükçe yönetilemez olur. Python'ın cevabı şudur: kuralı bir fonksiyon olarak üret, ayarını içine göm, ihtiyaç olunca çağır. Ayarını içinde taşıyan bu fonksiyona kapanış (closure) denir.

Fonksiyonel araçlar dersinde kapanışla ilk kez tanıştın (sayaç ve komisyon hesaplayıcı yazdın). Bu derste perdenin arkasına geçiyoruz: Python bir adı nerede arar, iç fonksiyon dış değişkeni nasıl "hatırlar", hatırladığı şey değer mi yoksa değişkenin kendisi mi, ve bu farkın döngülerde neden can sıktığı. Bu bilgi dekoratörlerin (decorator), geri çağırma (callback) fonksiyonlarının ve PCAP sınavının 5.3 konusunun temelidir.

LEGB tekrar: bir ad nerede aranır?

Kapsam ve modüller dersinde gördüğün kural: Python bir adı okurken sırayla dört yere bakar ve ilk bulduğunu kullanır.

HarfKapsamÖrnek
LYerel (Local): fonksiyonun kendi adlarıParametreler, fonksiyon içinde atanan adlar
EKapsayan (Enclosing): dıştaki fonksiyonların adlarıİç fonksiyonun gördüğü dış değişken
GGenel (Global): modül düzeyindeki adlarDosyanın en üstünde tanımlanan sabitler
BYerleşik (Built-in): Python'ın hazır adlarılen, sum, print
kdv = 0.20                      # G: genel

def fatura(tutar):              # tutar: L (fatura için)
    kargo = 30                  # L (fatura için)
    def satir(adet):            # adet: L (satir için)
        # kargo E'den, kdv G'den, round B'den gelir
        return round(adet * tutar * (1 + kdv) + kargo, 2)
    return satir(2)

print(fatura(100))

Önemli ayrıntı: Bir adın hangi kapsama ait olduğuna Python derleme anında karar verir, çalışma anında değil. Fonksiyonun gövdesinde bir ada herhangi bir yerde atama yapılıyorsa, o ad fonksiyonun tamamı boyunca yereldir. Bu yüzden aşağıdaki kod, atama satırı okumadan sonra gelse bile hata verir:

toplam = 0

def ekle(tutar):
    try:
        toplam += tutar          # toplam = toplam + tutar: önce okur!
    except UnboundLocalError as h:
        print("Hata:", h)

ekle(50)

toplam += tutar bir atama olduğu için toplam yerel sayılır; okumaya çalıştığında yerel toplam henüz bağlanmamıştır (unbound). Hata adı da buradan gelir: UnboundLocalError.

global ve nonlocal: hangi kutuya yazıyorsun?

Okumak için bildirim gerekmez; atamak istediğin ad yerel değilse Python'a söylemen gerekir. İki anahtar kelime vardır ve farklı kutulara yazar:

global adnonlocal ad
Nereye yazar?Modül düzeyindeki adaEn yakın kapsayan fonksiyondaki ada
Ad önceden var olmalı mı?Hayır, yoksa oluştururEvet, yoksa SyntaxError
Modül düzeyinde kullanılır mı?Anlamsız ama hata değilHayır, SyntaxError
Tipik kullanımNadiren; yapılandırma, önbellekKapanışta sayaç, bakiye, durum
Test edilebilirlikKötü: gizli paylaşılan durumİyi: durum kapanışa özel
sayac = 0                       # genel

def genel_artir():
    global sayac
    sayac += 1

def kasa_ac():
    sayac = 100                 # kasa_ac'a ait, genel sayac'tan bağımsız
    def islem():
        nonlocal sayac          # kasa_ac'taki sayac'a yazar
        sayac += 1
        return sayac
    return islem

genel_artir(); genel_artir()
k = kasa_ac()
print(k(), k())                 # 101 102
print("genel sayac:", sayac)    # 2: nonlocal genele dokunmadı
nonlocal genel değişkeni bulmaznonlocal yalnızca kapsayan fonksiyonlara bakar, modül düzeyine asla çıkmaz. Dış fonksiyonda o ad yoksa kod daha çalışmadan SyntaxError: no binding for nonlocal verir.

Kapanış neyi yakalar: değeri mi, değişkeni mi?

İç fonksiyon dıştaki bir adı kullandığında ve dış fonksiyon bittikten sonra da yaşamaya devam ettiğinde kapanış oluşur. Python o adı bir hücre (cell) nesnesine koyar; dış fonksiyon da iç fonksiyon da aynı hücreyi görür. Yakalanan şey o anki değer değil, hücrenin kendisidir. Hücredeki değer sonradan değişirse iç fonksiyon yeni değeri görür.

def carpan(k):
    def uygula(x):
        return x * k
    k = k + 1                   # dönmeden önce k değişti
    return uygula

c = carpan(3)
print(c(10))                                # 40, 30 değil
print(c.__code__.co_freevars)               # serbest değişken adları
print(c.__closure__)                        # hücre demeti
print(c.__closure__[0].cell_contents)       # hücredeki güncel değer

__code__.co_freevars iç fonksiyonun dışarıdan kullandığı serbest değişkenlerin (free variables) adlarını, __closure__ ise aynı sırayla hücreleri verir. Hiç serbest değişken kullanmayan fonksiyonda __closure__ None olur. Bu özellikler hata ayıklamada (debugging) ve PCAP sorularında karşına çıkar.

Hücre paylaşıldığı için aynı dış fonksiyondan dönen iki iç fonksiyon tek bir durumu ortak kullanabilir. Her yeni çağrı ise yeni hücre ve bağımsız durum üretir:

def hesap(bakiye):
    def yatir(tutar):
        nonlocal bakiye
        bakiye += tutar
    def goster():
        return bakiye
    return yatir, goster

yatir_a, goster_a = hesap(100)
yatir_b, goster_b = hesap(0)
yatir_a(50)
yatir_b(10)
print(goster_a(), goster_b())                          # 150 10
print(yatir_a.__closure__[0] is goster_a.__closure__[0])  # aynı hücre

Geç bağlama (late binding) tuzağı

"Hücreyi yakalar" kuralının en ünlü sonucu budur. Döngüde fonksiyon üretirsen hepsi döngü değişkeninin aynı hücresini paylaşır. Değer, fonksiyon çağrıldığında okunur; o anda döngü çoktan bitmiştir.

oranlar = [0.05, 0.10, 0.20]
kurallar = []
for oran in oranlar:
    kurallar.append(lambda tutar: tutar * oran)

print([k(100) for k in kurallar])      # üçü de 20.0!

# Çözüm 1: varsayılan argüman, değeri TANIM anında kopyalar
kurallar = [lambda tutar, oran=oran: tutar * oran for oran in oranlar]
print([k(100) for k in kurallar])

# Çözüm 2: fabrika fonksiyonu, her çağrı yeni hücre açar
def kural_uret(oran):
    return lambda tutar: tutar * oran
kurallar = [kural_uret(o) for o in oranlar]
print([k(100) for k in kurallar])

# Çözüm 3: functools.partial, argümanı o anda sabitler
from functools import partial
def kural(tutar, oran):
    return tutar * oran
kurallar = [partial(kural, oran=o) for o in oranlar]
print([k(100) for k in kurallar])
Hangi çözüm?Varsayılan argüman en kısa çözümdür ama fonksiyonun imzasına çağıranın değiştirebileceği gizli bir parametre ekler (k(100, oran=5)). Ekip kodunda fabrika fonksiyonu ya da partial daha temiz ve niyeti daha açıktır.

Fabrika fonksiyonları: kural üreten kural

Fabrika fonksiyonu (factory function), ayarları alıp bu ayarlarla çalışan yeni bir fonksiyon döndürür. Doğrulamayı fabrikada bir kez yaparsın; üretilen fonksiyon her çağrıda hızlı ve sade kalır.

def indirim_kurali(oran, esik=0):
    if not 0 < oran < 1:
        raise ValueError("oran 0 ile 1 arasında olmalı")
    def uygula(tutar):
        if tutar >= esik:
            return round(tutar * (1 - oran), 2)
        return tutar
    uygula.__name__ = f"indirim_{int(oran * 100)}"
    return uygula

hafta_sonu = indirim_kurali(0.10, esik=500)
ogrenci = indirim_kurali(0.15)
for tutar in [300, 750]:
    print(tutar, hafta_sonu(tutar), ogrenci(tutar))
print(hafta_sonu.__name__, ogrenci.__name__)

Ilgaz Bank'ta kart başına günlük limit de aynı kalıptır; bu kez kapanış durumu değiştirir, bu yüzden nonlocal gerekir:

def gunluk_limit(limit):
    kalan = limit
    def harca(tutar):
        nonlocal kalan
        if tutar > kalan:
            return f"RED: {tutar} TL, kalan {kalan} TL"
        kalan -= tutar
        return f"ONAY: {tutar} TL, kalan {kalan} TL"
    return harca

kart = gunluk_limit(1000)
for t in [400, 500, 200]:
    print(kart(t))

Kapanış mı, sınıf mı?

Kapanış "tek metotlu küçük bir nesne" gibidir. Durum büyüdükçe ve davranış çoğaldıkça sınıf daha okunur olur.

ÖlçütKapanışSınıf
Davranış sayısıBir (ya da birkaç iç fonksiyon)Çok sayıda metot
Durumu dışarıdan görmekZor: __closure__ ileKolay: nesne.alan
Kalıtım (inheritance), __repr__, karşılaştırmaYokVar
Satır sayısıAzDaha fazla
Tipik yerDekoratör, callback, key=, kuralHesap, sepet, bağlantı
class Carpan:
    def __init__(self, k):
        self.k = k
    def __call__(self, x):        # nesneyi fonksiyon gibi çağrılabilir yapar
        return x * self.k

def carpan(k):
    return lambda x: x * k

kdv_sinif = Carpan(1.2)
kdv_kapanis = carpan(1.2)
print(kdv_sinif(100), kdv_kapanis(100))
print(kdv_sinif.k, kdv_kapanis.__closure__[0].cell_contents)

functools.partial ile ilişkisi

partial, "argümanı önceden doldur" işini kapanış yazmadan yapar ve ayarı açıkça gösterir: .func, .args, .keywords. Kendi iç mantığı yoksa, sadece argüman sabitliyorsan partial seç; fabrikada doğrulama, hesap ya da durum varsa kapanış seç.

from functools import partial

def kargo_ucreti(desi, sehir, ekspres=False):
    tarife = {"İstanbul": 40, "Ankara": 45, "Van": 70}
    ucret = tarife[sehir] + desi * 5
    return ucret * 1.5 if ekspres else ucret

ankara = partial(kargo_ucreti, sehir="Ankara")
print(ankara(4), ankara(4, ekspres=True))
print(ankara.func.__name__, ankara.args, ankara.keywords)

def sehir_tarifesi(sehir):                 # aynı işin kapanış hâli
    def hesapla(desi, ekspres=False):
        return kargo_ucreti(desi, sehir, ekspres)
    return hesapla
print(sehir_tarifesi("Ankara")(4))
Gerçek işteKapanışları en çok üç yerde görürsün: (1) dekoratörlerde, sarılan fonksiyonu hatırlayan iç fonksiyon olarak; (2) arayüz ve olay sistemlerinde, "butona basılınca şu sipariş için şunu yap" geri çağırmalarında (geç bağlama hatasının en sık çıktığı yer burasıdır: tüm butonlar son siparişi işler); (3) iş kurallarının yapılandırmadan üretildiği kural motorlarında. Kod incelemesinde (code review) döngü içinde lambda gördüğünde ilk sorulacak soru şudur: "Bu değişken çağrı anında ne olacak?"
Uzmanlaş

Hücreler ve bellek

Kapanış dış fonksiyonun yalnızca kullandığı adları tutar, tüm yerel değişkenleri değil. Yine de büyük bir listeyi yakalayan kapanış yaşadıkça liste de bellekte kalır. Uzun ömürlü callback'lerde yalnızca gerekeni yakala: adet = len(buyuk_liste) deyip adet'i kullan.

Değiştirilebilir nesneyle nonlocal'sız durum

Hücredeki ad yeniden bağlanmıyorsa nonlocal gerekmez: onbellek = {} yakalayıp onbellek[k] = v demek bir atama değil, nesneyi değiştirmektir. Hafızalı (memoize) fonksiyonlar ve lru_cache'in basit hâli böyle yazılır. Ama onbellek = {} diye yeniden atarsan yine nonlocal ister.

Kapanış ve eşzamanlılık

Paylaşılan hücreyi birden fazla iş parçacığı (thread) değiştiriyorsa sayac += 1 atomik değildir; Eşzamanlılık (concurrency) dersinde bunun için kilit (lock) kullanacaksın. Ayrıca kapanışlar pickle ile serileştirilemez; çok işlemcili (multiprocessing) kodda modül düzeyi fonksiyon ya da partial tercih edilir.

PCAP 5.3 için kontrol listesi

  • İç fonksiyon dış adı okuyorsa bildirim gerekmez; atıyorsa nonlocal gerekir.
  • Dış fonksiyon döndükten sonra da iç fonksiyon dış değeri görür: kapanış.
  • Döngüde üretilen lambdalar son değeri görür; i=i ile düzelir.
  • __closure__ yoksa None; varsa hücre demeti, değer cell_contents'te.
Alıştırmalara geç (19)