استفاده پیشرفته از Type Hint ها در پایتون

در پایتون نسخه ۳.۵ ویژگی جدید به نام Type Hints معرفی شد که راهی برای ایمن بودن Type ها ایجاد کرد. حالا پس از گذشت این همه سال و نسخه های مختلفی که منتشر شده، Type Hint ها بسیار جامع تر شده اند. در این مقاله به بررسی استفاده های حرفه ای از این ویژگی می‌پردازیم.

استفاده پیشرفته از Type Hint ها در پایتون
Generated by DALL-E 3

در سپتامبر ۲۰۱۵ پایتون نسخه ۳.۵ منتشر شد که یک ویژگی جدید به نام Type Hints داشت. از این نسخه به بعد پایتون که به عنوان یک زبان Dynamic Type شناخته می‌شود ، راهی برای ایمن بودن Type ها پیدا کرد.

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

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

1. TypeVar : تعیین نوع متغیر

همونطور که از اسمش پیداست، TypeVar برای تعریف یک متغیر بدون نوع خاصی استفاده میشه. در نگاه اول غیر ضروری به نظر میاد. با توجه به اینکه نوع خاصی رو تعریف نمی کنیم، چرا زحمت استفاده از Type Hint رو به خودمون بدیم؟

بیاید اهمیتش رو از طریق یک مثال درک کنیم:

from typing import TypeVar, List

T = TypeVar('T')


def first_el(a: List[T]) -> T:
    return a[0] 


numbers: List[int] = [1, 2, 3]
a: int = first_el(numbers)
b: str = first_el(numbers)

کد بالا دارای مشکل عدم تطابق نوع ( Type Unmatched ) است. اگرچه در اجرای این برنامه پایتون تأثیری نخواهد گذاشت ولی نامرتب بودن Type ها ممکن است باعث ایجاد باگ های پنهان غیرمنتظره بشه و اجتناب از این مشکلات ناخواسته هدف اصلی Type Hint ها در پایتون است.

برای پیدا کردن مشکل، بیاید ابزار معروف چک کردن تایپ پایتون Mypy را با pip نصب کنیم:

pip install mypy

برای بررسی قطعه کد بالا، از دستور زیر استفاده کنید:

$ mypy main.py

main.py:12: error: Incompatible types in assignment (expression has type "int", variable has type "str")  [assignment]
Found 1 error in 1 file (checked 1 source file)

خروجی این دستور، به ما هشدار می‌دهد که نوع متغیر برگشتی باید با نوع متغیر a یکسان باشد. چرا ؟ چون تابع خودمون رو اینطوری تعریف کردیم:

def first_el(a: List[T]) -> T

حالا هدف مشخص تر شد، استفاده از TypeVar در این کد برای اطمینان از این مورد است که نوع مقدار برگشتی حتما با نوع داده ورودی یکسان باشه.
به طور کلی، TypeVar برای تعریف یک نوع از متغیر استفاده میشه که میتونه در مکان های مختلف برای اعمال Type Consistency استفاده بشه.

چرا اون رو با نام T تعریف کردیم؟ درواقع این یک اسم مستعار است و تا وقتی که آرگومان ارسال به ()TypeVar همنام با متغیری که بهش داده میشه باشه، سینتکس درستی دارید.

بیاید یه مثال پیشرفته تر رو نشونتون بدم:

from typing import TypeVar, Generic, List, Tuple

T = TypeVar('T')
Y = TypeVar('Y')


def swap(x: T, y: Y) -> Tuple[Y, T]:
    return y, x


a: Tuple[int, int] = swap(1, 2)
b: Tuple[str, int] = swap(6, 9)

کد بالا که جای متغیر ها رو عوض میکنه از Type Hint استفاده میکنه تا مطمئن بشه عملیات با نوع مربوطه مطابقت داشته باشه. به نظر می رسه متقیر b قراره مشکل داشته باشه. بیاید کد رو چک کنیم:

$ mypy main.py

main.py:12: error: Argument 2 to "swap" has incompatible type "int"; expected "str"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

همونطور که انتظار داشتیم، عبارت b: Tuple[str, int] = swap(6, 9) در مرحله بررسی نوع متغیر خطا داره چون نوع متغیر های ارسالی به تابع، با چیزی که براش تعریف شده تناقض داره.
در یک کلام، هر زمان که لازم باشه به یک نوع نامشخص بیشتر از یک بار مراجعه کنیم، TypeVar انتخاب خوب و ظریفی خواهد بود.

2. NewType : ایجاد نوع جدید بر اساس موارد قدیمی

در برخی موارد، متغیر های مختلف همگی یک نوع خاص مانند String یا Integer دارند ولی ممکن است نیاز داشته باشیم اون ها رو بیشتر از هم تفکیک کنیم.

اینطور مواقع NewType دقیقا همون چیزیه که بهش نیاز داریم.

به مثال زیر دقت کنید. اینجا دو متغیر از نوع int رو از هم تفکیک می‌کنیم:

from typing import NewType

UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)


def get_user_name(user_id: UserId) -> str:
    return "Arash Hatami"


def get_order_details(order_id: OrderId) -> str:
    return "RPI 4"


user_id = UserId(1)
order_id = OrderId(20)

# Correct usage!
print(get_user_name(user_id))
print(get_order_details(order_id))

# Incorrect usage!
print(get_user_name(1))

از اونجایی که آرگومان های ورودی توابع فوق از نوع new هستن، اگه یک متغیر int مستقیماً بهشون داده بشه، برنامه ما بررسی ها رو پاس نمی‌کنه. بیاید چک کنیم:

$ mypy main.py

main.py:23: error: Argument 1 to "get_user_name" has incompatible type "int"; expected "UserId"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

با اینکه استفاده از NewType خیلی حال میده و کد رو مرتب می‌کنه ولی در کمیونیتی های مختلف زیاد ازش استقبال نمیشه و اون رو تایید نمی‌کنن. ما داریم پایتون رو تبدیل به زبان های Static-Typed می‌کنیم.

توی خیلی از این زبان ها استفاده از Strong Type Variable ها توصیه میشه ولی خب اینجا ما پایتون رو داریم ... احتمالا نیاز به این چیزا نیست.

After all, the purpose of Python’s type-hint system is not copying the type-checking approaches from static-typed languages, it is to create a Pythonic way of type checking.

3. Final Type : تعریف ساده تر متغیر های Const

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

LOG_LEVEL = "INFO"

از پایتون نسخه ۳.۸ به بعد، نوع Final اضافه شد تا کدهامون تمیزتر بشه:

from typing import Final

LOG_LEVEL: Final = "INFO"

همونطور که در کد بالا مشاهده می‌کنید ما یک متغیر رو با نوع Final تعریف کردیم. این کار یک لایه امنیتی اضافی به برنامه مون اضافه میکنه تا اگر در ادامه کد این متغیر تغییر کرد، بررسی ها پاس نشه.

برای مثال اگر چنین کدی رو بررسی کنیم با خطا مواجه می‌شیم:

from typing import Final

LOG_LEVEL: Final = "INFO"

LOG_LEVEL = "ERROR"
$ mypy main.py

main.py:5: error: Cannot assign to final name "LOG_LEVEL"  [misc]
Found 1 error in 1 file (checked 1 source file)

4. TypedDict : تعریف بهتر دیکشنری ها

برای تعیین انواع جفت های Key/Value در پایتون، می‌تونیم کلاسی رو تعریف کنیم که از TypeDict ارث بری داشته باشه:

from typing import TypedDict


class Contact(TypedDict):
    name: str
    age: int


a: Contact = {"name": "Arash Hatami", "age": 29}

اگه با FastAPI کار کرده باشید، استفاده مشابهی رو خواهید دید:

from fastapi import FastAPI
from pydantic import BaseModel


class Contact(BaseModel):
    name: str
    age: int


app = FastAPI()


@app.post("/contact/")
async def create_contact(contact: Contact):
    # do something here
    return contact

فریمورک FastAPI از Pydantic استفاده میکنه که یک کتابخونه برای گسترش Type Hint های پایتون هست. هر کلاسی که از BaseModel ارث بری کند قابلیت انتخاب نوع متغیر برای KV های خودش رو داره و در نهایت یک TypeDict به شما تحویل میده.

در نهایت استفاده از TypeDict به شما قابلیت تعریف امن تر کلاس ها رو میده. ابزارهایی مانند Pydantic نیز هستند که امکانات مشابه به کاربر میده. از کدوم میخواید استفاده کنید؟ به نظرم فرقی نداره.

5. Protocol : پیاده سازی Duck Typing در پایتون

حتما با جمله معروفت "آزمون اردک" آشنا هستید:

آزمون اردک نمونه‌ای از استدلال استقرایی است با این بیان که: «اگر ظاهر چیزی شبیه اردک است، مثل اردک شنا می‌کند و صدای اردک درمی‌آورد، آن چیز احتمالاً اردک است»

این شیوه فرض می‌گیرد که شخص می‌تونه موضوعی ناشناخته رو با مشاهده خصوصیات اون موضوع شناسایی بکنه. این ایده پشت مفهوم Duck Typing در علوم کامپیوتر است.

پایتون این ایده رو دوست داشت و نوع Protocol برای تعریف Duck Typing یا اون چیزی که خودشون بهش “structural subtyping” میگن از نسخه 3.8 به بعد اضافه شد و در اختیار توسعه دهنده ها قرار گرفت.

این روش برای مشخص کردن این مورد به کار میاد که هر Object که با یک "ساختار" خاص مطابقت داره ( یعنی دارای متدها و attribute های خاصی باشه ) می تونه به عنوان یک آرگومان برای یک تابع یا به عنوان نوع یک متغیر بدون توجه به وراثت کلاس واقعی اون استفاده بشه.

شاید توضیح متنی اون سخت باشه، بیاید از طریق مثال زیر بهش بپردازیم:

from typing import Protocol


class SuperHero(Protocol):
    def fly(self) -> None:
        pass


# This function accepts any object that has a fly() method as defined in the SuperHero protocol
def call_a_hero(hero: SuperHero) -> None:
    # give the superhero a call
    pass


class IronMan(SuperHero):
    def fly(self) -> None:
        print("Ironman starts to fly")


class SpiderMan:
    def fly(self) -> None:
        print("Spider man starts to fly")


class Man:
    def run(self) -> None:
        print("Running")


Tony = IronMan()
Yang = Man()
Peter = SpiderMan()

call_a_hero(hero=Tony)
call_a_hero(hero=Yang)
call_a_hero(hero=Peter)

در این مثال تابع call_a_hero سه بار فراخوانی میشه ولی یکی از اون ها type-safe نیست. به نظرتون کدوم؟ بیاید یه بار کد رو بررسی کنیم:

$ mypy main.py

main.py:35: error: Argument "hero" to "call_a_hero" has incompatible type "Man"; expected "SuperHero"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

نتیجه بالا نحوه عملکرد نوع Protocol رو به شما نشون میده. کلاس SuperHero از Protocol به ارث رسیده و متد ()fly رو تعریف می‌کنه. بنابراین، کلاس‌های دیگه، فرقی نمی‌کند که از SuperHero به ارث رسیده باشند یا نه، تا زمانی که متد ()fly مشابهی دارند، تحت یک پروتکل هستند ( به عنوان نوع SuperHero در نظر گرفته می‌شوند ).

معنی "Duck Typing" اینجا میشه اینکه : اگر بتونید به عنوان یک ابرقهرمان پرواز کنید، شما یک ابرقهرمان هستید 😃

توجه: مثال بالا از پایتون نسخه 3.11 به بعد کار می کنه. این نسخه قابلیت‌های بررسی‌کننده‌های Type رو افزایش داد تا تشخیص دهند که یک کلاس با یک Protocol بر اساس ساختار اون ( متدها و attribute ها ) به تنهایی مطابقت دارد، بدون اینکه نیازی به ارث بری از کلاس Protocl باشد. قبل از نسخه 3.11، کلاس SpiderMan باید از SuperHero نیز ارث می‌برد.

6. Union Type : یک متغیر و چند نوع مختلف

انعطاف پذیری Type Hint ها در پایتون همیشه یکی از مزیت های این زبان برنامه نویسی خواهد بود 😍. به عنوان مثال، ما می توانیم چندین نوع را در یک متغیر از طریق نوع Union اعمال کنیم:

from typing import Union

number: Union[int, float] = 3.14

علاوه بر روش بالا، در نسخه 3.10 پایتون راه ساده تری بدون استفاده از عبارت Union معرفی شد:

number: int | float = 3.14

ظرافت هیچوقت تمام نمیشه، درسته؟

7. Callable Types : تعریف متغیری که یک فانکشن درون خودش داشته باشه

اگر ما بدونیم یک متغیر باید یک تابع با ورودی و و خروجی های مشخص باشه، چطور اون رو با Type Hint مشخص کنیم؟ اینجا جایی است که Callable خودش رو نشون میده.

from typing import Callable


def perform_action(action: Callable[[int, int], str], x: int, y: int) -> None:
    result = action(x, y)
    print(f"Action Result: {result}")


def sum_two(a: int, b: float) -> str:
    return f"The sum is: {a + b}"


perform_action(sum_two, 5, 3)
# Action Result: The sum is: 8

همونطور که در مثال بالا مشخص است، نوع Callable به عنوان Callable[[Arg1Type, Arg2Type, ...], ReturnType] تعریف میشه.

  • بخش اول داخل براکت ها، انواع آرگومان هایی که انتظار استفاده ازشون هست رو لیست میکنه
  • بخش دوم بعد از کاما، نوع برگشتی رو مشخص میکنه

با کمک این روش، تابع perform_action میتونه یک Action Parameter رو به صورت Type-Safe دریافت کنه.

8. Annotated : اضافه کردن اطلاعات بیشتر به Type Hint ها

نوع Annotated یک ویژگی منحصربه‌فرد است که در نسخه 3.9 پایتون معرفی شده و برای افزایش بیشتر قابلیت‌های Type Hint ها طراحی شده و به ما این امکان رو می‌ده تا Metadata های داشته باشیم.

همونطور که مثال زیر نشون می ده، استفاده از Annotated هیچ چیز تجملاتی و خفنی نیست. فقط یکسری محدودیت جدید به عنوان Metadata به متغیر فارق از نوعی که داره اضافه میشه.

from typing import Annotated


def max_length(length: int):
    return lambda value: len(value) <= length


def validate_positive(x: int) -> bool:
    return x > 0


# Using Annotated to attach metadata to type hints
Name = Annotated[str, max_length(10)]
Age = Annotated[int, validate_positive]


def register_user(name: Name, age: Age) -> None:
    print(f"Registered user {name} aged {age}")


register_user("Yang Zhou", Age(18))

همونطور که می‌دونیم، این محدودیت های اضافی فقط "راهنما" هستن و صرف نظر از اینکه اون ها رو رعایت کنیم یا نه، کدهای ما قابل اجرا هستند. اگر نیاز به اعمال محدودیت‌های اضافی ایجاد شده توسط Annotated داشته باشیم، می‌تونیم کد Validation براش بنویسیم یا از ابزارهای آماده استفاده کنیم.

باز هم، FastAPI از این تکنیک به خوبی استفاده می کنه و یک رویکرد Validation یکپارچه را ارائه می ده.

بر اساس مستندات رسمی خودش، تا زمانی که از ترفند Annotated استفاده کنیم، FastAPI کارهای اعتبارسنجی داده رو در بک‌گراند به صورت خودکار برای ما انجام میده. مثال زیر نمونه خوبی است و به ما نشون می ده که Annotated چقدر مفید و راحت هست و می‌تونیم همه جا ازش استفاده کنیم:

from typing import Annotated

from fastapi import FastAPI, Query

app = FastAPI()


@app.get("/items/")
async def read_items(q: Annotated[str | None, Query(max_length=50)] = None):
    results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]}
    if q:
        results.update({"q": q})
    return results

9. TypeAlias : ساده کردن استفاده مجدد از Type Hint های پیچیده

اگر یک Type Hint ساختار پیچیده ای داشته باشه، نوشتن دوباره و دوباره اون خیلی ما رو اذیت میکنه. خبر خوب اینکه پایتون نسخه 3.10 به فکر این مورد هم بوده و زندگی ما رو ساده کرده.

ویژگی TypeAlias به ما این امکان رو میده تا برای Type Hint های خودمون یک میانبر تعریف کنیم.

from typing import List, TypeAlias

Matrix: TypeAlias = List[List[int | float]]  # A list of lists of floats

A: Matrix = [[1, 2, 3], [4, 5, 6]]

همونطور که در کد بالا مشاهده می‌کنید، List[List[int | float]] یک میانبر برای Matrix است و ما دیگه نیاز نداریم داخل کد خودمون این عبارت پیچیده رو بارها تکرار کنیم.

بازهم فکر می‌کنید استفاده ازش سخته؟ اوکی ...توی نسخه 3.12 پایتون حتی کار ساده تر شده و راه بهتری برای این کار وجود داره:

from typing import List

type Matrix = List[List[int | float]] 

A: Matrix = [[1, 2, 3], [4, 5, 6]]

نتیجه گیری

استفاده از Type Hint ها پایتون رو وارد مرحله جدیدی کرده. با استفاده از اون ها، پروژه های ما خوانا تر، قابل اعتماد تر و Type-Safe خواهند بود.

در این مقاله 9 استفاده حرفه ای از Type Hint ها رو پوشش داد که اگر بخواهیم به ترتیب تاریخ اون ها رو بررسی کنیم اینطوری میشه:

  1. TypeVar — Python 3.5
  2. NewType — Python 3.5
  3. Final — Python 3.8
  4. TypedDict — Python 3.8
  5. Protocol — Python 3.8
  6. Union — Python 3.5 ( We can simply use | since Python 3.10 )
  7. Callable — Python 3.5
  8. Annotated — Python 3.9
  9. TypeAlias — Python 3.10 ( We can simply use type since Python 3.12 )