Іммутабельність проти мутацій
Можливо, ви чули, що мутації — це погано і небезпечно.
Але чому саме? І хіба той самий 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 стан іммутабельний — і саме це, на мою думку, робить його настільки надійним у великих проєктах.
Тож якщо можна — обирайте іммутабельність. А якщо не можна — не втрачайте контроль.
15 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів