Przy projektowaniu klas do przechowywania modelu danych przeczytałem, że przydatne może być tworzenie niezmiennych obiektów, ale w którym momencie ciężar listy parametrów konstruktora i głębokich kopii staje się zbyt duży i trzeba zrezygnować z niezmiennego ograniczenia?
Na przykład, tutaj jest niezmienna klasa reprezentująca nazwaną rzecz (używam składni C #, ale zasada dotyczy wszystkich języków OO)
class NamedThing
{
private string _name;
public NamedThing(string name)
{
_name = name;
}
public NamedThing(NamedThing other)
{
this._name = other._name;
}
public string Name
{
get { return _name; }
}
}
Nazwane rzeczy można konstruować, wyszukiwać i kopiować do nowych nazwanych rzeczy, ale nazwy nie można zmienić.
Wszystko dobrze, ale co się stanie, gdy chcę dodać kolejny atrybut? Muszę dodać parametr do konstruktora i zaktualizować konstruktor kopiowania; co nie jest zbyt pracochłonne, ale problemy zaczynają się, o ile widzę, gdy chcę uczynić złożony obiekt niezmiennym.
Jeśli klasa zawiera atrybuty i kolekcje zawierające inne złożone klasy, wydaje mi się, że lista parametrów konstruktora stałaby się koszmarem.
Więc w którym momencie klasa staje się zbyt złożona, aby była niezmienna?
Odpowiedzi:
Kiedy stają się ciężarem? Bardzo szybko (szczególnie jeśli wybrany język nie zapewnia wystarczającego wsparcia składniowego niezmienności).
Niezmienność jest sprzedawana jako srebrna kula dla wielordzeniowego dylematu i tak dalej. Ale niezmienność w większości języków OO zmusza cię do dodawania sztucznych artefaktów i praktyk do twojego modelu i procesu. Dla każdej złożonej niezmiennej klasy musisz mieć równie złożony (przynajmniej wewnętrznie) konstruktor. Bez względu na to, jak go zaprojektujesz, nadal wprowadza silne sprzężenie (dlatego lepiej mamy powód, aby je wprowadzić).
Nie zawsze jest możliwe modelowanie wszystkiego w małych, niezłożonych klasach. W przypadku dużych klas i struktur sztucznie dzielimy je na partycje - nie dlatego, że ma to sens w naszym modelu domen, ale dlatego, że musimy poradzić sobie z ich złożoną instancją i budowniczymi kodu.
Gorzej, gdy ludzie zbyt daleko idą na pojęcie niezmienności w języku ogólnego przeznaczenia, takim jak Java lub C #, czyniąc wszystko niezmiennym. W rezultacie widzisz ludzi wymuszających konstrukcje s-expression w językach, które nie obsługują takich rzeczy z łatwością.
Inżynieria to modelowanie poprzez kompromisy i kompromisy. Sprawianie, by wszystko było niezmienne przez edict, ponieważ ktoś przeczytał, że wszystko jest niezmienne w języku funkcjonalnym X lub Y (zupełnie inny model programowania), co jest niedopuszczalne. To nie jest dobra inżynieria.
Małe, ewentualnie jednolite rzeczy można uczynić niezmiennymi. Bardziej złożone rzeczy można uczynić niezmiennymi, gdy ma to sens . Ale niezmienność nie jest srebrną kulą. Zdolność do zmniejszania błędów, zwiększania skalowalności i wydajności, nie są jedyną funkcją niezmienności. Jest to funkcja właściwych praktyk inżynierskich . W końcu ludzie napisali dobre, skalowalne oprogramowanie bez niezmienności.
Niezmienność staje się naprawdę ciężkim obciążeniem (zwiększa przypadkową złożoność), jeśli jest wykonywana bez powodu, gdy jest wykonywana poza tym, co ma sens w kontekście modelu domeny.
Ja, na przykład, staram się tego unikać (chyba że pracuję w języku programowania z dobrą obsługą syntaktyczną).
źródło
Przeszedłem fazę nalegania, aby klasy były niezmienne, tam gdzie to możliwe. Czy konstruktorzy dla prawie wszystkiego, niezmiennych tablic itp. Itp. Znalazłem odpowiedź na twoje pytanie, jest prosta: w którym momencie niezmienne klasy stają się ciężarem? Bardzo szybko. Gdy tylko chcesz coś serializować, musisz być w stanie dokonać deserializacji, co oznacza, że musi to być zmienne; gdy tylko chcesz użyć ORM, większość z nich nalega na możliwość zmiany właściwości. I tak dalej.
Ostatecznie zastąpiłem tę politykę niezmiennymi interfejsami do obiektów zmiennych.
Teraz obiekt ma elastyczność, ale nadal można powiedzieć kodowi wywołującemu, że nie powinien edytować tych właściwości.
źródło
IComparable<T>
gwarantuje, że jeśliX.CompareTo(Y)>0
iY.CompareTo(Z)>0
wtedyX.CompareTo(Z)>0
. Interfejsy mają umowy . Jeśli umowaIImmutableList<T>
określa, że wartości wszystkich przedmiotów i właściwości muszą zostać „ustalone w kamieniu”, zanim jakakolwiek instancja zostanie wystawiona na świat zewnętrzny, to zrobią to wszystkie uzasadnione wdrożenia. Nic nie stoi na przeszkodzie, abyIComparable
implementacja naruszała przechodniość, ale implementacje, które to robią, są nielegalne. W przypadkuSortedDictionary
nieprawidłowego działania, gdy zostanie wydany nielegalnieIComparable
, ...IReadOnlyList<T>
będą niezmienne, biorąc pod uwagę, że (1) nie ma takiego wymogu jest zawarta w dokumentacji interfejsu, oraz (2) najczęściej realizacja,List<T>
, nie jest nawet tylko do odczytu ? Nie jestem do końca jasne, co jest niejednoznaczne w moich warunkach: kolekcja jest czytelna, jeśli dane w niej można odczytać. Jest tylko do odczytu, jeśli może obiecać, że zawarte w nim dane nie mogą zostać zmienione, chyba że kod zawiera jakieś zewnętrzne odniesienie, które by to zmieniło. Jest niezmienny, jeśli może zagwarantować, że nie można go zmienić, kropka.Nie sądzę, że istnieje ogólna odpowiedź na to pytanie. Im bardziej złożona jest klasa, tym trudniej jest jej uzasadnić zmiany stanu i tym droższe jest tworzenie jej nowych kopii. Zatem powyżej pewnego (osobistego) poziomu złożoności stanie się zbyt bolesne, aby uczynić klasę niezmienną.
Zauważ, że zbyt złożona klasa lub długa lista parametrów metody to same zapachy projektowe , niezależnie od niezmienności.
Tak więc zwykle preferowanym rozwiązaniem byłoby rozbicie takiej klasy na wiele odrębnych klas, z których każda może zostać samodzielnie zmieniona lub niezmienna. Jeśli nie jest to możliwe, można je zmienić.
źródło
Możesz uniknąć problemu z kopiowaniem, jeśli przechowujesz wszystkie swoje niezmienne pola w środku
struct
. Jest to w zasadzie odmiana wzoru pamiątkowego. Jeśli chcesz wykonać kopię, po prostu skopiuj pamiątkę:źródło
W pracy jest kilka rzeczy. Niezmienne zestawy danych doskonale nadają się do skalowania wielowątkowego. Zasadniczo możesz całkiem zoptymalizować swoją pamięć, aby jeden zestaw parametrów był jednym wystąpieniem klasy - wszędzie. Ponieważ obiekty nigdy się nie zmieniają, nie musisz się martwić synchronizacją dostępu do swoich członków. To dobra rzecz. Jednak, jak zauważyłeś, im bardziej złożony obiekt, tym bardziej potrzebujesz zmienności. Zacznę od rozumowania według następujących zasad:
W językach, które obsługują tylko obiekty niezmienne (takie jak Erlang), jeśli istnieje jakakolwiek operacja, która wydaje się modyfikować stan obiektu niezmiennego, wynikiem końcowym jest nowa kopia obiektu o zaktualizowanej wartości. Na przykład po dodaniu elementu do wektora / listy:
To może być rozsądny sposób pracy z bardziej skomplikowanymi obiektami. Na przykład podczas dodawania węzła drzewa powstaje nowe drzewo z dodanym węzłem. Metoda w powyższym przykładzie zwraca nową listę. W przykładzie w tym akapicie
tree.add(newNode)
zwróciłoby nowe drzewo z dodanym węzłem. Dla użytkowników praca z nim staje się łatwa. Dla autorów bibliotek staje się nudne, gdy język nie obsługuje niejawnego kopiowania. Ten próg zależy od twojej własnej cierpliwości. Dla użytkowników twojej biblioteki najbardziej rozsądnym limitem, jaki znalazłem, jest około trzech do czterech parametrów.źródło
Jeśli masz wielu członków klasy końcowej i nie chcesz, aby byli narażeni na wszystkie obiekty, które muszą je utworzyć, możesz użyć wzorca konstruktora:
zaletą jest to, że można łatwo utworzyć nowy obiekt z inną wartością o innej nazwie.
źródło
newObject
.NamedThing
w tym przypadku)Builder
zostanie ponownie użyte, istnieje realne ryzyko, że coś się wydarzy, o czym wspomniałem. Ktoś może budować wiele obiektów i zdecydować, że skoro większość właściwości jest taka sama, po prostu ponownie użyjBuilder
, a tak naprawdę zróbmy z tego globalny singleton wstrzykiwany w zależności! Ups Wprowadzono poważne błędy. Myślę więc, że ten wzór mieszanej instancji vs. statyczności jest zły.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 bezNamedThing
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:
... wtedy należy napisać w ten sposób:
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:
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ż: ...
Wróćmy do podstawowego pytania:
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).
źródło