Привет, Хабр!
Python - язык с динамической типизацией. Хорошо это или плохо? С одной стороны, это скорость разработки: не нужно объявлять и запоминать типы переменных. С другой, это ошибки, которые всплывают при запуске или... через месяц продакшена.
В этой статье я покажу, почему type hinting - инструмент, который сэкономит часы отладки и сделает код безопаснее.
Начинаем начинать
Аннотации типов впервые были представлены в Python 3.5, который появился в 2015 году. Но до сих пор многие разработчики продолжают писать код в старом стиле. Возьмем типичный код типичного разработчика:
def process_user_data(user_data): return user_data['name'].upper()
На первый взгляд - это простая функция с нормальным названием. Но в ней кроются несколько проблем:
Непрозрачность контракта - без изучения реализации невозможно понять:
Какие типы данных ожидаются?
Какой структуры должен быть
user_data?
Хрупкость - не понятно, что вернет функция в случае ошибки
Давайте улучшим код и разберем каждый элемент:
from typing import TypedDict class UserData(TypedDict): id: int name: str def get_user_name(user_data: UserData) -> str: return user_data["name"].upper()
Как работают аннотации:
Параметры:
arg: type(например,user_data: UserData)Возвращаемое значение:
-> return_type(в нашем случае-> str)
Базовые типы (примитивы)
Самые простые и часто используемые аннотации:
from numbers import Real def add(a: int, b: int) -> int: return a + b def add(a: Real, b: Real) -> Real: return a + b def greet(name: str) -> str: return f"Hello {name}" def is_active(status: bool) -> bool: return status
С аннотациями типов даже самые простые функции становятся понятнее и безопаснее, ведь ваша IDE подскажет, если вы попытаетесь передать неправильный тип.
Важно помнить, что, даже при неправильно переданных значениях, ваш код запустится. Аннотации служат лишь подсказкой для разработчика.
Если вы хотите больше узнать об AI и разработке на Python, подписывайтесь на мой телеграмм канал:
Union, Optional, Literal
Union позволяет указать несколько допустимых значений, например, int/float
from typing import Union def add(a: Union[int, float], b: Union[int, float]) -> Union[int, float]: return a + b
Здесь функция add принимает аргументы типа int или float и возвращает результат того же типа.
def get_element(d: dict[str, str], key: str) -> Union[str, None]: return d.get(key, None)
Optional - указывает, что значение может быть либо типа T, либо None. Это эквивалентно Union[T, None]
user_name: Optional[str] = None
Классы с опциональными полями:
class User: def __init__(self, name: str, phone: Optional[str] = None): self.name = name self.phone = phone # Телефон может отсутствовать user1 = User("Ваня", "+999999999") user2 = User("Петя")
Literal жёстко фиксирует допустимые варианты. Идеален для:
статусов (
"active","pending","completed")HTTP-методов (
"GET","POST","PUT")любых константных значений
HttpMethod = Literal["GET", "POST", "PUT", "DELETE"] def send_request(method: HttpMethod, url: str) -> None: print(f"{method} запрос к {url}") send_request("POST", "/api") # OK send_request("PATCH", "/") # Ошибка типа
from typing import Literal def set_status(status: Literal["active", "inactive", "pending"]) -> None: print(f"Статус изменён на: {status}") set_status("active") # OK set_status("deleted") # Ошибка в mypy: недопустимое значение
Аннотации для коллекций: списки, словари, кортежи
Python позволяет типизировать не только примитивы, но и сложные структуры данных.
def find_item(d: dict[str, int], key: str) -> int | None: return d.get(key)
Функция ожидает на вход словарь, где ключом будет строка, а значением - числом. Возвращает функция либо число, либо None.
TypedDict
TypedDict позволяет явно описать словарь:
Какие ключи обязательны
Какие типы значений у каждого ключа
from typing import TypedDict class ServerConfig(TypedDict): host: str port: int ssl: bool def start_server(config: ServerConfig): print(f"Starting server with config: {config}") config: ServerConfig = {"host": "localhost", "port": 8080, "ssl": False} start_server(config)
TypedDict для валидации данных. Аннотации типов и типизированные структуры хорошо подходят для валидации данных
class Book(TypedDict): title: str year: int def validate_book(date: dict) -> Book: required_keys = {"title", "year"} if not all(key in data for key in required_keys): return None return Book(**data) raw_data = {"title": "Python", "year": 2005} book: Book | None = validate_book(raw_data) if book: print(book["title"])
NamedTuple
Кортеж удобны для хранения неизменяемых данных, например, DTO. Но обращение к полям по индексам ([0], [1]) — ненадёжно и усложняет чтение кода. NamedTuple решает эту проблему,
from typing import NamedTuple class Product(NamedTuple): name: str price: int quantity: int def total_value(product: Product) -> int: return product.price * product.quantity
Callable, Generator
Для функций так же существуют аннотации. Это полезно для функций высших порядок, колбэков, генераторов. Напишем функцию, которая в качестве аргументов будет принимать другие функции с определенными аргументами.
def send_messages(on_success: Callable[[], None]), on_failure: Callable[[Exception], None], ) -> None: if random.random() < 0.5: return on_success() return on_failure(Exception('something went wrong')) def on_success() -> None: print("success") def on_failure(exception: Exception) -> None: print(exception) send_messages(on_success, on_failure)
Аннотация для генераторов имеет вид:
Generator[YieldType, SendType, ReturnType]
YieldType - тип значения которое генератор выдает через yield
SendType - тип значения, которое передается в генератор через send
ReturnType - тип значения, возвращаемого при завершении генератора
from typing import Generator def generator(words: list[str]) -> Generator[str, None, None]: for word in words: yield word words = ["Москва", "Питер", "Казань"] for word in word_generator(words): print(word)
Generics
Допустим, мы хотим создать функцию, которая возвращает первый элемент списка любого типа. Без Generics вам бы пришлось использовать аннотацию Any, которая была бы бесполезна.
from typing import TypeVar T = TypeVar("T") def get_first_element(lst: list[T]) -> T | None: return lst[0] if lst else None
Напишем реализацию стэка, который содержит значения одного типа с использованием Generics:
class Stack(Generic[T]): def __init__(self): self.stack: list[T] = [] def push(self, element: T) -> None: self.stack.append(element) def pop(self) -> T: return self.stack.pop() int_stack = Stack[int]() int_stack.push(1) int_stack.push("1") #IDE покажет ошибку
Собственные аннотации (TypeAlies, NewType, Protocol)
TypeAlias
Полезен, когда нам нужно описать сложную аннотацию, которая не является структурой данных и занимает много места. Например, когда нужно написать аннотацию для функцию, как одном из примеров выше
from typing import TypeAlias, Callable func: TypeAlias = Callable[[float, float], float] def apply_operation(a: float, b: float, op: func) -> float: return op(a, b) add_op: func = lambda x, y: x + y print(apply_operation(1.2, 2.5, add))
Без использования TypeAlias сигнатура функции выглядела бы громоздкой.
Также вы можете использовать TypeAlias для рекурсивных ссылок, например, для аннотирования JSON структуры, в которой структуры отличаются от питоновских.
from typing import TypeAlias, Union Json: TypeAlias = Union[ str, int, float, bool, None, dict[str, 'Json'], # рекурсивная ссылка list['Json'] ] def parse_json(data: Json) -> None: print(data) data: Json = { "name": "ViacheslavVoo", "scores": [95, 87, 91], "metadata": {"active": True, "tags": None}, } parse_json(data)
Здесь стоит сказать, что IDE может не показать ошибку при использовании неправильных типов. Для проверки сложных или рекурсивных аннотаций стоит использовать mypy, который покажет ошибку. Например, при попытке использовать tuple:
error: Dict entry 0 has incompatible type "str": "tuple[int, int, int]"; expected "str": "str | int | float | dict[str, Json] | list[Json] | None" [dict-item] Found 1 error in 1 file (checked 1 source file)
NewType
Этот вид аннотаций стоит использовать для создания нового типа данных на основе существующего. Основное отличие от других аннотаций - newtype позиционируется как обособленный вид переменных. То есть NewType('', int) != int, в отличие от TypeAlias. Поэтому его можно использовать для смыслового разделения. Например:
from typing import NewType UserId = NewType("UserId", int) OrderId = NewType("OrderId", int) def get_user(UserId) -> None: ... user_id = UserId(1) order_id = OrderId(1) get_user(user_id) get_user(order_id) #mypy покажет ошибку, так как UserId и OrderId - разные типы get_user(1) #ошибка, так как ожидается UserId
Примечание: до версии Python 3.10 объявление NewType не добавляло накладных расходов, но в версии 3.10 NewType стал классом оберткой. Это прибавило затрат во времени исполнения. В Python 3.11 производительность вернули на уровень Python 3.9
Protocol
Protocol можно использовать при создании интерфейсов, которые вы не хотите явно реализовывать или не имеете возможности унаследоваться. Под Protocol будет подходить любой класс, который имеет метод draw. Такая штука крайне полезна для тестирования
from typing import Protocol class Drawable(Protocol): def draw(self) -> None: ... class Circle: def draw(self) -> None: print("draw circle") def some_func(self): print("some_func") def render(obj: Drawable): obj.draw() render(Circle())
Типизация в Python — это не строгие ограничения, а способ сделать код понятнее, надёжнее и удобнее. Используйте Type hinting в своих проектах и это поможет сэкономить вам часы рефакторинга и сохранит нервы новым сотрудникам, которые будут поддерживать ваш код
Спасибо за прочтение!
Подписывайтесь на мой телеграмм, чтобы не пропустить новые статьи
