Więc w którym momencie klasa staje się zbyt złożona, aby była niezmienna?
Moim zdaniem nie warto zadawać sobie trudu, aby małe klasy były niezmienne w językach takich jak ten, który pokazujesz. Używam tutaj małych i nie skomplikowanych , ponieważ nawet jeśli dodasz dziesięć pól do tej klasy i to naprawdę wymyślne operacje na nich, wątpię, że zajmie to kilobajty, a co dopiero megabajty, a co dopiero gigabajty, więc każda funkcja wykorzystująca instancje twojego klasa może po prostu wykonać tanią kopię całego obiektu, aby uniknąć modyfikacji oryginału, jeśli chce uniknąć wywoływania zewnętrznych efektów ubocznych.
Trwałe struktury danych
Uważam, że osobistym zastosowaniem niezmienności jest duże, centralne struktury danych, które agregują garść drobnych danych, takich jak instancje klasy, którą pokazujesz, jak ta, która przechowuje milion NamedThings
. Przynależność do trwałej struktury danych, która jest niezmienna i znajduje się za interfejsem, który pozwala tylko na dostęp tylko do odczytu, elementy należące do kontenera stają się niezmienne bez NamedThing
konieczności radzenia sobie z klasą elementów ( ).
Tanie kopie
Trwała struktura danych umożliwia przekształcenie jej regionów i uczynienie ich unikalnymi, unikając modyfikacji oryginału bez konieczności kopiowania struktury danych w całości. To jest prawdziwe piękno tego. Jeśli chcesz naiwnie pisać funkcje, które unikają skutków ubocznych, które wprowadzają strukturę danych, która zajmuje gigabajty pamięci i tylko modyfikuje pamięć o wartości megabajta, musisz skopiować całą dziwaczną rzecz, aby uniknąć dotykania danych wejściowych i zwrócić nową wynik. Albo kopiuj gigabajty, aby uniknąć efektów ubocznych, albo powoduj skutki uboczne w tym scenariuszu, co oznacza, że musisz wybierać między dwoma nieprzyjemnymi wyborami.
Dzięki trwałej strukturze danych pozwala napisać taką funkcję i uniknąć kopiowania całej struktury danych, wymagając jedynie około megabajta dodatkowej pamięci na dane wyjściowe, jeśli tylko funkcja przekształciła pamięć o wielkości megabajta.
Ciężar
Jeśli chodzi o ciężar, przynajmniej w moim przypadku jest to natychmiastowe. Potrzebuję tych konstruktorów, o których mówią ludzie lub „przejściowych”, jak je nazywam, aby móc skutecznie wyrażać przekształcenia w tę ogromną strukturę danych bez dotykania jej. Kod taki jak ten:
void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
// Transform stuff in the range, [first, last).
for (; first != last; ++first)
transform(stuff[first]);
}
... wtedy należy napisać w ten sposób:
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);
}
Ale w zamian za te dwa dodatkowe wiersze kodu funkcja jest teraz bezpiecznie wywoływać w wątkach z tą samą oryginalną listą, nie powoduje żadnych skutków ubocznych itp. Ułatwia to również, aby ta operacja stała się niemożliwą do wykonania operacją użytkownika, ponieważ Cofnij może po prostu przechowywać tanią płytką kopię starej listy.
Wyjątek - bezpieczeństwo lub odzyskiwanie po błędzie
Nie każdy może czerpać tyle samo korzyści, co ja, z trwałych struktur danych w takich kontekstach (znalazłem dla nich tak wiele zastosowania w systemach cofania i nieniszczącej edycji, które są głównymi koncepcjami w mojej domenie VFX), ale jedna rzecz dotyczy tylko wszyscy powinni wziąć pod uwagę bezpieczeństwo wyjątków lub odzyskiwanie po błędzie .
Jeśli chcesz, aby oryginalna funkcja mutacji była bezpieczna dla wyjątków, potrzebuje logiki cofania, dla której najprostsza implementacja wymaga skopiowania całej listy:
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;
}
}
W tym momencie bezpieczna dla wyjątków wersja mutowalna jest jeszcze bardziej obliczeniowa i prawdopodobnie trudniejsza do napisania niż wersja niezmienna przy użyciu „konstruktora”. I wielu programistów C ++ po prostu zaniedbuje bezpieczeństwo wyjątków i być może jest to w porządku dla ich domeny, ale w moim przypadku chcę upewnić się, że mój kod działa poprawnie nawet w przypadku wyjątku (nawet pisanie testów, które celowo zgłaszają wyjątki w celu przetestowania wyjątku bezpieczeństwo), a to sprawia, że muszę być w stanie cofnąć wszelkie skutki uboczne, które funkcja powoduje w połowie funkcji, jeśli coś rzuci.
Jeśli chcesz być bezpieczny w wyjątkach i wdzięcznie odzyskać po błędach bez awarii i nagrywania aplikacji, musisz cofnąć / cofnąć wszelkie skutki uboczne, które funkcja może wywołać w przypadku błędu / wyjątku. I tam konstruktor może faktycznie zaoszczędzić więcej czasu programisty niż to kosztuje wraz z czasem obliczeniowym, ponieważ: ...
Nie musisz się martwić o wycofywanie efektów ubocznych w funkcji, która nie powoduje żadnych!
Wróćmy do podstawowego pytania:
W którym momencie niezmienne klasy stają się ciężarem?
Zawsze są one obciążeniem dla języków, które obracają się bardziej wokół zmienności niż niezmienności, dlatego uważam, że powinieneś ich używać, gdy korzyści znacznie przewyższają koszty. Ale na wystarczająco szerokim poziomie dla wystarczająco dużych struktur danych, wierzę, że istnieje wiele przypadków, w których jest to godziwy kompromis.
Również w moim mam tylko kilka niezmiennych typów danych, a wszystkie są ogromnymi strukturami danych przeznaczonymi do przechowywania ogromnej liczby elementów (piksele obrazu / tekstury, byty i komponenty ECS oraz wierzchołki / krawędzie / wielokąty siatka).