در فصل هشتم، با متغیرهای نوع (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()تفاوتهای این سینتکس با سینتکس جدید:
- شما باید
TypeVarوGenericرا از ماژولtypingوارد کنید. - باید متغیر نوع (
T) را در بیرون از کلاس و به صورت جداگانه تعریف کنید. - کلاس شما باید از
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) هستند. در فصل بعد خواهید دید که چطور میتوانید بدون استفاده از ارثبری، به تایپچکر بگویید که “هر چیزی که این متدها را داشته باشد، برای من قابل قبول است”.