Эссе о разработке игр, мышлении и книгах

Опыт портирования проекта на Python 3

Лого 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__ и никаких проблем с переездом не будет.