Небанальные правила чистого Python. Часть 1
- вторник, 1 ноября 2022 г. в 01:02:18
Большинство питонистов не раз слышали о таких правилах как «функции должны быть глаголами» или «не наследуйтесь явно от object в Python 3». В этой статье мы рассмотрим не такие банальные, но полезные правила чистого кода в Python.
Идея статьи возникла при выполнении Code review одного проекта. В тот момент я понял что пора объединить и структурировать накопленные правила чистого кода.
Эти правила я использую постоянно и, после их применения, начинаю быстрее читать и понимать код. Соглашаться с ними или нет - ваш выбор, но если считаете, что какое-то правило неэффективно, давайте обсудим это в комментариях.
Такой подход даёт понять, что функция не используется и не должна использоваться в других файлах. По крайней мере, на уровне соглашений.
Например в проекте есть модули «a.py», «b.py» и «c.py». Функция get_user_name
создана в модуле «a.py». Используется она тоже только в нём. Тогда её следует переименовать в _get_user_name
.
Напишем такую функцию:
def get_sum(number_1: int, number_2: int) -> int:
"""Вернёт сумму двух чисел.
Примеры:
get_sum(0, 2) = 2
get_sum(1, 2) = 3
get_sum(3, 5) = 8
"""
return number_1 + number_2
print(get_sum(10, 15))
Функция работает, но запустив код, мы никак не проверим примеры из docstring:
# Флаг «v» выводит дополнительные детали выполнения программы
$ python script.py -v
25
Исправим это с помощью модуля doctest:
from doctest import testmod
def get_sum(number_1: int, number_2: int) -> int:
"""Вернёт сумму двух чисел.
>>> get_sum(0, 2)
2
>>> get_sum(1, 2)
3
>>> get_sum(3, 5)
8
"""
return number_1 + number_2
if __name__ == "__main__":
print(get_sum(10, 15))
testmod()
Теперь запустим программу:
$ python script.py -v
25
Trying:
get_sum(0, 2)
Expecting:
2
ok
Trying:
get_sum(1, 2)
Expecting:
3
ok
Trying:
get_sum(3, 5)
Expecting:
8
ok
1 items had no tests:
__main__
1 items passed all tests:
3 tests in __main__.get_sum
3 tests in 2 items.
3 passed and 0 failed.
Test passed.
Мы получили результат работы программы и результат выполнения тестов из docstring. Уберите флаг «v», если хотите вывести только результат работы программы:
$ python script.py
25
Взгляните на эту функцию:
def is_user_name_valid(user_name): pass
Какое значение нужно передать в переменную user_name
? Строку с именем? Словарь с ФИО? Может ещё что-то? Скорее всего строку с именем, но для полной уверенности надо читать саму функцию. Type hint освобождает от этой траты времени:
def is_user_name_valid(user_name: str): pass
Плюсы использования type hint:
Позволяет не думать над типом аргумента;
Немного документирует код;
Уменьшает число ошибок, связанных с типом аргумента;
Облегчает разработку в некоторых IDE. Например PyCharm может ругаться на аргумент, который не соответствует type hint.
Для аргументов по умолчанию тоже можно задать type hint:
def is_user_name_valid(user_name: str = "admin"): pass
Особенно это полезно если аргумент может принимать значения разных типов:
# Для Python 3.10
def is_positive(number: int | float = 100): pass
# Для Python 3.9 и ниже
from typing import Union
def is_positive(number: Union[int, float] = 100): pass
Для переменных тоже можно указать type hint. Но нет смысла это делать, если тип переменной и так понятен.
Плохо:
cat_name: str = "Tom"
Хорошо:
# settings.PAGE_SIZE может иметь значение разных типов, например str и int
page_size: int = settings.PAGE_SIZE
Type hint полезен не только для аргументов и переменных, но и для возвращаемого значения функции. За счёт него можно не заглядывать в тело функции, а сразу понять какой тип она вернёт.
from typing import Callable
def get_user_name() -> str: ...
def is_user_name_valid(user_name: str) -> bool: ...
def get_wrapped_function() -> Callable: ...
def run_tests() -> None: ...
У функции, которая возвращает другую функцию, указывается type hint Callable. У функции, которая ничего не возвращает, указывается type hint None
.
Допустим, есть такой кот класс:
class Cat:
"""Просто кот"""
def __init__(self, name: str):
self.name = name
def ask_for_food(self) -> None:
self.__say_meow()
self.__say_meow()
def __say_meow(self) -> None:
print(f"{self.name} says meow")
Мы создаем его объект и вызываем публичный метод:
tom = Cat("Tom")
tom.ask_for_food()
Если человек захочет понять что делает метод ask_for_food
, то он прочитает содержимое класса Cat
в таком порядке:
Прочитает метод __init__
и поймёт куда заносится имя "Tom"
;
Прочитает метод ask_for_food
и увидит в нём вызов метода __say_meow
;
Прочитает метод __say_meow
.
Т.е. приватный пользовательский метод читается в последнюю очередь. Так всегда происходит со всеми не магическими private-методами, если в коде соблюдаются принципы ООП.
Что касается порядка создания публичных и магических методов, то это дело вкуса. Я обычно создаю методы в такой последовательности:
__new__
(если такой метод используется в классе);
__init__
;
Остальные магические методы;
Public-методы;
Protected-методы;
Private-методы.
Обычно вместо этого пишутся комментарии, но такой способ лучше - вы можете узнать единицу измерения в любом месте кода, где есть эта переменная.
Плохо
cooking_time = 30
user_weight = 5
Лучше, но всё ещё плохо:
# Время в минутах
cooking_time = 30
# Вес в килограммах
user_weight = 5
Хорошо
cooking_time_in_minutes = 30
user_weight_in_kg = 5
Дополнительно об этом правиле можно прочитать в книге «Чистый код», глава 2, пункт «Имена должны передавать намерения программиста».
Напишем следующий код:
for i in range(10):
print("Hello!")
Переменная i
внутри цикла не используется. Заменим её на нижнее подчеркивание - традиционное обозначение неиспользуемых переменных:
for _ in range(10):
print("Hello!")
С точки зрения Python мы поменяли имя переменной i
на _
. Работа программы от этого не изменилась. Но зато человек, который будет читать код, поймёт, что внутри цикла не используется итерационная переменная.
Это правило обычно применяется и при распаковке последовательностей:
# a = 1; _ = 2
a, _ = 1, 2
# a = 1; _ = [2, 3, 4]
a, *_ = (1, 2, 3, 4)
a, *_ = [1, 2, 3, 4]
a, *_ = {1, 2, 3, 4}
a, *_ = {1: '1', 2: '2', 3: '3', 4: '4'}
Т.е. значения 2, 3 и 4 мы использовать не собираемся, но сохранить их где-то надо.
Не используйте это правило если пишите на Django, и в вашем коде есть функция gettext. Её принято заменять на нижнее подчёркивание. Хотя ошибки в коде не произойдет, но у программиста может возникнуть недопонимание:
from django.utils.translation import gettext as _
title = _("Интернет-магазин «Кошачий рай»")
# Программист: «Почему здесь исользуется функция gettext?»
for _ in range(10): # Цикл спокойно работает
print(1)
Дополнительно об этом правиле читайте тут.
Для удобства пользователя, в большинстве приложений числа разделяются пробелом через каждые 3 цифры. Например, вместо 1000000 пишется 1 000 000. В Python тоже есть такая возможность, но вместо пробела используется нижнее подчеркивание.
Плохо
number_of_accounts = 1500
sum_in_rubles = 1234567890
Хорошо
number_of_accounts = 1_500
sum_in_rubles = 1_234_567_890
Дополнительно о правиле читайте в этой статье, в пункте «Example 5: Single underscore in numeric literals».
Плюсы применения правила:
Легче и быстрее понять, как появилось число;
Легче и быстрее изменить число - надо просто поменять параметры формулы;
Из кода удаляются «магические числа»;
В коде становится меньше лишних комментариев.
Плохо:
flight_time_in_seconds = 10_800
Лучше, но всё ещё плохо:
# 60 секунд * 60 минут * 3
flight_time_in_seconds = 10_800
Хорошо:
flight_time_in_seconds = 60 * 60 * 3
Очень хорошо:
MIN_IN_SECONDS = 60
HOUR_IN_SECONDS = MIN_IN_SECONDS * 60
flight_time_in_seconds = HOUR_IN_SECONDS * 3
Идеально:
# Код файла constants.py
MIN_IN_SECONDS = 60
HOUR_IN_SECONDS = MIN_IN_SECONDS * 60
# Код файла script.py
from constants import HOUR_IN_SECONDS
flight_time_in_seconds = HOUR_IN_SECONDS * 3
Объём кода становится больше, но времени на осознание и, при необходимости, изменение переменной flight_time_in_seconds
- меньше.
Во второй части статьи я расскажу про остальные правила. Также в ближайшее время планируется публикация по правилам чистого кода в Django-проектах.
Надеюсь, полученная информация принесла вам пользу. До скорых встреч)