2025, Nov 01 17:01

Python में ओवरलोडेड डेस्क्रिप्टर: क्लास पर Marker, इंस्टेंस पर वास्तविक मान

Python में क्लास और इंस्टेंस पर एक ही फ़ील्ड का अलग टाइप चाहिए? ओवरलोडेड __get__ डेस्क्रिप्टर से क्लास पर Marker, इंस्टेंस पर वास्तविक मान—बिना दोहरी एनोटेशन।

किसी क्लास-एट्रिब्यूट को इस तरह व्यवहार कराने का विचार कि वह क्लास पर एक तरह से और इंस्टेंस पर दूसरी तरह से काम करे, तब तक बढ़िया तरकीब है जब तक स्टैटिक टाइपिंग बीच में नहीं आती। ज़रूरत बहुत सीधी है: क्लास पर आपको ऐसा हल्का मार्कर ऑब्जेक्ट चाहिए जो फ़ील्ड का नाम उठाए; वहीं इंस्टेंस पर उसी फ़ील्ड का वास्तविक मान चाहिए। मुश्किल यह है कि टाइप चेकर को यह मानने पर राज़ी किया जाए कि दोनों नजरिए मान्य हैं—वह भी बिना एनोटेशन दोहराए।

मेटाक्लास के साथ सेटअप दोहराना

नीचे दिया पैटर्न क्लास बनने के समय हर फ़ील्ड के लिए मार्कर ऑब्जेक्ट क्लास में इंजेक्ट करता है, जबकि इंस्टेंस असली मान सहेजते और लौटाते हैं। रनटाइम पर यह ठीक चलता है, लेकिन स्टैटिक अनालाइज़र स्वाभाविक रूप से यह नहीं मानता कि क्लास-स्तर और इंस्टेंस-स्तर की पहुँच के प्रकार अलग हो सकते हैं।

from typing import Any
class Field:
    def __init__(self, label: str) -> None:
        self.key = label
    def __repr__(self):
        return f"Field(name={self.key!r})"
class ModelMeta(type):
    def __new__(mcls, type_name: str, parents: tuple[type, ...], attrs: dict[str, Any]) -> type:
        hints = attrs.get('__annotations__', {})
        attrs.update({k: Field(k) for k in hints})
        return super().__new__(mcls, type_name, parents, attrs)
class RecordBase(metaclass=ModelMeta):
    def __init__(self, payload: dict[str, Any]) -> None:
        if self.__class__ == RecordBase:
            raise RuntimeError
        self.__dict__ = payload
class Person(RecordBase):
    name: str
    height: int
p = Person({'name': 'Tom', 'height': 180})
print(Person.height)  # Field(name='height')
print(p.height)       # 180

टाइप चेकर कहाँ आपत्ति करता है

मेटाक्लास एनोटेशन के आधार पर क्लास एट्रिब्यूट इंजेक्ट करता है। रनटाइम में, क्लास पर एट्रिब्यूट एक्सेस करने से मार्कर ऑब्जेक्ट मिलते हैं, और इंस्टेंस पर एक्सेस करने से संग्रहीत मान। लेकिन स्थिर विश्लेषण में टाइप चेकर बस यह देखता है कि क्लास ने एनोटेटेड एट्रिब्यूट परिभाषित किए हैं, और मान लेता है कि वे एनोटेशन क्लास और इंस्टेंस—दोनों—एक्सेस का वर्णन करते हैं, जब तक कि आप ClassVar जैसी अलग क्लास-स्तरीय एनोटेशन स्पष्ट रूप से न दें। नतीजा या तो क्लास एक्सेस के लिए ग़लत प्रकार निकलते हैं, या फिर एक ही एट्रिब्यूट के लिए दो अलग एनोटेशन लिखने पड़ते हैं—जिससे हम बचना चाहते हैं।

कारगर तरीका: ओवरलोड वाले डेस्क्रिप्टर

“क्लास पर एक्सेस एक प्रकार लौटाए और इंस्टेंस पर दूसरा”—इसे व्यक्त करने का सही तरीका है कि एक्सेस को एक डेस्क्रिप्टर से होकर गुज़ारा जाए और उसके __get__ को ओवरलोड किया जाए। ओवरलोड्स टाइप चेकर को बताते हैं: जब डेस्क्रिप्टर क्लास पर एक्सेस होता है, तो वह मार्कर ऑब्जेक्ट लौटाता है; और जब इंस्टेंस पर एक्सेस होता है, तो एनोटेटेड वैल्यू का विशेष प्रकार। रनटाइम का व्यवहार वही रहता है जो मेटाक्लास-आधारित पैटर्न में है, लेकिन टाइपिंग मॉडल सटीक हो जाता है।

from typing import Any, Callable, overload, Never
class Marker:
    def __init__(self, title: str) -> None:
        self.title = title
    def __repr__(self) -> str:
        return f"Marker(name={self.title})"
class CoreRow:
    __needed__: set[str] = set()
    def __init__(self, record: dict[str, Any]) -> None:
        if self.__class__ == CoreRow:
            raise RuntimeError
        self.__stash__ = record
class Slot[TR: CoreRow, TV: Any]:
    def __init__(self, fn: Callable[[TR], TV] | Callable[[type[TR]], TV]):
        self._inner = fn
    def __set_name__(self, owner: type[TR], attr: str):
        self.attr = attr
        self.flag = Marker(attr)
        if not owner.__needed__:
            owner.__needed__ = set()
        owner.__needed__.add(attr)
    @overload
    def __get__(self, instance: TR, owner: type[TR]) -> TV:
        ...
    @overload
    def __get__(self, instance: None, owner: type[TR]) -> Marker:
        ...
    def __get__(self, instance: TR | None, owner: type[TR]) -> TV | Marker:
        if instance is None:
            return self.flag
        return instance.__stash__[self.attr]
# इस पैटर्न में रनटाइम पर Slot का बॉडी कभी निष्पादित नहीं होती
# और टाइप चेकर को संतुष्ट करने के लिए हम Never का उपयोग करते हैं।
def abort() -> Never:
    raise RuntimeError()
class User(CoreRow):
    @Slot 
    def name(self_or_cls) -> str: 
        abort()
    @Slot
    def height(self_or_cls) -> int:
        abort()
u = User({'name': 'Tom', 'height': 180})
print(User.height)  # Marker(name=height)
print(u.height)     # 180

__get__ पर किए गए ओवरलोड्स मंशा साफ़ कर देते हैं: instance का None होना क्लास-स्तरीय एक्सेस के बराबर है और वह Marker लौटाता है; अन्यथा संग्रहीत मान वापस मिलता है। डेस्क्रिप्टर का __set_name__ हुक एट्रिब्यूट नाम बाँधता है, मार्कर बनाता है, और बैकिंग मैपिंग में ज़रूरी कुंजियों का हिसाब रखता है।

यह क्यों मायने रखता है

यह तरीका डिक्लेरेटिव फ़ील्ड सूची की सुविधा को कायम रखते हुए स्टैटिक विश्लेषण को सही जानकारी देता है। आप एक ही एट्रिब्यूट के लिए एनोटेशन दोहराने से बचते हैं, क्लास और इंस्टेंस—दोनों—एक्सेस के लिए सही प्रकार पाते हैं, और रनटाइम व्यवहार भी बना रहता है: क्लास एट्रिब्यूट मार्कर ऑब्जेक्ट होते हैं, जबकि इंस्टेंस वास्तविक मान दिखाते हैं।

निष्कर्ष

जब एक ही एट्रिब्यूट के लिए दोहरा व्यवहार चाहिए, तो बारीकी से ओवरलोड किए गए __get__ वाला डेस्क्रिप्टर रनटाइम सेमांटिक्स और स्टैटिक टाइपिंग—दोनों—को एक रेखा में ला देता है। मार्कर इंजेक्ट करने वाला मेटाक्लास सुविधाजनक तो है, पर टाइप चेकर के लिए अपारदर्शी रहता है; वही तर्क डेस्क्रिप्टर में लाने से क्लास-स्तर और इंस्टेंस-स्तर की पहुँच का फर्क साफ़ दिखता है और कोड टूल्स तथा डेवलपर्स—दोनों—के लिए स्पष्ट रहता है।

यह लेख StackOverflow पर एक प्रश्न (लेखक: JoniKauf) और उसी के उत्तर पर आधारित है।