Принципы разработки DRY, KISS, YAGNI и цена преждевременной абстракции
0%

DRY, KISS, YAGNI и цена преждевременной абстракции

DRY, KISS, YAGNI и цена преждевременной абстракции

Эти три аббревиатуры знает каждый, кто хоть раз проходил собеседование. И почти каждый понимает их неправильно — потому что выучил лозунг, а не аргумент. DRY превращается в «запрещено копипастить», KISS — в «пиши поменьше кода», YAGNI — в «не думай о будущем». В таком виде принципы вредят: они делают код связанным, непонятным и хрупким, но с чистой совестью, ведь «мы же следуем принципам».

Эта статья — про то, что за лозунгами стоит. Разберём формулировки в оригинале, построим модель стоимости, из которой видно, когда абстракция окупается, и научимся отвечать на главный практический вопрос: дублировать сейчас или обобщать сейчас? Если вводная статья трека — https://courses.digitable.life/post/principles/00-overview/ — ещё не прочитана, начните с неё.


Общая интуиция: все три принципа — про стоимость изменения

Программу пишут один раз, а меняют десятки раз. Поэтому «хорошесть» кода почти полностью сводится к одному: сколько стоит следующее изменение. Все три принципа — эвристики, которые пытаются эту стоимость снизить, но каждая давит на свою переменную:

Принцип Что минимизирует Чем платит
DRY число мест, куда надо внести одну правку ростом связанности между этими местами
KISS когнитивную нагрузку на читателя иногда — многословием и повторами
YAGNI объём кода, который надо тащить и не сломать иногда — переделкой, когда будущее всё-таки наступило

Обратите внимание: они конфликтуют между собой. DRY толкает к обобщению, KISS — к прямолинейности, YAGNI — к тому, чтобы не строить обобщение вообще. Инженерия начинается там, где вы умеете выбирать между ними осознанно, а не цитировать тот, что удобнее в споре.


DRY: принцип про знание, а не про текст

Формулировка Энди Ханта и Дэйва Томаса в The Pragmatic Programmer (1999):

Every piece of knowledge must have a single, unambiguous, authoritative representation within a system. — «Каждый фрагмент знания должен иметь единственное, непротиворечивое и авторитетное представление в системе.»

Ключевое слово — knowledge, не «код», не «строки», не «символы». В 20-м юбилейном издании авторы отдельно оговариваются, что принцип «украли» и превратили в «не копируй код». DRY нарушается, когда одно и то же решение о том, как устроен мир, записано дважды: при изменении мира их придётся править синхронно, а если синхронности нет — система начинает противоречить сама себе.

Что считается нарушением DRY на самом деле

DRY — не только про код. В книге выделены четыре вида дублирования: навязанное (среда заставляет: схема БД и DTO, IDL и сгенерированный клиент, документация и код — лечится генерацией из одного источника истины), неумышленное (не заметили, что поле вычислимо из других: храним total, subtotal и tax, а знание одно), нетерпеливое («скопирую, потом причешу» — обычная копипаста) и межразработческое (две команды независимо написали свой parse_phone_number — самый дорогой и самый незаметный вид).

# Нарушение DRY БЕЗ единой одинаковой строки: знание «заказ дороже 5000 ₽ — крупный»
# записано трижды разными словами, и версии уже разошлись (> против >=).
def needs_manual_review(order):
    return order.total > 5000                              # место 1

def shipping_cost(order):
    return 0 if order.total >= 5000 else 400               # место 2 — здесь уже баг

REPORT_SQL = "SELECT * FROM orders WHERE total > 5000"     # место 3

# Исправление — не «вынести константу», а НАЗВАТЬ ЗНАНИЕ:
LARGE_ORDER_THRESHOLD = Money(5000, "RUB")

def is_large(order) -> bool:
    """Единственное авторитетное определение «крупного заказа»."""
    return order.total > LARGE_ORDER_THRESHOLD

Дальше все три места спрашивают is_large(order). Отчётный SQL — отдельная боль: знание утекло в другой рантайм, и лечится это либо вычисляемой колонкой/view в БД, либо генерацией запроса из того же определения. Утечка бизнес-правил в отчёты, дашборды и ETL — самый частый источник «цифры в BI не сходятся с цифрами в приложении».

И наоборот: одинаковый код, который НЕ является дублированием

# billing/validators.py  И  support/validators.py — символ в символ одинаковые функции
def validate(payload):
    if not payload.get("id"):
        raise ValueError("id обязателен")
    if len(payload.get("comment", "")) > 255:
        raise ValueError("comment слишком длинный")

Код совпадает буква в букву. Но это совпадение, а не одно знание: лимит комментария в биллинге задан требованием платёжного шлюза, а в поддержке — шириной колонки в интерфейсе оператора. Завтра поддержка поднимет лимит до 2000, а биллинг не сможет. Если вы «отDRYили» эти функции в общий validate_payload, вы получите флаг is_billing=True или тихий баг.

Правильный вопрос не «одинаково ли выглядит?», а «обязаны ли эти два места меняться одновременно и по одной причине?». Это тот же критерий, что и в Single Responsibility Principle (https://courses.digitable.life/post/principles/01-solid/): DRY и SRP — две проекции одной мысли, одна причина изменения → одно место в коде.


Цена преждевременной абстракции

Самая точная формулировка принадлежит Сэнди Метц:

Duplication is far cheaper than the wrong abstraction. — Sandi Metz, The Wrong Abstraction (2016)

Метц описывает конкретный сценарий деградации, и он воспроизводится в любой кодовой базе:

Ловушка в том, что каждый шаг локально рационален. Никто не решает «давайте сделаем нечитаемый код»: добавить флаг всегда дешевле, чем разъединить функцию обратно. Классический эффект невозвратных затрат — код написан, «жалко выбрасывать», абстракция обрастает исключениями.

Кривые стоимости: дублирование, верная и неверная абстракция

Считаем: когда абстракция окупается

Модель совокупной стоимости владения. Пусть n — число мест со знанием, c — стоимость правки в одном месте, m — число будущих изменений, B — стоимость построения абстракции, v — доля изменений, которые «почти подходят», p — штраф за вариацию в общем коде. Тогда:

  • Дублирование: Cost_dup = m · n · cO(m·n), линейно и предсказуемо.
  • Верная абстракция (v ≈ 0): Cost_abs = B + m · cO(m).
  • Неверная абстракция (v > 0): Cost_wrong = B + m·c + v·m·p·n, причём сам p растёт с числом накопленных флагов — на практике это суперлинейно, ближе к O(m · 2^k) по числу комбинаций флагов k, которые надо удерживать в голове и в тестах.

Абстракция выигрывает у копии, когда B + m·c < m·n·c, то есть при n > 1 + B / (m · c). Отсюда вся практика: при большом B и малом m (редко меняющийся код, дорогое обобщение) копируйте; при большом m (горячая зона изменений) порог по n падает почти до двух; при заметном v (вы не уверены, что случаи одинаковы) третий член съедает всю выгоду. Порог зависит не от количества строк, а от вашей уверенности в том, что случаи одинаковы. Именно поэтому работает правило трёх: два совпадения могут быть случайностью, три обычно означают закономерность.

Правило трёх (Дон Робертс, приведено Фаулером в Refactoring): «Первый раз — просто делаешь. Второй раз морщишься от дублирования, но всё равно дублируешь. На третий — рефакторишь.»

Прикинем численно.

"""Расчёт O(1) по времени и памяти; его ценность — превратить неявные
допущения спора («так же чище!») в явные числа, которые можно оспорить."""

def compare(n, m, c, B, v, p):
    dup = m * n * c                              # правим каждую копию каждый раз
    abstr = B + m * c + v * m * p * n            # строим один раз + штраф за вариации
    if abs(dup - abstr) / max(dup, abstr) < 0.15:   # разница в пределах шума оценок
        return dup, abstr, "паритет — решает читаемость, а не арифметика"
    return dup, abstr, "абстракция" if abstr < dup else "дублирование"

print(compare(n=3, m=12, c=1.0, B=6,  v=0.05, p=2))  # (36.0, 21.6, 'абстракция')
print(compare(n=2, m=12, c=1.0, B=6,  v=0.6,  p=2))  # (24.0, 32.4, 'дублирование')
print(compare(n=8, m=2,  c=1.5, B=20, v=0.3,  p=3))  # (24.0, 28.4, 'дублирование')
# 1) ядро биллинга, 3 места, стабильные правила → обобщать
# 2) валидация в 2 модулях с разными причинами изменения → копировать
# 3) шаблон в 8 адаптерах, правки дважды в год → копировать

Третий сценарий — самый поучительный: восемь копий, и всё равно абстракция проигрывает, потому что код почти не меняется, а обобщение дорогое. Число копий само по себе ничего не доказывает.

Как выглядит неверная абстракция в коде

# ПЛОХО: одна функция «на все случаи». Классическая boolean trap.
def send_notification(user, text, *, is_urgent=False, is_marketing=False,
                      skip_quiet_hours=False, use_fallback_sms=False,
                      dry_run=False, legacy_template=False):
    if is_marketing and not user.marketing_consent:
        return
    if not skip_quiet_hours and user.in_quiet_hours() and not is_urgent:
        return
    template = OLD_TEMPLATES if legacy_template else TEMPLATES
    ...

# Шесть булевых параметров = 2^6 = 64 логических пути, осмысленны из них 4–5.
# Вызов send_notification(u, t, False, True, False, True, False, False) нечитаем,
# тестами это не покрывается, каждый новый флаг ломает старые сочетания.

# ЛУЧШЕ: одинаковое — доставка (механика). Разное — правила, кому и когда
# писать (политика). Механику объединяем, политики держим раздельно.

class Channel(Protocol):
    def deliver(self, user: User, message: Message) -> DeliveryResult: ...

def _deliver_with_retry(channel: Channel, user, message, attempts: int = 3):
    """Единственное знание про ретраи — оно действительно одно на всех."""
    for attempt in range(attempts):
        result = channel.deliver(user, message)
        if result.ok or not result.retriable:
            return result
        sleep(backoff(attempt))
    return result

def send_marketing(user, message):
    """Знание маркетинга: нужно согласие, уважаем тихие часы."""
    if not user.marketing_consent or user.in_quiet_hours():
        return DeliveryResult.suppressed()
    return _deliver_with_retry(PushChannel(), user, message)

def send_security_alert(user, message):
    """Знание безопасности: игнорируем тихие часы, падаем в SMS."""
    result = _deliver_with_retry(PushChannel(), user, message)
    return result if result.ok else _deliver_with_retry(SmsChannel(), user, message)

Здесь дублируется вызов _deliver_with_retry — и это нормально. Мы вынесли механику (то, что действительно одно знание) и оставили раздельными политики (разные знания, которые меняются по разным причинам и разными людьми). Структурно разница выглядит так:


KISS: простое — это не то же самое, что короткое

KISS («keep it simple, stupid») приписывают Кларенсу «Келли» Джонсону, главному инженеру Lockheed Skunk Works. Исходный смысл был инженерно-конкретным: самолёт должен чиниться в полевых условиях средним механиком с базовым набором инструментов. Не «сделайте попроще», а «сложность обязана окупаться в эксплуатации». Главное непонимание: короткий код часто не простой — однострочник с тремя вложенными comprehension’ами короче и сложнее цикла.

Простое против лёгкого

Рич Хикки в докладе Simple Made Easy (Strange Loop, 2011) разводит два понятия, которые постоянно путают. Simple (лат. sim-plex, «одна складка») — объективное свойство: у вещи одна ответственность, она не сплетена с другими; антоним — complex, «сплетённый». Easy (лат. adjacens, «лежащий рядом») — субъективное: близко к тому, что вы уже знаете.

Знакомое ≠ простое. ORM с ленивой загрузкой — easy (одна строка) и при этом complex (сплетает время жизни объекта, транзакцию, сессию и SQL). Явный запрос — hard поначалу и simple по существу. Хикки называет сплетение complecting — ровно то, что делает преждевременная абстракция: связывает несвязанные случаи ради видимого сокращения кода.

Сущностная и привнесённая сложность

Фред Брукс в No Silver Bullet (1986) разделил сложность на essential (сложность самой задачи: налоги действительно устроены сложно) и accidental (внесённую нашими инструментами и решениями: сборка, конфиги, пять слоёв обёрток вокруг HTTP-клиента). KISS — это война с accidental. Попытка «упростить» essential даёт не простоту, а неправильную программу. Практический критерий: если после «упрощения» появились случаи, которые система обрабатывает неверно, вы не упростили — вы удалили требования.

Практика KISS: измеримые вещи

# ДО: цикломатическая сложность 8, глубина вложенности 4.
def price(order, user, promo):
    if order is not None:
        if order.items:
            total = 0
            for item in order.items:
                if item.qty > 0:
                    if item.category == "book":
                        total += item.price * item.qty * 0.9
                    elif user.is_premium:
                        total += item.price * item.qty * 0.95
                    else:
                        total += item.price * item.qty
            if promo and promo.valid_until > now():
                total *= (1 - promo.percent / 100)
            return total
        else:
            return 0
    return 0

# ПОСЛЕ: guard clauses + таблица правил. Цикломатическая сложность 3.
CATEGORY_DISCOUNT = {"book": Decimal("0.10")}   # знание о скидках — в данных, а не в ветках
PREMIUM_DISCOUNT = Decimal("0.05")

def price(order, user, promo) -> Decimal:
    if not order or not order.items:
        return Decimal(0)
    total = sum(_line_total(i, user) for i in order.items if i.qty > 0)
    return _apply_promo(total, promo)

def _line_total(item, user) -> Decimal:
    default = PREMIUM_DISCOUNT if user.is_premium else Decimal(0)
    return item.price * item.qty * (1 - CATEGORY_DISCOUNT.get(item.category, default))

def _apply_promo(total: Decimal, promo) -> Decimal:
    if not promo or promo.valid_until <= now():
        return total
    return total * (1 - Decimal(promo.percent) / 100)

Что произошло: ранние возвраты убрали два уровня вложенности (глубина вложенности — лучший дешёвый прокси читаемости, правило «не больше двух уровней» работает); ветвление превратилось в таблицуif category == ... был данными, притворявшимися кодом, и новая категория больше не требует правки логики (это OCP из https://courses.digitable.life/post/principles/01-solid/); каждая функция называет своё намерение, что делает комментарии ненужными (https://courses.digitable.life/post/principles/04-clean-code/); заодно исправлен реальный баг — float в деньгах, где 0.1 + 0.2 != 0.3.

Формально цикломатическая сложность (McCabe, 1976) = число решающих узлов + 1 = число независимых путей. Она задаёт нижнюю границу количества тестов для покрытия ветвей — то есть сложность буквально равна вашему счёту за тестирование (https://courses.digitable.life/post/principles/06-testing-principles/).

Глубина модуля — лучшая метрика простоты

Джон Оустерхаут в A Philosophy of Software Design предлагает критерий, который я считаю самым полезным из всех существующих:

Польза модуля = объём спрятанной сложности − сложность его интерфейса.

Глубокий модуль против мелкого

open(path) в Unix — глубокий модуль мечты: три слова в интерфейсе, за ними права доступа, inode, кэш страниц, драйверы и файловые системы. Обёртка UserServiceHelperFactory с восемью методами, каждый из которых делегирует одну строку, — мелкий модуль: интерфейса больше, чем содержимого. Преждевременная абстракция почти всегда порождает мелкие модули — это её наглядный симптом. Если после извлечения абстракции читателю приходится знать столько же или больше, вы добавили слой, а не спрятали сложность.


YAGNI: «вам это не понадобится»

Принцип из экстремального программирования, авторство обычно приписывают Рону Джеффрису. Каноничный разбор — Yagni Мартина Фаулера.

Важнейшее уточнение, которое почти всегда теряют: YAGNI применим к предполагаемой функциональности, а не к качеству кода. Тесты, понятные имена, обработка ошибок, модульность — это не «на будущее», это то, что делает изменения возможными сегодня. «YAGNI, поэтому я не пишу тесты» — не YAGNI, а его противоположность.

Четыре стоимости по Фаулеру

Тонкость cost of repair: неверно угаданная абстракция дороже пустого места. Когда фича наконец нужна, приходится не «дописать», а сначала разобрать неправильное обобщение, не сломав нынешних потребителей. Тот же механизм, что у неверной абстракции.

Пример: плагинная архитектура на одну реализацию

// YAGNI-нарушение: инфраструктура под «будущих провайдеров», которых пока один.
interface PaymentProvider {
  charge(amount: Money, token: string): Promise<ChargeResult>;
  refund(chargeId: string, amount: Money): Promise<RefundResult>;
  supports(currency: Currency): boolean;
}

class PaymentProviderRegistry {
  private providers = new Map<string, PaymentProvider>();
  register(name: string, p: PaymentProvider) { this.providers.set(name, p); }
  resolve(currency: Currency): PaymentProvider { /* правила выбора */ }
}
// В проекте зарегистрирован ровно один провайдер. Полтора года.

// YAGNI-версия: прямолинейно и честно.
export async function charge(amount: Money, token: string): Promise<ChargeResult> {
  return stripe.charges.create({
    amount: amount.minorUnits, currency: amount.currency, source: token,
  });
}

Плохо здесь не «в принципе», а конкретно. Во-первых, интерфейс спроектирован под единственную известную реализацию и неизбежно повторяет её особенности (token: string — потому что так у Stripe); когда придёт провайдер с трёхшаговым 3-D Secure, интерфейс всё равно придётся переписать. Абстракция, выведенная из одного примера, — не абстракция. Во-вторых, каждый разработчик теперь делает два лишних прыжка по коду, чтобы понять, что происходит при оплате: это cost of carry, помноженный на размер команды. В-третьих, реестр и резолвинг надо тестировать и поддерживать, не давая пользователю ничего.

Когда появится второй провайдер, у вас будет два реальных примера, и интерфейс получится выведенным из различий, а не угаданным. Извлечь интерфейс из готовой функции дёшево (B мало), а исправить угаданный — дорого. Ровно это и говорит арифметика из раздела выше.

Где YAGNI не работает

YAGNI — эвристика про обратимые решения. Она молчит там, где откат невозможен или цена ошибки катастрофична. Забыть tenant_id в схеме мультитенантной системы — это не «дописать потом», а переписать всё и мигрировать продовые данные. Публичный API нельзя молча поменять: версионирование закладывают сразу. Аудит-лог, шифрование PII и разделение прав по принципу «добавим, когда потребуется» означают «добавим после инцидента». То же с наблюдаемостью (https://courses.digitable.life/post/principles/07-twelve-factor/) и с решениями, задающими форму системы: если она точно будет распределённой, синхронный вызов вместо очереди — не YAGNI, а долг с процентами.

Джефф Безос делит решения на «тип 1» (односторонняя дверь, назад не выйдешь) и «тип 2» (двусторонняя). YAGNI — про двери второго типа. Для первых работает обратная эвристика: думайте заранее, потому что откат стоит дороже всего проектирования.


Как это выглядит в проде

Go и «немного копирования». Одна из Go Proverbs Роба Пайка: «A little copying is better than a little dependency.» Стандартная библиотека Go осознанно дублирует небольшие куски вместо общих утилит: зависимость между пакетами — это навсегда, а тридцать строк копии локальны и удаляются в одиночку (https://courses.digitable.life/post/golang/00-overview/).

Микросервисы и общие библиотеки. Самый дорогой вид неверного DRY — вынести общий код в разделяемую библиотеку между сервисами разных команд: чтобы поменять поле, нужно согласовать релиз библиотеки с пятью командами и задеплоить их в правильном порядке — получается распределённый монолит. В DDD это разница между Shared Kernel (дорогой контракт, требующий постоянной координации) и Anticorruption Layer (каждый контекст держит свою модель); см. также https://courses.digitable.life/post/principles/03-coupling-and-cohesion/. Правило индустрии: дублировать DTO между сервисами — норма; шарить доменную логику — почти всегда ошибка.

Кодогенерация вместо «ручного DRY». Там, где дублирование навязано средой (схема БД ↔ модель ↔ API-клиент), правильный ответ — не абстракция в рантайме, а единственный источник истины плюс генерация: Protobuf/gRPC, OpenAPI-генераторы, sqlc, Ent, Prisma. Знание одно, представлений много, синхронизирует их машина.

Монорепозитории. В монорепо Google (статья в CACM) общих библиотек больше, потому что там решена главная проблема: атомарное изменение всех потребителей одним коммитом. Вывод общий: допустимая степень DRY зависит от того, насколько дёшево у вас синхронно менять все места.

DAMP в тестах. В тестах принято отступать от DRY в пользу DAMP (Descriptive And Meaningful Phrases): тест читается сверху вниз без прыжков в хелперы. Общий setUp и цепочки фабрик экономят строки, но когда тест падает, вы не видите, в каком он был состоянии. В тестах читаемость важнее отсутствия повторов.


Типичные ошибки

  1. DRY по форме, а не по смыслу. Объединили куски с разными причинами изменения. Симптом: через месяц в функции появился первый булев флаг.
  2. Абстракция из одного примера. Интерфейс, выведенный из единственной реализации, описывает эту реализацию, а не концепцию. Ждите второго и третьего.
  3. Boolean trap. Флаг, меняющий поведение функции, — это две функции, слипшиеся в одну.
  4. «Слой на всякий случай». Обёртка вокруг логгера «вдруг сменим логгер»: смените один раз за десять лет, а читать этот слой будут ежедневно.
  5. Конфигурируемость вместо решения. Каждый неразрешённый спор становится параметром конфига. Через год — 200 флагов, и никто не знает работающую комбинацию.
  6. KISS как оправдание невежества. «Не буду разбираться в транзакциях, сделаю проще» — это не простота, а удаление сущностной сложности вместе с корректностью.
  7. YAGNI против качества. Отказ от тестов и осмысленных имён «на потом». Фаулер писал прямо: YAGNI — про фичи, не про ремесло.
  8. Отказ разбирать плохую абстракцию. Добавить ещё флаг вместо inline обратно. Возврат к дублированию — легитимный рефакторинг (https://courses.digitable.life/post/principles/05-code-smells-and-refactoring/).
  9. DRY через наследование. Общий базовый класс «чтобы не повторяться» — самый жёсткий вид связанности; композиция почти всегда лучше, см. https://courses.digitable.life/post/principles/01-solid/.
  10. Игнорирование горизонта. Код, который выкинут через три месяца, не нуждается в абстракциях: m в формуле близко к нулю.

Чеклист на ревью

Увидев извлечённую абстракцию, задайте шесть вопросов. Одно ли это знание — изменятся ли все потребители одновременно и по одной причине? Сколько примеров её породило: меньше трёх — обосновывайте отдельно. Есть ли флаги-режимы — каждый флаг минус к обоснованности. Уже ли интерфейс, чем спрятанное — если нет, это не абстракция, а перекладывание. Есть ли имя из предметной областиPricingPolicy хорошо, DataProcessorUtils — признак того, что объединили несвязанное. Обратимо ли решение — если разъединить легко, можно рискнуть раньше; если абстракция станет публичным API, ждите дольше.

Мини-итог одной фразой:

DRY — про знание, а не про текст. KISS — про читателя, а не про длину. YAGNI — про фичи, а не про качество. Дублируйте, пока не поймёте, что именно у вас общего; абстрагируйте, когда это станет очевидным, а не правдоподобным.


Источники


Что дальше

Мы много раз упирались в одну и ту же мысль: абстракция плоха не сама по себе, а тем, что создаёт связанность между местами, которые должны меняться независимо. Пора разобрать это понятие строго — измерить виды связанности, понять, что такое связность модуля, и научиться формулировать правила доступа между объектами.

Следующая статья: Связанность, связность и закон Деметры.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов