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 ভেতরে এটা করে:

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 বানানো

ধরো ১ কোটি সংখ্যার বর্গ দরকার:

Python
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 লেখা যায়:

Python
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

10 GB file লাইন পড়া filter শুধু ERROR parse সময় আর message ফলাফল গোনা/লেখা যেকোনো মুহূর্তে memory তে থাকে মাত্র একটা লাইন
প্রতিটা ধাপ generator হলে পুরো file memory তে আনতে হয় না। ১০ GB file ও ছোট server এ process করা যায়।
Python
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 এর উপর দাঁড়ানো।

Python
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 লেখা আসলে এই লাইনের সংক্ষিপ্ত রূপ:

Python
generate_report = timed(generate_report)
wrapper (decorator যা বানালো) ১. শুরুর সময় নাও generate_report() ২. শেষের সময় নাও, print করো, ফলাফল ফেরত দাও call ফলাফল
বাইরে থেকে মনে হয় একই function ডাকছো, কিন্তু আসলে ডাকছো wrapper কে, যে ভেতরে আসল function চালায়।

#Argument সহ decorator: retry

Network call মাঝে মাঝে ব্যর্থ হয়। কয়েকবার চেষ্টা করার decorator:

Python
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 নিয়মিত দেখবে

Python
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 নিশ্চিত করে যে যাই ঘটুক শেষে পরিষ্কারের কাজটা হবে:

Python
with open("data.json", encoding="utf-8") as f:
    data = f.read()
# এখানে f নিজে থেকেই বন্ধ, মাঝে error হলেও

#নিজের context manager

সবচেয়ে সহজ উপায় contextlib:

Python
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:

Python
@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__ লিখে:

Python
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 ভরে যাবে।