10.03.2023

Слишком сухо — тоже плохо.

Когда принцип DRY перестаёт работать

Привет! Меня зовут Илья Осипов, я методист курса программирования на Python «Девман» и больше пяти лет пишу код на этом языке. Сегодня расскажу про магию принципа DRY, и всегда ли она работает.

Аббревиатуры DRY, KISS и SOLID сейчас повторяет каждый IT-блогер, они встречаются во многих учебниках по программированию. Из-за популярности люди часто возводят эти принципы в абсолют. Если в коде нарушен любой из принципов — вам непременно на это укажут, ссылаясь на волшебные буквы, как на законы физики.
Но у любого инструмента есть границы использования. Как не стоит забивать гвозди микроскопом, так и DRY и другими «волшебными» аббревиатурами злоупотреблять не нужно. Иначе можно «пересушить» код и сделать его хуже, чем он был бы с дублированием.

Самое интересное: примеры!
Я питонист, поэтому буду приводить примеры на этом языке. Но не спешите закрывать статью, думаю, программистам из других языков тоже будут понятны большинство из них.

Вынос в функцию «очевидных строчек»


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


def read_file(path, **kwargs):
    with open(path, 'r', **kwargs) as file:
        return file.read()

def write_file(path, data, **kwargs):
    with open(path, 'w', **kwargs) as file:
        return file.write(data)

Некоторые могут отличиться ещё больше и начать сращивать их в единый интерфейс. Приведу наивный пример:

def work_with_file(path, mode, data=None, **kwargs):
    with open(path, mode, **kwargs) as file:
        if 'w' in mode or 'a' in mode:
            return file.write(data)
        if 'r' in mode:
            return file.read()

Этот подход ужасен по нескольким причинам:

  1. Функцией всё равно никто не будет пользоваться. Если другому программисту из вашей команды понадобится открыть файл, он скорее напишет две примитивные строчки заново, чем будет вспоминать где лежит ваша «высушенная версия».
  2. Это порождает неконсистентность и следует из предыдущего пункта. Теперь по коду разбросаны вызовы read_file или work_with_file вперемешку с обычными with open. Python — язык про консистентность, у нас даже правила расстановки пробелов вокруг знака = приведены к единому стандарту. А в вашем коде появилось сразу два способа открывать файлы.
  3. Функция просто повторяет интерфейс контекстного менеджера open. По сути вы не пишете новую функцию. Вы пытаетесь «завелосипедить» стандартный open. В сущности ничего нового вы не сделали, вызов вашей work_with_file ничем не отличается от вызова open, кроме того, что он на 1 строчку короче.

Зачем вообще нужен принцип DRY? Чтобы упростить и ускорить разработку, сделать её более стабильной. Если избавление от копипасты начинает противоречить этим целям, принцип применять не надо. Repeat Yourself, если это сделает работу с кодом проще для вас и команды.

DRY призывает создавать дополнительные абстракции. Каждая функция или класс в коде – это абстракция, которая объединяет строчки, написанные внутри, общим названием. Создание новых функций требует от вас дать им новые названия, иногда даже заводить новые понятия и термины.

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

Такая ошибка похожа на создание «универсальных инструментов» вроде «молотка с функцией микроскопа». Если вам кажется заманчивой идея использовать эти инструменты, предлагаю задуматься, почему их не продают в магазинах :D
Ещё один пример на тему — функция-обёртка вокруг requests. Она используется для запросов в сеть, позволяет отправить HTTP-запрос:

import requests

def fetch_url(url, **kwargs):
    response = requests.get(url, **kwargs)
    response.raise_for_status()
    return response.json()
Проблемы тут аналогичные:
  • Это слишком тонкая абстракция → непонятно, зачем ею пользоваться
  • Это повтор интерфейса метода requests.get()

Вынос в функцию «общей бизнес-логики»


Ещё один типовой случай в разработке – когда программист видит одинаковые строчки и априори считает их копипастой. Пример: вы пишете сайт интернет-магазина. Из интернет-магазина можно заказать товары с доставкой. Клиент указывает свой адрес, доставщик видит его на карте и привозит товар прямо к двери.

Чтобы организовать такую доставку, понадобится код преобразования адреса в геолокацию на карте:
Естественно, вы собираете весь этот код в один модуль. Это логично, тут нет никаких претензий. Самое интересное после: строчки с вызовом геокодера тоже повторяются! Вот сразу несколько разных ситуаций, где нужно рассчитать координаты:
  • При оформлении заказа клиентом
  • При изменении адреса доставки через интерфейс менеджера (например, если клиент позвонил и попросил перенести адрес доставки или вроде того)
  • При открытии новых филиалов с самовывозом.

Бывает, даже опытных разработчиков начинает раздражать, что код с вызовом геокодера разбросан сразу в трёх местах, и они решают собрать это всё в одну, «общую» функцию. В Django для этого используются сигналы и переопределение метода save() модели – это два способа заставить код запускаться при каждом изменении в БД.
Кажется, отлично: выпилили из кода 3 блока копипасты, подсобрали весь этот код в одно место. Всё в лучших правилах DRY. Но у этого подхода есть проблема. Все эти кусочки кода пусть и похожи, но это не копипаста. Этот код вызывается совсем в разных контекстах и со временем будет «расходиться» и становиться всё менее похожим друг на друга.

Например, если не удалось определить координаты клиента по его адресу при оформлении заказа – заказ всё равно стоит принять, а уже позже в частном порядке разобраться, что не так: пользователь ошибся в адресе или ещё что-то. Обычно к заказу прикрепляется номер, ему можно просто позвонить.

Если же вы открыли новый филиал, то в случае ошибки определения координат можно попросить менеджера магазина заполнить точные координаты в отдельном поле в форме создания филиала.

Таким образом, поведение программы должно отличаться в разных ситуациях. Общий смысл проблемы описывается таким циклом:
  • Вынесли «общую» логику в новый слой абстракции
  • Пришёл заказчик с новым требованием. Оно почти вписывается в вашу абстракцию. Процентов на 95. Но есть одно «но», ради которого вы добавляете ветвление if-else
  • Теперь абстракция не так очевидна, но всё ещё читаема
  • Снова пришёл заказчик. Снова почти, снова добавляем if-else…
  • Снова пришёл заказчик. Снова почти, снова добавляем if-else…
Что получаем в конце?

  1. Функция полностью нечитаема, из-за массы «костылей» и подпорок внутри под разные случаи.
  2. Функцию тяжело тестировать и не хочется трогать, потому что тестировать её придётся сразу на 3-5 совершенно разных юзкейсах, разбросанных по сайту.
  3. Функция – очаг нестабильности в коде, как следствие из предыдущего пункта. Скорее всего в ней масса багов, которые ваша команда не видит, потому что не хочет читать этот код.

Ещё один пример, когда DRY – вреден: когда в стремлении за избавлением от копипасты вы случайно объединяете в одну абстракцию код, который на самом деле копипастой не является, это 3-5 разных контекстов, в которых программа должна вести себя по-разному.

Пересушивание деплойных скриптов


Ещё один пример ситуации, где можно «пересушить» код — это деплойные скрипты.

Допустим, у вас есть сайт, и он должен работать сразу в нескольких окружениях: на продакшне, на тестовом сервере и на компьютерах каждого из разработчиков.

Естественно, в каждом из окружений поведение сайта чуть отличается: на сервере, в норме, ошибок быть не должно, поэтому в случае падения сайта нужно поднимать тревогу, например, звонить тимлиду на мобильный или тэгать команду разработки в Slack — как устроено в вашей компанде.

В случае падения сайта локально, обычно, ничего делать не нужно. Ну подумаешь, ошибка в коде, разработчик сам увидит её в консоли и исправит. Представьте, если бы тимлиду звонили каждый раз, когда кто-то из его команды положил локальную версию.
Деплойные скрипты для разных окружений тоже нужны разные:
  • На сервере при деплое нужны только те библиотеки, которые используются сайтом, а локально ещё и библиотеки, которые помогают в разработке и тестировании кода.
  • На сервере код хранится в /opt/app/..., например, а локально — да хоть в C:\Users\User\Desktop\app
  • На сервере сайт раздаётся веб-сервером, вроде Nginx, а локально можно обойтись и более простыми решениями

Но, постойте, скрипты хоть и отличаются, но в них и копипасты хватает! Код хранится всё ещё в одном и том же репозитории, команда для установки зависимостей всё ещё одинаковая.

О чем говорит принцип DRY? Don't Repeat Yourself. Делаем множество универсальных функций, каждая из которых требует массу аргументов. Каждая запускает только одну команду, но донастраивает аргументами. В самом деплойном скрипте появляется масса `if`: если продакшн, то одно, если локально, другое…
К чему это придёт? Редактирование кода превратится в ад:
  • Читать деплойный скрипт для конкретного окружения сверху-вниз станет невозможно. Теперь нужно прыгать между функциями внутри bash-скрипта.
  • В коде куча новых функций с непонятными границами, а значит придётся скакать по коду и вычитывать функции.
  • Функции переполнены boilerplate-кодом, из-за чего 80% прочитанного в конкретной ситуации вам будет просто не нужно.
  • Из-за массы if не ясно, что из этого запустится, а что нет. Это дополнительно усложняет чтение и создаёт неуверенность в результате запуска кода. Для деплоя на сервер такое просто неприемлемо.
  • Из-за массы if в коде растёт цикломатическая сложность, из-за чего вам сложнее уместить общую логику в голове.
  • Код теперь тяжело тестировать, ведь для качественного теста нужно прокликать каждую комбинацию условий.
Кажется, вы нажили себе массу проблем. А ради чего?
  • Весь код теперь лежит в одном месте, в одном файле.
  • Копипасты в коде нет.
  • Чувствуете моральное удовлетворение от того, что система деплоя стала сложнее и вы теперь один из немногих, кто понимает, как она работает.

Но давайте вспомним, а зачем вам избавление от копипасты? Почему DRY хорош? Упростить и ускорить разработку, сделать её более стабильной.

Получается, что в такой ситуации вы повторите форму, но не содержание: разработка стала сложнее и менее стабильной. Проблема та же: новые абстракции слишком тонкие и повторяют интерфейс консольных команд, которые в них обёрнуты.
Волшебное решение: WET (нет)
Я не первый разработчик, который заметил минусы DRY в некоторых ситуациях. В противовес ему родился его брат-близнец со знаком «не-», принцип WET: Write Everything Twice — «Пиши все дважды», или «We enjoy typing» — «Нам нравится печатать».

Но, помните, с чего мы начинали? Нет никаких «волшебных таблеток». Прочитав этот материал, не стоит сходить с ума в обратную сторону: отменять DRY и копипастить код повсюду. Я не призываю к этому.

Стоит изучать ограничения данных вам инструментов, исследовать, в каких ситуациях ваши принципы и инструменты хороши, а в каких нет, и почему.
Хотите попробовать курсы бесплатно?
У нас есть курсы Python и программы для учеников с разным опытом — от нуля до сильного джуна.

С нами можно:
— впервые начать изучать язык,
— подтянуть конкретные темы,
— вырасти до мидла,
— трудоустроиться в ближайшие пару месяцев.

Если хотите выбрать тот трек, который поможет вам сейчас в вашей конкретной точке роста, свяжитесь с менеджером.
Даю согласие на обработку персональных данных