Чистый код A: хорошие имена, маленькие функции и единственная ответственность

Цели урока
  • Объяснить, что такое Clean Code и почему код читают гораздо чаще, чем пишут
  • Выбирать понятные имена для переменных, функций и файлов по соглашениям Python
  • Распознавать функцию, которая делает больше одного дела, и разбивать её
  • Применять принцип Single Responsibility на уровне функций

Что мы изучим?

До сих пор целью было «чтобы код работал». С сегодняшнего дня цель — чтобы он был ещё и понятным. Мы изучим:

  • Именование (naming) — как выбирать имена для переменных, функций и файлов.
  • Маленькие функции (small functions) — почему функции должны быть короткими.
  • Единственную ответственность (single responsibility) — каждая функция делает одно дело.

Зачем это нужно?

Код читают гораздо чаще, чем пишут. Функцию пишут один раз, а читают при каждом исправлении бага, каждой новой функциональности и каждом Code Review коллеги. А через месяц и вы сами будете читать её как посторонний.

В компаниях код, который работает, но нечитаем, отклоняют на Code Review. На собеседованиях проверяют не только «решили ли вы», но и «можно ли понять решение».

Сравните:

def f(l):
    t = 0
    for x in l:
        t = t + x[1] * x[2]
    return t * 1.17
VAT_RATE = 1.17

def calculate_order_total(items):
    subtotal = 0
    for item in items:
        subtotal = subtotal + item["price"] * item["quantity"]
    return subtotal * VAT_RATE

Обе функции делают абсолютно одно и то же. Вторую понимаешь без усилий.

Объяснение с основ

Что такое Clean Code?

Clean Code (чистый код) — код, который другой человек может легко и уверенно прочитать, понять и изменить. Это не набор жёстких правил, а набор привычек, которые делают код понятным.

Именование — хорошие имена

Хорошее имя отвечает на вопрос «что это?» или «что это делает?» без необходимости читать окружающий код.

Соглашения об именах в Python (по PEP 8, официальному руководству по стилю):

ЧтоСтильПример
Переменные и функцииsnake_casetotal_price, send_email()
Константы (значения, которые не меняются)UPPER_SNAKE_CASEVAT_RATE, MAX_RETRIES
Классы (тема ООП)PascalCaseStudent, BankAccount
Файлы и модулиsnake_case.pyorder_utils.py

Правила хорошего имени:

  1. Описывает намерение — days_until_exam лучше, чем d.
  2. Функция = глагол — calculate_total(), send_invoice(), is_valid_email(). Переменная = существительное — total, invoice, email.
  3. Логическое значение звучит как вопрос — is_active, has_permission, can_edit.
  4. Список — во множественном числе — students (список), student (один элемент).
  5. Без непонятных сокращений — customer, а не cstmr. (Известные сокращения вроде id, url, max допустимы.)
  6. Без «магических чисел» (magic numbers) — вместо * 1.17, смысла которого никто не знает, — именованная константа: VAT_RATE = 1.17.

Маленькие функции

Длинную функцию (50+ строк) трудно понять, трудно протестировать и трудно изменить. Эмпирическое правило: если функция не помещается на один экран или ей трудно дать имя без слова «и», она, вероятно, делает слишком много.

Единственная ответственность

Принцип единственной ответственности (Single Responsibility Principle, SRP): у каждой функции должна быть одна причина для изменения, то есть одна задача.

Аналогия: ресторан

В ресторане есть официант, повар и кассир. Если повар ещё и принимает заказы, и готовит, и берёт деньги, то любое изменение (новое меню, новый кардридер) требует «менять» его, а каждая его ошибка вредит всему. Когда у каждого одна задача, легко заменить, улучшить и найти, где проблема.

Синтаксис

Для чистого кода нет «нового синтаксиса». Это тот же Python, просто организованный по-другому. Шаблон:

CONSTANT_NAME = value          # constants at the top, UPPER_SNAKE_CASE

def verb_noun(clear_parameter):   # a function does ONE thing, named by what it does
    ...
    return result

def main():                     # one function that connects the small pieces
    data = get_something()
    result = process_something(data)
    show_something(result)

main()

Первый пример кода

До — код, который работает, но не чистый. week-2/grades_before.py:

def do(g):
    s = 0
    for x in g:
        s = s + x
    a = s / len(g)
    if a >= 55:
        print("avg", a, "pass")
    else:
        print("avg", a, "fail")

do([80, 45, 92, 60])

После — то же поведение, чистый код. week-2/grades_after.py:

PASSING_GRADE = 55

def calculate_average(grades):
    total = 0
    for grade in grades:
        total = total + grade
    return total / len(grades)

def is_passing(average):
    return average >= PASSING_GRADE

def print_report(average):
    if is_passing(average):
        status = "pass"
    else:
        status = "fail"
    print("Average:", average, "-", status)

student_grades = [80, 45, 92, 60]
average = calculate_average(student_grades)
print_report(average)

Вывод (версия «после». Старая версия печатает avg 69.25 pass — тот же расчёт, сформулированный менее понятно):

Average: 69.25 - pass

Разбор кода построчно

В старом кодеВ чистом кодеПочему это лучше
doТри функции с именамиИмя говорит, что делает каждая.
g, s, x, agrades, total, grade, averageНе нужно «расшифровывать».
55 внутри кодаPASSING_GRADE = 55Значение со смыслом и именем. Если проходной балл изменится, меняете в одном месте.
Расчёт + решение + печать в одной функцииcalculate_average, is_passing, print_reportКаждая функция делает одно дело.

Практическое преимущество: хотите показывать среднее на сайте, а не на экране? Меняется только print_report. Хотите изменить проходной балл? Только PASSING_GRADE. А calculate_average пригодится и в других местах, потому что она возвращает значение, а не печатает его.

Ещё один пример

Функция «которая делает всё» в системе регистрации, и как её разбить. До:

def register(n, e, a):
    if "@" not in e:
        print("bad email")
        return
    if a < 16:
        print("too young")
        return
    u = n.strip().lower().replace(" ", ".")
    print("Welcome " + n + "! Your username is " + u)

После:

MIN_AGE = 16

def is_valid_email(email):
    return "@" in email

def is_old_enough(age):
    return age >= MIN_AGE

def create_username(full_name):
    return full_name.strip().lower().replace(" ", ".")

def register_user(full_name, email, age):
    if not is_valid_email(email):
        print("Invalid email address")
        return
    if not is_old_enough(age):
        print("You must be at least", MIN_AGE, "to register")
        return
    username = create_username(full_name)
    print("Welcome " + full_name + "! Your username is " + username)

register_user("Dana Cohen", "dana@mail.com", 24)
register_user("Avi Levi", "avi-mail.com", 30)

Вывод:

Welcome Dana Cohen! Your username is dana.cohen
Invalid email address

Разбор:

  • "@" in email — in проверяет, встречается ли кусок текста внутри другого текста, и возвращает True/False.
  • .strip() — убирает пробелы в начале и в конце. .lower() — строчные буквы. .replace(" ", ".") — заменяет пробелы точками. Это методы str, которые можно вызывать цепочкой один за другим.
  • return без значения — немедленно выходит из функции (возвращает None). Так мы не продолжаем регистрацию после ошибки. Этот приём называется Early Return (или Guard Clause).
  • register_user теперь «читается как рассказ»: проверить email, проверить возраст, создать имя пользователя, поприветствовать. Все детали — в маленьких функциях.

Типичная ошибка

Ошибка 1: имена, которые лгут.

def get_user(user_id):
    user = find_in_list(user_id)
    user["last_login"] = "today"   # changes data!
    delete_old_sessions(user)      # deletes data!
    return user

Имя говорит «получить», а функция изменяет и удаляет. Тот, кто вызовет её, чтобы «просто получить пользователя», будет удивлён. Имя должно описывать всё, что делает функция. А если это трудно описать без «и», то ответственность не одна.

Ошибка 2: чрезмерное дробление. Обратное — тоже проблема:

def add_one(number):
    return number + 1

Однострочная функция, которая скрывает очевидную операцию, только добавляет переходов при чтении. Разбивайте, когда есть идея с именем (is_old_enough), а не каждую строку.

Ошибка 3: слишком общие имена. data, info, temp, result, manager, process(). Такие имена почти всегда можно улучшить: data → student_records, process() → apply_discount().

Практика

Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.

Упражнение 1: Имена, которые объясняют сами себя

Следующий код работает, но понять, что он делает, невозможно:

def f(a, b):
    c = a * b
    if c > 500:
        c = c - 50
    return c

print(f(120, 5))
print(f(30, 2))

Перепишите его с хорошими именами для функции и переменных и замените числа 500 и 50 именованными константами. Вывод должен остаться прежним:

550
60
Показать решение
DISCOUNT_THRESHOLD = 500
DISCOUNT_AMOUNT = 50


def calculate_order_price(unit_price, quantity):
    price = unit_price * quantity
    if price > DISCOUNT_THRESHOLD:
        price = price - DISCOUNT_AMOUNT
    return price


print(calculate_order_price(120, 5))
print(calculate_order_price(30, 2))
550
60
  • Имя функции — глагол, который описывает, что она делает.
  • 500 и 50 были магическими числами — теперь ясно, что это, и если скидка изменится, меняете в одном месте.

Упражнение 2: Соглашения об именах

Для каждого имени — записано ли оно по соглашениям Python? Если нет, напишите правильное имя.

TotalPrice      (a variable)
numberofstudents (a variable)
max_grade       (a variable)
vat_rate        (a constant that never changes)
GetAverage      (a function)
Показать решение
ИмяПравильно?Правильное имя
TotalPriceНет — переменные пишут в snake_casetotal_price
numberofstudentsНет — трудно читать без подчёркиванийnumber_of_students
max_gradeДа—
vat_rateНет — константы пишут заглавными буквамиVAT_RATE
GetAverageНет — функции пишут в snake_case, и лучше глаголget_average / calculate_average

Упражнение 3: Одна функция — одна ответственность

Следующая функция вычисляет среднее, решает, сдал ли студент, и ещё печатает — три задачи:

def report(name, grades):
    total = 0
    for g in grades:
        total = total + g
    avg = total / len(grades)
    if avg >= 55:
        print(name + ": average", avg, "- passed")
    else:
        print(name + ": average", avg, "- failed")

report("Dana", [80, 85])
report("Avi", [50, 46])

Разбейте её на три функции: calculate_average, is_passing и print_report(name, average). print_report получает уже вычисленное среднее и только печатает. Вывод должен остаться:

Dana: average 82.5 - passed
Avi: average 48.0 - failed
Показать решение
PASSING_GRADE = 55


def calculate_average(grades):
    return sum(grades) / len(grades)


def is_passing(average):
    return average >= PASSING_GRADE


def print_report(name, average):
    if is_passing(average):
        status = "passed"
    else:
        status = "failed"
    print(name + ": average", average, "-", status)


dana_average = calculate_average([80, 85])
print_report("Dana", dana_average)
avi_average = calculate_average([50, 46])
print_report("Avi", avi_average)
Dana: average 82.5 - passed
Avi: average 48.0 - failed
  • calculate_average только вычисляет, is_passing только решает, а print_report получает готовое среднее и только показывает его. Вычисление происходит вне её.
  • Если бы print_report сама вызывала calculate_average, она снова и вычисляла бы, и печатала. Код короче, но ответственности не разделены.
  • Теперь calculate_average можно использовать и там, где печатать не нужно.

Упражнение 4: Без магических чисел

В интернет-магазине следующая функция получает сумму заказа и возвращает сумму к оплате:

def final_price(total):
    if total < 200:
        total = total + 30
    return total

print(final_price(150))
print(final_price(250))
  1. Что означают 200 и 30 в магазине? (Подсказка: что происходит с маленьким заказом?) Дайте им имена в виде констант.
  2. Перепишите функцию так, чтобы её имя точно объясняло, что она добавляет.

Ожидаемый вывод:

180
250
Показать решение
FREE_SHIPPING_MINIMUM = 200
SHIPPING_FEE = 30


def add_shipping(order_total):
    if order_total < FREE_SHIPPING_MINIMUM:
        return order_total + SHIPPING_FEE
    return order_total


print(add_shipping(150))
print(add_shipping(250))
180
250

200 — минимальная сумма для бесплатной доставки, а 30 — стоимость доставки. С именами никому не нужно гадать.

Упражнение 5: Маленькая функция, которая отвечает на один вопрос

Напишите функцию is_valid_password(password), которая возвращает True, если длина пароля не меньше 8 символов и в нём есть хотя бы одна цифра, иначе False. Функция не печатает — только возвращает.

print(is_valid_password("abc12345"))
print(is_valid_password("abcdefgh"))
print(is_valid_password("a1"))

Ожидаемый вывод:

True
False
False
Показать решение
MIN_PASSWORD_LENGTH = 8


def has_digit(text):
    for char in text:
        if char.isdigit():
            return True
    return False


def is_valid_password(password):
    return len(password) >= MIN_PASSWORD_LENGTH and has_digit(password)


print(is_valid_password("abc12345"))
print(is_valid_password("abcdefgh"))
print(is_valid_password("a1"))
True
False
False

Имя is_valid_password начинается с is_ — ясно, что она возвращает True или False. Проверка цифры вынесена в собственную маленькую функцию.

Итоги

  • Clean Code = код, который другие (и вы через месяц) легко понимают и меняют. Код читают гораздо чаще, чем пишут.
  • Имена: snake_case для переменных и функций, UPPER_SNAKE_CASE для констант. Функция = глагол, логическое значение = вопрос (is_...).
  • Без магических чисел. Значение со смыслом получает именованную константу.
  • Маленькие функции с единственной ответственностью (SRP). Если в имени нужно «и», вероятно, нужно разбить.
  • Функция, которая вычисляет, — возвращает. Печать — отдельная ответственность.

Официальная документация