Первый принцип, с которым вы знакомитесь, начиная свой путь в мир разработок ПО и записывая первые строки кода, — это постулат DRY (“Не повторяйся”). А как он серьезно и убедительно звучит! Более того, этот принцип как нельзя лучше объясняет, почему многие из нас так любят программирование: он дарит свободу от скучной и однообразной работы. Зерно этой идеи легко понять и объяснить (в отличие от принципа подстановки Барбары Лисков, который мне приходится каждый раз гуглить, как только речь заходит о SOLID (объектно-ориентированное программирование). Воплощение DRY в жизнь обычно сопровождается особым чувством удовольствия, когда всё сходится. Что же тут может не нравиться?
Предотвращение повторов в коде — мысль, бесспорно, хорошая. Но иногда, на мой взгляд, это непродуктивно. И вот подобные случаи мне бы хотелось с вами обсудить в данной статье.
Код без повторов может привести к сильному зацеплению
Совместное использование одного фрагмента кода двумя инициаторами вызова зачастую является прекрасным решением. Если у вас есть два сервиса, которые должны отправить транзакционное e-mail, заполнив его полученными данными о пользователе и отобразив шаблон, то код будет выглядеть так:
class OrderService:
# ...
def send_order_receipt(self, user_id, order_id):
user = UserService.get(user_id)
subject = f"Order {order_id} received"
body = f"Your order {order_id} has been received and will be processed shortly"
content = render('user_email.html', user=user, body=body)
email_provider.send(user.email_address, subject, content)
class PaymentService:
# ...
def send_invoice(self, user_id, order_id):
user = UserService.get(user_id)
subject = f"Payment for {order_id} received"
body = f"Payment for order {order_id} has been received, thank you!"
content = render('user_email.html', user=user, body=body)
email_provider.send(user.email_address, subject, content)
Вы только посмотрите, сколько здесь повторов! Так и хочется скорректировать код по принципу DRY:
def send_transaction_email(user_id, order_id, subject, body):
user = UserService.get(user_id)
content = render('user_email.html', user=user, body=body)
email_provider.send(user.email_address, subject, content)
Чудесно! Мы извлекли повторяющийся в сервисах код во вспомогательную функцию, и теперь наши сервисы выглядят так:
class OrderService:
# ...
def send_order_receipt(self, user_id, order_id):
subject = f"Order {order_id} received"
body = f"Your order {order_id} has been received and will be processed shortly"
send_transaction_email(user_id, ,order_id, subject, body)
class PaymentService:
# ...
def send_invoice(self, user_id, order_id):
subject = f"Payment for {order_id} received"
body = f"Payment for order {order_id} has been received, thank you!"
send_transaction_email(user_id, ,order_id, subject, body)
Гораздо чище, согласитесь?
Одно из обещаний принципа заключается в том, что он позволяет развивать наше ПО. Бизнес-требования и технические условия постоянно меняются, и если нам нужно изменить поведение фрагмента кода, то мы сделаем это только один раз, и он будет отображен везде.
В примере выше мы довольно легко можем изменить не только способ получения информации о пользователе, но и e-mail провайдера.
Но слепое следование принципу DRY в коде может наоборот сильно осложнить возможность изменений. Что, если в нашем примере e-mail счет-фактуры PaymentService
должен использовать другой шаблон в связи с бизнес-решением? Как бы мы это осуществили? Или если для получения списка покупок и передачи его в шаблон e-mail потребуется OrderService
? Извлечение совместно используемой логики в метод send_transaction_email
привело бы к сильному зацеплению OrderService
и PaymentService
, т. е. вы не сможете изменить одно, не изменив другое.
Как учил меня один мой хороший друг:
“Когда вам встречается класс с именем Helper (помощник), то вряд ли от него стоит ждать помощи”.
Принцип DRY может усложнить читаемость кода
Приведем еще один пример. Допустим, мы пишем модульные тесты для разрабатываемого веб-сервера, и на данный момент у нас их два:
func TestWebserver_bad_path_500(t *testing.T) {
srv := createTestWebserver()
defer srv.Close()
resp, err := http.Get(srv.URL + "/bad/path")
if err != nil {
t.Fatal("failed calling test server")
}
if resp.StatusCode != 500 {
t.Fatalf("expected response code to be 500")
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal("failed reading body bytes")
}
if string(body) != "500 internal server error: failed handling /bad/path" {
t.Fatalf("body does not match expected")
}
}
func TestWebserver_unknown_path_404(t *testing.T) {
srv := createTestWebserver()
defer srv.Close()
resp, err := http.Get(srv.URL + "/unknown/path")
if err != nil {
t.Fatal("failed calling test server")
}
if resp.StatusCode != 404 {
t.Fatalf("expected response code to be 400")
}
if resp.Header.Get("X-Sensitive-Header") != "" {
t.Fatalf("expecting sensitive header not to be sent")
}
}
Как же много дублирования, требующего рефакторинга! Оба теста делают почти одно и то же: запускают тестовый сервер, отправляют ему вызов GET и затем выполняют простые утверждения на http.Response
.
Можно это выразить, используя вспомогательную функцию:
func runWebserverTest(t *testing.T, request Requester, validators []Validator) {
srv := createTestWebserver()
defer srv.Close()
response := request(t, srv)
for _, validator := range validators {
validator.Validate(t, response)
}
}
Здесь я редактирую точные определения Requester
и Validator
для экономии места, но вы можете ознакомиться с полной реализацией на Github Gist.
Теперь при помощи рефакторинга наши тесты станут аккуратными и без повторов:
func Test_DRY_bad_path_500(t *testing.T) {
runWebserverTest(t,
getRequester("/bad/path"),
[]Validator{
getStatusCodeValidator(500),
getBodyValidator("500 internal server error: failed handling /bad/path"),
})
}
func Test_DRY_unknown_path_404(t *testing.T) {
runWebserverTest(t,
getRequester("/unknown/path"),
[]Validator{
getStatusCodeValidator(404),
getHeaderValidator("X-Sensitive-Header", ""),
})
}
Отметим несколько интересных моментов в связи с этим процессом изменения:
1.Быстрее будет написать новые похожие тесты. Если у нас есть 15 конечных точек, которые схожи в своем поведении и требуют аналогичных утверждений, то мы можем выразить их лаконичным и эффективным способом.
2.Наш код стало гораздо сложнее читать и расширять. Если тест провалится из-за какого-либо изменения в будущем, то не позавидуешь тому разработчику, который возьмет на себя его отладку, так как ему придется поломать голову, прежде чем он разберется в происходящем. А мы заменили обычный и простой код на умные абстракции и косвенную адресацию.
Но когда же использовать принцип DRY в коде?
Практика размещения общего кода в библиотеках для последующего совместного использования приложениями стала уже проверенной и эффективной традицией, давно закрепившейся в нашей индустрии, и, конечно, мы не призываем ее прекращать!
Чтобы определиться, в каких ситуациях лучше использовать неповторяющийся код, мне бы хотелось представить вам одну идею из потрясающей книги Энди Ханта и Дейва Томаса “Программист-прагматик. Ваш путь к мастеру” (The Pragmatic Programmer: Your Journey to Mastery), недавно опубликованной в обновленном 2-ом издании (в первом издании книга вышла в 1999 году под названием “Программист-прагматик. Путь от подмастерья к мастеру”):
“Программа считается хорошо спроектированной, если она адаптируется под людей, использующим ее. В отношении кода это значит, что он должен адаптироваться к изменениям. Поэтому мы полагаемся на принцип: “Чем легче изменить, тем лучше”. Вот так!
Исходя из нашего опыта, каждый принцип проектирования—это отдельный случай подтверждения данной мысли. Почему следует снижать зацепление? Потому что изолируя задачи, мы облегчаем изменения каждой из них. Вывод — чем легче изменить, тем лучше.
Почему так полезен принцип единственной ответственности? Потому что изменение в требованиях влечет за собой изменение только в одном модуле. И снова наш принцип в действии”.
Д. Томас “The Pragmatic Programmer: Your Journey to Mastery”, 2-ое издание. Тема. 8. Принципы грамотного проектирования.
В этом гениальном фрагменте главы Хант и Томас развивают идею о том, что существует мета-принцип оценки проектировочных решений, которые часто сталкиваются друг с другом: насколько легко будет развивать кодовую базу, если мы выберем конкретно этот путь? В наших рассуждениях выше были показаны два случая, при которых написание кода без повторов может осложнить его дальнейшее изменение по причине либо сильного зацепления, либо затруднения читаемости, что не соответствует идее: “Чем легче изменить, тем лучше”.
Имея в виду эти возможные последствия написания кода по принципу DRY, можно определить ситуации, когда нам не следует, а когда следует соблюдать этот тезис. Давайте вернемся к нашему оригинальному “Евангелию” от Ханта и Томаса и еще раз рассмотрим данный постулат.
Принцип DRY был впервые представлен миру этими авторами в книге, изданной в 1999 году. Они пишут:
“Каждый фрагмент знания должен иметь единственное, однозначное и надежное представление в системе. Альтернативой является представление одного и того же предмета в двух или более местах.
Если вы меняете одно, то не забудьте поменять и другое, [..]. Вопрос не в том, вспомните ли вы об этом, а в том, когда вы об этом забудете”.
Д. Томас “The Pragmatic Programmer: Your Journey to Mastery”, 2-ое издание. Тема. 9. Недостатки дублирования.
Обратите внимание, что принцип DRY изначально не имеет никакого отношения к повторению или дублированию. Он указывает на опасность отсутствия у фрагмента знания единственного и истинного представления в системе.
Когда мы проводили рефакторинг метода send_transaction_email
, чтобы устранить дублирование кода в OrderService
и PaymentService
, мы спутали понятия дублированного кода и дублированного знания. Если два процесса протекают идентично в определенный момент времени, то это не значит, что в перспективе они нам потребуются в таком же виде. Нам нужно научиться различать процессы, которые совместно используются по случаю или по существу.
В завершении нашей темы, должен признать, что в целом принцип DRY является довольно важным советом. Однако следует помнить, что использовать его надо с умом.
Заключение
- Устранение дублирования кажется верным решением, но зачастую оказывается ошибочным.
- Принцип DRY не имеет никакого отношения к дублированию кода.
- Мета-принцип грамотного проектирования звучит так: “Чем легче изменить, тем лучше”.
Читайте также:
- Запуск DBT в Azure Functions с помощью Snowflake
- Proxy - сокровище JavaScript
- Как сжимать коммиты в Git с помощью git squash
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Rotem Tamir: Is the DRY Principle Bad Advice?