Привет, Хабр!
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 подскажет, если вы попытаетесь передать неправильный тип.
Важно помнить, что, даже при неправильно переданных значениях, ваш код запустится. Аннотации служат лишь подсказкой для разработчика.
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 в своих проектах и это поможет сэкономить вам часы рефакторинга и сохранит нервы новым сотрудникам, которые будут поддерживать ваш код
Спасибо за прочтение!
Подписывайтесь на мой телеграмм, чтобы не пропустить новые статьи
Источник: https://habr.com/ru/articles/924836/