Дубльований код. Чи завжди варто намагатися його позбутися
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Всім привіт. Мене звати Олег Шастітко, я займаюсь розробкою, проєктуванням і аналізом архітектури програмних продуктів, побудованих на .NET. Свій шлях розробника я почав ще у 2001 році, з використанням технології ASP і згодом почав розробку .NET застосунків, починаючи з виходу першої версії .NET у 2002 році.
Більшість з розробленого та спроєктованого мною ПЗ було з використанням ASP.NET й інших технологій мережевої взаємодії. Тобто мій основний напрям — системи з фокусом на використання як веб зі взаємодією між компонентами системи, а не на розробку високонавантажених багатопотокових застосунків.
Я прихильник побудови гнучкої архітектури з можливістю розширювання і рознесення відповідальності між компонентами й написання чистого, гарно структурованого кода з можливістю його розвитку. Терпіти не можу регресію в коді, тому багато часу приділяю його проєктуванню і рефакторингу ще до того, як недоліки дадуть про себе знати.
В цій статті я хочу розповісти про дубльований код, за який дуже часто критикують розробників. Розповім, як я бачу цю проблему і чи завжди дубльований код — сигнал до негайного рефакторингу.
Вступ
Дубльований код та copy-paste — це чи не перше, за що критикують код. Його часто розглядають як червоний сигнал світлофора під час code-review ззовні. Подібним чином це сприймають і безпосередньо розробники. Дубльований код викликає у них занепокоєння і бажання його позбутися, відсунувши всі інші завдання на другий план.
Але чи справді дубльований код — це завжди привід бити на сполох?
Скажу одразу — ні, не завжди! Дубльований код — це лише сигнал звернути увагу на цей фрагмент, уважно проаналізувати архітектуру та задачі, які він виконує в обох (або більш ніж в обох) випадках і тільки після цього, у разі потреби, робити рефакторинг!
Хочу одразу зазначити, що приклад нижче стався у реальному проєкті, який я нещодавно аналізував. Подібний підхід з анемічними моделями (наслідування від базового класу DTO-класів, що належать до різних шарів) я бачив багато разів за свою кар’єру, що і спонукало мене написати цю статтю.
Дублювання анемічної моделі
Розгляньмо одну тривіальну задачу. Є реляційна база даних. Ми вибираємо з неї дані за допомогою сервісу, що перетворює їх на DTO-об’єкт (зрозуміло, що при проєктуванні БД як мінімум 4NF нормальної форми, цей клас, швидше за все, буде розбитий в реляційній БД на декілька класів. Наприклад, Department зберігатиметься в окремій таблиці). Нехай цей DTO-клас виглядає так:
public class ClientDto { public string ClientName { get; set; } public string ClientDepartment { get; set; } public string CompanyName { get; set; } public string? Phone { get; set; } public string? Email { get; set; } public string? Website { get; set; } public string? MailingAddress { get; set; } public string? City { get; set; } public decimal Balance { get; set; } public int DistanceMeters { get; set; } }
Ми викликаємо цей метод з контролера ASP.NET, і метод цього контролера створює View model, передаючи її далі в безпосередньо View. В більшості випадків цей клас View model буде вражаючи схожий на наш DTO-клас або навіть ідентичний з ним. Наприклад:
public class ClientVm { public string ClientName { get; set; } public string ClientDepartment { get; set; } public string CompanyName { get; set; } public string? Phone { get; set; } public string? Email { get; set; } public string? Website { get; set; } public string? MailingAddress { get; set; } public string? City { get; set; } public decimal Balance { get; set; } public int DistanceMeters { get; set; } }
Тут ми бачимо всі ознаки дублювання коду: якщо з’являється нове поле — ми повинні його додати в обидва класи, якщо поле буде перейменовано — важливо не забути зробити це для обох класів. Інакше на виході побачимо зовсім не те, що очікуємо. Особливо якщо використовуємо засоби для автоматичного мепінгу, наприклад Automapper
, і не зобов’язані явно присвоювати кожне поле. І, дуже прошу, без холівара про автомапер і його особливості, будь ласка!)
Але не поспішайте бити на сполох, звинувачувати себе в тому, що ми мало не припустилися помилки Junior’а, і терміново кидатися створювати новий абстрактний клас (назвемо його ClientAbstract
), від якого будуть успадковуватися ці обидва класи.
Тому що правильним в цьому випадку є саме дублювання коду! Між дублюванням та змішуванням відповідальності вибирайте менше зло, а саме дублювання. Тому воно на поверхні й не тягне за собою далекосяжні наслідки в архітектурі.
Я міг би сказати, що створення абстрактного класу для цих двох буде змішування шарів. А ви б відповіли: але ж ми можемо винести ці абстрактні класи в окрему збірку без будь-яких залежностей. Частково ви матимете рацію. Саме частково, тому що сервіс, що повертає ClientDto
— це високорівневі бізнес-правила, описані передусім саме бізнесом і непорушні самі по собі. Адже вони диктують додатку, яким йому бути, а не навпаки. А ClientVm
— це низькорівнева реалізація веб, яка всього лише «морда». Сьогодні вона одна, завтра — інша та бізнесу стосується опосередковано.
Але принцип єдиної відповідальності (SRP) говорить нам про те, що клас має мати одну і тільки одну причину для змін. Для сервісу бізнес-логіки — це зміна бізнес-логіки. Для вебу — те, як саме цей конкретний застосунок має показати дані. Веб, найімовірніше, буде змінюватися набагато частіше і непередбачуваніше, ніж бізнес-логіка. Кожна зміна в вебі поставить питання про зміну цього базового абстрактного класу ClientAbstract
, який має бути стабільним настільки, наскільки стабільна бізнес-логіка. Принцип стійких залежностей SDP говорить нам про те, що менш стійкі компоненти, тобто ті, які більш схильні до змін, повинні залежати від більш стійких. У нашому випадку веб повинен залежати від бізнес-логіки, але не навпаки.
Уявіть, що завтра клієнти вирішать: змінна Balance
, що виводиться вами як число decimal, виглядає не по фен-шуй для вебсторінки та її потрібно виводити зі знаком $ і роздільниками тисяч і дробових значень, але не більше двох цифр після коми. А DistanceMeters
має виводитися або в метрах, або в кілометрах, тобто клас View Model набуває вигляду. Ну а ми на рівні контролера робимо ці перетворення. Можна звичайно нічого не чіпати й перетворити на клієнтові, але якщо він не підтримує клієнтський код, або є вимога мати його якомога менше:
public class ClientVm { public string ClientName { get; set; } ...... public string BalanceDollars { get; set; } public string Distance { get; set; } }
Вам доведеться в абстрактному ClientAbstract
прибирати ці 2 поля, додавати їх безпосередньо в ClientDto
у тому вигляді, в якому вони були в ClientAbstract
і далі все тестувати й намагатися переконатися, що нічого не зламалося. Але програмування — це не математика. В програмуванні не можна просто довести як теорему, що все добре. Ми можемо мінімізувати ймовірність помилки, але виключити її неможливо. Принцип «працює — не чіпай» якраз тут дуже доречний.
Цей банальний приклад показує, що іноді не потрібно позбуватися продубльованого коду, іноді він — норма.
Дублювання логіки й алгоритмів
Я зустрічав таку думку, що принцип DRY відноситься насамперед до логіки, а не до структур на кшталт анемічних моделей DTO. Але розгляньмо приклад, коли дубльований код в алгоритмах не повинен зазнати рефакторингу.
Уявімо, що у нас є якась бактерія, що зі стійкістю атома урану-235 ділиться навпіл. Скажімо, щохвилини. Тобто через одну хвилину (перше покоління) у нас буде 2 бактерії, через 2 хвилини (друге покоління) — 4 бактерії. Ми пишемо свій видатний і неперевершений у складності алгоритм, який рахує, скільки ж бактерій буде у Х поколінні. Виглядає він так:
public int GetBacteriaCount(int generation) { return Math.Pow(2, generation); }
Все чудово. Тепер в іншому місці нашого проєкту треба порахувати, скільки предків має людина у Х поколінні. Тобто у першому поколінні у нас двоє предків (мама й тато), у другому — четверо (дві бабусі та два дідуся) тощо. Пишемо другий алгоритм:
public int GetAncestorsCount(int generation) { return Math.Pow(2, generation); }
Бінго! Тепер ми дивимося на обидва методи й бачимо, що вони дивовижно схожі. Згадуємо наше чарівне Don’t repeat yourself і що робимо? Вірно, об’єднуємо обидва алгоритми в один. Все працює!
Але що поєднує ці два алгоритми? Не в площині підрахунків, а в площині бізнес-правил? Хіба процес розмноження бактерії має щось спільне із нашим родинним деревом? Хіба ці два процеси йдуть однаково за одними й тими самими законами? Ні!
Коли ми починаємо рахувати предків у, наприклад, 50 поколінні (а це лише брати мають родинні зв’язки. Наші лінії предків перетиналися безліч разів. Тобто 1000 років тому у нас були не мільярди унікальних прапрабабусь з такими ж дідусями, а значно менше число. Їх нащадки потім створювали пари між собою (так, це спорідненість, але настільки далека, що вже не має негативного впливу на їх розвиток). Тобто ми маємо запровадити якийсь коефіцієнт на кшталт:
public int GetAncestorsCount(int generation, double k) { return k * Math.Pow(2, generation); }
Або це буде зовнішній сервіс, що впроваджений у наш клас, який повертає цей коефіцієнт залежно від покоління. Наприклад:
public async Task<int> GetAncestorsCountAsync(int generation) { var k = await _extService.GetCoefficientAsync(generation); return k * Math.Pow(2, generation); }
Або ще якийсь спосіб підрахунку корекції.
А що з розмножуванням бактерій? Вони з часом стають обмежені середовищем, в якому живуть, поживними речовинами та іншими факторами. Ми можемо це описати як:
public int GetBacteriaCount(int generation, int minutes) { ..... }
Або, знову ж таки, з використанням якогось зовнішнього сервісу. У будь-якому випадку ми бачимо, що шляхи розвитку цих алгоритмів розійшлися, хоч спочатку вони були ідентичні! Тому що вони підкоряються різним бізнес-правилам. Мають різні причини для зміни.
Тому це також той випадок, коли краще залишити дубльований код, ніж намагатися натягнути сову на глобус. Хтось все одно мені може заперечити, мовляв, можна ж використовувати базовий метод на кшталт (або без virtual, а просто визивати цей метод з базового класу у класах-нащадках, це не принципово щодо розглядаємої теми):
public virtual int GetEntityCount(int generation) { return Math.Pow(2, generation); }
А параметри, специфічні для конкретної реалізації, передавати іншим способом (наприклад, через конструктори класів-нащадків). Але це буде шлях, коли замість того, щоб відрубати непотрібне вже зараз, ми тягнемо його далі. Чи мають спільну основу два алгоритми? Залежно від цього і треба приймати рішення, повинні вони мати щось спільне при проєктуванні чи ні! В нашому випадку — не мають. І ніщо не гарантує, що нові дослідження завтра не скажуть нам, що бактерія ділиться не за законом степеня двійки, а за степеня трійки чи ще якось.
Декілька слів про принцип DRY
До речі, додам щодо принципу DRY: якщо вдуматися у сенс виразу «Don’t repeat yourself», він закликає «не повторюватися». Що не означає явно «дубльований код». «Не повторюватися» означає не створювати саме такий код. Це може бути як дубльований код там, де його можна і потрібно відрефакторити, так і абсолютно різний код, який при цьому виконує те саме! Наприклад, два різних алгоритми, які виконують одну й ту саму задачу. Це якраз порушення принципу DRY, на відміну від описаних вище випадків.
Висновок
Повторюся (майже як і з кодом :), якщо ви бачите дубльований код, насамперед запитайте себе: «чи справді він змінюватиметься з однієї причини?» Якщо так — найімовірніше, його дійсно потрібно позбутися, зробивши рефакторинг. Якщо ні — можливо, ті два схожі один на одного алгоритми, що виконують зовсім різні задачі, повинні такими й залишатися? Може, якщо завтра потрібно буде внести якусь корекцію до одного з них, ми маємо можливість зробити саме так, не вплинувши на інший?
PS. У зоології є поняття «конвергентна еволюція» — коли абсолютно різні види тварин/рослин/грибів мають зовнішню схожість або інші подібні ознаки. Але ж ми не можемо стверджувати, що змії — це близькі родичі черв’яків, чи кити — близькі родичі риб. Змії набагато ближче до черепах, хоча формою тіла більше нагадують черв’яків. Але ця схожість — лише через подібний спосіб переміщення і не більше. В іншому вони дуже сильно відрізняються.
Так і з наслідуванням класів в програмуванні. Побачивши загальні поля чи поведінку кількох класів, запитайте себе — чи не є ця подібність лише зовнішньою й актуальною лише на цю мить? Як і під якими зовнішніми умовами обидва класи можуть розвиватися далі? Чи є у них спільні передумови розвиватися разом, тобто чи можливо таке, що зміна в одному автоматично спровокує аналогічні зміни в іншому?
PS. Дякую Nikola Utiuzhnikov за зауваження щодо помилки в моєму тривіальному коді :)
80 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів