فصل دهم: کلاس‌های ژنریک

Please login to bookmark Close

در فصل هشتم، با متغیرهای نوع (TypeVar) آشنا شدید. دیدید که چطور یک تابع می‌تواند نوع ورودی خود را حفظ کند و همان نوع را به خروجی منتقل کند. حالا همین سوال را در مورد کلاس‌ها مطرح می‌کنیم.

یک کلاس ساده برای پشته (Stack) در نظر بگیرید. پشته یک ساختمان داده است که مانند یک انبار عمل می‌کند. شما می‌توانید اشیاء را روی آن قرار دهید (push) و آخرین شیء قرار داده شده را بردارید (pop).

class Stack:
    def __init__(self) -> None:
        self.items = []

    def push(self, item):
        self.items.append(item)

    def pop(self):
        return self.items.pop()

این کلاس به ظاهر ساده، یک مشکل اساسی دارد. به این مثال توجه کنید:

numbers = Stack()
numbers.push(1)        # یک عدد صحیح روی پشته قرار می‌دهیم
value = numbers.pop()  # آخرین مقدار را برمی‌داریم
value.upper()          # سعی می‌کنیم روی آن متد upper را صدا بزنیم

در خط آخر، ما سعی می‌کنیم روی یک عدد صحیح، متد upper() را صدا بزنیم. این کار در زمان اجرا با خطا مواجه می‌شود، چون اعداد صحیح متد upper() ندارند. اما تایپ‌چکر هیچ خطایی نمی‌گیرد. چرا؟ چون تایپ‌چکر نمی‌داند که متد pop() چه نوعی را برمی‌گرداند. ما به او نگفته‌ایم که پشته‌ی ما فقط اعداد صحیح را نگه می‌دارد.

اگر بخواهیم این مشکل را با تایپ‌هینت حل کنیم، به یک بن‌بست می‌رسیم. اگر خروجی pop() را int در نظر بگیریم، پشته‌ی ما فقط می‌تواند اعداد صحیح را نگه دارد و پشته‌ی رشته‌ها را از دست می‌دهیم. اگر خروجی را Any در نظر بگیریم، به همان وضعیت اولیه برمی‌گردیم و تایپ‌چکر هیچ کمکی به ما نمی‌کند.

ما به راهی نیاز داریم که به تایپ‌چکر بگوییم: «این کلاس با یک نوع خاص کار می‌کند، اما من الان نمی‌دانم آن نوع چیست. فقط می‌دانم که وقتی یک نمونه از این کلاس می‌سازم، نوع را مشخص می‌کنم و از آن به بعد، همه‌ی متدها با همان نوع کار می‌کنند.»

کلاس را ژنریک کنید

پایتون به ما اجازه می‌دهد که کلاس‌های ژنریک (Generic) بنویسیم. یک کلاس ژنریک، کلاسی است که با یک یا چند نوع نامعلوم کار می‌کند. شما نوع را در زمان ساختن نمونه (ایجاد شیء) مشخص می‌کنید.

class Stack[T]:
    def __init__(self) -> None:
        self.items: list[T] = []

    def push(self, item: T) -> None:
        self.items.append(item)

    def pop(self) -> T:
        return self.items.pop()

    def peek(self) -> T | None:
        return self.items[-1] if self.items else None

بیایید این کد را خط به خط بررسی کنیم:

  • [T] بعد از نام کلاس: این به پایتون و تایپ‌چکر می‌گوید که این کلاس یک متغیر نوع به نام T دارد. T یک نوع نامعلوم است که در تمام کلاس در دسترس خواهد بود.
  • self.items: list[T]: لیست items یک لیست از نوع T است. یعنی این لیست فقط می‌تواند اشیائی از همان نوع T را در خود نگه دارد.
  • push(self, item: T) -> None: متد push یک آیتم از نوع T دریافت می‌کند و آن را به لیست اضافه می‌کند.
  • pop(self) -> T: متد pop یک آیتم از نوع T را از لیست برمی‌دارد و برمی‌گرداند.
  • peek(self) -> T | None: متد peek بالاترین آیتم را بدون حذف کردن نشان می‌دهد. اگر پشته خالی باشد، None برمی‌گرداند.

حالا بیایید از این کلاس استفاده کنیم:

# یک پشته برای اعداد صحیح
numbers: Stack[int] = Stack()
numbers.push(1)
numbers.push(2)
value = numbers.pop()  # نوع value: int
print(value + 10)      # درست است

# یک پشته برای رشته‌ها
names: Stack[str] = Stack()
names.push("Ali")
names.push("Sara")
name = names.pop()     # نوع name: str
name.upper()           # درست است و اتوکامپلیت کار می‌کند

تایپ‌چکر دقیقاً می‌داند که هر پشته با چه نوعی کار می‌کند. اگر سعی کنید یک رشته را در پشته‌ی اعداد صحیح قرار دهید، تایپ‌چکر به شما خطا می‌دهد:

numbers: Stack[int] = Stack()
numbers.push("hello")  # خطا: نمی‌توان یک رشته را در پشته‌ی اعداد قرار داد

سینتکس قدیمی‌تر

در پایتون ۳.۱۱ و نسخه‌های پایین‌تر، سینتکس متفاوتی برای کلاس‌های ژنریک وجود داشت. این سینتکس قدیمی‌تر است، اما هنوز هم در بسیاری از پروژه‌ها دیده می‌شود. بنابراین آشنایی با آن ضروری است.

from typing import Generic, TypeVar

T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self) -> None:
        self.items: list[T] = []

    def push(self, item: T) -> None:
        self.items.append(item)

    def pop(self) -> T:
        return self.items.pop()

تفاوت‌های این سینتکس با سینتکس جدید:

  1. شما باید TypeVar و Generic را از ماژول typing وارد کنید.
  2. باید متغیر نوع (T) را در بیرون از کلاس و به صورت جداگانه تعریف کنید.
  3. کلاس شما باید از Generic[T] ارث‌بری کند.

هر دو سینتکس کار یکسانی را انجام می‌دهند. در پروژه‌های جدید، از سینتکس کوتاه (پایتون ۳.۱۲ و بالاتر) استفاده کنید. اما شکل قدیمی را هم بشناسید تا بتوانید کدهای موجود را بخوانید و درک کنید.

تعیین نوع در زمان ساخت

حالا که یک کلاس ژنریک داریم، تایپ‌چکر چگونه می‌فهمد که T در یک نمونه‌ی خاص، چه نوعی است؟ سه راه برای این کار وجود دارد.

راه اول: اعلان صریح روی متغیر

شما می‌توانید نوع متغیر را به طور کامل مشخص کنید:

stack: Stack[int] = Stack()

اینجا به تایپ‌چکر می‌گویید که stack از نوع Stack[int] است. پس T برابر int خواهد بود.

راه دوم: تعیین نوع در زمان ساخت (Constructor)

شما می‌توانید نوع را هنگام ساختن نمونه، مشخص کنید:

stack = Stack[int]()

این روش شبیه روش قبلی است، اما به جای اینکه نوع را روی متغیر مشخص کنید، آن را به خود کلاس می‌دهید.

راه سوم: استنتاج از ورودی سازنده

اگر سازنده‌ی کلاس (متد __init__) یک پارامتر از نوع T داشته باشد، تایپ‌چکر می‌تواند نوع را از روی آرگومانی که به سازنده می‌دهید، تشخیص دهد.

class Box[T]:
    def __init__(self, value: T) -> None:
        self.value = value

box = Box(42)        # تایپ‌چکر تشخیص می‌دهد: Box[int]
box2 = Box("hello")  # تایپ‌چکر تشخیص می‌دهد: Box[str]

در این حالت، نیازی به ذکر نوع ندارید. تایپ‌چکر خودش از روی مقدار ورودی، نوع را استنتاج می‌کند.

نکته مهم: اگر هیچ‌یک از این سه روش را به کار نبرید، تایپ‌چکر T را به عنوان یک نوع نامعلوم در نظر می‌گیرد. در این حالت، بسیاری از مزایای تایپ‌هینت از بین می‌رود و تایپ‌چکر نمی‌تواند به شما کمک کند. بنابراین همیشه سعی کنید یکی از این روش‌ها را به کار ببرید.

چند پارامتر نوع

کلاس‌های ژنریک می‌توانند بیش از یک پارامتر نوع داشته باشند. این ویژگی برای ساختن داده‌ساختارهایی که با کلید و مقدار کار می‌کنند، بسیار مفید است.

class Pair[K, V]:
    def __init__(self, key: K, value: V) -> None:
        self.key = key
        self.value = value

    def swapped(self) -> "Pair[V, K]":
        return Pair(self.value, self.key)

در این مثال، Pair دو متغیر نوع دارد: K برای نوع کلید و V برای نوع مقدار.

p = Pair("age", 32)      # تایپ‌چکر تشخیص می‌دهد: Pair[str, int]
q = p.swapped()          # تایپ‌چکر تشخیص می‌دهد: Pair[int, str]

متد swapped یک جفت جدید برمی‌گرداند که در آن کلید و مقدار جای خود را عوض کرده‌اند. توجه کنید که ما در نوع خروجی، از کوتیشن استفاده کرده‌ایم: "Pair[V, K]". این کار به دلیل ارجاع روبه‌جلو (Forward Reference) است که در فصل هفتم با آن آشنا شدید.

یک مثال واقعی‌تر: کلاس Cache

class Cache[K, V]:
    def __init__(self) -> None:
        self.data: dict[K, V] = {}

    def get(self, key: K) -> V | None:
        return self.data.get(key)

    def set(self, key: K, value: V) -> None:
        self.data[key] = value

استفاده از این کلاس:

sessions: Cache[str, dict] = Cache()
sessions.set("abc123", {"user_id": 1, "name": "Ali"})
session = sessions.get("abc123")  # نوع session: dict | None

اینجا K از نوع str (رشته) و V از نوع dict (دیکشنری) است. تایپ‌چکر دقیقاً می‌داند که کلیدها باید رشته باشند و مقادیر باید دیکشنری باشند.

محدود کردن با bound

همانطور که در فصل هشتم دیدید، می‌توانید متغیر نوع را با استفاده از bound محدود کنید. این کار به شما اجازه می‌دهد که فقط نوع‌هایی را بپذیرید که ویژگی‌ها یا متدهای خاصی دارند.

class Model:
    id: int

class Repository[T: Model]:
    def __init__(self) -> None:
        self.items: list[T] = []

    def add(self, item: T) -> None:
        self.items.append(item)

    def find_by_id(self, item_id: int) -> T | None:
        for item in self.items:
            if item.id == item_id:   # این خط مجاز است، چون T زیرکلاس Model است
                return item
        return None

در این مثال، ما به تایپ‌چکر می‌گوییم که T باید از نوع Model باشد یا از کلاسی که از Model ارث‌بری کرده است. بنابراین، در متد find_by_id، ما مطمئن هستیم که هر شیء از نوع T، یک ویژگی به نام id دارد. بدون این محدودیت، تایپ‌چکر خطا می‌داد چون نمی‌دانست که T حتماً id دارد.

مزیت اصلی bound این است که نوع دقیق را حفظ می‌کند.

class User(Model):
    name: str

users: Repository[User] = Repository()
user = users.find_by_id(1)   # نوع user: User | None، نه Model | None

اگر bound نداشتیم، خروجی find_by_id از نوع Model | None بود و ما اطلاعات مربوط به زیرکلاس (مثل ویژگی name) را از دست می‌دادیم. اما با bound، تایپ‌چکر می‌داند که خروجی از همان نوع T است که ما به Repository داده‌ایم.

متد ژنریک در کلاس غیرژنریک

همیشه لازم نیست که کل کلاس را ژنریک کنید. گاهی فقط یک متد خاص باید با انواع مختلف کار کند. در این موارد، می‌توانید فقط آن متد را ژنریک کنید.

class Serializer:
    def to_list[T](self, item: T) -> list[T]:
        return [item]

    def first_or_default[T](self, items: list[T], default: T) -> T:
        return items[0] if items else default

در این کلاس، خود کلاس Serializer ژنریک نیست، اما متدهای to_list و first_or_default ژنریک هستند. هر متد متغیر نوع مخصوص به خود را دارد (T). این T فقط در همان متد معنا دارد و با T متد دیگر متفاوت است. تایپ‌چکر هر بار که این متدها را صدا می‌زنید، نوع را به طور مستقل تعیین می‌کند.

s = Serializer()
result1 = s.to_list(42)         # نوع result1: list[int]
result2 = s.to_list("hello")    # نوع result2: list[str]

چه زمانی کلاس را ژنریک کنیم و چه زمانی متد را؟

قانون ساده‌ای برای این تصمیم وجود دارد. اگر نوع، ویژگی خود شیء است و در تمام طول عمر شیء ثابت می‌ماند، کلاس را ژنریک کنید. مثلاً پشته‌ی اعداد صحیح، همیشه اعداد صحیح را نگه می‌دارد. اما اگر نوع فقط در طول یک فراخوانی مطرح است، متد را ژنریک کنید. مثلاً متدی که یک آیتم را به لیست تبدیل می‌کند، فقط در همان لحظه با نوع سروکار دارد.

ارث‌بری از کلاس ژنریک

کلاس‌های ژنریک نیز مانند کلاس‌های معمولی می‌توانند از یکدیگر ارث‌بری کنند. سه حالت مختلف برای این کار وجود دارد.

حالت اول: تعیین نوع در زیرکلاس (غیرژنریک)

در این حالت، زیرکلاس، نوع را مشخص می‌کند و خودش دیگر ژنریک نیست.

class IntStack(Stack[int]):
    def sum(self) -> int:
        return sum(self.items)

IntStack فقط با اعداد صحیح کار می‌کند و شما نمی‌توانید یک IntStack[str] بسازید. این کلاس یک متد جدید به نام sum دارد که مجموع تمام اعداد را محاسبه می‌کند.

حالت دوم: زیرکلاسی که ژنریک می‌ماند

در این حالت، زیرکلاس هم ژنریک است و نوع را به کلاس والد می‌دهد.

class LoggedStack[T](Stack[T]):
    def push(self, item: T) -> None:
        print(f"در حال قرار دادن {item} روی پشته")
        super().push(item)

LoggedStack همچنان ژنریک است. شما می‌توانید LoggedStack[str] بسازید و متد push علاوه بر کار اصلی، یک پیام نیز چاپ می‌کند.

حالت سوم: ارث‌بری از چند کلاس ژنریک

شما می‌توانید از چند کلاس ژنریک به طور همزمان ارث‌بری کنید.

class Registry[K, V](Cache[K, V], Sized):
    pass

اینجا Registry از Cache و Sized ارث‌بری می‌کند و خودش هم با دو پارامتر نوع K و V ژنریک است.

رفتار در زمان اجرا

یکی از مهم‌ترین نکاتی که باید در مورد کلاس‌های ژنریک بدانید، رفتار آنها در زمان اجرا است.

stack = Stack[int]()
print(type(stack))  # خروجی: <class '__main__.Stack'>

در زمان اجرا، Stack[int] و Stack[str] هر دو یک کلاس هستند. پایتون در زمان اجرا، اطلاعات مربوط به نوع‌های ژنریک را از بین می‌برد. این پدیده به “پاک شدن نوع” (Type Erasure) معروف است.

# این کد کار نمی‌کند
isinstance(stack, Stack[int])   # TypeError: isinstance() argument 2 cannot be a parameterized generic

شما نمی‌توانید با isinstance بررسی کنید که یک نمونه، از نوع Stack[int] است یا نه. تنها کاری که می‌توانید انجام دهید این است که بررسی کنید آیا نمونه، از نوع Stack است یا خیر.

isinstance(stack, Stack)  # این کار می‌کند و True برمی‌گرداند

اگر در زمان اجرا به نوع دقیق نیاز دارید، باید خودتان آن را در جایی ذخیره کنید. مثلاً می‌توانید یک ویژگی به کلاس اضافه کنید که نوع را در خود نگه دارد.

باور غلط رایج

یک تصور غلط رایج این است که شما می‌توانید یک Stack[int] را به تابعی بدهید که Stack[object] انتظار دارد. این تصور از اینجا ناشی می‌شود که int زیرکلاس object است.

def show(stack: Stack[object]) -> None:
    stack.push("یک رشته")

numbers: Stack[int] = Stack()
show(numbers)   # تایپ‌چکر این را قبول نمی‌کند

تایپ‌چکر به شما خطا می‌دهد. چرا؟ چون اگر این کار مجاز بود، تابع show می‌توانست یک رشته را در پشته‌ای که قرار بود فقط اعداد صحیح را نگه دارد، قرار دهد. این کار امنیت نوع را نقض می‌کرد. تایپ‌چکر از شما محافظت می‌کند و اجازه نمی‌دهد چنین کاری انجام دهید.

خلاصه

در این فصل، یاد گرفتید که چطور کلاس‌های ژنریک بنویسید. این کلاس‌ها به شما اجازه می‌دهند که یک ساختار داده را برای انواع مختلف به کار ببرید، بدون اینکه اطلاعات نوع را از دست بدهید.

  • یک کلاس ژنریک با class Stack[T] تعریف می‌شود (در پایتون ۳.۱۲ و بالاتر) یا با class Stack(Generic[T]) (در نسخه‌های قدیمی‌تر).
  • سه راه برای تعیین نوع در زمان ساخت وجود دارد: اعلان روی متغیر، تعیین در زمان ساخت با []، و استنتاج از ورودی سازنده.
  • می‌توانید چندین پارامتر نوع داشته باشید، مثل Cache[K, V].
  • با استفاده از bound می‌توانید نوع را محدود کنید و به ویژگی‌های آن دسترسی داشته باشید.
  • می‌توانید فقط یک متد را ژنریک کنید، بدون اینکه کل کلاس را ژنریک کنید.
  • در زمان اجرا، Stack[int] و Stack[str] یک کلاس هستند و نمی‌توانید با isinstance آنها را از هم تشخیص دهید.
  • تایپ‌چکر اجازه نمی‌دهد که یک Stack[int] را به جایی بدهید که Stack[object] انتظار دارد، چون این کار امنیت نوع را نقض می‌کند.

در فصل بعد

آخرین ابزار گرامری این دوره، پروتکل‌ها (Protocols) هستند. در فصل بعد خواهید دید که چطور می‌توانید بدون استفاده از ارث‌بری، به تایپ‌چکر بگویید که “هر چیزی که این متدها را داشته باشد، برای من قابل قبول است”.

Please login to bookmark Close
نظرات

دیدگاهتان را بنویسید

پیشرفت
فهرست مطالب

سرفصل دوره

تمرین

این قسمت تمرین ندارد!

پاسخ تمرین ها

هنوز برای تمرین‌های این قسمت پاسخی ثبت نشده است!

اشتراک گذاری

چرا بهتره از فیلترشکن استفاده کنید؟

من همه ویدئو ها و پادکست های کُدباز رو توی یوتیوب و ساندکلود و پلتفرم هایی آپلود می‌کنم که اغلب فیلتر هستند.

اغلب آموزش‌ها ویدئو و پادکست دارند. پس اگر می‌خواهید از محتوای سایت بیشترین استفاده رو ببرید نیاز به فیلتر شکن دارید.

توجه داشته باشید که برای خرید از فروشگاه بهتره فیلتر شکن رو خاموش کنید.

تنظیمات

انتخاب زبان
تغییر تم