Iterator, Generator, Decorator, Context Manager
FastAPI আর বড় library গুলো যেভাবে লেখা হয়
এই অধ্যায়ের চারটা জিনিস (iterator, generator, decorator, context manager) প্রথমে একটু জাদুর মতো লাগে। কিন্তু FastAPI, SQLAlchemy, pytest এর মতো library গুলো এগুলো দিয়েই বানানো। @app.get("/") একটা decorator, with Session() as s: একটা context manager, আর ১ GB এর file লাইন ধরে পড়া একটা generator। এগুলো বুঝলে তুমি library কে শুধু ব্যবহার করবে না, ভেতরে কী হচ্ছে সেটাও বুঝবে, যা AI এর লেখা code review করার জন্য জরুরি।
#Iterator: for loop আসলে কীভাবে চলে
for x in something লিখলে Python ভেতরে এটা করে:
items = ["আম", "জাম"]
it = iter(items) # একটা iterator চাও
print(next(it)) # আম
print(next(it)) # জাম
print(next(it)) # StopIteration exception, মানে শেষfor loop এই next() বারবার ডাকে, আর StopIteration পেলে থামে। List, dict, string, file সবই iterable, কারণ সবাই iter() দিতে পারে।
#Generator: দরকার হলে তবেই value বানানো
ধরো ১ কোটি সংখ্যার বর্গ দরকার:
squares_list = [n * n for n in range(10_000_000)] # সব একসাথে memory তে: প্রায় ৪০০ MB
squares_gen = (n * n for n in range(10_000_000)) # কিছুই বানায়নি এখনো: কয়েক byte[...] হলো list, সব value এখনই বানিয়ে memory তে রাখে। (...) হলো generator expression, যখন যেটা চাওয়া হবে তখন সেটা বানায়।
yield দিয়ে generator function লেখা যায়:
def read_large_log(path: str):
"""বিশাল log file থেকে শুধু ERROR লাইন, একটা একটা করে।"""
with open(path, encoding="utf-8") as f:
for line in f: # file ও একটা iterator, পুরোটা memory তে আনে না
if "ERROR" in line:
yield line.strip() # একটা দিয়ে থামে, পরের বার এখান থেকে চলে
for error_line in read_large_log("app.log"):
print(error_line)return function শেষ করে দেয়। yield একটা value দিয়ে function কে থামিয়ে রাখে, পরের next() এ ঠিক সেখান থেকে আবার চলে।
#Generator এর pipeline
def lines(path):
with open(path, encoding="utf-8") as f:
yield from f # f এর প্রতিটা লাইন yield করো
def only_errors(rows):
return (r for r in rows if "ERROR" in r)
def parse(rows):
for r in rows:
date, time, level, *msg = r.split()
yield {"time": f"{date} {time}", "message": " ".join(msg)}
errors = parse(only_errors(lines("app.log")))
for e in errors:
print(e["time"], e["message"])#Decorator: function এর গায়ে বাড়তি ক্ষমতা
Decorator হলো এমন একটা function যে আরেকটা function নেয়, আর তার চারপাশে কিছু বাড়তি কাজ জুড়ে নতুন function ফেরত দেয়। এটা closure এর উপর দাঁড়ানো।
import time
import functools
def timed(func):
@functools.wraps(func) # মূল function এর নাম, docstring ঠিক রাখে
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs) # আসল function চালাও
elapsed = (time.perf_counter() - start) * 1000
print(f"{func.__name__} নিল {elapsed:.1f} ms")
return result
return wrapper
@timed
def generate_report(n: int) -> int:
return sum(i * i for i in range(n))
generate_report(1_000_000) # generate_report নিল 52.3 ms@timed লেখা আসলে এই লাইনের সংক্ষিপ্ত রূপ:
generate_report = timed(generate_report)#Argument সহ decorator: retry
Network call মাঝে মাঝে ব্যর্থ হয়। কয়েকবার চেষ্টা করার decorator:
import random
import time
import functools
def retry(times: int = 3, delay: float = 0.5, exceptions: tuple = (ConnectionError,)):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, times + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
if attempt == times:
raise
wait = delay * 2 ** (attempt - 1) # 0.5, 1, 2 ... (exponential backoff)
print(f"চেষ্টা {attempt} ব্যর্থ ({e}), {wait}s পর আবার")
time.sleep(wait)
return wrapper
return decorator
@retry(times=4, delay=0.2)
def flaky_api() -> str:
if random.random() < 0.6:
raise ConnectionError("timeout")
return "সফল"
print(flaky_api())তিন স্তরের function কেন? retry(times=4) প্রথমে চলে আর decorator ফেরত দেয়, তারপর সেই decorator function কে মোড়ায়। এই exponential backoff ধারণাটা system design এ খুব গুরুত্বপূর্ণ, বিস্তারিত Resilience অধ্যায়ে।
#যেসব decorator নিয়মিত দেখবে
from functools import lru_cache, cache
@lru_cache(maxsize=1000) # একই input এর ফলাফল মনে রাখে (memoization)
def exchange_rate(currency: str) -> float:
print("API call...") # দ্বিতীয়বার একই currency তে এটা চলবে না
return 120.5
# অন্যান্য: @property, @staticmethod, @classmethod, @dataclass
# FastAPI: @app.get("/"), @app.post("/orders")
# pytest: @pytest.fixture, @pytest.mark.parametrize#Context manager: with এর জাদু
কিছু জিনিস খুললে বন্ধ করতে হয়: file, database connection, lock, transaction। ভুলে গেলে বা মাঝখানে error হলে বন্ধ না হয়ে থেকে যায়। with নিশ্চিত করে যে যাই ঘটুক শেষে পরিষ্কারের কাজটা হবে:
with open("data.json", encoding="utf-8") as f:
data = f.read()
# এখানে f নিজে থেকেই বন্ধ, মাঝে error হলেও#নিজের context manager
সবচেয়ে সহজ উপায় contextlib:
from contextlib import contextmanager
import time
@contextmanager
def timer(label: str):
start = time.perf_counter()
try:
yield # with block এর ভেতরের code এখানে চলে
finally:
print(f"{label}: {(time.perf_counter() - start) * 1000:.1f} ms")
with timer("report"):
total = sum(range(5_000_000))বাস্তব উদাহরণ, database transaction: সব ঠিক হলে commit, error হলে rollback:
@contextmanager
def transaction(conn):
try:
yield conn
conn.commit() # ভেতরে কোনো error না হলে
except Exception:
conn.rollback() # error হলে সব বাতিল, অর্ধেক কাজ থাকবে না
raise
with transaction(conn) as c:
c.execute("UPDATE accounts SET balance = balance - 500 WHERE id = 1")
c.execute("UPDATE accounts SET balance = balance + 500 WHERE id = 2")
# দুটো একসাথে হবে, নয়তো একটাও নাএটা transaction অধ্যায়ের মূল ধারণা: টাকা এক account থেকে কাটা হলো কিন্তু আরেকটায় যোগ হলো না, এমন অবস্থা কখনো হবে না।
Class দিয়েও বানানো যায়, __enter__ আর __exit__ লিখে:
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc, tb):
self.ms = (time.perf_counter() - self.start) * 1000
return False # False মানে exception হলে সেটা চাপা দিও না
with Timer() as t:
sum(range(1_000_000))
print(t.ms)#অনুশীলন
#নিজেকে যাচাই করো
`[x for x in data]` আর `(x for x in data)` এর পার্থক্য কী?
প্রথমটা list: সব value এখনই বানিয়ে memory তে রাখে। দ্বিতীয়টা generator: চাওয়ার সময় একটা একটা করে বানায়, প্রায় কোনো memory নেয় না, কিন্তু একবারই ঘোরা যায়।
`yield` আর `return` এর পার্থক্য কী?
return value দিয়ে function পুরোপুরি শেষ করে। yield value দিয়ে function কে থামিয়ে রাখে, পরের বার চাইলে ঠিক সেখান থেকে আবার চলে।
`@timed` লেখা আসলে কোন লাইনের সংক্ষিপ্ত রূপ?
func = timed(func)। মানে decorator function টাকে argument হিসেবে নেয় আর যা ফেরত দেয় সেটা দিয়ে মূল নামটা বদলে দেওয়া হয়।
Decorator এ `functools.wraps` না দিলে কী হয়?
নতুন function এর নাম হয়ে যায় wrapper, docstring হারিয়ে যায়। Debug, log আর FastAPI এর মতো library যারা function এর নাম বা signature পড়ে, তাদের সমস্যা হয়।
Database transaction এর জন্য context manager কেন ভালো?
এটা নিশ্চিত করে যে block সফল হলে commit আর মাঝে কোনো error হলে rollback হবে, প্রতিবার হাতে মনে রাখতে হবে না। ফলে অর্ধেক কাজ হয়ে থাকা (টাকা কাটা কিন্তু জমা হয়নি) অবস্থা হয় না।
চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।