Іммутабельність проти мутацій

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Можливо, ви чули, що мутації — це погано і небезпечно.

Але чому саме? І хіба той самий React зі своїм іммутабельним станом не робить мутацій в середині?

В цій статті ми розберемось:

  • Коли мутації безпечні, а коли ні
  • Як іммутабельність дозволяє писати більш надійний код
  • Як знайти баланс між мутаціями і іммутабельністью

Мутація (mutation) — це просто модне слово для зміни якогось поля в обʼєкті.

const obj = { count: 1 };
obj.count = 2; // Це мутація

Хоч, код вище і використовує мутацію, але насправді в ньому немає абсолютно ніякої проблеми. Насправді проблеми з мутаціями починають тоді коли в нас є shared mutable state і асинхронність. Розглянемо приклад приклад коду в якому є проблема:

async function doSomethingAsync() {
  if (!sharedMutableState.user) {
    return;
  }

  await Promise(r => setTimeout(r, 1000));

  const { name } = sharedMutableState.user; // Обережно Баг!

  //...
}

В цьому коді ми наче і перевірили що user є. Але перевірка і читання name знаходяться в різних макро задачах. А це значить що між двума ціма діями може бути виконаний ще якийсь код. І цей код може змінити user посередині виконання асинхронної функції.

sharedMutableState.user.name = 'Other Person'
// або
sharedMutableState.user = null;

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

async function doSomethingAsync() {
  const user = structuredClone(sharedMutableState.user);

  if (!user) {
    return;
  }

  await Promise(r => setTimeout(r, 1000));

  // тут можна бути впевненими що з user нічого не сталось
}

Або ж ми можемо піти іншим шляхом як пішов React, а саме уникати shared mutable state взагалі і зробити наш стан іммутабельним. Ось приклад як би це виглядало в React:

const [user, setUser] = useState(null);

async function doSomethingAsync() {
  if (!user) {
    return;
  }

  await Promise(r => setTimeout(r, 1000));

  // тут можна бути впевненими що з user нічого не сталось
}

Іммутабельність (immutability) означає що ми не можемо зробити просто ось так:

user.name = 'Other Person'

А замість цього нам треба створити новий обʼєкт user і запланувати апдейт:

setUser(user => ({
  ...user,
  name: 'Other Person',
}))

На перший погляд це виглядає як ускладнення, але це повністю вирішує проблему оскільки:

  • Ми більше не читаємо user із sharedMutableState де його може вже не бути
  • Ми не змінюємо сам обʼєкт user, тож він ніколи не зможе змінитися десь по середині виконання асинхронної функції

Таким чином React і іммутабельність звільняє нас від великої частини багів які зумовлені shared mutable state і асинхронністю.

Додаткова перевага іммутабельності

Також іммутабельність дає нам перевагу навіть в синхронному коді. Розглянемо код:

function doSomethingSync(data) {
  doSomething1(data);
  doSomething2(data);
  doSomething3(data);
}

В данному випадку якщо кожна функція може мутейтити data, то по цьому коду ми не можемо відповісти на питання:

  • Чи повʼязані ці функції між собою?
  • Чи важливий порядок виклику?
  • Якщо змінити код однієї з них чи вплине це на інші?

З цього випливає, що з мутаціями, щоб відредагувати щось одне, нам потрібно розуміти як працює все.

Якщо ж data в нас іммутабельна, то звʼязки між функціями будуть явними:

function doSomethingSync(data) {
  const result1 = doSomething1(data);
  const result2 = doSomething2(data);
  doSomething3(result1, result2, data);
}

Тут ми бачимо, що doSomething1 і doSomething2 не залежать одна від одної, тож їх порядок виклику не має значення. А doSomething3 залежить від результату попередніх двох і вхідних даних.

Таким чином іммутабельність зменшує когнітивне навантаження на розробника, бо тепер редагуючи код doSomething1 ми точно знаємо що це може вплинути на doSomething3 але не вплине на doSomething2. За рахунок іммутабельності нам не потрібно розуміти як працює вся система для редагування тільки однієї її частини.

Більш такого, від іммутабельності до чистих функцій лише один крок. А чисті функції — найбільш надійні будівельні блоки програмного забезпечення. У випадку з кодом вище doSomething1 та doSomething2 мають бути чистими функціями, а якщо вони роблять якісь сайд ефекти, то ці сайд ефекти треба винести в окремі функції.

У іммутабельності є ще декілька переваг, але це виходить за межі статті. Тож повернемось до мутацій.

Чи завжди мутація це погано?

Коротка відповідь ні, не завжди.

Мутація абсолютно безпечна якщо вона локальна. Тобто там де обʼєкт був створений там його і мутуємо і нікуди його не передаємо. Ось приклад коли мутація навіть в асинхронному коді буде безпечною.

function async createPerson(name, age, country) {
  const person = {
    name,
    age,
    country,
  };

  const adultAge = await getAdultAge(country);

  person.isAdult = age >= adultAge;

  return person;
}

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

function async createPerson(name, age, country) {
  const adultAge = await getAdultAge(country);

  const person = {
    name,
    age,
    country,
    isAdult: age >= adultAge;
  };

  return person;
}

Проте інколи подібні мутації можуть бути просто зручними.

Інколи виникає ситуація що в обʼєкта просто немає іншого API для редагування окрім мутабельного. Як наприклад в Set. В таких випадках щоб мутація залишалась локальною треба створювати нову копію.

setSelected((selected) => {
  const newSelected = new Set(selected); // Нова копія створена тут

  if (newSelected.has(value)) {
    newSelected.delete(value);
  } else {
    newSelected.add(value);
  }

  return newSelected;
});

Така мутація абсолютно безпечна оскільки ніякий зовнішній код про неї не дізнається. Це як маленький секрет.

Приклад хорошої мутації — це бібліотека immer. Ця бібліотека дозволяє робити іммутабельні апдейти на даних дуже легко за допомогою мутацій.

import { produce } from "immer"

const baseState = [
  {
    title: "Learn TypeScript",
    done: true,
  },
  {
    title: "Try Immer",
    done: false,
  },
];

const nextState = produce(baseState, draft => {
    draft[1].done = true;
    draft.push({ title: "Tweet about it" });
})

Мутація над draft є локальною оскільки за межами produce draft не доступний, а baseState залишиться без змін. Тож данна мутація є абсолютно безпечною.

Раніше я казав, що shared mutable state і асинхронність — це потенційне джерело серйозних багів. Але в деяких випадках зашерити данні між асинхронними операціям — це саме те, що нам потрібно.

Розглянемо приклад:

const [data, setData] = useState([]);
const [search, setSearch] = useState('');

useEffect(() => {
  searchApi(search).then(data => {
    setData(data);
  })
}, [search]);

Цей код — типовий приклад race condition. Якщо search буде змінюватись швидко то в нас буде відпоравлено декілька запитів на searchApi і в стан data потрапить той, для якого Promise зарезолвиться останній. Є багато способів це пофіксити, і один із них, хоч і не найкращий — це використання shared mutable state.

const [data, setData] = useState([]);
const [search, setSearch] = useState('');
const lastSearchRef = useRef(null)

useEffect(() => {
  lastSearchRef.current = search;

  searchApi(search).then(data => {
    if (lastSearchRef.current === search) {
      setData(data);
    }
  })
}, [search]);

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

Підсумок

В мутаціях немає нічого поганого доки вони локальні. Але як тільки ми передаємо mutable state в якусь іншу функцію він перетворюється на shared mutable state.

Shared mutable state вимагає уважності: щоб безпечно з ним працювати, потрібно знати всі місця, де він читається або змінюється.

Я особисно вважаю, що варто використовувати:

  • іммутабельність за замовчуванням.
  • локальні мутації коли це зручно, спрощує код (як immer), або немає іншого API окрім мутабельного (як в Set)
  • shared mutable state тільки там, де він дійсно еффективно вирішує проблему, але памʼятати, що він потрібує особливого контролю

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

Тож якщо можна — обирайте іммутабельність. А якщо не можна — не втрачайте контроль.

👍ПодобаєтьсяСподобалось1
До обраногоВ обраному0
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
іммутабельність за замовчуванням.

Що спричиняє перерозхід памʼяті та збільшене навантаження на GC.

А якщо не можна — не втрачайте контроль.

Проблема не в контролі, а в рахітектурі фреймворків та навʼязуванні певних патернів поведінки. В кожному з підходів є свої плюси та мінуси, що в мутабельності, що в імутабельності. Набагато кращі архітектури ті, які дозволяють та активно використовують мутабельність, а не навпаки. Саме тому, що будь-хто та будь-коли може змінити дані. Звучить конроверсійно, але це працює на довгій дистанції набагато краще.

Візьмемо самий перший приклад
  const { name } = sharedMutableState.user; // Обережно Баг!
Тут немає бага, якщо user буде реалізовано як геттер. Не сильно очевидно? Тоді це мусить бути фукнцією getUser(), якщо хочеться от прямо красиво-очевидно.

Що спричиняє перерозхід памʼяті та збільшене навантаження на GC.

У всього є своя ціна. За рахунок structural sharing, це не є критичним на практиці. Тож це ціна яку я вважаю варто заплатити за впевненість в своєму коді.

будь-хто та будь-коли може змінити дані

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

Тут немає бага, якщо user буде реалізовано як геттер.

Саме по собі додавання геттера, не вирішить проблему. Ось наприклад якщо код виглядає так, то проблема залишаєтся.

async function doSomethingAsync() {
  if (!userService.checkUser()) {
    return;
  }

  await Promise(r => setTimeout(r, 1000));

  const name = userService.getUserName(); // Обережно Баг!

  //...
}
Чи вирішило це проблему? Ні.
Чи правильний це код з точки зору ООП? Також ні.
Тож просто додати геттер недостатньо, треба використати паттер з ООП. І їх там буде з десяток на вибір для вирішення цієї проблеми.
За рахунок structural sharing, це не є критичним на практиці.

Ви все одне маєте перерозхід памʼяті, бо вам треба зберігати всі версії оновлених часток даних. А підтримка версійності теж вигамає.... даних в памʼяті. А для нормальної збірки фінального обʼєкта ще й CPU треба додатково напружувати. А для чого це все? Для субʼєктивної впевненості розробника в коді? Ок, почув.

Якщо так, то ми ніколи не можемо бути впевнені в данних і маємо їх постійно перевіряти, чи вони в тому стані, що ми очікуємо.

Це ваша думка, тому що ви звикли до імутабельних обʼєктів. Не обовʼязково перевіряти всі стани на кожному кроці. Ваш код мусить бути організований краще, щоб всі функції стали або синхронними, або асинхронними. Не міксами. Мікси допускаються, коли асинхронний код є останнєю дією в тілі функції, тоді вже неважливо, що буде потім.

Чи вирішило це проблему? Ні.

Проблеми не буде існувати, якщо у вас функція «розумна». Який, на вашу думку, код мусить бути всередині getUserName()?

Проблеми не буде існувати, якщо у вас функція «розумна». Який, на вашу думку, код мусить бути всередині getUserName()

В цьому і справа, для того щоб сказати функція безпечна чи ні, там треба заглибитись в деталі її реалізації. Як розробник, я не хочу знати деталей реалізації всіх функцій, просто хочу щоб вони працювали і нічого не ламали. А мутації якраз вимагають знати деталі реалізації всього, чого торкаються. Про це я і кажу — мутації потребують контроля. І ООП — це якраз спосіб їх контролювати.

В цьому і справа, для того щоб сказати функція безпечна чи ні, там треба заглибитись в деталі її реалізації.

Не обовʼязково якщо у вас архітектура проекта побудована за одним стандартом.

просто хочу щоб вони працювали і нічого не ламали

Я програмізд, в мене лапки. Чудова життєва позиція.

А мутації якраз вимагають знати деталі реалізації всього, чого торкаються.

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

Повернемося до коду. Найпростіший варіант реалізації «розумної» функції getUserName(), це повертати значення, якщо воно є, та кидати ексепшин, коли імʼя пусте ("", null, undefined). Якщо ви не хочете використовувати ексепшини, то ця функція мусить стати асинхронною та запускати процедуру отримання імені з API, тобто гарантувати 100% результат. Переускладнення?

Дуже цікаво.

Як ви гарантуєте 100% результат?
А якщо в нас на початку функції name мав одне значення, а після асинхронної операції став інший? Як я наводив в прикладі:

sharedMutableState.user.name = 'Other Person';

Викинути ексепшин ніяк не допоможе пофіксити проблему, бо навіть наївна реалізація типу

function getUserName() {
  return this.user.name;
};
І так викине ексепш якщо юзера не буде. Тож що ви пропонуєте, завжди огортати цю функцію в try catch? Це і є додатковий контроль про який я говорив.
Я програмізд, в мене лапки. Чудова життєва позиція.

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

Також дуже цікаво що ви мали на увазі ось тут

Ваш код мусить бути організований краще, щоб всі функції стали або синхронними, або асинхронними. Не міксами.

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

А якщо в нас на початку функції name мав одне значення, а після асинхронної операції став інший?

З якого переляку зміна імені стала помилкою? У вас корнер-кейси тільки наступні

  • Юзера видалено, його імʼя в такому випадку мусить бути undefined або null
  • Валідація не спрацювала, імʼя користувача було скинуто в пусту строку «"
  • Сесія припинила дію, імʼя невідоме допоки не буде відновлена авторизація
Можна придумати ще деякі, але саме така поведінка є позаштатною ситуацією, коли продовження виконання коду функції перестане нести хоч якийсь сенс. А зміна імені — це штатна, робоча та нормальна поведінка системи.
Тож що ви пропонуєте, завжди огортати цю функцію в try catch?

Не обовʼязково саме тут це робити. Це може бути зроблено в іншому місці.

Це і є додатковий контроль про який я говорив.

Можна й так це назвати.

В моєму розумінні ніяких міксів не існує, є лише синхронні і асинхронні функції.

З точки зору декларації — все вірно. Але тіло функції може бути написане в різних стилях. Наприклад, ваша async function doSomethingAsync() є міксовою фукнцією, ви робите в ній частину дій асинхронно, але потім очікуєте, що наступна операція буде по завершенню виконання асинхронної частини, в синхронному імперативному стилі. Тому ви й маєте той «баг».

Щоб краще зрозуміти різницю та підходи я б порекомендував подивитися на updating functions в xQuery. Там це найяскравіше видно, в чому різниця між різними типами.

З якого переляку зміна імені стала помилкою?

Якщо половина функції відпрацювали з одним іменем користувача, а половина з іншим, це не окей. І тут треба поглянути на цю проблему більш абстрактно, це може бути не юзер, а довільний обʼєкт і в ньому змінилось довільне поле, бо обʼєкт мутабельний. А наш код який знаходиться далі може зовсім не очікувати такої зміни. І відпрацювати не коректно, і не обовʼязково це буде краш, може просто баг, а може нічого і не буде. Якщо буде баг, його буде важко відтворити, бо для того щоб він відбувся потрібно щоб зірки зійшлись. Це в програмуванні ще називається «spooky action at a distance».

Щоб краще зрозуміти різницю та підходи я б порекомендував подивитися на updating functions в xQuery.

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

Якщо половина функції відпрацювали з одним іменем користувача, а половина з іншим, це не окей.

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

А наш код який знаходиться далі може зовсім не очікувати такої зміни.

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

Ніколи не чув про цю мову програмування.

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

Почекайте, в асинхронних архітектурах ви не можете гарантувати нічого, це абсолютно нормальний стан речей.

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

Ви мабуть Бекенд розробник. На Фронтенді в нас є великий shared mutable state — DOM і асинхронна архітектура. І колись були часи коли на Фронтенді ми працювали з DOM напряму і було саме так як ви кажете — ми не могли нічого гарантувати. Але потім зʼявився React і значно спростив нам життя. Звичайно іммутабельний стан не єдине, що приніс з собою React. Але це мабуть найголовніше, що відрізняє його від інших фреймворків. І на мою думку — це головна його перевага перед ними, якщо не брати до уваги розмір екосистеми.

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

А навіщо? У вас жива система, яка постійно змінюється. Між даними на UI та BE може бути розсинхрон.

Ви мабуть Бекенд розробник.

Я мабуть FE зі стажем, який бачив в своєму житті не тільки React, а ще й написав своїх фреймворків парочку.

І колись були часи коли на Фронтенді ми працювали з DOM напряму і було саме так як ви кажете — ми не могли нічого гарантувати.

Це не є правдою. Всі DOM методи — синхронні. А там де можливі були колізії через асинхнонність, наприклад в вебворкерах, доступ до DOM випиляли. Чистими асинхронними рішеннями наразі, з того що я знаю, є

  • fetch (XHR)
  • Webworkers
  • indexedDB
.
Але потім зʼявився React і значно спростив нам життя.

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

Це не є правдою. Всі DOM методи — синхронні. А там де можливі були колізії через асинхнонність, наприклад в вебворкерах, доступ до DOM випиляли.

Камон, невже ви не розумієте про що я кажу.
Дуже гарний приклад з блогу Дена Абрамова:

function trySubmit() {
  let spinner = createSpinner();
  formStatus.appendChild(spinner);
  submitForm().then(() => {
    formStatus.removeChild(spinner);
    let successMessage = createSuccessMessage();
    formStatus.appendChild(successMessage);
  }).catch(error => {
    formStatus.removeChild(spinner);
    let errorMessage = createErrorMessage(error);
    let retryButton = createRetryButton();
    formStatus.appendChild(errorMessage);
    formStatus.appendChild(retryButton)
    retryButton.addEventListener('click', function() {
      formStatus.removeChild(errorMessage);
      formStatus.removeChild(retryButton);
      trySubmit();
    });
  })
}
Тут перемішується асинхронність і робота з shared mutable state.
Ви намагаєтесь перекласти відповідальність з shared mutable state на розробника і сказати, що в них просто криві руки. Але насправді проблема в тому що DOM API мутабельне і дозволяє таке робити.

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

Тому всі адекватні фреймворки замість мутабельного API пропонують API, де ми декларативно описуємо як має виглядати DOM від state.

Але якщо state сам по собі залишається shared mutable state, з ним будуть траплятися такі самі проблеми як в цьому коді. Просто тому, що це можливо зробити.

Не варто сподіватися що всі розробники вміють так же добре програмувати як ви, краще дати їм інструменти з якими вони з меншою вірогідністю вистрілять собі в ногу.

Камон, невже ви не розумієте про що я кажу.

Не розумію.

Дуже гарний приклад з блогу Дена Абрамова:

Це типовий антипатерн. Як це тепер розбачити...

Тут перемішується асинхронність і робота з shared mutable state.

Почекайте, де тут в цьому прикладі shared mutable state? Ви впевнені, що правильний термін тут використовуєте?

Але насправді проблема в тому що DOM API мутабельне і дозволяє таке робити.

Не DOM API мутабельне, а DOM побудований як мутабельна структура даних. Це абсолютно логічно та нормально. Це єдина правильна реалізація. Інакше браузери були б повільними як не знаю що.

то такий код буде траплятися коли ви перейдете на інший проєкт.

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

Тому всі адекватні фреймворки замість мутабельного API пропонують API, де ми декларативно описуємо як має виглядати DOM від state.

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

Не варто сподіватися що всі розробники вміють так же добре програмувати як ви, краще дати їм інструменти з якими вони з меншою вірогідністю вистрілять собі в ногу.

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

Почекайте, де тут в цьому прикладі shared mutable state? Ви впевнені, що правильний термін тут використовуєте?

Так, я впевнений, термін правильний. DOM — типовий приклад shared mutable state.

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

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

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

Що ж, можливо немає state — немає проблем. Як фреймворк називається?

Так, я впевнений, термін правильний.

Ок, як скажете.

Так, але в React, навіть з найпоганішим кодом можна працювати і дебажити нормально

Це працює для будь-якого коду.

Як фреймворк називається?

ez.framework
Його немає зараз ніде в публічному доступі. Скоро вийдуть пару проектів на ньому.

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