С появлением подсказок типов (type hints) в Python 3.5+ добавилась опциональная статическая типизация – поэтому эти подсказки так мне нравятся. Теперь я аннотирую ими все мои проекты.

Фрагмент кода, carbon.now.sh
Фрагмент кода, carbon.now.sh

Когда еще в 2016 году вышел Python 3.6, меня восхитили некоторые новые возможности, которые в нем предоставлялись. Среди них меня особенно впечатлили f-строки (если вы ими пока не пользовались, начните, не откладывая).

Спустя некоторое время после того, как я апгрейдил мой стек для работы с Python 3.6, я услышал об аннотациях типов из видео Дэна Бейдера. Он объяснил, что это такое, и для чего они ему пригодились.

Хотя, аннотации типов были введены в Python 3.5 (еще в 2015 году), о них по-прежнему интересно поговорить более подробно.

Думаю, как раз настало время поделиться с вами опытом!

Статическая типизация в Python?! Нет, это не для меня

Впервые услышав об аннотациях типов, я ими не впечатлился. Думал, что аннотации типов – это какой-то костыль на уровне языка Python.

Идея указывать типы в языке с динамической типизацией показалась мне, мягко говоря, странной, учитывая, что динамическая природа Python годами меня устраивала.

На тот момент я считал, что вполне нормально написать такой код:

def set_pos(self, pos):
    self.x = pos[0]
    self.y = pos[1]

Что должно содержаться в pos? Это же очевидно – просто смотрим в код и видим, что там должен находиться кортеж с двумя числами (а какими именно числами? Целыми? С плавающей точкой?).

Я также узнал, что в среде выполнения Python аннотации типов вообще не использовались. Ранее они полностью игнорировались. Поэтому я подумал: зачем их использовать, если они никак не влияют на выполнение кода, который я пишу?

В самом деле, мне было непонятно, откуда весь этот растущий сыр-бор по поводу введения статической типизации в Python.

Пока вы не начали сотрудничать с другими разработчикам

Один из первых фрагментов кода, который я прочитал, был написан моим наставником. Он – из тех, у кого есть большой практически опыт работы с Java, но для выполнения задач ему также потребовалось освоить Python. Поскольку наставник исходно работал со статически типизируемыми языками, он настойчиво выступал за аннотирование типов в Python и в своем коде использовал их повсюду. Когда я спросил, зачем, он просто мне ответил:

Так всем понятнее. Аннотации типов поясняют читателю твоего кода, каков в этом коде ввод и вывод – даже спрашивать об этом не приходится.

Меня поразило, что в этом тезисе такой акцент делается на удобстве других людей.

В принципе, он сказал, что человек использует аннотации типов, чтобы другим было проще понимать его код.

Удобочитаемость важна

Задумайтесь об этом. Когда вы работаете над проектом, тот код, который вы пишете сейчас, может казаться вам вполне осмысленным. Вы не ощущаете нужды подробно его документировать.

Но другие люди (в том числе, вы через полгода) в будущем должны будут читать ваш код и понимать, что он означает. Как я полагаю, при этом читателю кода приходится ответить как минимум на три основных вопроса:

  1. Что этот фрагмент принимает в качестве ввода?

  2. Как он обрабатывает ввод?

  3. Что этот код выдает в качестве вывода?

По мере того, как я все больше читал код коллег – в том числе, сложный унаследованный код — я осознал, что аннотации типов на самом деле крайне полезны. Аннотации типов позволили мне ответить на вопросы 1 и 3 мгновенно. (на вопрос 2 можно ответить не менее просто, если в коде правильно выбраны имена функций.)

Давайте сыграем в игру

Ниже я написал функцию, ее тело скрыто. Можете мне рассказать, что она делает?

def concat(a, b):
    ...

Вот моя версия — судя по имени функции, я бы сказал, что concat() принимает два списка (или кортежа?) и сцепляет их, возвращая в результате единый список, в котором содержатся элементы a и b.

Очевидно, правда? Не вполне.

На самом деле, здесь есть две возможности. Что, если на самом деле concat() просто сцепляет две строки, например?

Вот в чем дело — мы, в принципе, не понимаем, что делает concat(), поскольку мы не можем ответить на все три вопроса, приведенных выше. Можно примерно ответить только на вопрос 2: «делается какое-то сцепление».

А теперь давайте добавим аннотации типов для concat():

def concat(a: int, b: int) -> str:
    ...

Ага! Значит, мы в обоих вопросах ошиблись. По-видимому, concat() принимает два целых числа и на выходе выдает строку.

Так что, теперь я скажу: она принимает на ввод два целых числа, преобразует их в строковые представления, сцепляет их и возвращает результат.

А вот что именно она делает:

def concat(a: int, b: int) -> str:
    return str(a) + str(b)

Этот пример показывает, что знание ввода и вывода критически важно, чтобы понять этот фрагмент кода. А аннотации типов позволяют решить эту задачу почти мгновенно.

Здесь обычно был обходной маневр

Возвращаясь к моему опыту, скажу, что уже знал об этом – задолго до того, как начал использовать аннотации типов — и, вероятно, вы тоже знали.

Мне всегда нравился чистый код, который я при этом документировал, насколько мне удавалось. Думаю, это знак дисциплинированности – добавлять строку docstring во все ваши функции и классы, чтобы объяснить, что они делают (функционал) и почему они вообще существуют (цель).

Вот конкретный фрагмент кода из моего личного проекта, над которым я работал несколько лет назад:

def randrange(a, b, size=1):
    """Return random numbers between a and b.

    Parameters
    ----------
    a : float
        Lower bound.
    b : float
        Upper bound.
    size : int, optional
        Number of numbers to return. Defaults to 1.

    Returns
    -------
    ns : list of float
    """
    ...

Посмотрим-ка… в docstring описываются параметры, а также их типы и выводимое значение к каждому типу…

Ого.

В каком-то смысле я уже пользовался аннотациями типов — через docstring.

Не поймите меня неправильно: документировать ваш код при помощи docstrings хорошо и полезно, когда в компоненте заложено много логики. Есть стандартные форматы (выше я пользовался форматом документов NumPy) и они полезны тем, что помогают поддерживать соглашения по документации, а также могут интерпретироваться некоторыми IDE.

Однако при работе с простыми функциями использование полноценной строки docstring просто для описания аргументов и возвращения значений иногда кажется обходным маневром — поскольку (как я полагал) в Python вообще не предлагается каких-либо подсказок типов.

Иногда аннотации типов могут полностью заменить docstring, поскольку они — по моему мнению — очень чисто и просто документируют как ввод, так и вывод. В итоге ваш код становится более удобочитаемым как для вас самих, так и для ваших коллег.

Но подождите! Это еще не все.

Аннотации типов были добавлены в Python 3.5 вместе с модулем типизации.

Этот модуль позволяет аннотировать всевозможные типы (например, списки, словари, функции или генераторы) и даже поддерживает вложения, дженерики, а также возможность определять собственные специальные типы.

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

Возьмем, к примеру, namedtuple. Это структура данных из модуля collections — точно как ChainMap, рассмотренный в статье A practical usage of ChainMap in Python.

Что делает namedtuple: он генерирует класс, чьи экземпляры действуют как кортежи (они неизменяемые) но допускают доступ к атрибутам через точечное представление.

Как правило, namedtuple используется следующим образом:

from collections import namedtuple

Point = namedtuple("Point", "x y")
point = Point(x=1, y=5)
print(point.x)  # 1

Ранее, говоря о важности документирования ввода и вывода, мы сталкивались с подобным случаем: здесь нам чего-то не хватает. Мы не знаем, каковы типы x и y.

Оказывается, в модуле typing есть эквивалент namedtuple, называемый NamedTuple, он позволяет использовать аннотации типов.

Давайте заново определим класс Point с применением NamedTuple:

from typing import NamedTuple


class Point(NamedTuple):
    x: int
    y: int


point = Point(x=4, y=0)
print(point.x)  # 4

Мне нравится. Вот таким красивым, чистым и выразительным может быть код Python.

Обратите внимание: Point используется точно так, как и ранее, с той оговоркой, что теперь работать удобнее, поскольку код гораздо легче читается – и наши IDE и редакторы помогут нам обнаруживать потенциальные ошибки при написании типов. Также это делается благодаря статическим инструментам проверки, например, MyPy (и различным вариантам их интеграции).

При помощи аннотаций типов можно делать еще множество классных вещей, особенно теперь, когда они входят в ядро языка Python.

Например, в Python 3.7 были введены классы данных, потрясающий новый вариант генерируемых классов для простого, но при этом эффективного хранения данных. Однако, о них стоило бы написать отдельную статью.

А что с философской точки зрения?

Python проектировался как динамический язык программирования, а мы теперь вводим в него статическую типизацию. Здесь уже пора задуматься о следующем:

Как это вписывается в философию языка?

Может быть, разработчики ядра Python наконец осознали, что динамическая типизация была ошибочным выбором?

Не вполне. Попробуйте погуглить python philosophy – и найдете следующий документ:

PEP 20 - The Zen of Python

«Дзен Python» - это документ, направляющий всю философию языка Python.

По-моему, аннотации типов на 100% вписываются в философию Python. Вот некоторые истины, воплощенные в них.

Явное лучше неявного.

В принципе, именно ради этого и изобретались аннотации типов. Просто сравните:

def process(data):
    do_stuff(data)

и:

from typing import List, Tuple


def process(data: List[Tuple[int, str]]):
    do_stuff(data)

Простое лучше сложного.

В простых случаях подсказки типов гораздо удобнее полноразмерных строк docstring.

Удобочитаемость важна

Об этом мы уже поговорили.

Должен быть один — и желательно всего один — способ это сделать.

Этот момент реализуется при помощи строгого (но при этом простого) синтаксиса аннотаций типов. Они – верное средство, если вы хотите документировать и поддерживать статические типы в  Python!

И наконец…

Лучше поздно, чем никогда

По-моему, аннотации типов полностью поменяли правила игры:

  • Благодаря им мой код стал лучше.

  • Они предоставляют стандартный способ документирования ввода и вывода, помогая вам и другим людям гораздо лучше понимать код и увереннее о нем рассуждать.

  • Также они открывают новые способы писать код более чисто и лаконично.

Если вы пока еще не пользуетесь аннотациями типов – попробуйте! О них есть много отличной информации - изучайте для начала.

Если вы уже пользуетесь аннотациями типов — и, надеюсь, хорошо закопались в них, помогите с их популяризацией! 

Комментарии (21)


  1. SadOcean
    25.02.2022 10:24
    +12

    Типы, как и читаемый код в целом, нужны не просто для других людей.

    Через пару месяцев ты сам становишься "другим человеком"

    Поражает, что питон, декларируя, что явное лучше неявного, полагается на неясность в выводе типов.


  1. Myxach
    25.02.2022 10:35
    -1

    А Шаблоны или generic есть в пютоне? То-есть, в первой версии я вижу код, который складывает str значение двух любых типов.

    Может и 1+2=12, может и [1,2] + [2,3]=[1,2][2,3], может и 1.2+2.3=1.22.3, а после добавление аннотации, код только с int начал работать, то-есть для float нужно писать второй раз такую функцию

    ~~~

    А, дочитал, есть, ну ок


  1. katletmedown
    25.02.2022 10:57
    +8

    Ух ты, новости из 2015.


  1. lxsmkv
    25.02.2022 11:07

    В свое время, до третьей версии питона, я отмечал ожидаемый тип переменной в имени переменной: temp__int, urls__list_str, updated_data__list_any, response__sparse_dict_int_str. Тип переменной именно суффиксом, а не префиксом, для удобства работы с автодополнением в редакторе. Также и для функций, тип возвратного значения заносится в имя.
    Эти соглашения в readme потом пишутся одним абзацем. Вообще не о чем говорить, а польза большая. Хотя бы то, что ты явно задумываешься о типах переменных и можешь избежать оплошностей по неосторожности, которые всплыли бы потом, во время исполнения


    1. pfffffffffffff
      25.02.2022 12:02
      +7

      С использованием аннотации ide тебе подскажет если ты накосячил, в вашем подходе нет такой фичи


  1. Andrey_Solomatin
    25.02.2022 11:29

    а мы теперь вводим в него статическую типизацию

    Аннотации типов это просто фича языка, которая позволяет вам более точно выразить ваши ожидания от типов.


  1. nerudo
    25.02.2022 11:35
    +2

    def concat(a: int, b: int) -> str:
        return a+b

    Как придумать еще один вариант записи комментариев, который может не отражать содержимого.


    1. ertaquo
      25.02.2022 12:24
      +3

      Аннотации типов нужны по большей части для самого программиста и IDE.

      Интерпретатор сейчас их не валидирует никак - к огромному сожалению.


      1. lorc
        25.02.2022 16:20
        +1

        Но есть же mypy


        К тому же я вроде бы несколько раз для ловил TypeError в рантайме


    1. Hait
      25.02.2022 16:23
      +1

      Нужны анализаторы. За такое они по рукам надают


    1. santjagocorkez
      25.02.2022 20:52
      +2

      Так можно вообще на любой язык набросить. Отстрелить себе намеренно ногу и публично предать язык анафеме: вот, ногу мне повредил.


    1. Murtagy
      25.02.2022 22:52

      ну так на базу нужно натравить тайп чекер. просто текст кода понятно что можно что угодно написать.


  1. Andrey_Solomatin
    25.02.2022 11:43
    +5

    Я стараюсь документировать код, а аннотиции типов это самая легкая форма документации.
    Есть поддержка со стороны языка и инструментов.

    Забыли сказать про главную проблему типизации.
    Если объект на тип который вы ссылаетесь, еще не определён, можно его тип взять в кавычки.

    class Foo:
      def foo(self) -> "Foo":
        return self  

    Иногда аннотации позволяют выразить то, что сложно сделать в коде.
    Mapping это неизменяемый словарь. Родной реализации такого словаря в Питоне нет, писать самому или устанавливать зависимости не всегода хочется.

    В данном случае я явно указываю свои ожидания, что возвращаемое значние менять не надо.

    from typing import Mapping
    
    
    def get_config() -> Mapping:
      config = {}  
      ...
      return config


    1. yesworldd
      25.02.2022 13:43
      +6

      Забыли сказать про главную проблему типизации.
      Если объект на тип который вы ссылаетесь, еще не определён, можно его тип взять в кавычки.

      Теперь в 3.10 можно указывать в методе класса, сам класс.

      class Foo:
        def do_something() -> Foo:
      		return Foo()
      


      1. poofeg
        26.02.2022 22:19

        А в 3.11 нам завезут тип Self, который решает проблему типизации при наследовании, когда метод возвращает именно self, и у наследника это по сути будет уже другой тип.


    1. ivanych
      27.02.2022 00:08

      А как в аналогичном случае предлагается аннотировать аргументы?

      Допустим, аргумент - экземпляр класса, который в текущей функции и вообще в текущем модуле не определён. Как аннотировать такой аргумент?


  1. WASD1
    25.02.2022 16:55
    +3

    Когда говорят о типах - примеры это всегда самое сложное.
    Потому, что вы вводите типы, чтобы сделать код более хм... разумным.
    Но когда вы приводите плохой пример - вы портите всё впечатление от разумных доводов.
    Скажем вместо того, чтобы типизировать вот эту функцию - её просто (ровно в таком виде) не надо писать никогда:

    def concat(a: int, b: int) -> str:
        return str(a) + str(b)



    Т.е. может существовать либо "библиотечная" ф-ия:

    def concat(a: str, b: str) -> str:
        return a + b
    .....
    x = concat(str(a), str(b))

    Либо прикладная функция:

    def getForm15Result(a: int, b: int) -> str:
        return str(a) + str(b)
    


    1. ivanych
      27.02.2022 00:04

      Почему?


      1. WASD1
        27.02.2022 11:28

        Потому, что именование должно быть разумным.

        Когда вы нарушаете правило разумного именования, а потом типизируете эту функцию - вы просто "заметаете проблемы под ковёр".


        1. ivanych
          27.02.2022 13:31

          Што? О чём речь? Что тут неразумного?


  1. Vindicar
    25.02.2022 17:34
    +1

    Аннотации плюс рефлексия позволяют делать чертовски изящные штуки со стороны библиотек. Пример — discord.py, либа для создания чат-ботов под Discord.
    Там аннотации типов используются для прозрачного разбора и преобразования аргументов команды. Например:

    @bot.command(pass_context=True)
    def plus(context, x: int, y: int):
        context.send(f'{x} + {y} = {x+y}')
    

    При вводе команды "!plus 2 3", библиотека попытается разбить строку на правильное число параметров и конвертировать их в нужные типы. Если получится — то вызовет обработчик plus() с правильными аргументами.
    Для пользователя библиотеки это дико удобно, по-моему.