Опыт портирования проекта на Python 3
Портировал Сказку на Python 3.
Хочу поделиться опытом портирования проекта с Python 2.7 на Python 3.5. Необычными засадами и прочими интересными нюансами.
Немного о проекте:
- Браузерка: сайт + игровая логика (иерархические конечные автоматы + куча правил);
- Возраст: 4 года (начат в 2012);
- 64k loc логики + 57k loc тестов;
- 2400 коммитов.
Портирование проводилось с помощью утилиты 2to3 с последующим восстановлением работоспособности тестов. Сколько это заняло времени сказать сложно, проект — хобби — занимаюсь им в свободное время.
2to3
2to3 конвертирует исходники Python 2 в пригодный для Python 3 вид. Для этого она применяет к ним набор эвристик (их списк можно настраивать). В целом, с утилитой проблем не возникло, но если у вас большой и/или сложный проект, то лучше перед запуском ознакомиться со списком эвристик.
После обработки исходников очень рекомендую вычитать изменения, поскольку производительность — это не то, что ставится во главу угла при конвертировании.
Также есть вероятность, что некоторые ваши имена пересекутся с удаляемыми/изменяемыми методами. Например, 2to3 изменила код, который работал с моим методом has_key моего же класса (этот метод есть у словаря Python 2 и удалён в Python 3).
Цена прогресса
Итак, о что можно споткнуться, если начать двигать прогресс в сторону Python 3. Начну с самого интересного.
Банковское округление
«ЧЕЕЕЕГОООО?!?» о_О
Примерно такой была моя реакция, когда, разбираясь с очередным тестом, я увидел в консоли следующее:
round(1.5)
2
round(2.5)
2
«Банковское» округление — округление к ближайшему чётному. Это новые правила округления, заменившие «школьное» округление в большую сторону.
Смысл «банковского» округления в том, что при работе с большим количеством данных и сложных вычислениях оно сокращает вероятность накопления ошибки. В отличие от обычного «школьного» округления, которое всегда приводит половинчатые значения к большему числу.
Для большинства это изменение не критично, но оно может привести к совсем неожиданному изменению поведения программы. В моём случае, например, изменилось расположение дорог на игровой карте.
Обратите внимание, оно работает для любой точности:
round(1.65, 1)
1.6
round(1.55, 1)
1.6
Целочисленное деление стало дробным
Если вы полагались на целочисленную арифметику с типом int (когда 1/4 == 0
), то готовьтесь к длительному вычитыванию кода, поскольку теперь 1/4 == 0.25
и провести автоматическую замену /
на //
(оператор целочисленного деления) не получится из-за отсутствия информации о типах переменных.
Guido van Rossum подробно объяснил причину этого изменения.
Новая семантика map
Изменилось поведение функции map при итерации по нескольким последовательностям.
- В Python 2, если одна последовательность короче остальных, она дополняется объектами
None
. - В Python 3, если одна последовательность короче остальных, итерация прекращается.
Python 2:
map(lambda x, y: (x, y), [1, 2], [1])
[(1, 1), (2, None)]
Python 3:
list(map(lambda x, y: (x, y), [1, 2], [1]))
[(1, 1)]
В теле классов в генераторах и списковых выражениях нельзя использовать атрибуты класса
Приведённый ниже код будет работать в Python 2, но вызовет исключение NameError: name 'x' is not defined
в Python 3:
class A(object):
x = 5
y = [x for i in range(1)]
Это связано с изменениями в областях видимости генераторов, списковых выражений и классов. Подробный разбор на Stackoverflow.
Но будет работать следующий код:
def make_y(x): return [x for i in range(1)]
class A(object):
x = 5
y = make_y(x)
Новые и удалённые методы у стандартных классов
Если вы полагались на наличие или отсутствие методов с конкретными именами, то могут возникнуть неожиданные проблемы. Например, в одном месте, где творилась чёрная волшба, я отличал строки от списков по наличию метода __iter__
. В Python 2 его у строк нет, в Python 3 он появился и код сломался.
Семантика операций стала строже
Некоторые операции, которые по умолчанию работали в Python 2, перестали работать в Python 3. В частности, запрещено сравнение объектов без явно заданных методов сравнения.
Выражение object() < object()
:
- В Python 2 вернёт
True
илиFalse
(в зависимости от «identity» объектов). - В Python 3 приведёт к исключению
TypeError: unorderable types: object() < object()
.
Изменения реализации стандартных классов
Думаю их много разных, но я столкнулся с изменением поведения словаря. Следующий код будет иметь разные эффекты в Python 2 и Python 3:
D = {'a': 1,
'b': 2,
'c': 3}
print(list(D.values()))
В Python 2 он всегда печатает [1, 3, 2]
(или, как минимум, одинаковую последовательность для конкретной сборки Python на конкретной машине).
В Python 3 последовательность элементов отличается при каждом запуске. Соответственно, результаты выполнения кода, полагавшегося на эту «фичу» станут отличаться.
Конечно, я не полагался специально на фиксированную последовательность элементов в словаре, но, как оказалось, сделал это неявно.
Использование памяти и процессора
К сожалению, из-за совмещения портирования, переезда на новый сервер и рефакторинга сделать конкретные замеры не получилось.
Выводы
Мой главный вывод — Python стал более идиоматичным:
- неопределённое поведение стало действительно неопределённым;
- рекомендуемый стиль программирования более рекомендуемым;
- плохим практикам стало сложнее следовать;
- хорошим практикам стало проще следовать.
В коде стало легче обнаружить семантические ошибки, которые в былые времена могли прятаться годами.
Второй вывод: если вы завязаны на математические операции, лучше начинать реализовывать их сразу в правильном для Python 3 ключе, даже если вы собираетесь тянуть с переездом до 20-ого года.
Пишите код на Python 2 с использованием __future__ и никаких проблем с переездом не будет.
Читать далее
- Миграции backend на практике
- Open source сервисы аутентификации
- Модная типизация в Python
- Генерация подземелий — от простого к сложному
- Автоматический генератор квестов
- Топовые LLM фреймворки могут быть не так надёжны, как вы думаете
- Блог переехал на новый движок
- GraphQL & Python
- Python & OpenAPI
- Типы в Python не радуют