Упрощение кода приложений Python с помощью рефакторинга. Часть 2
Вторая статья про рефакторинг в Python. Первую статью можно почитать здесь.
Рефакторинг в Python
Рефакторинг — это улучшения кода приложения (или архитектуры) за счет внесения внутренних изменений, без изменения внешнего функционала. Этими улучшениями могут быть увеличение стабильности, производительности или уменьшение сложности.
Одна из старейших в мире подземных железных дорог, лондонское метро, было основано в 1863 году. Это было метро с деревянные вагонами с газовой подсветкой, перевозимые паровозами. И только в 1900 году появились электрические поезда.
К 1908 году лондонское метро расширилось до 8 железных дорог. Во время Второй мировой войны станции лондонского метро были закрыты для поездов и использовались в качестве бомбоубежищ. Современное лондонское метро перевозит миллионы пассажиров в день на более чем 270 станциях:
Почти невозможно написать идеальный код с первого раза, и требования часто меняются. Если бы вы попросили первоначальных создателей железной дороги спроектировать сеть, рассчитанную на 10 миллионов пассажиров в день в 2020 году, они бы не спроектировали сеть, которая в итоге существует сегодня, они бы могли бы сделать это гораздо лучше, но так не бывает.
Вместо этого железная дорога претерпела серию непрерывных изменений, чтобы улучшить ее работу, дизайн и планировку в соответствии с изменениями в городе. Это был своего рода рефакторинг.
В этой статье вы узнаете, как безопасно проводить рефакторинг, используя тесты и современные инструменты. Вы также увидите, как использовать функцию рефакторинга в Visual Studio Code и PyCharm:
Избежание рисков с помощью рефакторинга: использование инструментов и проведение тестов
Если смысл рефакторинга состоит в том, чтобы улучшить внутреннюю часть приложения, не влияя на внешние, как узнать, что внешние элементы не изменились?
Прежде чем приступить к серьезному рефакторингу большого проекту, вы должны убедиться, что у вас есть надежный набор тестов для вашего приложения. В идеале, этот набор тестов должен быть в основном автоматизированным, чтобы при внесении изменений вы видели влияние на пользователя и быстро устраняли егов случае необходимости.
Если вы хотите узнать больше о тестировании в Python, почитайте эту статью Getting Started With Testing in Python.
Не существует идеального количества тестов для вашего приложения. Но чем надежнее и тщательнее будет создан набор тестов, тем более агрессивно вы можете проводить рефакторинг своего кода.
Две наиболее распространенные задачи, которые вы будете выполнять при рефакторинге:
- Переименование модулей, функций, классов и методов
- Поиск использования функций, классов и методов, чтобы увидеть, где они вызываются
Вы можете делать это вручную, используя поиск и замену, но это отнимет много времени и будет достаточно рискованно. Вместо этого есть несколько отличных инструментов для выполнения этих задач.
Использование rope для рефакторинга
rope — бесплатная утилита для рефакторинга кода Python. Эта утилита поставляется с обширным набором API для рефакторинга и переименования компонентов.
rope
можно использовать двумя способами:
- Используя плагин редактора, для Visual Studio Code, Emacs, или Vim
- Непосредственно путем написания скриптов для рефакторинга вашего приложения
Чтобы использовать rope в качестве библиотеки, сначала установите rope, выполнив команду pip:
$ pip install rope
Полезно работать с rope в REPL, чтобы вы могли изучить проект и увидеть изменения в режиме реального времени. Для начала импортируйте тип Project и создайте его экземпляр с указанием пути к проекту:
>>> from rope.base.project import Project >>> proj = Project('requests')
Переменная proj теперь может выполнять ряд команд, таких как get_files и get_file, чтобы получить конкретный файл. Получите файл api.py и назначьте его переменной api:
>>> [f.name for f in proj.get_files()] ['structures.py', 'status_codes.py', ...,'api.py', 'cookies.py'] >>> api = proj.get_file('api.py')
Если вы хотите переименовать этот файл, вы можете просто переименовать его в файловой системе. Однако любые другие файлы Python в вашем проекте, которые импортировали старое имя, теперь будут повреждены. Давайте переименуем api.py в new_api.py через rope:
>>> from rope.refactor.rename import Rename >>> change = Rename(proj, api).get_changes('new_api') >>> proj.do(change)
Запустив git status, вы увидите, что rope внесла некоторые изменения в репозиторий:
$ git status On branch master Your branch is up to date with 'origin/master'. Changes not staged for commit: (use "git add/rm <file>..." to update what will be committed) (use "git checkout -- <file>..." to discard changes in working directory) modified: requests/__init__.py deleted: requests/api.py Untracked files: (use "git add <file>..." to include in what will be committed) requests/.ropeproject/ requests/new_api.py no changes added to commit (use "git add" and/or "git commit -a")
Три изменения, сделанные с помощью rope, следующие:
- Удален
requests/api.py
и созданrequests/new_api.py
- Изменен
requests/__init__.py
, в нем добавлен импортnew_api
и удален импортapi
- Создана новая директория
.ropeproject
Что бы сбросить все изменения вы можете запустить git reset
.
Обо всем что можно сделать с rope можно почитать здесь.
Использование кода Visual Studio для рефакторинга
Visual Studio Code содержит небольшое подмножество команд рефакторинга, доступных в отдельном пользовательском интерфейсе.
С помощью его вы можете:
- Извлечь переменные из оператора
- Извлечь методы из блока кода
- Отсортировать импорт в логическом порядке
Вот пример использования команды «Извлечь методы» из палитры команд:
Использование PyCharm для рефакторинга
Если вы используете или планируете использовать PyCharm в качестве редактора Python, стоит обратить внимание на его мощные возможности рефакторинга.
Вы можете получить доступ ко всем функциям рефакторинга с помощью команды Ctrl + T в Windows и macOS. Ярлык для доступа к рефакторингу в Linux — Ctrl + Shift + Alt + T.
Поиск вызывающих сущностей и использование функций и классов
Перед тем, как удалить метод или класс или изменить его поведение, вам необходимо знать, какой код зависит от него. PyCharm может искать все способы использования метода, функции или класса в вашем проекте.
Чтобы получить доступ к этой функции, выберите метод, класс или переменную, щелкнув правой кнопкой мыши и выберите «Найти использование»(Find Usages):
Весь код, который использует ваши критерии поиска, показан на панели внизу. Вы можете дважды щелкнуть по любому элементу, чтобы перейти непосредственно к рассматриваемой строке.
Использование инструментов рефакторинга PyCharm
Некоторые из других команд рефакторинга включают в себя возможность:
- Извлечение методов, переменных и констант из существующего кода
- Извлечение абстрактных классов из существующих сигнатур классов, включая возможность указания абстрактных методов
- Переименование практически всего, от переменной до метода, файла, класса или модуля
Вот пример переименования того же модуля api.py, который вы переименовали ранее, с помощью rope в new_api.py:
Команда переименования (rename) контекстуализирована для пользовательского интерфейса, что делает рефакторинг быстрым и простым.
Другим полезным рефакторингом является команда Изменить подпись (Change Signature). Она может быть использовано для добавления, удаления или переименования аргументов функции или метода. Эта команда ищет все случаи использования и обновляет их для вас:
Вы можете установить значения по умолчанию, а также решить, как рефакторинг должен обрабатывать новые аргументы.
Выводы
Рефакторинг — важный навык для любого разработчика. Инструменты и IDE уже оснащены мощными функциями рефакторинга, позволяющими быстро вносить изменения.
Анти-паттерны сложности
Теперь, когда вы знаете, как можно измерить сложность, как ее измерить и как реорганизовать ваш код, пришло время изучить 5 распространенных анти-шаблонов, которые делают код более сложным, чем нужно:
1. Функции, которые должны быть объектами
Python поддерживает процедурное программирование с использованием функций, а также программирование с помощью ООП. Оба подхода самодостаточны и должны применяться к различным проблемам.
Возьмем для примера модуль для работы с изображениями. Логика в функциях была удалена для краткости:
# imagelib.py def load_image(path): with open(path, "rb") as file: fb = file.load() image = img_lib.parse(fb) return image def crop_image(image, width, height): ... return image def get_image_thumbnail(image, resolution=100): ... return image
Есть несколько проблем с этим дизайном:
- Непонятно, могут ли crop_image() и get_image_thumbnail() изменить исходную переменную изображения или они создают новое изображение. Если вы хотите загрузить изображение, а затем создать обрезанное (уменьшенное в размере), так и миниатюрное изображение (thumbnail), вам придется сначала скопировать экземпляр? Вы конечно можете прочитать исходный код в функциях, но нельзя рассчитывать на то, что каждый разработчик сделает это.
- Вы должны передавать переменную изображения в качестве аргумента при каждом вызове функций изображения.
Вот как может выглядеть вызывающий код:
from imagelib import load_image, crop_image, get_image_thumbnail image = load_image('~/face.jpg') image = crop_image(image, 400, 500) thumb = get_image_thumbnail(image)
Некоторые признаки кода, использующего функции, который может быть преобразованы в классы:
- Подобные аргументы в разных функциях
- Большее число уникальных операндов (Halstead h2)
- Смешивание изменчивых (mutable) и неизменных (immutable) функций
- Функции распределены по нескольким файлам Python
Переработанная версия этих трех функций, где происходит следующее:
.__init__()
заменяетload_image()
.crop()
становится методом класса.get_image_thumbnail()
становится свойством.
Переменная thumbnail_resolution стало свойством класса, теперь его можно изменить глобально или в конкретном случае:
# imagelib.py class Image(object): thumbnail_resolution = 100 def __init__(self, path): ... def crop(self, width, height): ... @property def thumbnail(self): ... return thumb
Если бы в этом коде было намного больше связанных с изображениями функций, рефакторинг для класса мог бы радикально измениться.
Как теперь будет выглядеть измененный пример:
from imagelib import Image image = Image('~/face.jpg') image.crop(400, 500) thumb = image.thumbnail
В полученном коде мы решили исходные проблемы:
- Теперь понятно, что thumbnail возвращает эскиз, так как это свойство, и что он не изменяет экземпляр.
- Код больше не требует создания новых переменных для операции обрезки (crop).
2. Объекты, которые должны быть функциями
Иногда верно обратное. Существует объектно-ориентированный код, который лучше подходит для одной или двух простых функций.
Вот несколько признаков неправильного использования классов:
- Классы с 1 методом (кроме .__init__ ())
- Классы, которые содержат только статические методы
Возьмем этот пример класса аутентификации:
# authenticate.py class Authenticator(object): def __init__(self, username, password): self.username = username self.password = password def authenticate(self): ... return result
Было бы разумнее иметь простую функцию authenticate(), которая принимает имя пользователя и пароль в качестве аргументов:
# authenticate.py def authenticate(username, password): ... return result
Вам не нужно сидеть и искать классы, которые соответствуют этим критериям вручную: в pylint есть правило, согласно которому классы должны иметь как минимум 2 открытых метода. Чтобы узнать больше о PyLint и других инструментах проверки качества кода, вы можете почитать здесь Python Code Quality.
Чтобы установить pylint, введите в консоли следующую команду:
$ pip install pylint
pylint принимает несколько необязательных аргументов, а затем путь к одному или нескольким файлам и папкам. Если вы запустите pylint с его настройками по умолчанию, он выдаст много выходных данных, поскольку pylint по умолчанию содержит огромное количество правил. Вместо этого вы можете запустить проверку на определенные правила. Идентификатор правила «слишком мало открытых методов» — R0903. Вы можете почитать об этом больше на сайте документации:
$ pylint --disable=all --enable=R0903 requests ************* Module requests.auth requests/auth.py:72:0: R0903: Too few public methods (1/2) (too-few-public-methods) requests/auth.py:100:0: R0903: Too few public methods (1/2) (too-few-public-methods) ************* Module requests.models requests/models.py:60:0: R0903: Too few public methods (1/2) (too-few-public-methods) ----------------------------------- Your code has been rated at 9.99/10
Этот вывод говорит нам, что auth.py содержит 2 класса, которые имеют только 1 открытый метод. Эти классы находятся в строках 72 и 100. Существует также класс в строке 60 models.py только с одним открытым методом.
3. Преобразование «Triangular» кода в плоский код
Если бы вам пришлось уменьшить исходный код и наклонить голову на 90 градусов вправо, станет ли пробел плоским, как Голландия, или горным, как Гималаи? Горный код является признаком того, что ваш код содержит много вложений.
Один из принципов в дзен Python (Zen of Python) гласит:
Плоский код лучше вложенного “Flat is better than nested”
— Tim Peters, Zen of Python
Почему плоский код будет лучше, чем вложенный код? Потому что вложенный код затрудняет чтение и понимание происходящего. Читатель должен понимать и запоминать условия, проходящие через ветви.
Признаки сильно вложенного кода:
- Высокая цикломатическая сложность из-за количества ветвей кода
- Низкий индекс ремонтопригодности из-за высокой цикломатической сложности относительно количества строк кода
Возьмем этот пример, который просматривает аргумент data на наличие строк, которые соответствуют слову error. Сначала проверяется, является ли аргумент data списком. Затем он перебирает каждый из них и проверяет, является ли элемент строкой. Если это строка и значение соответствует слову «error», то возвращается True. В противном случае возвращается False:
def contains_errors(data): if isinstance(data, list): for item in data: if isinstance(item, str): if item == "error": return True return False
Эта функция будет иметь низкий индекс ремонтопригодности, потому что она небольшая, и имеет высокую цикломатическую сложность.
Вместо этого мы можем реорганизовать эту функцию, так чтобы удалить уровень вложенности и вернув False, если значение data не указано в списке. Затем с помощью .count() объекта списка для подсчета наличия «error». Тогда возвращаемое значение будет оценкой того, что .count() больше нуля:
def contains_errors(data): if not isinstance(data, list): return False return data.count("error") > 0
Еще один метод сокращения вложенности заключается в использовании списочных представлений. Это общий шаблон создания нового списка, прохождения каждого элемента в списке, чтобы увидеть, соответствует ли он критерию, а затем добавление всех совпадений в новый список:
results = [] for item in iterable: if item == match: results.append(item)
Этот код можно заменить более быстрым и эффективным генератором списка (list comprehension).
Преобразуйте последний пример в список с оператором if:
results = [item for item in iterable if item == match]
Этот новый пример меньше, имеет меньшую сложность и более производительный.
Если ваши данные не являются списком, вы можете использовать пакет itertools в стандартной библиотеке, которая содержит функции для создания итераторов из структур данных. Вы можете использовать его для объединения итераций, отображения структур, циклического повторения или повторения существующих итераций.
Itertools также содержит функции для фильтрации данных, такие как filterfalse()
. Чтобы узнать больше об Itertools, ознакомьтесь с Itertools in Python 3, By Example.
4. Обработка сложных словарей с помощью инструментов запросов
Одним из самых мощных и широко используемых типов данных Python является словарь. Этот тип быстр, эффективен, масштабируем и очень гибок.
Если вы новичок в словарях, вы можете почитать Dictionaries in Python.
У этого типа есть один главный побочный эффект: когда словари сильно вложены, код, который их запрашивает, тоже становится вложенным.
Возьмем этот пример данных, пример линий метро Токио, которые вы видели ранее:
data = { "network": { "lines": [ { "name.en": "Ginza", "name.jp": "銀座線", "color": "orange", "number": 3, "sign": "G" }, { "name.en": "Marunouchi", "name.jp": "丸ノ内線", "color": "red", "number": 4, "sign": "M" } ] } }
Если вы захотите получить строку, которая соответствует определенному числу, то создадите подобную функции:
def find_line_by_number(data, number): matches = [line for line in data if line['number'] == number] if len(matches) > 0: return matches[0] else: raise ValueError(f"Line {number} does not exist.")
Несмотря на то, что сама функция небольшая, вызов функции неоправданно сложен, потому что данные вложены:
>>> find_line_by_number(data["network"]["lines"], 3)
Существуют сторонние инструменты для запроса словарей в Python. Одними из самых популярных являются JMESPath, glom, asq и flupy.
JMESPath может помочь с нашей сетью поездов. JMESPath — это язык запросов, разработанный для JSON, с плагином, доступным для Python, который работает со словарями Python. Чтобы установить JMESPath, выполните следующие действия:
$ pip install jmespath
Затем откройте Python REPL, чтобы изучить API JMESPath, скопировав его в словарь данных. Для начала импортируйте jmespath и вызовите search() со строкой запроса в качестве первого аргумента и данными в качестве второго. Строка запроса «network.lines» означает возвращаемые data[‘network’][‘lines’]:
>>> import jmespath >>> jmespath.search("network.lines", data) [{'name.en': 'Ginza', 'name.jp': '銀座線', 'color': 'orange', 'number': 3, 'sign': 'G'}, {'name.en': 'Marunouchi', 'name.jp': '丸ノ内線', 'color': 'red', 'number': 4, 'sign': 'M'}]
При работе со списками вы можете использовать квадратные скобки и предоставить запрос внутри. Что создать запрос «Все» просто используйте *. Затем вы можете добавить имя атрибута внутри каждого соответствующего элемента для возврата. Если вы хотите получить номер строки для каждой строки, вы можете сделать это:
>>> jmespath.search("network.lines[*].number", data) [3, 4]
Вы можете создавать более сложные запросы, такие как == или <. Синтаксис немного необычен для разработчиков Python, поэтому держите документацию под рукой.
Если мы хотим найти строку с номером 3, это можно сделать одним запросом:
>>> jmespath.search("network.lines[?number==`3`]", data) [{'name.en': 'Ginza', 'name.jp': '銀座線', 'color': 'orange', 'number': 3, 'sign': 'G'}]
Если мы хотим получить цвет этой строки, вы можете добавить атрибут в конце запроса:
>>> jmespath.search("network.lines[?number==`3`].color", data) ['orange']
JMESPath можно использовать для сокращения и упрощения кода, который запрашивает и ищет в сложных словарях.
5. Использование attrs
и dataclasses
для уменьшение кода
Другая цель при рефакторинге состоит в том, чтобы просто уменьшить объем кода в кодовой базе при достижении того же поведения.
Что такое Boilerplate?
Код Boilerplate — это код, который может использоваться во многих местах практически без изменений.
Если взять в качестве примера нашу сеть поездов, если бы мы преобразовали ее в типы, используя классы Python и подсказки типов Python 3, это могло бы выглядеть примерно так:
from typing import List class Line(object): def __init__(self, name_en: str, name_jp: str, color: str, number: int, sign: str): self.name_en = name_en self.name_jp = name_jp self.color = color self.number = number self.sign = sign def __repr__(self): return f"<Line {self.name_en} color='{self.color}' number={self.number} sign='{self.sign}'>" def __str__(self): return f"The {self.name_en} line" class Network(object): def __init__(self, lines: List[Line]): self._lines = lines @property def lines(self) -> List[Line]: return self._lines
Теперь вы также можете добавить другие магические методы, например .__eq__ (). Этот код является стандартным. Здесь нет бизнес-логики или каких-либо других функций: мы просто копируем данные из одного места в другое.
dataclasses
Введенный в стандартную библиотеку в Python 3.7, с пакетом backport для Python 3.6, модуль dataclasses может помочь удалить множество шаблонов для типов классов, где вы просто храните данные.
Чтобы преобразовать приведенный выше класс Line в dataclass, преобразуйте все поля в атрибуты класса и убедитесь, что они имеют аннотации типов:
from dataclasses import dataclass @dataclass class Line(object): name_en: str name_jp: str color: str number: int sign: str
Затем вы можете создать экземпляр типа Line с теми же аргументами, что и раньше, с теми же полями и даже с реализованными .__str__(), .__repr__() и .__eq__():
>>> line = Line('Marunouchi', "丸ノ内線", "red", 4, "M") >>> line.color red >>> line2 = Line('Marunouchi', "丸ノ内線", "red", 4, "M") >>> line == line2 True
Dataclasses — отличный способ уменьшить код с помощью одного импорта, который уже доступен в стандартной библиотеке. Для полного ознакомления вы можете обратиться к The Ultimate Guide to Data Classes in Python 3.7.
attrs
attrs — это сторонний пакет, который существует намного дольше, чем dataclass. attrs и обладает гораздо большей функциональностью а так же доступен на Python 2.7 и 3.4+.
Если вы используете Python 3.5 или ниже, attrs — отличная альтернатива dataclass. Кроме того, он предоставляет гораздо больше возможностей.
Эквивалентный пример классов данных в attrs будет выглядеть аналогично. Вместо использования аннотаций типов атрибутам класса присваивается значение из attrib(). Это может принимать дополнительные аргументы, такие как значения по умолчанию и обратные вызовы для проверки ввода:
from attr import attrs, attrib @attrs class Line(object): name_en = attrib() name_jp = attrib() color = attrib() number = attrib() sign = attrib()
attrs может быть полезным пакетом для удаления стандартного (boilerplate) кода и проверки правильности ввода для классов данных.
Заключение
Теперь, когда вы узнали, как распознавать и обрабатывать сложный код, вспомните шаги, которые вы можете предпринять, чтобы упростить рефакторинг и улучшить управление вашим приложением:
- Начните с создания базовой линии вашего проекта с помощью такого инструмента, как wily.
- Посмотрите на некоторые метрики и начните с модуля с самым низким индексом ремонтопригодности.
- Выполните рефакторинг этого модуля, используя заранее подготовленные тесты, и знания таких инструментов, как PyCharm и rope.
Выполнив эти шаги и рекомендации из этой статьи, вы сможете делать и другие интересные вещи в своем приложении, такие как добавление новых функций и повышение производительности.