Тож у який момент клас стає занадто складним, щоб бути незмінним?
На мою думку, не варто заважати робити невеликі класи незмінними мовами, подібними до тієї, яку ви показуєте. Я використовую тут невеликі, а не складні , тому що навіть якщо до цього класу додати десять полів, і це дійсно фантазійні операції над ними, я сумніваюся, що це займе кілобайт, не кажучи вже про мегабайти, не кажучи вже про гігабайти, тому будь-яка функція використовує екземпляри вашого клас може просто зробити дешеву копію всього об’єкта, щоб уникнути зміни оригіналу, якщо він хоче уникнути зовнішніх побічних ефектів.
Стійкі структури даних
Там, де я знаходжу для особистого використання незмінність, - це великі центральні структури даних, які об'єднують купу підліткових даних, таких як екземпляри класу, який ви показуєте, як такий, який зберігає мільйон NamedThings
. Приналежність до стійкої структури даних, яка є незмінною і знаходиться за інтерфейсом, що дозволяє лише доступ лише для читання, елементи, що належать до контейнера, стають незмінними без класу елементів ( NamedThing
) з цим не потрібно мати справу.
Дешеві копії
Стійка структура даних дозволяє трансформувати її області та робити їх унікальними, уникаючи змін оригіналу без необхідності копіювання структури даних у повному обсязі. Ось справжня краса цього. Якщо ви хочете наївно записати функції, які уникають побічних ефектів, що вводять структуру даних, яка займає гігабайти пам'яті і лише змінює мегабайт вартості пам’яті, тоді вам доведеться скопіювати всю чудернацьку річ, щоб не торкатися вводу та повернути новий вихід. Це або скопіювати гігабайти, щоб уникнути побічних ефектів або викликати побічні ефекти в цьому сценарії, тому вам доведеться вибирати між двома неприємними варіантами.
Завдяки стійкій структурі даних вона дозволяє записати таку функцію і уникнути копіювання всієї структури даних, вимагаючи лише близько мегабайта додаткової пам’яті для виводу, якщо ваша функція лише перетворила мегабайт вартості пам'яті.
Обтяження
Що стосується тягаря, то тут, принаймні, в моєму випадку є негайне. Мені потрібні ті будівельники, про яких люди говорять, або "перехідні", як я їх закликаю, щоб мати можливість ефективно виражати перетворення до цієї масивної структури даних, не торкаючись її. Код таким:
void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
// Transform stuff in the range, [first, last).
for (; first != last; ++first)
transform(stuff[first]);
}
... тоді має бути написано так:
ImmList<Stuff> transform_stuff(ImmList<Stuff> stuff, int first, int last)
{
// Grab a "transient" (builder) list we can modify:
TransientList<Stuff> transient(stuff);
// Transform stuff in the range, [first, last)
// for the transient list.
for (; first != last; ++first)
transform(transient[first]);
// Commit the modifications to get and return a new
// immutable list.
return stuff.commit(transient);
}
Але в обмін на ці дві додаткові рядки коду тепер функція безпечна для виклику через потоки з однаковим оригінальним списком, це не викликає побічних ефектів тощо. Це також робить це дуже просто зробити цю операцію непридатною дією користувача, оскільки скасувати може просто зберігати дешеву дрібну копію старого списку.
Виняток-безпека або відновлення помилок
Не всі можуть отримати користь так само, як я, від стійких структур даних у таких контекстах (я знайшов для них стільки використання в системах скасування та неруйнівному редагуванні, які є центральними поняттями в моєму домені VFX), але одне, що стосується майже всі, хто має враховувати, це виняток безпеки та відновлення помилок .
Якщо ви хочете зробити оригінальну функцію вимкнення мутації безпечною для виключення, тоді їй потрібна логіка відката, для якої найпростіша реалізація вимагає копіювання всього списку:
void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
// Make a copy of the whole massive gigabyte-sized list
// in case we encounter an exception and need to rollback
// changes.
MutList<Stuff> old_stuff = stuff;
try
{
// Transform stuff in the range, [first, last).
for (; first != last; ++first)
transform(stuff[first]);
}
catch (...)
{
// If the operation failed and ran into an exception,
// swap the original list with the one we modified
// to "undo" our changes.
stuff.swap(old_stuff);
throw;
}
}
На даний момент безпечна для винятків змінна версія - навіть обчислювально дорожча і, можливо, навіть важче написати правильно, ніж незмінна версія, використовуючи "builder". І багато розробників C ++ просто нехтують безпекою винятків, і, можливо, це добре для їхнього домену, але в моєму випадку я хотів би переконатися, що мій код працює правильно навіть у випадку винятку (навіть написання тестів, які навмисно викидають винятки, щоб перевірити виняток безпека), і це робить його таким, що я повинен мати можливість відкатати будь-які побічні ефекти, яка функція викликає на півдорозі функції, якщо щось кине.
Якщо ви хочете бути захищеними від винятків і вигадувати помилки вишукано, без вашої програми та збоїв, тоді вам доведеться повернути / скасувати будь-які побічні ефекти, які функція може спричинити у разі помилки / виключення. І там будівельник може фактично заощадити більше програмного часу, ніж це коштує разом з обчислювальним часом, тому що: ...
Вам не доведеться турбуватися про відміну побічних ефектів у функції, яка не викликає жодної!
Тож повернемось до основного питання:
У який момент непорушні заняття стають тягарем?
Вони завжди навантажують мови, які обертаються більше навколо незмінність, ніж незмінність, і саме тому, я думаю, ви повинні використовувати їх там, де користь значно перевищує витрати. Але на досить широкому рівні для достатньо великих структур даних я вважаю, що є багато випадків, коли це гідна компромісія.
Крім того, у мене є лише декілька типів непорушних даних, і всі вони є величезними структурами даних, призначеними для зберігання величезної кількості елементів (пікселів зображення / текстури, сутностей та компонентів ECS, а також вершин / країв / полігонів сітка).