अध्याय 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 आपको क्या सुविधा देता है
  • फ़ंक्शंस पर टाइप हिंट्स (type hints) लिखना — list[str], dict[str, float], int | None
  • यह समझना कि हिंट्स रनटाइम पर कुछ नहीं करते, लेकिन चेकर के तहत सब कुछ करते हैं
  • asdict की मदद से डेटाक्लास को JSON में बदलना

ज़रूरी शर्तें: क्लासेस (Classes)।


@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)

Runtime पर Hints कुछ नहीं करते

यह शुरुआत में ही स्पष्ट होना चाहिए, क्योंकि इसके नाम से कुछ और लग सकता है।

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 लिखा गया था और सीधे एक टेक्स्ट स्ट्रिंग चली गई। पायथन हिंट्स की जाँच नहीं करता — यह सिर्फ़ उन्हें रिकॉर्ड करता है, और उनके उपयोग का काम किसी और पर छोड़ देता है।

एरर बहुत बाद में आया, जब किसी चीज़ ने उस वैल्यू के साथ गणना करने की कोशिश की। इसके बीच में वह प्रिंट हुआ, लॉग हुआ, और संभवतः किसी फ़ाइल में लिखा भी गया।

यहीं पर टाइप चेकर (type checker) की अहमियत समझ आती है। mypy एक अलग प्रोग्राम है जो कोड को बिना चलाए पढ़ता है:

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

लाइन नंबर, कौन सा तर्क (argument), क्या दिया गया और क्या अपेक्षित था — और इनमें से किसी के लिए भी प्रोग्राम को चलाने की आवश्यकता नहीं पड़ी।

पूरी बात दो वाक्यों में: हिंट्स रनटाइम पर कुछ नहीं करते और चेकर के तहत सब कुछ करते हैं। हिंट लिखना चेकर और कोड पढ़ने वाले के लिए एक वादा है, इंटरप्रेटर के लिए नहीं।

रिकॉर्ड किए गए हिंट्स को देखा भी जा सकता है:

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


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

परिवर्तनीय डिफ़ॉल्ट (Mutable Default), तीसरी बार

इक्कीसवें अध्याय में यह फ़ंक्शन का डिफ़ॉल्ट तर्क था। छब्बीसवें अध्याय में यह क्लास एट्रिब्यूट था। अब डेटाक्लास फ़ील्ड में — और इस बार पायथन खुद इसे रोकता है:

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 रोक दिया गया — जैसे टुपल (tuple) के साथ होता है।

और अब यह सेट (set) में जा सकता है। पिछले अध्याय की अंतिम समस्या याद करें: __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 कहता है "यह नहीं बदलेगा", इसलिए पायथन हैश प्रदान करने के लिए तैयार हो जाता है।

नियम सीधा है: जो चीज़ बनने के बाद बदलने वाली नहीं है, उसे फ़्रीज़ (freeze) कर दें। इससे एक वादा बनता है, और उसके साथ सेट का सदस्य या डिक्शनरी की की (key) बनने का अधिकार मिलता है।

फ़ंक्शंस पर हिंट्स

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' है — जो व्यवहार में किसी भी अन्य एरर से ज़्यादा देखा जाता है — बस अंतर यह है कि यह प्रोग्राम चलने से पहले ही पकड़ में आ गया, बिना उस विशेष इनपुट का सामना किए जो इसका कारण बनता।

डेटाक्लास से 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 यहाँ एक पुल का काम करता है, और यह नेस्टेड डेटाक्लासेस को भी बदल देता है।


पूर्ण उदाहरण

पिछले अध्याय का ऑर्डर, डेटाक्लासेस और हिंट्स के साथ दोबारा बनाया गया।

python
"""छब्बीसवें अध्याय का ऑर्डर, डेटाक्लासेस और टाइप हिंट्स के साथ।"""

import json
from dataclasses import dataclass, field, asdict

TAX_RATE = 0.15


@dataclass(frozen=True)
class OrderLine:
    """फ़्रोज़न: बनने के बाद लाइन कभी नहीं बदलती, इसलिए यह सेट में रह सकती है।"""

    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:
    """फ़्रोज़न नहीं है: समय के साथ इसमें और लाइन्स जोड़ी जाती हैं।"""

    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 होने का मतलब है कि उसकी मात्रा कम से कम एक है।

एक फ़्रोज़न है और दूसरा नहीं, जानबूझकर। एक बार बनने के बाद लाइन में बदलाव नहीं होना चाहिए, जबकि एक ऑर्डर में नई लाइनें जुड़ती रहती हैं। सवाल हर बार वही है: क्या यह चीज़ बनने के बाद बदलती है?

यह 1 इस बात का प्रमाण है कि फ़्रीज़िंग ने काम किया। दो समान 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 वाला डेटाक्लास सेट में नहीं जा सकता।

ग़लत टाइप दिया गया और कुछ नहीं हुआ जैसा कि अपेक्षित था — हिंट्स रनटाइम पर कुछ नहीं करते। mypy चलाएं।

mypy मेरी फ़ाइल को नहीं देख रहा है उसे पाथ दें — mypy main.py। और अगर पहला रन बहुत सारे एरर दिखाए, तो mypy --ignore-missing-imports से शुरू करें।

TypeError: Object of type X is not JSON serializable asdict(obj) भेजें।

क्लास के अंदर उसी के नाम के लिए NameError नाम को कोट्स में रखें: -> "Order"।