Всем привет!
У нас уже есть одна статья про развитие типизации в Ostrovok.ru. В ней объясняется, зачем мы переходим с pyContracts на typeguard, почему переходим именно на typeguard и что в итоге получаем. А сегодня я расскажу подробнее о том, каким образом происходит этот переход.
Объявление функции с pyContracts в общем случае выглядит так:
from contracts import new_contract
import datetime
@new_contract
def User (x):
from models import User
return isinstance(x, User)
@new_contract
def dt_datetime (x):
return isinstance(x, datetime.datetime)
@contract
def func(user_list, amount, dt=None):
"""
:type user_list: list(User)
:type amount: int|float
:type dt: dt_datetime|None
:rtype: bool
"""
…
Это абстрактный пример, потому что я не нашла в нашем проекте определения функции, короткого и содержательного по количеству случаев для проверки типа. Обычно определения для pyContracts хранятся в файлах, не содержащих никакой другой логики. Обратите внимание, что здесь User – это определенный пользовательский класс, и он не импортируется напрямую.
А это желаемый результат с typeguard:
from typechecked import typechecked
from typing import List, Optional, Union
from models import User
import datetime
@typechecked
def func (user_list: List[User], amount: Union[int, float], dt: Optional[datetime.datetime]=None) -> bool:
...
Вообще функций и методов с проверкой типа в проекте так много, что если сложить их в стопку, то можно дотянуться до Луны. Так что переводить их с pyContracts на typeguard вручную просто невозможно (я пробовала!). Поэтому я решила написать скрипт.
Скрипт разделяется на две части: одна кэширует импорты новых контрактов, а вторая занимается рефакторингом кода.
Хочу отметить, что ни тот, ни другой скрипт не претендует на универсальность. Мы не ставили целью написание инструмента для решения всех требуемых случаев. Поэтому я часто опускала автоматическую обработку каких-то частных случаев, если они редко встречаются в проекте — быстрее поправить руками. К примеру, скрипт генерации маппинга контрактов и импортов собрал 90% значений, оставшиеся 10% — это крафтовые маппинги ручной работы.
Логика работы скрипта для генерации маппинга:
Шаг 1. Пройтись по всем файлам проекта, прочитать их. Для каждого файла:
- если подстроки "@new_contract" нет, пропустить этот файл,
- если есть, то разбить файл по строке "@new_contract". Для каждого элемента:
– распарсить на определение и импорт,
– если получилось, записать в файл успехов,
– если нет, записать в файл ошибок.
Шаг 2. Вручную обработать ошибки
Теперь, когда у нас есть имена всех типов, которыми пользуется pyContracts (это они и были определены с декоратором new_contract), и есть все необходимые импорты, можно писать код для рефакторинга. Пока я переводила с pyContracts на typeguard вручную, я поняла, что мне нужно от скрипта:
- Это команда, которая принимает аргументом имя модуля (можно несколько), в котором надо заменить синтаксис аннотаций функций.
- Пройтись по всем файлам модуля, прочитать их. Для каждого файла:
- если подстроки “@contract” нет, пропустить этот файл;
- если есть, то превратить код в ast (абстрактное синтаксическое дерево);
- найти все функции, которые находятся под декоратором contract, для каждой:
- получить докстринг, распарсить, потом удалить,
- создать словарь вида {arg_name: arg_type}, с его помощью заменить аннотацию функции,
- запомнить новые импорты,
- модифицированное дерево записать в файл через astunparse;
- добавить новые импорты в начало файла;
- заменить строки "@contract" на "@typechecked" потому что так проще, чем через ast.
Решать вопрос "а не импортируется ли уже данное имя в данном файле?" я изначально не собиралась: с этой проблемой мы справимся дополнительным прогоном библиотеки isort.
Зато после прогона первой версии скрипта возникли вопросы, которые решать все же пришлось. Оказалось, что 1) ast не всемогущ, 2) astunparse более всемогущ, чем хотелось бы. Это проявлялось в следующем:
- в момент перехода к синтаксическому дереву из кода пропадают все однострочные комментарии;
- пустые строки тоже пропадают;
- ast не различает функции и методы класса, пришлось добавить логику;
- обратно, при переходе от дерева к коду многострочные комментарии в тройных кавычках записываются комментариями в одинарных кавычках и занимают одну строку, а переносы на новую строку заменяются на \n;
- появляются ненужные скобки, например if A and B and C or D становится if ((A and B and C) or D).
Код, пропущенный через ast и astunparse, остается рабочим, но его читаемость снижается.
Самый серьезный недостаток из перечисленных — это исчезающие однострочные комментарии (в других случаях мы ничего не теряем, а только приобретаем — скобочки, например). С этим обещает разобраться библиотека horast, основанная на ast, astunparse и tokenize. Обещает и делает.
Теперь пустые строки. Вариантов решения было два:
- tokenize умеет определять “часть речи” питона, и этим пользуется horast, когда достает токены типа comment. Но tokenize так же имеет токены типа NewLine и NL. Значит, надо посмотреть, как horast восстанавливает комментарии, и скопировать, заменив тип токена.
— предложила Аня, опыт в разработке 2 месяца - Раз horast может восстанавливать комментарии, то сначала заменим все пустые строки на определенный комментарий, потом пропустим через horast, и заменим наш комментарий на пустую строку.
— придумал Женя, опыт в разработке 8 годиков
Про тройные кавычки у комментариев скажу немного ниже, а с лишними скобками было довольно легко смириться, тем более, что часть из них убирается автоформатированием.
В horast’е мы пользуемся двумя функциями: parse и unparse, но обе неидеальны — parse содержит странные внутренние ошибки, в редких случаях не может распарсить исходный код, а unparse не может записать что-то, что имеет тип type (такой тип, который получится, если сделать type(any_other_type)).
С parse я решила не разбираться, потому что логика работы довольно запутанная, а исключения случаются редко — здесь работает принцип неуниверсальности.
А вот unparse работает предельно ясно и довольно изящно. Функция unparse создает экземпляр класса Unparser, который в init обрабатывает дерево, а потом записывает его в файл. Horast.Unparser последовательно наследуется от многих других Unparser’ов, где самый базовый класс — это astunparse.Unparser. Все классы-наследники просто расширяют функционал базового класса, но логика работы остается такой же, так что рассмотрим astunparse.Unparser. В нем есть пять важных методов:
- write – просто записывает что-то в файл.
- fill – использует write с учетом количества отступов (количество отступов хранится как поле класса).
- enter – увеличивает отступ.
- leave – уменьшает отступ.
- dispatch – определяет тип узла дерева (допустим, T), вызывает соответствующий ему метод по имени типа узла, но с нижним подчеркиванием (то есть _T). Это мета-метод.
Все остальные методы — это методы вида _T, например, _Module или _Str. В каждом таком методе может: 1) рекурсивно вызываться dispatch для узлов поддерева или 2) использоваться write для записи содержимого узла или добавления символов и ключевых слов, чтобы результат был валидным выражением на python.
Например, нам попался узел типа arg, в котором ast хранит имя аргумента и узел аннотации. Тогда dispatch вызовет метод _arg, который сначала запишет имя аргумента, потом запишет двоеточие и запустит dispatch для узла аннотации, где будет разбираться поддерево аннотации, и для этого поддерева все так же будут вызываться dispatch и write.
Вернемся к нашей проблеме невозможности обработки типа type. Теперь, когда понятно, как работает unparse, создать свой тип несложно. Создадим некий тип:
class NewType(object):
def __init__ (self, t):
self.s = t.s
Он хранит в себе строку, и не просто так: нам же нужно типизировать аргументы функций, а типы аргументов мы как раз получаем в виде строк из докстринга. Поэтому давайте подменим аннотации аргументов не теми типами, которые нам требуются, а объектом NewType, хранящем внутри только имя нужного типа.
Для этого расширим horast.Unparser – напишем свой UnparserWithType, отнаследовавшись от horast.Unparser, и добавим обработку нашего нового типа.
class UnparserWithType(horast.Unparser):
def _NewType (self, t):
self.write(t.s)
Это сочетается с духом библиотеки. Названия переменных выполнены в стилистике ast, и именно поэтому они состоят из одной буквы, а не потому что я не умею придумывать названия. Думаю, что t – это сокращение от tree, а s – от string. Кстати, NewType – это не строка. Если бы мы хотели, чтобы он интерпретировался как тип строки, то нам надо было бы до и после вызова write записать кавычки.
А теперь магия monkey patch: заменим horast.Unparser нашим UnparserWithType.
Как это теперь работает: у нас есть синтаксическое дерево, в нем есть какая-то функция, в функции — аргументы, у аргументов — аннотации типов, в аннотации типов спрятана игла, а в ней — смерть Кощеева. Раньше узлов аннотаций вообще не было, это мы их создали, причем любой такой узел есть экземпляр NewType. Мы вызываем функцию unparse для нашего дерева, и она для каждого узла вызывает dispatch, которая классифицирует этот узел и вызывает соответствующую ему функцию. Как только функция dispatch получает узел аргумента, она записывает имя аргумента, потом смотрит, есть ли аннотация (раньше это был None, но мы положили туда NewType), если есть, то пишет двоеточие и вызывает dispatch для аннотации, который вызывает наш _NewType, который просто записывает строку, которую хранит — это имя типа. В итоге получаем записанным аргумент: тип.
Вообще-то, это не совсем легально. С точки зрения компилятора, мы записали аннотации аргументов какими-то словами, которые нигде не определены, так что когда unparse завершает свою работу, мы получаем неправильный код: нам нужны импорты. Я просто формирую строку правильного формата и добавляю ее в начало файла, а потом дописываю результат unparse, хотя могла бы добавить импорты и как узлы в синтаксическое дерево, так как ast поддерживает узлы Import и ImportFrom.
Решение проблемы тройных кавычек не сложнее добавления нового типа. Мы создадим класс StrType и метод _StrType. Метод ничем не отличается от использовавшегося ранее для аннотации типов метода _NewType, а вот определение класса изменилось: мы будем хранить не только саму строку, но и уровень табуляции, на котором ее следует записывать. Число отступов определим так: если эта строка встретилась в функции, то один, если в методе, то два, а случаев, когда функция определена в теле другой функции и при этом декорирована, в нашем проекте нет.
class StrType(object):
def __init__ (self, s, indent):
self.s = s
self.indent = indent
def __repr__ (self):
return '"""\n' + self.s + '\n' + ' ' * 4 * self.indent + '"""\n'
В repr определим, как должна выглядеть наша строка. Я думаю, это далеко не единственное решение, но оно работает. Можно было бы поэкспериментировать с astunparse.fill и astunparse.Unparser.indent, тогда это было бы более универсально, но эта идея пришла мне в голову уже во время написания этой статьи.
На этом решенные трудности заканчиваются. После работы моего скрипта иногда возникает проблема циклических импортов, но это вопрос архитектуры. Я не нашла готового стороннего решения, а обрабатывать такие случаи в рамках моего скрипта кажется серьезным усложнением поставленной задачи. Наверное, с помощью ast возможно обнаруживать и разрешать циклические импорты, но эту идею нужно обдумать отдельно. В общем, ничтожно малое количество таких казусов в нашем проекте вполне позволяло мне не обрабатывать их автоматически.
Еще одна сложность, которая мне встретилась — это отсутствие в ast обработки выражений from A import B as C. Внимательный читатель уже знает, что monkey patch — лекарство от всех болезней. Пусть это будет для него домашним заданием, а я решила сделать так: просто добавить такие импорты в файл маппинга, потому что обычно эта конструкция используется для обхода конфликта имен, а у нас их мало.
Несмотря на найденные несовершенства, скрипт делает то, ради чего задумывался. Что в итоге:
- Время, за которое запускается проект, сократилось с 10 до 3 секунд;
- Уменьшилось число файлов за счет удаления определений new_contract. Сократились сами файлы: я не замеряла, но в среднем гит насчитывал n добавленных строк и 2n удаленных;
- Умные IDE стали делать разные подсказки, поскольку теперь это не комментарии, а честные импорты;
- Улучшилась читаемость;
- Кое-где появились скобочки.
Спасибо!
Полезные ссылки:
Комментарии (23)
amarao
18.12.2019 14:57+1У меня есть один вопрос: а в чём смысл типизационного насилия над питоном? Питон плохой язык для статической типизации. Язык активно сопротивляется попыткам заменить утиную типизацию на типофашизм.
Возьмите язык с строгой типизацией и type elision. В догонку получите кратное повышение производительности программ, защиту от undefined behavior в многопоточных приложениях, отсутствие GC при автоматической деаллокации ресурсов и выразительную силу современной системы типов. Плюс blazing fast веб-фреймворки, у которых input sanity обеспечивается системой типов, а не костылями.
ой, что это я всё про Rust, да про Rust.
Вы говорили про принудительные сигнатуры функций для Питона? А, может, не надо?
Ketovdk
а почему бы просто не использовать строго типизированный язык? (с учетом того, что по-честному не типизированных языков не бывает)
defaultvoice
Forth и asm не существуют?
Ketovdk
из википедии
annbgn Автор
Да, но у нас уже есть достаточно большой проект на питоне, and we have to deal with it.
Ketovdk
я понимаю, просто мне, как питонохейтеру еще со времен вуза, когда по некоторым предметам было почти обязательно писать именно на нем было всегда интересно, зачем люди его выбирают и почему он такой популярный
germn
Попробуйте, как будет возможность, забыть на недельку о предрассудках и создать какой-нибудь проект на динамически типизированном языке. Гораздо быстрее получать результат, гораздо проще выражать свои мысли через код.
В большинстве случаев отсутствие проверки типов компилятором не будет проблемой (добавьте сюда, что PyCharm неплохо проверяет типы в Python на лету). Для проектов, где надёжность очень важна (а таких, кстати, немного), её гораздо удобнее добиться тестами (которые в отличие от проверки типов ещё и снимут страх модификации старого кода).
Ну а если изначально использовать TDD, то…
В общем, убедить в чём-то чего-то-хейтера, я думаю, очень сложно, можно только последнему попробовать разобраться самому. Но вы, кстати, очень точно заметили сами: «почему он такой популярный» — вот именно, что причины для этого есть.
Ketovdk
просто по моим ощущением, то, что вы описываете и есть типизация. Вы тестами явно задаете, что ожидаете на вход (не говоря уже о том, что даже если у вас есть функция a+b, то это уже интерфейс, что у a есть оператор +). Да, когда надо попарсить JSON, в питоне это было намного проще и быстрее, чем в типизированных языках (сейчас тоже проще и быстрее, но не настолько), но даже парсинг json остается типизированным, т.к. вы явно задаете, какое поле получить (а иногда после этого еще и проводите с ним манипуляции). Ну то есть мне кажется намного разумнее подход с изначально типизированным языком и только в редких случаях dynamic/jobject/что-нибудь еще
mmMike
Могу предложить аналогично "забудьте на недельку у питоне и попробуйте писать на языке со строгой типизацией".
Гораздо более предсказуемый код получается.
Поскольку пишу и на том и том, то я то могу сравнить. Как минимум для себя.
Для крупного проекта преимущество питона "гораздо быстрее получить результат" аннулируется тем, что результат менее предсказуем на этапе эксплуатации.
А попытка добавления везде тестов на проверку типов в питоне аннулирует достоинство "гораздо быстрее получить результат". Да еще код делает тяжелым для восприятия.
IMHO, питон для простых задач где "Сломалось во время выполнения — не слишком критично. Поправим и дальше". Никогда не порекомендую питон для высоко нагруженного сервиса с высокой стоимостью отказа.
FINTER
«Сломалось во время выполнения» сломаться может что угодно и когда угодно и почему угодно, типы или ЯП последнее, что тут важно.
Причем как раз очень опасно думать, что условно статическая типизация повышает качество кода, это очень опасное заблуждение. Это как с велосипедными шлемами: люди думают, что велосипедный шлем повышает их безопасность и ездят более быстро/агрессивно. Тут можно было бы развить аналогию дальше…
Это же обычный bias. И вообще, что такие предсказуемость? Отказоустойчивость я могу понять: можно посчитать скажем отношение общего времени работы к даунтаймам или число ошибок на число всех запусков и т.д. Отказоустойчивые системы можно писать на любом языке программирования, вон взять тот же Erlang с его знаменитыми девять-девяток. А предсказуемость это что-то из области самовнушения.
mmMike
Вопрос можно?
Какие языки Вы используете в работе регулярно?
У меня основные это Java, Python. И я могу сравнивать.
Реже JS (Vue JS) и С++ (Старый код на работе и хобби по микроконтроллерам)
А предсказуемость поведения…
Так статья как раз и посвящена "как добавить свои костыли с типизацией в языки, где ее нет" по причине "Да, но у нас уже есть достаточно большой проект на питоне, and we have to deal with it."
FINTER
Языки программирования тут вообще не при чем. Мне приходилось программировать на всем от асма до пролога, от сайтика до петофлопного суперкомпьютера. Но мерения органами тут делу не помогут и я как раз не люблю спускаться на личности и разговариваю с вами заведомо как с равным, проблема как раз не в языке, что я и пытаюсь показать.
К статье у меня нет вопросов, чуваки создали стартап прототипировали прототипировали да не выпрототипировали, решили, что им не хватает авторефакторинга и автокомплита — флаг им в руки, ни на что не претендуют. Вы же пишете про более концептуальные вещи, взгляд на которые у вас не одинок, при этом он дико предвзятый.
Понятно, что быстрое преобразование фурье на голом питоне писать не надо и Си отлично тут подойдет, а вот для оркестрации сокетов и асинхронных низкоуровневых вызовов питон отлично подходит за счет гибкости, так как для этого он и создавался. Информационную трубу с кучей соединений с юзерами, базой и тд я бы писал на питоне или эрланге скорее, чем на с++ или голых сях.
Что такое «высокая стоимость отказа»? Может быть высокая цена ошибки? Аля космос медицина? Или высокая доступность? Опять непонятно.
Высокая надежность кода достигается например верификацией в той же медицине или космосе. Привет пролог.
Написание отказоустойчивых распределенных систем — это опять же не про стек свистелок и перделок, это математика в первую очередь. Со всякими прикольными моделями типа конечных автоматов, сетей петри и прочими крутыми штуками. Большинство программистов сейчас даже сформулировать определение алгоритма не смогут.
Тестирование? И тут опять математика со своими минимальными тестами. И кстати с точки зрения тестирования — типизация будет избыточной. В этом нет ничего плохого кроме того, что программист подсознательно начинает считать код с большим числом тестов более надежным, и вместо реального тестирования системы хорошими интеграционными тестами, все тестирование скатывается в тривиальные бессистемные дублирования вроде f(x) = x + x тестируем f(2) == 4; f(3) == 6; f(-1) = -2. Отлично, только вот с точки зрения надежности мы ничего не получили. В идеальном мире тестирование — это минимальный набор данных с ответами, которые в случае прохождения гарантируют корректность системы. Только вот проблема чаще всего бывает не в самих тестах, а в интерпретации и переводе спецификации с человеческого языка в структурный. И тут опять привет пролог. Попробуйте как нибудь на досуге дать строгое определение хотя бы списку. Слишком просто? Окей, а теперь графу.
Я это все к чему? Вместо того, чтобы поднимать качество концептуальных знаний, техники программирования и общей грамотности программистов, мы раз к разу спускаемся до разговоров про «а если я случайно инту строку присвою что тогда?». Это так же смешно, как разговор про велосипедные шлемы, если нет вело инфраструктуры и велосипедист вынужден выезжать на тротуары и обочины, он неизбежно будет убивать себя и других участников движения, и пенопластовая шапочка тут не поможет.
Ketovdk
возможно в том и проблема, что вам приходилось программировать со времен ассемблера и у вас сохранилось представление о программистах, как о людях, которые собирали компьютер в гараже, но это не так.
Понятно, что вы можете настряпать багов или наоборот написать супер-отказоустойчивую систему.
Но хороший язык должен обеспечивать такой код, что программист без стажа в 50 лет, который только-что закончил (или не закончил) вуз и пришел к вам на работу сможет на нем продуктивно работать, не настряпав багов и не читая документацию 10 часов, чтобы сделать таск, который делается за час.
Также хороший язык помогает работать с ним людям, которые не знакомы с проектом. В этом плане типизация работает как некоторое удобное и минималистичное документирование, которое еще и поддерживается компиляторами. Например, увидев у какой-то сущности long Id я сразу понимаю, что это число, а не строка, например (а в том же python нужно было бы писать совершенно бессмысленный комментарий тому, кто этот код писал, либо вручную проверять тому, кто на него пришел)
FINTER
Вы не в ту сторону воюете, я не противник статической типизации. Но и обманывать себя не надо. Статическая типизация не делает код надежней или безопасней, все, что вы получаете — это автокомплит и авторефакторинг + возможность оптимизации кода до рантайма. Это и нужно держать в голове, за надежность отвечают другие инструменты. Автотесты и в крайних случаях верификация. Да в Го вы не смоете присвоить строке число по ошибке, но зато легко можете прочитать побитые данные не из того файлика и упасть там, где не ожидаете, потому что нарушена логика. Логические ошибки куда более частые, чем попытка взять квадратный корень из строки.
Если мы говорим опять же про современный мир, то ну да наверно какой-то монолит писать на джаве удобней, чем на питоне, но если у вас зоопарк микросервисов, тут на Го, тут на Эрланге, тут на Скале, там на R, а сверху обвязка на торнадо, то вам тут не поможет статическая типизация. У вас должна быть документация по как минимум API взаимодействия. А еще лучше, если документация описывает философию проекта. Сферический стажер в вакууме без присмотра вам сломает любую систему на любом языке программирования (вы обязаны ревьюить его код, а в идеале любой коммит должен кто-то принимать и не тот, кто его писал).
leon_nikitin
данная статья ярко показывает ненужность статической типизации. ведь, лишь, только за полной ненужностью, даже в питон пытаются внедрить некое подобие этой типизации. так сказать, от нечего делать.
germn
Аннотации типов — нужны для документации и плюшек в IDE, а не для типизации. Никто из разработчиков языка внедрить в него типизацию не пытается.
А автор статьи пытается относиться к аннотациям как к типам от отсутствия опыта и кругозора (на мой взгляд), т.е. в общем от нечего делать, да.
leon_nikitin
а для чего объявление типов в php добавляют?
germn
По той же причине, по которой для JS придумали TypeScript: и php, и JS языки со слабой динамической типизацией (в отличие от Питона, где типизация сильная). Подробнее тут.
Слаботипизированные языки — боль, да. Им тестов и IDE не хватает, чтобы спасаться от большинства ошибок, вот и вводят типы.
LighteR
По-моему, все как раз наоборот. В первую очередь это статический анализ, а уже потом автокомплит. Гвидо не просто так пилил mypy в дропбоксе. Другие крупные компании тоже пилят тайп-чекеры: pyre (Facebook), pyright (Microsoft), pytype (Google). Активное развитие стандартного модуля typing во многом связано с issues, которые заводились для mypy. Ну и вообще если писать тайп-хинты и не использовать тайп-чекер, то такие тайп-хинты постоянно будут в невалидном/неактуально состоянии
germn
> В первую очередь это статический анализ, а уже потом автокомплит.
Автокомплит — это и есть продукт статического анализа (т.е. анализа без запуска целевого кода), который IDE проводит в фоне. Но вообще я имел в виду в первую очередь подсказки об ошибках, которые фундаментально — тот же mypy, только в фоне и во время работы. Это то, что я называю «плюшками», и ничего против этого не имею.
Под типизацией (на мой взгляд) обычно понимают проверку типов и кидание ошибок на этапе компиляции или в случае с динамическими языками в рантайме — то есть то, чем занимаются в статье.
Только если в статических языках — это часть языка, то в динамических это внедрение рантайм-недо-проверок типов путём засорения кода (избыточными аннотациями и повсеместными @typechecked) при наличии гораздо лучшей альтернативы в виде покрытия тестами и того же статического анализа.
Как-то так. А вообще, мнение авторов языка написано тут. Процитирую выделенное: «Python останется динамически типизированным языком, и у авторов нет желания когда-либо делать подсказки типов обязательными, даже в условиях наличия возможностей.»
germn
> Могу предложить аналогично «забудьте на недельку у питоне и попробуйте писать на языке со строгой типизацией».
А я на Питон с плюсов перешёл, так что уже проходил :)