Чистый код B: DRY, KISS, разделение ответственности, комментарии и рефакторинг

Цели урока
  • Распознавать повторяющийся код и заменять его функцией или циклом (DRY)
  • Предпочитать простое, читаемое решение «хитрому» (KISS)
  • Разделять ввод, логику и вывод (Separation of Concerns)
  • Знать, когда писать комментарий, а когда код должен объяснять себя сам
  • Выполнять рефакторинг маленькими шагами, сохраняя то же поведение

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

  • DRY — Don’t Repeat Yourself («не повторяйся»).
  • KISS — Keep It Simple, Stupid («будь проще»).
  • Separation of Concerns — разделение ответственности между частями программы.
  • Комментарии — когда комментарий помогает, а когда вредит.
  • Рефакторинг (refactoring) — улучшение структуры кода без изменения его поведения.

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

В теме о компьютере и операционной системе мы написали project_check.py с четырьмя почти одинаковыми блоками. Что будет, когда понадобится проверить ещё 20 файлов? Или поменять сообщение с OK на ✓? Придётся трогать 20 мест, и в одном из них вы обязательно забудете.

Сегодняшние принципы отвечают на вопрос: как писать код, который легко менять? В реальности большая часть времени разработчика уходит на изменение существующего кода, а не на написание нового.

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

DRY — Don’t Repeat Yourself

Каждое знание должно находиться только в одном месте. Когда одна и та же логика встречается в нескольких местах:

  • Исправление бага нужно сделать во всех местах, и одно всегда забывают.
  • Код длиннее, и трудно увидеть, что уникально в каждой части.

Инструменты исправления: функция (повторяющаяся логика), цикл (действие над многими элементами), константа (повторяющееся значение).

Тревожный сигнал: если вы копируете код (Copy-Paste) и меняете в нём маленькую деталь, вероятно, нужна функция с параметром.

KISS — Keep It Simple

Самое простое работающее решение обычно лучшее. «Хитрый» код (одна сложная строка, трюки) трудно читать, трудно отлаживать и трудно менять. Цель кода — быть понятым, а не впечатлить.

Разделение ответственности

Concern = область ответственности. В каждой программе есть несколько разных областей:

  • Ввод — откуда приходит информация (клавиатура, файл, HTTP-запрос).
  • Логика — что мы с ней делаем (вычисления, решения, правила).
  • Вывод — куда уходит информация (экран, файл, HTTP-ответ).

Когда они разделены, одну можно заменить, не трогая остальные. Это именно структура сервера в темах бэкенда и баз данных: маршрут (Route, HTTP-ввод/вывод) отделён от кода доступа к базе данных (Database).

SRP (из предыдущего урока) говорит об одной функции. Separation of Concerns говорит о всей программе: какие части существуют и как они разделены.

Комментарии

Код объясняет «что» и «как». Комментарий объясняет «почему».

Хороший комментарийНенужный / вредный комментарий
Объясняет, почему мы выбрали определённый способПовторяет то, что уже говорит код
Предупреждает о неожиданном поведенииКомпенсирует плохое имя
Указывает на источник (закон, стандарт, документ)«Мёртвый код», превращённый в комментарий
Старый комментарий, который уже неверен, — хуже всего

Рефакторинг

Рефакторинг = изменение структуры кода без изменения его поведения. Вы переименовываете, разбиваете функции, убираете повторяющийся код. В итоге код делает ровно то же самое, только понятнее.

Важное правило: маленькие шаги и проверка после каждого шага. Измените одно, запустите, сравните с исходным выводом и продолжайте.

Синтаксис

# DRY: repeated code  →  one function + loop
def check(item):
    ...

for item in items:
    check(item)

# Comment: explain WHY, not WHAT
# Tax authority requires rounding up to whole shekels (regulation 12b)
total = math.ceil(total)

# Docstring: a string right under def that describes the function
def calculate_shipping(weight_kg):
    """Return shipping cost in NIS for a package of the given weight."""
    ...

Docstring — строка в тройных кавычках ("""..."""), которая стоит в первой строке функции и описывает, что она делает. VS Code показывает её, когда вы наводите курсор на вызов функции.

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

DRY в действии. Помните project_check.py из темы о компьютере, операционной системе и сети? До:

import os

base = "../shop"

if os.path.exists(base + "/data"):
    print("data -> OK")
else:
    print("data -> MISSING")

if os.path.exists(base + "/logs"):
    print("logs -> OK")
else:
    print("logs -> MISSING")

if os.path.exists(base + "/app.py"):
    print("app.py -> OK")
else:
    print("app.py -> MISSING")

После:

import os

PROJECT_DIR = "../shop"
REQUIRED_PATHS = ["data", "logs", "app.py", "data/products.txt"]

def check_path(relative_path):
    full_path = PROJECT_DIR + "/" + relative_path
    if os.path.exists(full_path):
        status = "OK"
    else:
        status = "MISSING"
    print(relative_path, "->", status)

for path in REQUIRED_PATHS:
    check_path(path)

Вывод (то же поведение):

data -> OK
logs -> OK
app.py -> OK
data/products.txt -> OK

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

СтрокаОбъяснение
PROJECT_DIR = "../shop"Путь определён один раз. Если проект переедет в другую папку, меняется одна строка.
REQUIRED_PATHS = [...]Данные (что проверять) отделены от логики (как проверять). Добавить проверку = добавить имя в список.
def check_path(relative_path):Логика, которая повторялась четыре раза, написана один раз. То, что менялось между блоками (имя пути), стало параметром.
status = ... и затем один printВместо двух почти одинаковых print — переменная со статусом и одна печать. Это тоже DRY в малом масштабе.
for path in REQUIRED_PATHS:Цикл запускает проверку для каждого элемента.

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

KISS + разделение ответственности + комментарии. Программа, которая вычисляет стоимость доставки. До — «хитро», всё вперемешку и с ненужными комментариями:

w = float(input("w: "))  # get w
# calculate the price
print("Price:", (w <= 1) * 20 + (1 < w <= 5) * 35 + (w > 5) * (35 + (w - 5) * 4))  # print

После:

BASE_PRICE_SMALL = 20
BASE_PRICE_MEDIUM = 35
PRICE_PER_EXTRA_KG = 4
SMALL_MAX_KG = 1
MEDIUM_MAX_KG = 5

def read_weight():
    return float(input("Package weight (kg): "))

def calculate_shipping(weight_kg):
    """Return the shipping price in NIS for a package of the given weight."""
    if weight_kg <= SMALL_MAX_KG:
        return BASE_PRICE_SMALL
    if weight_kg <= MEDIUM_MAX_KG:
        return BASE_PRICE_MEDIUM
    # Above the medium tier the courier charges per extra kg (price list 2026)
    extra_kg = weight_kg - MEDIUM_MAX_KG
    return BASE_PRICE_MEDIUM + extra_kg * PRICE_PER_EXTRA_KG

def show_price(price):
    print("Shipping price:", price, "NIS")

weight = read_weight()
price = calculate_shipping(weight)
show_price(price)

Запуск:

Package weight (kg): 7.5
Shipping price: 45.0 NIS

Разбор:

  • KISS: трюк (w <= 1) * 20 работает (потому что True считается как 1, а False как 0), но с первого взгляда его никто не поймёт. Простая цепочка if читается сразу.
  • Разделение ответственности: read_weight (ввод), calculate_shipping (логика), show_price (вывод). Завтра вес придёт из HTTP-запроса? Меняется только read_weight. Логика не меняется.
  • Комментарии: # get w и # print удалены, потому что повторяли код. Остался один комментарий, который объясняет почему (прайс-лист курьерской компании), — информацию, которую нельзя понять из самого кода.
  • Docstring описывает, что возвращает функция и в каких единицах (шекели, NIS).

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

Ошибка 1: комментарий вместо хорошего имени.

d = 30  # number of days until the subscription expires

Лучше: days_until_expiry = 30 — совсем без комментария. Если для объяснения переменной нужен комментарий, имя недостаточно хорошее.

Ошибка 2: комментарий, который лжёт.

# Discount is 10%
price = price * 0.85

Кто-то обновил код и забыл про комментарий. Теперь читатель не знает, чему верить. Python не проверяет комментарии, поэтому они «гниют». Это ещё одна причина писать мало комментариев, но хороших.

Ошибка 3: DRY слишком рано. Два куска кода, которые похожи случайно, но представляют разные бизнес-правила, не обязательно объединять. Например, «НДС 17%» и «комиссия 17%» — совпадение, а не одно и то же знание. Если объединить их в одну константу, изменение одного сломает другое.

Ошибка 4: «большой» рефакторинг за раз. Вы меняете 10 вещей, запускаете, что-то ломается, и вы понятия не имеете, что именно. Один шаг, запуск, сравнение, следующий шаг.

Практика

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

Упражнение 1: DRY — чек без копирования

print("Pen", 5, "x", 4, "=", 5 * 4)
print("Notebook", 12, "x", 2, "=", 12 * 2)
print("Bag", 60, "x", 1, "=", 60 * 1)
print("Total:", 5 * 4 + 12 * 2 + 60 * 1)

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

Pen 5 x 4 = 20
Notebook 12 x 2 = 24
Bag 60 x 1 = 60
Total: 104
Показать решение
def line_total(price, quantity):
    return price * quantity


def print_receipt(names, prices, quantities):
    total = 0
    for i in range(len(names)):
        amount = line_total(prices[i], quantities[i])
        print(names[i], prices[i], "x", quantities[i], "=", amount)
        total = total + amount
    print("Total:", total)


print_receipt(["Pen", "Notebook", "Bag"], [5, 12, 60], [4, 2, 1])
Pen 5 x 4 = 20
Notebook 12 x 2 = 24
Bag 60 x 1 = 60
Total: 104

Каждое вычисление теперь встречается один раз. Четвёртый товар = ещё одно значение в списках, без новой строки кода.

Упражнение 2: KISS — слишком «хитрый» код

def parity(n):
    return (n % 2 == 0) * "even" + (n % 2 == 1) * "odd"

print(parity(4))
print(parity(7))

Код работает, но его трудно понять. Перепишите parity самым простым и читаемым способом.

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

even
odd
Показать решение
def parity(n):
    if n % 2 == 0:
        return "even"
    return "odd"


print(parity(4))
print(parity(7))
even
odd

KISS — Keep It Simple. Обычный if читается сразу, без размышлений о том, как работает умножение True на строку.

Упражнение 3: Комментарии — почему, а не что

Для каждого комментария — хороший он, ненужный или вредный? Объясните.

1.  count = count + 1   # add 1 to count
2.  # 30 days: the refund policy of the shop
    REFUND_DAYS = 30
3.  total = price * 2   # multiply price by 3
4.  # retry 3 times — the payment API sometimes fails on the first call
Показать решение
  1. Ненужный — повторяет то, что уже говорит код.
  2. Хороший — объясняет, почему 30, — информацию, которой нет в коде.
  3. Вредный — лжёт (код умножает на 2). Необновлённый комментарий хуже, чем никакого.
  4. Хороший — объясняет, почему код делает что-то странное на вид.

Правило: код говорит что, комментарий говорит почему.

Упражнение 4: Разделение ответственности — вычисление отдельно от ввода и вывода

def bmi():
    weight = float(input("Weight: "))
    height = float(input("Height: "))
    print("BMI:", round(weight / (height * height), 1))

Функция делает три вещи: читает ввод, вычисляет и печатает. Поэтому вычислением нельзя воспользоваться без клавиатуры. Разбейте её на три функции, по одной на каждую ответственность:

  1. read_number(message) — только читает число от пользователя и возвращает его.
  2. calculate_bmi(weight, height) — только вычисляет и возвращает, без input и без print.
  3. print_bmi(bmi) — только печатает уже вычисленный результат.

Проверьте вычисление и печать без клавиатуры:

bmi = calculate_bmi(70, 1.75)
print_bmi(bmi)

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

BMI: 22.9
Показать решение
def read_number(message):
    return float(input(message))


def calculate_bmi(weight, height):
    return round(weight / (height * height), 1)


def print_bmi(bmi):
    print("BMI:", bmi)


bmi = calculate_bmi(70, 1.75)
print_bmi(bmi)
BMI: 22.9

В настоящей программе три функции соединяются, и каждая строка запускает одну ответственность:

weight = read_number("Weight: ")
height = read_number("Height: ")
bmi = calculate_bmi(weight, height)
print_bmi(bmi)
  • input есть только в read_number, а print — только в print_bmi. У calculate_bmi нет ни ввода, ни вывода, поэтому её можно вызвать с данными откуда угодно: из ввода, из файла или в тесте.
  • print("BMI:", calculate_bmi(70, 1.75)) — код короче, но печать по-прежнему склеена с вычислением в одной строке, и ни одна функция не отвечает за показ. Хотите показать результат иначе? С разделением заменяется только print_bmi.

Упражнение 5: Рефакторинг по шагам

t = 12
if t < 15:
    print(12, "cold")
elif t < 28:
    print(12, "pleasant")
else:
    print(12, "hot")
t = 24
if t < 15:
    print(24, "cold")
elif t < 28:
    print(24, "pleasant")
else:
    print(24, "hot")

Выполните рефакторинг: функция classify(temperature), которая возвращает классификацию, константы для границ и цикл по списку температур [12, 24, 33].

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

12 cold
24 pleasant
33 hot
Показать решение
COLD_LIMIT = 15
HOT_LIMIT = 28


def classify(temperature):
    if temperature < COLD_LIMIT:
        return "cold"
    if temperature < HOT_LIMIT:
        return "pleasant"
    return "hot"


for temperature in [12, 24, 33]:
    print(temperature, classify(temperature))
12 cold
24 pleasant
33 hot

Шаги: (1) вынести функцию, (2) перенести 15 и 28 в константы, (3) заменить повторяющийся код циклом — и запускать после каждого шага, чтобы убедиться, что вывод не изменился.

Итоги

  • DRY — каждое знание в одном месте. Повторяющийся код → функция, цикл или константа.
  • KISS — простое и читаемое лучше «хитрого».
  • Разделение ответственности — ввод, логика и вывод в отдельных частях. Это структура любого сервера.
  • Комментарии объясняют почему. Сам код (с хорошими именами) объясняет что. Лживый комментарий хуже, чем никакого.
  • Рефакторинг — улучшайте структуру без изменения поведения, маленькими шагами с запуском после каждого шага.

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