ডেটাক্লাস আর টাইপ হিন্ট
@dataclass কী লিখে দেয়, field(default_factory=...) কেন লাগে, frozen=True কী দেয়, ফাংশনে টাইপ হিন্ট, আর প্রোগ্রাম চলার সময় হিন্ট কিছু না করে টাইপ চেকারের কাছে সবকিছু করার মানে।
কীভাবে শিখতে চান?
যে সমস্যাটা আমরা সমাধান করছি
গত অধ্যায়ের Book ক্লাসটা পুরোটা লিখলে দাঁড়ায় এরকম:
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))Book('one', 'rafi', 300)
Trueতেরো লাইন, আর তার মধ্যে একটাও নিজস্ব সিদ্ধান্ত নেই। প্রতিটা ফিল্ডের নাম বারবার লেখা হয়েছে — __init__-এর প্যারামিটারে, self. বসানোর লাইনে, __repr__-এ আর __eq__-এ।
চার নম্বর একটা ফিল্ড যোগ করতে হলে এই সব জায়গায় যোগ করতে হবে। কোনো একটা ভুলে গেলে এরর আসবে না — কেবল __repr__ বা __eq__ চুপচাপ অসম্পূর্ণ থেকে যাবে।
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))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-এর False হওয়া, আর একই নাম বারবার লেখা — একসাথে শেষ।
মেথড লেখা যায় আগের মতোই:
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())OrderLine(name='pen', price=15.0, quantity=3)
51.75ডিফল্ট ভ্যালুও দেওয়া যায়। নিয়মটা ২০ নম্বর অধ্যায়ের সেই একই: ডিফল্টওয়ালা ফিল্ড পরে।
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int = 1
print(Book("one"))
print(Book("one", 300))Book(title='one', pages=1)
Book(title='one', pages=300)প্রোগ্রাম চলার সময় হিন্ট কিছুই করে না
এটা শুরুতেই পরিষ্কার হওয়া দরকার, কারণ নামটা শুনে অন্য কিছু মনে হয়।
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
b = Book("one", "three hundred")
print(b)
print(b.pages + 1)Book(title='one', pages='three hundred')
TypeError: can only concatenate str (not "int") to strpages: int লেখা থাকা সত্ত্বেও একটা স্ট্রিং দিব্যি ঢুকে গেছে। পাইথন হিন্ট যাচাই করে না। সে হিন্টগুলো শুধু জমা করে রাখে; সেগুলো কাজে লাগানোর দায়িত্ব অন্য কারো।
এররটা এসেছে অনেক পরে, যখন ওই ভ্যালু দিয়ে হিসাব করার চেষ্টা হয়েছে। মাঝখানে সেটা প্রিন্ট হয়েছে — বাস্তব প্রোগ্রামে হয়তো লগে গেছে, ফাইলেও লেখা হয়েছে।
এখানেই টাইপ চেকারের কাজ। mypy একটা আলাদা প্রোগ্রাম, যে কোডটা না চালিয়ে পড়ে। উপরের কোডটা main.py নামে রেখে mypy main.py চালালে:
main.py:10: error: Argument 2 to "Book" has incompatible type "str"; expected "int" [arg-type]লাইন নম্বর, কোন আর্গুমেন্ট, কী পাওয়া গেছে, কী আশা করা হয়েছিল — আর এই মেসেজটা এসেছে প্রোগ্রামটা একবারও না চালিয়ে।
পুরো ব্যাপারটা দুই কথায়: প্রোগ্রাম চলার সময় হিন্ট কিছুই করে না; টাইপ চেকারের কাছে হিন্টই সব। হিন্ট লেখা মানে টাইপ চেকার আর পাঠককে কথা দেওয়া, ইন্টারপ্রেটারকে নয়।
জমা রাখা হিন্টগুলো দেখাও যায়:
def shout(text: str) -> str:
return text.upper()
print(shout("pen"))
print(shout.__annotations__)PEN
{'text': <class 'str'>, 'return': <class 'str'>}মিউটেবল ডিফল্ট — তৃতীয়বার
২১ নম্বর অধ্যায়ে এটা ছিল ফাংশনের ডিফল্ট আর্গুমেন্টে। ২৬ নম্বর অধ্যায়ে ছিল ক্লাস-লেভেলের অ্যাট্রিবিউটে। এবার dataclass-এ — আর এবার পাইথন নিজেই থামিয়ে দেয়:
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []ValueError: mutable default <class 'list'> for field books is not allowed: use default_factoryখেয়াল করুন, কোনো অবজেক্ট তৈরিই করা হয়নি — এররটা এসেছে ক্লাসটা ডিফাইন করার সময়। পাইথন এই ভুলটা এত বেশি দেখেছে যে এখানে সে অনুমতিই দেয় না।
ঠিক পথটা হলো একটা ফ্যাক্টরি দেওয়া — বানানো জিনিসটা নয়, বরং কী বানাতে হবে সেটা বলে দেওয়া:
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)Shelf(name='a', books=['one'])
Shelf(name='b', books=[])field(default_factory=list) মানে “প্রতিটা নতুন অবজেক্টের জন্য একবার করে list() ডাকো”। ২১ নম্বর অধ্যায়ের if basket is None: basket = []-এর মতোই কাজ, শুধু এক লাইনে আর ভুল করার সুযোগ ছাড়া।
frozen=True
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 = 5Point(x=1, y=2)
{Point(x=1, y=2)}
dataclasses.FrozenInstanceError: cannot assign to field 'x'এখানে দুটো জিনিস একসাথে ঘটেছে, আর তারা একে অপরের সাথে যুক্ত।
অবজেক্টটা আর বদলানো যায় না। p.x = 5 এরর দিয়ে থেমে গেছে — ঠিক টাপলের মতো।
আর অবজেক্টটা এখন সেটে রাখা যায়। গত অধ্যায়ের শেষ সমস্যাটা মনে আছে: __eq__ লিখলে হ্যাশ চলে যায়। এখানে দুটো “সমান” পয়েন্ট সেটে গিয়ে একটাই হয়ে গেছে, আর কোনো এরর নেই।
frozen ছাড়া হলে:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
print({Point(1, 2)})TypeError: unhashable type: 'Point'কারণটা ১৬ নম্বর অধ্যায় থেকেই আসে: যা বদলাতে পারে, তাকে হ্যাশ করা নিরাপদ নয়। সেটে রাখার পর বদলে গেলে তাকে আর খুঁজে পাওয়া যেত না।
frozen=True বলে দেয় “এটা বদলাবে না”, তাই পাইথন হ্যাশ দিতে রাজি হয়।
নিয়মটা সহজ: যে অবজেক্ট তৈরি হওয়ার পর বদলানোর কথা নয়, তাকে frozen করুন। তাতে একটা নিশ্চয়তা পাওয়া যায়, আর সাথে সেটে বা ডিকশনারির key হিসেবে ব্যবহারের সুযোগ।
ফাংশনে হিন্ট
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 resultlist[str], dict[str, float], tuple[str, float] — এভাবে ভিতরে কী আছে সেটাও বলা যায়, আর সেটাই হিন্টকে কাজের করে তোলে।
শুধু list লিখলে কেবল জানা যায় “একটা লিস্ট”। list[tuple[str, float]] লিখলে জানা যায় লুপে প্রতিবার কী আসবে।
ভুল কিছু দিলে — ধরুন main.py-র ৯ নম্বর লাইনে totals("pen") লেখা — mypy বলে:
main.py:9: error: Argument 1 to "totals" has incompatible type "str"; expected "list[tuple[str, float]]" [arg-type]“থাকতেও পারে, না-ও থাকতে পারে” বোঝাতে |:
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"))pen
Noneহিন্টের সবচেয়ে বড় লাভটা এখানেই। ফলটা None কিনা না দেখেই ব্যবহার করলে — ধরুন ৮ নম্বর লাইনটা হলো print(find(["pen"], "pen").upper()) — mypy বলে:
main.py:8: error: Item "None" of "str | None" has no attribute "upper" [union-attr]এটা আসলে AttributeError: 'NoneType' object has no attribute 'upper' — বাস্তবে সবচেয়ে বেশি দেখা এররগুলোর একটা। কিন্তু এখানে সেটা ধরা পড়ছে প্রোগ্রাম চালানোর আগেই, আর যে ইনপুটে None আসে সেটা হাতে না পেয়েই।
dataclass থেকে JSON
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))){'title': 'one', 'pages': 300}
{"title": "one", "pages": 300}
TypeError: Object of type Book is not JSON serializable২৫ নম্বর অধ্যায়ের সেই লিস্টটা মনে আছে — নিজের বানানো অবজেক্ট সরাসরি JSON-এ যায় না। asdict সেই সেতু। সে ভিতরে থাকা dataclass-গুলোকেও ডিকশনারি বানিয়ে দেয়।
একটা সম্পূর্ণ উদাহরণ
গত অধ্যায়ের অর্ডারটাই, এবার dataclass আর হিন্ট দিয়ে।
"""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()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 সেটে গিয়ে একটা হয়ে গেছে।
গত অধ্যায়ের OrderLine দিয়ে একই লাইন চালালে আসত 2। সেখানে কোনো এরর হতো না: সেই ক্লাসে __eq__ ছিল না, তাই ডিফল্ট হ্যাশ ছিল, কিন্তু দুটো আলাদা অবজেক্ট কখনো সমান হতো না — সেট দুটোকেই রেখে দিত।
list[OrderLine] কেবল সাজসজ্জা নয়। টাইপ চেকার এখান থেকেই জানে যে line.total() ঠিক আছে। line.totl() লিখলে সে ধরে ফেলবে — কোডটা না চালিয়েই।
"Order" কোটেশনের ভিতরে। add মেথডের সিগনেচারে যখন Order নামটা লেখা হচ্ছে, ক্লাসটার ডেফিনিশন তখনো শেষ হয়নি — নামটা তখনো তৈরি হয়নি। কোটেশনের ভিতরে লিখলে পাইথন সেটা তখনই খোঁজে না।
এই কোর্সের ভার্সন, পাইথন ৩.১২-তে, ক্লাসের ভিতরে নিজের ক্লাসের নাম হিন্টে লিখতে হলে দুটো উপায়: নামটা কোটেশনে লিখুন, নয়তো ফাইলের একদম শুরুতে from __future__ import annotations লিখুন।
কিছু ভাঙা অবস্থা ও তার সমাধান
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"। অথবা ফাইলের শুরুতে from __future__ import annotations দিন।
ধাপ ৪ / ৬ — অনুমান
যাচাই করুন
সাত লাইনের একটা dataclass। দুটো লাইন কী প্রিন্ট করবে?
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
print(Book("one", 300))
print(Book("one", 300) == Book("one", 300))- A<__main__.Book object at 0x...> False
- BBook(title='one', pages=300) True
- CBook(title='one', pages=300) False
- DBook('one', 300) True
কোনো অবজেক্ট বানানো হয়নি, কেবল ক্লাসটা লেখা হয়েছে। কী হবে?
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []- Aকিছুই হয় না; সমস্যাটা প্রথম অবজেক্ট বানালে দেখা দেবে
- B`ValueError` — ক্লাসটা ডিফাইন হওয়ার সময়েই
- Cএকটা `TypeError`
- Dকিছুই হয় না; সব ইনস্ট্যান্স একই লিস্ট ভাগ করবে
frozen=True দেওয়া আছে। কী হবে?
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
print({p, Point(1, 2)})
p.x = 5- Aএকটা জিনিসের সেট প্রিন্ট হয়, তারপর `FrozenInstanceError`
- Bদুটো জিনিসের সেট প্রিন্ট হয়, তারপর `FrozenInstanceError`
- C`TypeError: unhashable type` — সেট বানানোর সময়েই
- Dএকটা জিনিসের সেট প্রিন্ট হয়, তারপর `p.x` হয়ে যায় `5`
উত্তর দিতে অ্যাকাউন্ট লাগবে
উত্তর মিলিয়ে দেখতে সাইন ইন করুন
প্রশ্নগুলো উপরে আছে, আর মাথায় মাথায় উত্তর ভেবে নেওয়াই আসল কাজ। সঠিক উত্তর, ব্যাখ্যা আর তিন ধাপের ইঙ্গিত দেখতে সাইন ইন করুন।
নিজে করুন
২৬ নম্বর অধ্যায়ের Book আর Shelf আবার লিখুন, এবার dataclass দিয়ে।
Book হবে frozen=True, ফিল্ড title: str, author: str, pages: int, আর __post_init__-এ pages < 1 হলে ValueError। Shelf-এ থাকবে name: str আর books: list[Book] = field(default_factory=list), সাথে add, total_pages() -> int, longest() -> Book | None আর by_author(name: str) -> list[Book]।
তারপর pip install mypy করে mypy library.py চালান, আর কোনো মেসেজ না আসা পর্যন্ত ঠিক করুন।
তারপর ছয়টা পরীক্ষা:
pages-এ একটা স্ট্রিং দিন —Book("one", "rafi", "300")। প্রোগ্রামটা চলে? তারপরmypyকী বলে?books-এরfield(default_factory=list)সরিয়ে= []লিখুন। এররটা কখন আসে — অবজেক্ট বানানোর সময়, নাকি তার আগে?- দুটো একই রকম
Bookবানিয়ে একটা সেটে রাখুন। কয়টা থাকে? তারপরfrozen=Trueসরিয়ে আবার চেষ্টা করুন। longest()-এর ফলটা সরাসরি ব্যবহার করুন —shelf.longest().title— খালি তাক নিয়ে। প্রোগ্রামটা কী বলে, আরmypyআগেই কী বলেছিল?asdict(shelf)করেjson.dumpsদিয়ে লিখুন, তারপরjson.loadsদিয়ে ফেরত আনুন। ফেরত আসা জিনিসটা কিShelf?- একটা অ্যাট্রিবিউটের নাম ভুল বানানে পড়ুন —
book.pagse।mypyকি সেটা ধরে?
শেষ দুটো পরীক্ষা এই অধ্যায়ের সীমা আর শক্তি — দুটোই দেখায়। JSON থেকে ফেরত আসে ডিকশনারি, dataclass নয় — অনুবাদটা একমুখী, আর ফেরার পথটা আপনাকেই লিখতে হয়। কিন্তু ২৬ নম্বর অধ্যায়ের যে ফাঁকটা তখন বন্ধ করা যায়নি — ভুল নাম কেবল চালানোর সময় ধরা পড়ত — টাইপ চেকার সেটা চালানোর আগেই ধরে ফেলে।
ধাপ ৬ / ৬
কঠিন করা — অধ্যায়ের কুইজ
সহজ থেকে কঠিন — দশটি প্রশ্ন, শেষেরগুলো ইচ্ছে করেই কঠিন।
সাইন ইন করে কুইজ দিন