DRY vs WET: коли повторення — це не помилка, а стратегія

DRY vs WET: коли повторення — це не помилка, а стратегія

  • 25 січня
  • читати 9 хв
Володимир Шайтан
Володимир Шайтан Senior Full Stack Developer у UKEESS Software House, Викладач Комп'ютерної школи Hillel.

На початку шляху, коли тільки вчишся програмувати, часто доводиться чути, що не можна повторюватися, треба використовувати принцип DRY. І в якийсь момент студенти дійсно починають вносити кожен повторюваний рядок у функцію і створювати утиліти геть на всі випадки життя. Але чи дійсно це правильний підхід завжди?

Одразу скажу: найчистіший і найідеальніший DRY-код на реальному проєкті дуже важко підтримувати, тому доведеться шукати баланс. Про це ми сьогодні й говоритимемо.

Почнемо з того, що якісний абстрактний код — це чудово, але не коли фікс однієї фічі ламає логіку інших трьох фіч. Обидва принципи: і DRY і WET — це інструменти, які потрібно вміти правильно використовувати.

У цій статті розберемо:

  • що таке DRY насправді (і чому це не просто «менше рядків коду»);
  • коли варто застосовувати WET;
  • типові помилки й антипатерни;
  • конкретні приклади з життя;

І головне — як не впасти в крайнощі.

ЩО Ж ТАКЕ DRY?

DRY — Don't Repeat Yourself. І це не означає «ніколи не пиши однаковий код двічі», це означає «вмій розрізнити код, який виконує одну й ту саму функцію, від схожого коду».

Ми почнемо з одного простого прикладу, який показує, коли код повторюваний і як цей самий код має виглядати.

Не DRY:

// Порушення DRY: Знання про ставку податку продубльовано
function calculateTotalPrice(price) {
 const tax = price * 0.2;  // 20% податок
 const total = price + tax;
 return total;
}

function generateInvoice(items) {
 const subtotal = items.reduce((sum, item) => sum + item.price, 0);
 const tax = subtotal * 0.2;  // 20% податок — дублікат!
 return {
   subtotal,
   tax,
   total: subtotal + tax
 };
}

Що ж тут не так:

  • інформація про податкову ставку записана два рази;
  • змінювати цю інформацію потрібно буде в 2 різних місцях, які можуть бути більше розкидані по коду, ніж на прикладі, що призводить до того, що одне з цих місць дуже просто забути і загубити.

DRY:

// DRY: Єдине джерело знання про податок
const TAX_RATE = 0.2;

function calculateTax(amount) {
 return amount * TAX_RATE;
}

function calculateTotalPrice(price) {
 const tax = calculateTax(price);
 return price + tax;
}

function generateInvoice(items) {
 const subtotal = items.reduce((sum, item) => sum + item.price, 0);
 const tax = calculateTax(subtotal);
 return {
   subtotal,
   tax,
   total: subtotal + tax
 };
}

Що змінилося:

  • код простіше читати, редагувати й тестувати;
  • тепер шансів забути про одну змінну взагалі немає і відповідно шансів знайти баг, який відноситься до цього, теж стає набагато менше.

Важливе уточнення: іноді може здаватися, що код однаковий і це помилково буде розцінено як «краще це винести».

Дуже поширений приклад такого коду:

// Це НЕ порушення DRY, хоча код схожий
function validateLoginForm(data) {
 if (!data.email) return "Email обов'язковий";
 if (!data.password) return "Пароль обов'язковий";
 if (data.password.length < 8) return "Пароль занадто короткий";
 return null;
}

function validateRegistrationForm(data) {
 if (!data.email) return "Email обов'язковий";
 if (!data.password) return "Пароль обов'язковий";
 if (data.password.length < 8) return "Пароль занадто короткий";
 if (!data.confirmPassword) return "Підтвердження паролю обов'язкове";
 if (data.password !== data.confirmPassword) return "Паролі не співпадають";
 return null;
}

Чому це нормально:

  • тут різні контексти: логін і реєстрація;
  • у майбутньому, коли код буде розширюватися, логіка може доповнюватись і змінюватися;
  • це не абстракція, тому читати це легше;
  • ну і найбільш прикладне для цього коду — в одному місці може бути 2-факторна перевірка, а в іншому капча.

DRY не змушує «винеси весь проєкт в утиліти».

Є таке поняття, як рання абстракція, і хоч звучить це наче і непогано, але на ділі може викликати проблеми пізніше. Момент, коли потрібно використати DRY пропонується визначати так:

  • перший раз ви просто пишете код і ще не знаєте патерн;
  • другий раз ви помічаєте випадкову схожість коду (однаковий шаблон обʼєктів User і Product), але іще не абстрагуєте. Це може бути випадковою подібністю;
  • якщо вже втретє ви пишете схожий код, то саме тут час подумати про абстракцію.

Як ви вже зрозуміли, існує принцип-протилежність DRY і називається він теж протилежно. Розберімось і з ним теж.

ЩО ТАКЕ WET І ЧОМУ ЦЕ НЕ ЗАВЖДИ ПОГАНО?

WET — Write Everything Twice (або «We Enjoy Typing»).

Це звучить як антипаттерн (але не поспішайте робити висновки). У певному контексті це може бути найкращим рішенням.

Чому? Бо, як ми вже зрозуміли раніше, передчасна абстракція може наробити нам багів при масштабуванні й підтримці.

Коли WET краще:

  • ви ще не впевнені, що код справді однаковий;
  • дублювання підкреслює різні контексти;
  • код читається легше, ніж при абстрагуванні.

До останнього пункту є дуже зрозумілий приклад: 

// Погана абстракція
function processData(data, type, config = {}) {
 if (type === 'user') {
   if (config.includeAvatar) {
     return { ...data, avatar: generateAvatar(data.name) };
   }
   return data;
 } else if (type === 'product') {
   if (config.includePrice) {
     return { ...data, price: calculatePrice(data.basePrice) };
   }
   return data;
 }
}

// Краще — просто і зрозуміло
function processUser(data) {
 return { ...data, avatar: generateAvatar(data.name) };
}

function processProduct(data) {
 return { ...data, price: calculatePrice(data.basePrice) };
}

Приклади з практики

Шлях від WET до DRY наглядно:

// 📌 ВЕРСІЯ 1: WET — пишемо швидко, розуміємо проблему
function handleLoginSubmit() {
 const email = document.getElementById('email').value;
 const password = document.getElementById('password').value;

 if (!email) {
   showError('email', 'Введіть email');
   return;
 }
 if (!password) {
   showError('password', 'Введіть пароль');
   return;
 }

 submitLogin({ email, password });
}

function handleRegistrationSubmit() {
 const email = document.getElementById('email').value;
 const password = document.getElementById('password').value;
 const name = document.getElementById('name').value;

 if (!email) {
   showError('email', 'Введіть email');
   return;
 }
 if (!password) {
   showError('password', 'Введіть пароль');
   return;
 }
 if (!name) {
   showError('name', 'Введіть ім\'я');
   return;
 }

 submitRegistration({ email, password, name });
}

// 📌 ВЕРСІЯ 2: Помітили паттерн — час рефакторити
function validateField(fieldId, errorMessage) {
 const value = document.getElementById(fieldId).value;
 if (!value) {
   showError(fieldId, errorMessage);
   return false;
 }
 return true;
}

function handleLoginSubmit() {
 if (!validateField('email', 'Введіть email')) return;
 if (!validateField('password', 'Введіть пароль')) return;

 submitLogin({
   email: document.getElementById('email').value,
   password: document.getElementById('password').value
 });
}

function handleRegistrationSubmit() {
 if (!validateField('email', 'Введіть email')) return;
 if (!validateField('password', 'Введіть пароль')) return;
 if (!validateField('name', 'Введіть ім\'я')) return;

 submitRegistration({
   email: document.getElementById('email').value,
   password: document.getElementById('password').value,
   name: document.getElementById('name').value
 });
}

// 📌 ВЕРСІЯ 3: Фінальна — чиста абстракція
function validateForm(fields) {
 return fields.every(({ id, errorMessage }) =>
   validateField(id, errorMessage)
 );
}

function getFormValues(fieldIds) {
 return fieldIds.reduce((acc, id) => {
   acc[id] = document.getElementById(id).value;
   return acc;
 }, {});
}

function handleLoginSubmit() {
 const fields = [
   { id: 'email', errorMessage: 'Введіть email' },
   { id: 'password', errorMessage: 'Введіть пароль' }
 ];

 if (!validateForm(fields)) return;
 submitLogin(getFormValues(['email', 'password']));
}

function handleRegistrationSubmit() {
 const fields = [
   { id: 'email', errorMessage: 'Введіть email' },
   { id: 'password', errorMessage: 'Введіть пароль' },
   { id: 'name', errorMessage: 'Введіть ім\'я' }
 ];

 if (!validateForm(fields)) return;
 submitRegistration(getFormValues(['email', 'password', 'name']));
}

Тобто наш шлях такий: написали код вперше, все працює -> помітили повторення, написали невелику абстракцію, все досі працює -> довели до чистоти з розумінням патерну. Важливо, щоб патерн розуміли не тільки ви, а й ваші колеги, які будуть редагувати ваш код пізніше.

РЕКОМЕНДАЦІЇ ВІД СЕБЕ:

Якщо вам досі важко зрозуміти, коли що використовувати, то не хвилюйтесь, у мене є маленька шпаргалка, яка може вам допомогти.

Перед тим, як робити DRY спитайте у себе це:

  • Чи я бачив цей самий паттерн більше, ніж в 3-х місцях? 
  • Чи логіка завжди буде змінюватися синхронно?
  • Чи абстракція має просте і зрозуміле API?
  • Тести після абстракції залишаються простими?
  • Чи код стає легшим для розуміння після абстракції? Чи не стає він складнішим?
  • Назва абстракції чітко описує ЩО вона робить.

І якщо у вас більше, ніж 4 ТАК, то можна використати DRY. 

Коли ж слід повернутися до WET?

  • Додавання нової фічі вимагає змін в 5+ місцях.
  • Всередині абстракції є if/else.
  • Функція має понад 5 параметрів.
  • Ніхто, крім вас самих не розуміє як працює ця абстракція.
  • Назва абстракції містить в собі слова по типу «common», «universal», «generic», «helper».

ВИСНОВОК

Не женіться за DRY як за самоціллю. Програмування, як процес — це точно не про те, щоб писати правильний, ідеальний код (існування якого, до речі, вчені досі не довели) з першого разу. Ми тут говоримо про програмування, як про те, щоб постійно адаптувати код під нові вимоги й він працював правильно при цьому.

Не бійтеся повторювати код, це не страшно. Головне завжди розуміти ЧОМУ він повторюється і вміти розрізнити, коли це так і має бути.

Не бійтеся рефакторити, це дає вам можливість побачити нові шляхи для розв'язування проблем.

Хороший код — це той, який легко змінювати і адаптувати, а не той, де менше рядків.

Рекомендуємо публікації по темі