Гайд з використання DRY principle: розуміння принципу, дублювання коду та створення абстракцій

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Вітаю! Мене звати Віталій, і я працюю Senior Full Stack Software Engineer в компанії Sisense. Багато парадигм та принципів програмування не втратили своєї актуальності з моменту першого формулювання і донині. Про один з таких принципів — DRY — я і хотів би сьогодні поговорити. Адже він надважливий для забезпечення підтримки та читабельності вашого коду в майбутньому. І яким би очевидним він не здавався, пропоную розібратися в різних аспектах формулювання та застосування цього принципу.

Ви впевнені, що правильно розумієте DRY?

Напевно, лише дуже лінивий розробник (або ще зовсім початківець) не чув про принцип Don’t-Repeat-Yourself. Але я впевнений, що не всі замислювалися, чи правильно розуміють його. Адже якщо не знати його історію, то можна припустити, що принцип стосується будь-якого повторення коду. Але це не так.

Що ж таке принцип DRY?

Вперше він був опублікований у книзі The Pragmatic Programmer 1999 року. Сам по собі він використовувався і раніше, але саме в цій публікації був сформований та отримав свою назву. І звучить цей принцип як:

«Every piece of knowledge must have a single, unambiguous, authoritative representation within a system»

тобто

«Кожна частинка знання повинна мати єдине, однозначне і надійне представлення в системі»

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

Для прикладу, ви створюєте комп’ютерну гру, де герой має врятувати принцесу і здолати всесвітнє зло (багато років тому я саме таку і створив, вивчаючи JS, тому частина прикладів буде на цю тему). На шляху йому трапляються численні монстри. Очевидно, що монстр є тим самим «знанням», бо є частиною бізнес-логіки проєкту. Можна написати об’єкт кожного монстра окремо, але логічніше буде створити клас, в якому описати всі загальні риси всіх монстрів, та на його основі створювати потім кожного окремо. І тут ми прийшли до поняття абстракції, що є досить популярним інструментом для того, щоб підтримувати ваш код DRY. Ми повернемось до абстракцій трохи згодом, а поки що подумаємо ще трошки про відмінність між дублюванням знання та дублюванням коду.

DRY principle та дублювання коду: в чому різниця

Отже, виходячи з формулювання принципу, можна із впевненістю сказати, що принцип DRY і дублювання коду — це не одне й те ж. Розберемо простий приклад:

const LoginBlock = () => {
  const [loginValue, setLoginValue] = 
    React.useState('Enter your login name or your email');
  const [passwordValue, setPasswordValue] = 
    React.useState('');
  const [showLoginValidationWarning, setShowLoginValidationWarning] = 
    React.useState(false);
  const [showPasswordValidationWarning, setShowPasswordValidationWarning] = 
    React.useState(false);
  
  const handleChange = (e) => {
    switch (e.target.name) {
      case 'login':
        setLoginValue(e.target.value);
        setShowLoginValidationWarning(e.target.value.length < 2);
        break;
      case 'password':
        setPasswordValue(e.target.value);
        setShowPasswordValidationWarning(e.target.value.length < 8);
        break;
      default:
    }
  };

  const handleClick = (e) => {
    switch (e.target.name) {
      case 'login':
        if (e.target.value === 'Enter your login name or your email') {
        	setLoginValue('');
        }
        break;
      case 'password':
        setPasswordValue('');
        break;
      default:
    }
  };
  
  return (
    <div>
      <input
        type='text'
        name='login'
        value={loginValue}
        onChange={handleChange}
        onClick={handleClick}
        ></input>
      {
        showLoginValidationWarning &&
          <p>You got less than minimum number of characters required</p>
      }
      <input
        type='password'
        name='password'
        value={passwordValue}
        onChange={handleChange}
        onClick={handleClick}
        ></input>
      {
        showPasswordValidationWarning &&
          <p>You got less than minimum number of characters required</p>
      }
    </div>
  );
};


const StartButton = () => {
  const handleClick = () => {
    // Start the game!;
  };
  
  return <button type='button' onClick={handleClick}>Start</button>;
}

const Game = () => {
  return (
    <div>
      <h1>Hi! Please enter your login details and press the Start button</h1>
      <LoginBlock/>
      <StartButton/>
    </div>
  );
}

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

  • назва метода, що опрацьовує натискання (handleClick), співпадає в обох компонентах;
  • текст помилки валідації для обох полів абсолютно ідентичний;
  • сам елемент помилки дублюється;
  • перевірка тексту поля «логін» йде через рядок (повторення рядка зі state);
  • в обох методах handleClick та handleChange в конструкції switch використовується ім’я полів у вигляді рядка («login» та «password»);
  • обидва поля для введення даних дуже схожі;

Розберемо які ж з цих повторів порушують принцип DRY:

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

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

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

const loginPlaceholder = 'Enter your login name or your email';
const validationError = 'You got less than minimum number of characters required';
const loginName = 'login';
const passwordName = 'password';

const LoginInputWithError = (props) => {
  return (
    <div>
      <input 
        type={props.type}
        name={props.name}
        value={props.value}
        onChange={props.handleChange}
        onClick={props.handleClick}
        ></input>
      {
        props.showError && 
          <p>{validationError}</p>
      }
    </div>
  );
};

const LoginBlock = () => {
  const [loginValue, setLoginValue] = 
    React.useState(loginPlaceholder);
  const [passwordValue, setPasswordValue] = 
    React.useState('');
  const [showLoginValidationWarning, setShowLoginValidationWarning] = 
    React.useState(false);
  const [showPasswordValidationWarning, setShowPasswordValidationWarning] = 
    React.useState(false);
  
  const handleChange = (e) => {
    switch (e.target.name) {
      case loginName:
        setLoginValue(e.target.value);
        setShowLoginValidationWarning(e.target.value.length < 2);
        break;
      case passwordName:
        setPasswordValue(e.target.value);
        setShowPasswordValidationWarning(e.target.value.length < 8);
        break;
      default:
    }
  };

  const handleClick = (e) => {
    switch (e.target.name) {
      case loginName:
        if (e.target.value === loginPlaceholder) {
        	setLoginValue('');
        }
        break;
      case passwordName:
        setPasswordValue('');
        break;
      default:
    }
  };


  return (
    <div>
      <LoginInputWithError
        type='text'
        name={loginName}
        value={loginValue}
        handleChange={handleChange}
        handleClick={handleClick}
        showError={ShowLoginValidationWarning}/>
      <LoginInputWithError
        type='password'
        name={passwordName}
        value={passwordValue}
        handleChange={handleChange}
        handleClick={handleClick}
        showError={ShowLPasswordValidationWarning}/>
    </div>
  );
};


const StartButton = () => {
  const handleClick = () => {
    // Start the game!;
  };
  
  return <button type='button' onClick={handleClick}>Start</button>;
}

const Game = () => {
  return (
    <div>
      <h1>Hi! Please enter your login details and press the Start button</h1>
      <LoginBlock/>
      <StartButton/>
    </div>
  );
}

Наведений вище приклад створено, виключно щоб продемонструвати різновиди повторюваного коду та можливі шляхи його покращення. Звісно, його можна покращувати та покращувати. Але треба завжди пам’ятати — ви можете зробити код складнішим, що не завжди є бажаним результатом.

Абстракції: коли допомагають, а коли можуть нашкодити

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

Розглянемо більш широко приклад про монстрів у грі. Окрім монстрів у грі буде і головний герой. А якщо йти далі, робити підтримку багатьох користувачів, то таких героїв буде багато. Отже, вам потрібна буде абстракція у вигляді класу героя, з якої потім будуть створені персонажі для кожного гравця. Чудово, у нас є дві абстракції: герой та монстр. А чи не обʼєднати їх у ще одну абстракцію вищого рівня і не створити клас істота?

Здавалося б, ми приберемо частину повторюваної логіки, яка описує параметри та певні можливості обох, але тоді ми ризикуємо створити справжнього «монстра», бо спільного між цими класами виявиться не так багато, а код буде більш сплутаний (зросте coupling, що є абсолютно небажаним з точки зору Single-responsibility principle, першого з принципів SOLID). Таким чином, намагаючись діяти згідно з одним принципом програмування, ми починаємо порушувати інший. І, використовуючи цей узагальнений клас, можна зіткнутись з проблемою, описаною Джо Армстронгом (Joe Armstrong), автором мови програмування Erlang:

«... Because the problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle»

тобто

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

Розмірковуючи над підтримкою такого коду можно згадати Сенді Метц (Sandi Metz), авторку книги Practical Object-oriented Design, яка дуже влучно описала цю проблему:

«Duplication is far cheaper than the wrong abstraction»

тобто

«Дублювання набагато дешевше за погану абстракцію»

Таким чином, перш ніж створювати абстракцію, потрібно дуже добре зважити користь від неї, бо у довгостроковій перспективі вона може заподіяти більше шкоди. По суті, правило просте — починайте писати код, і в міру того, як розвиваються вимоги до вашої програми, робіть потрібні абстракції. Не треба заздалегідь продумувати всі можливі шляхи розвитку, бо те, що на вашу думку може стати корисним колись потім, може створити вам проблеми зараз, а вимоги для цього «потім» так ніколи й не надійдуть.

Ще трохи про застосування DRY

Ми вже розібрали кілька прикладів, як можна використати принцип DRY. Окрім абстракцій та констант наведу ще типовий спосіб застосування — функції, що інкапсулюють певну логіку, яку можна відокремити та перезастосувати. Як приклад можна розглянути будь-які утилітарні функції, що ви будете застосовувати в багатьох місцях вашої програми на кшталт форматування дати. Адже в будь-якому модулі дата повинна мати однаковий формат, і ця логіка є непов’язаною з бізнес-логікою того чи іншого модуля, а отже, може бути відокремлена, а функція надалі використана усюди, де це потрібно. Тобто на моменті, коли вам потрібно в черговий раз використати якусь чітку і відокремлену логіку, сміливо створюйте утилітарну функцію.

Кілька років тому я зіткнувся з проблемою переходу на нову версію API, коли декілька версій співіснували одночасно. Це було пов’язано з розширенням чи зміною архітектури певних ендрпоінтів, але перехід старішої функціональності застосунку потребував дуже багато часу. Отже, було прийнято рішення про винесення в допоміжні утилітарні функції тих частин сервісів, які повторювалися в обох версіях API. Це значно спростило подальшу підтримку цих ендпоінтів, адже треба було щось змінити в одній функції, й ця зміна одночасно впливала на обидві версії.

Навмисне порушення DRY

Іноді трапляється так, що принцип DRY потрібно порушити з тих чи інших причин. І я зараз кажу не про небажання рефакторити код, а про умисне часткове дублювання логіки. Колись я працював в одному стартапі, де було ухвалене рішення, що ми будемо проводили А/Б-тестування (A/B testing). Робилось це для дослідження доцільності додавання/зміни тих чи інших фіч. Суть його досить проста — є так званий feature flag типу boolean, що може або включити варіант А, або включити варіант Б. Певній аудиторії вашого продукту ви показуєте А, іншій — Б (можливе одночасне тестування більш ніж двох варіантів, але для прикладу зупинимось на двох).

Так от, коли це абсолютно нова функціональність продукту, яка повністю відокремлена від всього іншого — проблем жодних. Але що, коли ви тестуєте зручність нового дизайну, наприклад, модального вікна. Так, щось нове (стилі, порядок розміщення елементів) буде, але ж і значна кількість того, що на ньому вже використовується, буде використовуватися і надалі. І ось тут треба дуже добре оцінити вклад на впровадження цього самого feature flag, бо якщо це один if блок, то добре, але якщо в компоненті цих if блоків потрібно з десяток?

const env = {
  newFeatureFlag: true
};

const CustomModal = (props) => {
  const propOne = props.featureFlag ? 1 : 2;
  const propTwo = '';
  const propThree = props.featureFlag;
  const propFour = props.featureFlag ? 'Error' : 'NoError';

  const methodOne = () => {
    // old logic
  }
  
  const methodTwo = () => {
    // partially changed in new version
    if (props.featureFlag) {
    	// add some logic
    } 
  }
  
  const methodThree = () => {
    // partially changed in new version
    if (props.featureFlag) {
    	// add some logic
    } 
  }
  
  const methodFour = () => {
    // old logic
  }
  
  const methodFive = () => {
    // partially changed in new version
    if (props.featureFlag) {
    	// add some logic
    } 
  }

  return (
    <div type='button' onClick={methodOne}>
      {
        props.featureFlag
          ? <div type='button' onClick={methodTwo}>
              <div type='button' onClick={methodThree}>
                <div type='button' onClick={methodFour}>
                  {propOne}
                </div>
                <div type='button' onClick={methodFive}>
                  {propFour}
                </div>
              </div>
            </div>
          : <div type='button' onClick={methodTwo}>
              <div type='button' onClick={methodThree}>

              </div>
              <div type='button' onClick={methodFour}>
                {propOne}
              </div>
              <div type='button' onClick={methodFive}>
                {propFour}
              </div>
            </div>
      }
    </div>
  )
};


const PageWithModal = () => {
  return (
    <div>
      <CustomModal featureFlag={env.newFeatureFlag}/>
    </div>
  );
}

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

const env = {
  newFeatureFlag: true
};

const NewCustomModal = () => {
  const propOne = 1;
  const propTwo = '';
  const propThree = true;
  const propFour = 'Error';
  
  const methodOne = () => {
    // new logic
  }
  
  const methodTwo = () => {
    // new logic
  }
  
  const methodThree = () => {
    // new logic
  }
  
  const methodFour = () => {
    // new logic
  }
  
  const methodFive = () => {
    // new logic
  }

  return (
    <div type='button' onClick={methodOne}>
      <div type='button' onClick={methodTwo}>
        <div type='button' onClick={methodThree}>
          <div type='button' onClick={methodFour}>
            {propOne}
          </div>
          <div type='button' onClick={methodFive}>
            {propFour}
          </div>
        </div>
      </div>
    </div>
  )
}

const CustomModal = () => {
  const propOne = 2;
  const propTwo = '';
  const propThree = false;
  const propFour = 'NoError';
  
  const methodOne = () => {
    // old logic
  }
  
  const methodTwo = () => {
    // old logic
  }
  
  const methodThree = () => {
    // old logic
  }
  
  const methodFour = () => {
    // old logic
  }
  
  const methodFive = () => {
    // old logic
  }
  
  return (
    <div type='button' onClick={methodOne}>
      <div type='button' onClick={methodTwo}>
        <div type='button' onClick={methodThree}>

        </div>
        <div type='button' onClick={methodFour}>
          {propOne}
        </div>
        <div type='button' onClick={methodFive}>
          {propFour}
        </div>
      </div>    
    </div>
  )
}

const PageWithModal = () => {
  return (
    <div>
      {
        env.newFeatureFlag 
          ? <NewCustomModal/>
          : <CustomModal/>
      }
    </div>
  );
}

Після закінчення тестування і прийняття фінальної версії такий флаг разом із непотрібною вже версією модального вікна дуже легко прибираються, і ви продовжуєте далі без дубліката.

Пошук та усунення дублікатів

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

ESLint. Найпопулярнішим лінтером для JS на цей момент є ESLint. Багато компаній вже використовують його в репозиторіях для стандартизації та уніфікації певних правил написання коду між багатьма розробниками. Як саме він може допомогти позбавитись дублікатів? Певні правила ESLint можуть допомогти знайти дублікати імпортованих модулів, аргументів функцій, if-else блоків, коментарів в коді тощо. Але на цьому допомога лінтерів закінчується. Ось тут можна знайти перелік популярних конфігурацій та плагінів для ESLint: awesome-eslint.

IDE. Такі IDE як, наприклад, IntelliJ IDEA, мають вбудовані інструменти для пошуку дублювання. Їх налаштування досить просте: analyze duplicates with IDEA. Такий інструмент може порівнювати код по рядках. Таким чином ви можете знаходити абсолютно однаковий код, який повторюється в більш ніж одному місці протягом мінімального заданого розміру:

Але і цей інструмент не є ідеальним, адже якщо у вас буде дві функції, що роблять одне й те саме (наприклад, складання двох чисел), але вони будуть написані по-різному та матимуть різну назву, то вони не будуть знайдені як дублікати, тобто, такі функції:

const addNumbers = (a, b) => {
  return a + b;
};

const numbersAdd = (a, b) => {
  return b + a;
};

будуть абсолютно різними для цього інструменту.

SonarLint. Щоб не шукати IDE, де є потрібні вам інструменти, можна просто скористатися потрібним розширенням. SonarLint — одне з таких розширень, що працює з усіма популярними IDE та допомагає знайти і виправити проблеми з якістю та безпекою вашого коду включно з дублюванням коду.

Clone Doctor. Якщо ж у вас є час та натхнення і ви хочете зробити код ідеальним (ну або близьким до ідеалу), розуміючи, що його вже дуже важко (дорого) підтримувати, то тут вам у пригоді може стати спеціалізоване рішення на кшталт Clone Doctor від Semantics Design. Після довгого пошуку ви отримаєте результати, де у відсотковій вірогідності будуть представлені дублікати, поділені на категорії дублювання:

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

Тож чому варто використовувати DRY?

Підсумовуючи все наведене вище, ми бачимо, що дотримання принципу DRY робить код набагато більш придатним для довгострокової підтримки. Створюйте абстракції, константи, утилітарні функції для однозначної локалізації знання в системі та отримання single source of truth кожної частинки логіки. Але завжди, завжди зважуйте доцільність створення абстракцій вищого рівня та довгого й виснажливого рефакторингу.

Пам’ятайте, що найкраще дотримуватись принципу в процесі створення вашого застосунку, адже на пошук та рефакторинг буде витрачатись дуже багато часу. І головне, не забувайте про паралельне дотримання інших принципів, особливо мого найулюбленішого — KISS (Keep It Simple, Stupid).

👍ПодобаєтьсяСподобалось15
До обраногоВ обраному7
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Поиск в броузере показывает, что в статье про DRY аббревиатура DRY используется 20 раз. По-хорошему, для такой темы достаточно одного твита «Don’t Repeat Yourself», ну и практики лет пять. ;)

DRY

на фронтенде нужен в исчезающе малом числе случаев. обычно там где фронтендщики городят dry, нужна копипаста.

Якщо ви уявили що фронтенд це HTML+jQuery, то ви із цими уявленнями запізнились на років 10. Теперішні застосунки на фронтенді навіть інколи складніші, ніж на бекенді.

DRY вже без фреймворків не продемонструвати?

Але є одне величезне «але». Людський фактор. Код пишеться не стільки для машин, стільки для людей — саме читабельний код допомагає уникнути помилок, знизити вартість помилки, локалізувати її.

А з людьми все не так просто. Пам′ять сприйматиме контент, якщо там 90% вже знайомого матеріалу, і не більше 10% нового. Тому конденсація логіки — то добре. Але абстракції... вони мають бути логічними одиницями, мати закінчений зміст для людини. В іншому випадку саме абстракція порушує KISS, отруюючи код нібито впровадженням DRY.

Іншими словами, кожен шматок коду чи даних, який ви переносите в інше місце, має бути просто і легко зрозумілим по заголовку, що воно таке. Якщо ж у місці відокремлення треба багато розуміти, є важкі залежності від стану, якщо вам важко дати ім′я абстракції — НЕ РОБІТЬ абстракції. Краще 100 разів повторити один і той самий код із 3 строчок, аніж 100 разів отруїти, вимагаючи від мозку побудувати складну абстрактну модель, та ще й прогулятися у інший файл дивитися, а що ж ви там таке робите.

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

Типовим прикладом одиничного абстрагування є текстове форматування, що ускладнює швидке читання коду. Як то, багато відступів, чи навпаки, дуже компактний код.

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

Якщо коротко: принципу DRY взагалі не має існувати. Він вироджений. Скорочення повторень — це наслідок гарного розділення коду на зрозумілі абстракції. Але аж ніяк не мета. Не робіть знищення повторень метою — бо повторення то не помилка, і навіть не проблема. Лише привід зробити рефакторинг, коли повторюється великий блок коду. А якщо кілька строчок — то залиште, вони лише спрощують читання.

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

Я первый раз полностью прочитал комментарий Алексея Пение )
А сам коммент — в рамочку и на стеночку.
DRY головного мозга — одно из самых страшных заболеваний, которое не лечится

Підписатись на коментарі