অধ্যায় 27

ডেটাক্লাস আর টাইপ হিন্ট

@dataclass কী লিখে দেয়, field(default_factory=...) কেন লাগে, frozen=True কী দেয়, ফাংশনে টাইপ হিন্ট, আর হিন্ট চালানোর সময় কিছু না করে যাচাইকারীর নিচে সবকিছু করার মানে।

36 মিনিটPython 3.12
  1. 1সমস্যা
  2. 2বোঝা
  3. 3উদাহরণ
  4. 4অনুমান
  5. 5নিজে করা
  6. 6কঠিন করা

যে সমস্যাটা আমরা সমাধান করছি

গত অধ্যায়ের Book ক্লাসটা লিখতে গেলে দাঁড়ায় এরকম:

python
class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages

    def __repr__(self):
        return f"Book({self.title!r}, {self.author!r}, {self.pages!r})"

    def __eq__(self, other):
        return (self.title, self.author, self.pages) == (other.title, other.author, other.pages)


a = Book("one", "rafi", 300)
print(a)
print(a == Book("one", "rafi", 300))
text
Book('one', 'rafi', 300)
True

তেরো লাইন, আর তার মধ্যে একটাও সিদ্ধান্ত নেই। প্রতিটা ক্ষেত্রের নাম তিনবার লেখা হয়েছে — __init__-এর প্যারামিটারে, self. বসানোর লাইনে, আর __repr__-এ। চতুর্থ একটা ক্ষেত্র যোগ করতে হলে তিন জায়গায় যোগ করতে হবে, আর কোনো একটা ভুলে গেলে ত্রুটি আসবে না — কেবল __repr__-টা অসম্পূর্ণ হবে, নীরবে।

python
from dataclasses import dataclass


@dataclass
class Book:
    title: str
    author: str
    pages: int


a = Book("one", "rafi", 300)
print(a)
print(a == Book("one", "rafi", 300))
text
Book(title='one', author='rafi', pages=300)
True

সাত লাইন, আর ক্ষেত্রগুলোর নাম একবার করে।

এই অধ্যায় শেষে আপনি পারবেন

  • @dataclass লিখতে, আর বলতে সে কী কী তৈরি করে দেয়
  • field(default_factory=...) ব্যবহার করতে, আর বলতে কেন সেটা লাগে
  • frozen=True কী দেয় তা বলতে
  • ফাংশনে টাইপ হিন্ট লিখতে — list[str], dict[str, float], int | None
  • বলতে হিন্ট চালানোর সময় কিছুই করে না, আর যাচাইকারীর নিচে সবকিছু করে
  • asdict দিয়ে একটা dataclass-কে JSON-এ পাঠাতে

পূর্বশর্ত: ক্লাস।


@dataclass কী লিখে দেয়

উপরের সাত লাইনের বিনিময়ে যা পাওয়া গেল:

  • একটা __init__, যার প্যারামিটারগুলো ক্ষেত্রগুলোর ক্রমেই
  • একটা __repr__, যা প্রতিটা ক্ষেত্র নামসহ দেখায়
  • একটা __eq__, যা সব ক্ষেত্র মিললে সমান বলে

গত অধ্যায়ের তিনটে সমস্যা — <object at 0x...>, a == b মিথ্যা হওয়া, আর একই কথা তিনবার লেখা — একসাথে শেষ।

মেথড লেখা যায় আগের মতোই:

python
from dataclasses import dataclass


@dataclass
class OrderLine:
    name: str
    price: float
    quantity: int

    def total(self) -> float:
        return round(self.price * self.quantity * 1.15, 2)


line = OrderLine("pen", 15.0, 3)
print(line)
print(line.total())
text
OrderLine(name='pen', price=15.0, quantity=3)
51.75

ডিফল্ট মানও, আর নিয়মটা বিংশ অধ্যায়ের সেই একই: ডিফল্টওয়ালা ক্ষেত্র পরে।

python
from dataclasses import dataclass


@dataclass
class Book:
    title: str
    pages: int = 1


print(Book("one"))
print(Book("one", 300))
text
Book(title='one', pages=1)
Book(title='one', pages=300)

হিন্ট চালানোর সময় কিছুই করে না

এটা প্রথমেই পরিষ্কার হওয়া দরকার, কারণ নামটা অন্য কিছু বলে।

python
from dataclasses import dataclass


@dataclass
class Book:
    title: str
    pages: int


b = Book("one", "three hundred")
print(b)
print(b.pages + 1)
text
Book(title='one', pages='three hundred')
TypeError: can only concatenate str (not "int") to str

pages: int লেখা সত্ত্বেও একটা লেখা দিব্যি ঢুকে গেছে। পাইথন হিন্ট যাচাই করে না — সে সেগুলো সংরক্ষণ করে রাখে, আর কাজে লাগানোর দায়িত্ব অন্য কারো।

ত্রুটিটা এসেছে অনেক পরে, যখন ওই মানটা দিয়ে হিসাব করার চেষ্টা হয়েছে। মাঝখানে সেটা ছাপা হয়েছে, লগে গেছে, হয়তো ফাইলেও লেখা হয়েছে।

এখানেই যাচাইকারীর জায়গা। mypy একটা আলাদা প্রোগ্রাম যে কোডটা না চালিয়ে পড়ে:

text
main.py:10: error: Argument 2 to "Book" has incompatible type "str"; expected "int"  [arg-type]

লাইন নম্বর, কোন আর্গুমেন্ট, কী পাওয়া গেছে, কী আশা করা হয়েছিল — আর এই বার্তাটা এসেছে প্রোগ্রামটা একবারও না চালিয়ে।

দুটো বাক্যে গোটা ব্যাপারটা: হিন্ট চালানোর সময় কিছুই করে না, আর যাচাইকারীর নিচে সবকিছু করে। হিন্ট লেখা মানে যাচাইকারীকে আর পাঠককে কথা দেওয়া, ইন্টারপ্রেটারকে নয়।

সংরক্ষিত হিন্টগুলো দেখাও যায়:

python
def shout(text: str) -> str:
    return text.upper()


print(shout("pen"))
print(shout.__annotations__)
text
PEN
{'text': <class 'str'>, 'return': <class 'str'>}

পরিবর্তনীয় ডিফল্ট — তৃতীয়বার

একবিংশ অধ্যায়ে এটা ছিল ফাংশনের ডিফল্ট আর্গুমেন্টে। ষড়বিংশে ক্লাস-স্তরের বৈশিষ্ট্যে। এবার dataclass-এ — আর এবার পাইথন নিজেই থামিয়ে দেয়:

python
from dataclasses import dataclass


@dataclass
class Shelf:
    books: list = []
text
ValueError: mutable default <class 'list'> for field books is not allowed: use default_factory

লক্ষ্য করুন, কোনো বস্তু তৈরিই করা হয়নি — ত্রুটিটা এসেছে ক্লাসটা সংজ্ঞায়িত হওয়ার সময়। পাইথন এই ভুলটা এত বেশি দেখেছে যে এখানে সে অনুমতিই দেয় না।

ঠিক পথটা হলো একটা কারখানা দেওয়া — কী বানাতে হবে তা বলা, বানানো জিনিসটা নয়:

python
from dataclasses import dataclass, field


@dataclass
class Shelf:
    name: str
    books: list[str] = field(default_factory=list)


a = Shelf("a")
b = Shelf("b")
a.books.append("one")

print(a)
print(b)
text
Shelf(name='a', books=['one'])
Shelf(name='b', books=[])

field(default_factory=list) মানে «প্রতিবার list() ডাকো»। একবিংশ অধ্যায়ের if basket is None: basket = []-এর সমান কাজ, কেবল এক লাইনে আর ভুল করার সুযোগ ছাড়াই।

frozen=True

python
from dataclasses import dataclass


@dataclass(frozen=True)
class Point:
    x: int
    y: int


p = Point(1, 2)
print(p)
print({p, Point(1, 2)})
p.x = 5
text
Point(x=1, y=2)
{Point(x=1, y=2)}
dataclasses.FrozenInstanceError: cannot assign to field 'x'

দুটো জিনিস একসাথে ঘটেছে, আর তারা সম্পর্কিত।

বস্তুটা আর বদলানো যায় না। p.x = 5 থেমে গেছে — টাপলের মতোই।

আর সেটা এখন সেটে যায়। গত অধ্যায়ের শেষ সমস্যাটা মনে আছে: __eq__ লিখলে হ্যাশ চলে যায়। এখানে দুটো «সমান» বিন্দু সেটে একটাই হয়ে বসেছে, আর কোনো ত্রুটি নেই।

frozen ছাড়া হলে:

python
from dataclasses import dataclass


@dataclass
class Point:
    x: int
    y: int


print({Point(1, 2)})
text
TypeError: unhashable type: 'Point'

কারণটা ষোড়শ অধ্যায় থেকেই আসে: যা বদলাতে পারে, তাকে হ্যাশ করা নিরাপদ নয় — সেটে রাখার পর বদলে গেলে তাকে আর খুঁজে পাওয়া যেত না। frozen=True বলে «এটা বদলাবে না», তাই পাইথন হ্যাশটা দিতে রাজি হয়।

নিয়মটা সহজ: যে বস্তু তৈরি হওয়ার পর বদলানোর কথা নয়, তাকে frozen করুন। তাতে একটা প্রতিশ্রুতি পাওয়া যায়, আর সাথে সেট ও ডিকশনারির কী হিসেবে ব্যবহারের সুযোগ।

ফাংশনে হিন্ট

python
def totals(rows: list[tuple[str, float]]) -> dict[str, float]:
    result: dict[str, float] = {}
    for name, amount in rows:
        result[name] = result.get(name, 0.0) + amount
    return result

list[str], dict[str, float], tuple[str, float] — ভিতরে কী আছে সেটাও বলা যায়, আর সেটাই হিন্টকে কাজের করে তোলে। list লিখলে কেবল «একটা লিস্ট» জানা যায়; list[tuple[str, float]] লিখলে জানা যায় লুপে কী আসবে।

ভুল কিছু দিলে:

text
main.py:9: error: Argument 1 to "totals" has incompatible type "str"; expected "list[tuple[str, float]]"  [arg-type]

«থাকতেও পারে, না-ও পারে» বোঝাতে |:

python
def find(names: list[str], wanted: str) -> str | None:
    for name in names:
        if name == wanted:
            return name
    return None


print(find(["pen"], "pen"))
print(find(["pen"], "bag"))
text
pen
None

আর এখানেই হিন্টের সবচেয়ে বড় দামটা পাওয়া যায়। ফলটা যাচাই না করে ব্যবহার করলে:

text
main.py:8: error: Item "None" of "str | None" has no attribute "upper"  [union-attr]

এটা AttributeError: 'NoneType' object has no attribute 'upper' — সেই বার্তাটা, যা বাস্তবে সবচেয়ে বেশি দেখা যায় — কিন্তু প্রোগ্রামটা চালানোরও আগে, আর সেই নির্দিষ্ট ইনপুটটা না পেয়েই।

dataclass থেকে JSON

python
import json
from dataclasses import dataclass, asdict


@dataclass
class Book:
    title: str
    pages: int


print(asdict(Book("one", 300)))
print(json.dumps(asdict(Book("one", 300))))
print(json.dumps(Book("one", 300)))
text
{'title': 'one', 'pages': 300}
{"title": "one", "pages": 300}
TypeError: Object of type Book is not JSON serializable

পঞ্চবিংশ অধ্যায়ের সেই তালিকাটা মনে আছে — নিজের বানানো বস্তু JSON-এ যায় না। asdict সেই সেতুটা, আর সে ভিতরের dataclass-গুলোকেও নামিয়ে আনে।


একটা সম্পূর্ণ উদাহরণ

গত অধ্যায়ের অর্ডারটাই, এবার dataclass আর হিন্ট দিয়ে।

python
"""The order of chapter twenty-six, rebuilt with dataclasses and hints."""

import json
from dataclasses import dataclass, field, asdict

TAX_RATE = 0.15


@dataclass(frozen=True)
class OrderLine:
    """Frozen: a line never changes once made, so it can live in a set."""

    name: str
    price: float
    quantity: int

    def __post_init__(self) -> None:
        if self.quantity < 1:
            raise ValueError(f"quantity must be at least 1: {self.quantity}")

    def total(self) -> float:
        return round(self.price * self.quantity * (1 + TAX_RATE), 2)


@dataclass
class Order:
    """Not frozen: lines are added over time."""

    customer: str
    lines: list[OrderLine] = field(default_factory=list)

    def add(self, name: str, price: float, quantity: int) -> "Order":
        self.lines.append(OrderLine(name, price, quantity))
        return self

    def total(self) -> float:
        return round(sum(line.total() for line in self.lines), 2)

    def largest(self) -> OrderLine | None:
        if not self.lines:
            return None
        return max(self.lines, key=lambda line: line.total())


def main() -> None:
    order = Order("rafi")
    order.add("pen", 15.0, 3)
    order.add("bag", 850.0, 1)
    order.add("ink", 120.0, 2)

    for line in order.lines:
        print(f"{line.name:<6} {line.total():>9.2f}")

    print(f"{'total':<6} {order.total():>9.2f}")
    print()
    print(order.largest())
    print(json.dumps(asdict(order)))

    print(len({OrderLine("pen", 15.0, 3), OrderLine("pen", 15.0, 3)}))

    try:
        order.add("clip", 5.0, 0)
    except ValueError as err:
        print("rejected:", err)


if __name__ == "__main__":
    main()
text
pen        51.75
bag       977.50
ink       276.00
total    1305.25

OrderLine(name='bag', price=850.0, quantity=1)
{"customer": "rafi", "lines": [{"name": "pen", "price": 15.0, "quantity": 3}, {"name": "bag", "price": 850.0, "quantity": 1}, {"name": "ink", "price": 120.0, "quantity": 2}]}
1
rejected: quantity must be at least 1: 0

পাঁচটা জিনিস দেখার মতো।

__post_init__ যাচাইয়ের জায়গা। @dataclass নিজেই __init__ লিখে দেয়, তাই যাচাই বসানোর মতো জায়গা নেই — __post_init__ সেই ফাঁকটাই পূরণ করে, আর সে চলে ক্ষেত্রগুলো বসানোর ঠিক পরে। গত অধ্যায়ের প্রতিশ্রুতিটা তাই অক্ষত: একটা OrderLine থাকা মানেই তার পরিমাণ অন্তত এক।

একটা frozen, অন্যটা নয় — আর সেটা ইচ্ছাকৃত। একটা লাইন তৈরি হওয়ার পর বদলানোর কথা নয়, কিন্তু একটা অর্ডারে লাইন যোগ হতেই থাকে। প্রতিটা ক্ষেত্রে প্রশ্নটা একই: এই জিনিসটা কি তৈরি হওয়ার পর বদলায়?

1 সংখ্যাটাই frozen-এর প্রমাণ। দুটো অভিন্ন OrderLine সেটে গিয়ে একটাই হয়েছে — গত অধ্যায়ে এই লাইনটা TypeError দিত।

list[OrderLine] কেবল সাজসজ্জা নয়। যাচাইকারী এখান থেকেই জানে line.total() বৈধ, আর line.totl() লিখলে সে ধরে ফেলবে — কোডটা না চালিয়েই।

"Order" উদ্ধৃতির ভিতরে। add-এর ভিতরে যখন Order-কে উল্লেখ করা হচ্ছে, ক্লাসটার সংজ্ঞা তখনো শেষ হয়নি — নামটা তখনো নেই। উদ্ধৃতি দিলে পাইথন সেটাকে পরে মেলায়। নিজের ক্লাসকে নিজের ভিতরে উল্লেখ করতে গেলে এটা লাগবেই।


কিছু ভাঙা অবস্থা ও তার সমাধান

ValueError: mutable default ... use default_factory = [] বা = {} লেখা হয়েছে। field(default_factory=list) নিন।

TypeError: non-default argument follows default argument ডিফল্টওয়ালা ক্ষেত্র আবশ্যকটার আগে বসেছে। বিংশ অধ্যায়ের সেই নিয়মটাই: আবশ্যক আগে।

dataclasses.FrozenInstanceError frozen=True বস্তুতে মান বসানোর চেষ্টা। বদল দরকার হলে dataclasses.replace(obj, x=5) একটা নতুন বস্তু দেয়।

TypeError: unhashable type frozen=True ছাড়া dataclass সেটে যায় না।

ভুল ধরনের মান ঢুকে গেছে আর কিছুই ঘটেনি সেটাই স্বাভাবিক — হিন্ট চালানোর সময় কিছু করে না। mypy চালান।

mypy আমার ফাইলটা দেখছেই না পথটা দিন — mypy main.py। আর প্রথমবার একগাদা বার্তা এলে mypy --ignore-missing-imports দিয়ে শুরু করুন।

TypeError: Object of type X is not JSON serializable asdict(obj) দিয়ে পাঠান।

NameError — নিজের ক্লাসের নাম নিজের ভিতরে নামটা উদ্ধৃতির ভিতরে লিখুন: -> "Order"।