Типизация в Python: как аннотации спасают ваш код и ускоряют разработку

Привет, Хабр!

Python — язык с динамической типизацией. Хорошо это или плохо? С одной стороны, это скорость разработки: не нужно объявлять и запоминать типы переменных. С другой, это ошибки, которые всплывают при запуске или… через месяц продакшена.
В этой статье я покажу, почему type hinting — инструмент, который сэкономит часы отладки и сделает код безопаснее.

Начинаем начинать

Аннотации типов впервые были представлены в Python 3.5, который появился в 2015 году. Но до сих пор многие разработчики продолжают писать код в старом стиле. Возьмем типичный код типичного разработчика:

def process_user_data(user_data):
    return user_data['name'].upper()

На первый взгляд — это простая функция с нормальным названием. Но в ней кроются несколько проблем:

  1. Непрозрачность контракта — без изучения реализации невозможно понять:
    • Какие типы данных ожидаются?
    • Какой структуры должен быть user_data?
  2. Хрупкость — не понятно, что вернет функция в случае ошибки

Давайте улучшим код и разберем каждый элемент:

from typing import TypedDict

class UserData(TypedDict):
    id: int
    name: str

def get_user_name(user_data: UserData) -> str:
    return user_data["name"].upper()

Как работают аннотации:

  1. Параметры: arg: type (например, user_data: UserData)
  2. Возвращаемое значение: -> 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/