Kiedy używać System.Collections.Generic, a kiedy System.Collections.Immutable?

1

(Odnoszę wrażenie, że to może być wbrew pozorom zależne od języka programowania: coś mi się zdaje na podstawie bardzo ograniczonego doświadczenia własnego, że w C# panuje raczej irytacja FP evangelism, podczas gdy w Javie ewangeliści FP rządzą, dlatego pytam właśnie tu, a nie w dziale inżynieria programowania)

Kiedy używać System.Collections.Immutable zamiast System.Collections.Generic?

Odnoszę wrażenie, że odpowiedź będzie brzmiała "zawsze" albo "nigdy" w zależności od podejścia programisty do FP?

  • Jeśli jestem "fanbojem FP" i chcę mieć te wszystkie matematycznie dowiedzione własności wynikające z niemutowalności, to używając System.Collections.Generic strzelam sobie w stopę. Właśnie straciłem te własności i otworzyłem kod na całą klasę bugów, przed którymi niemutowalność miała mnie chronić. Jeśli koniecznie potrzebuję wydajności, to (narzekając i jojcząc) użyję niskopoziomowych struktur, takich jak Span czy chociażby zwykła tablica, a znów nie nic z System.Collections.Generic?
  • Jeśli nie jestem "fanbojem FP" i nie uważam, by te własności były warte utrudniania sobie życia przez wymaganie wszędzie niemutowalności, to znowu nie ma sensu nigdy użyć System.Collections.Immutable. Jeśli nie chcę modyfikować kolekcji, to po prostu jej nie modyfikuję. Z tej perspektywy System.Collections.Immutable w porównaniu do System.Collections.Generic wnoszą wyłącznie gorszą wydajność i nakładanie sobie samemu kajdanek na ręce, jeśli później jednak będę chciał kolekcję modyfikować. Jeśli chcę zabronić consumerom mojego kodu modyfikacji kolekcji, to wystawiam ją jako ReadOnly collection z System.Collections.ObjectModel.

Czy jest coś, co przeoczyłem? Jakaś sytuacja, w której ktoś mógłby chcieć używać czasem "zwykłych" kolekcji, a czasem tych immutable?

1

pytania pomocnicze:

  • często robisz kopie defensywne żeby nikt nie zmienił twojej kolekcji?
  • uzywasz Select/ManySelect? bo wtedy i tak tworzysz nową kolekcję i mutowalna nie przynosi zysku
3

Kiedy używać System.Collections.Immutable zamiast System.Collections.Generic?

Odnoszę wrażenie, że odpowiedź będzie brzmiała "zawsze" albo "nigdy" w zależności od podejścia programisty do FP?

programista - fanboj FP:

System.Collections.Immutable - kiedy się da
System.Collections.Generic - kiedy się nie da

programista - nie fanboj FP

System.Collections.Generic - zawsze
System.Collections.Immutable - nigdy

4

Niemutowalność przydaje się przede wszystkim przy programowaniu współbieżnym, gdzie może się okazać że rozwiązanie właśnie na niemutowalnych obiekatach/kolecjach jest szybsze i wygodniejsze niż synchronizacja dostępu do sekcji krytycznej. Cały kompilator c# Roslyn, jest oparty na niemutowalnych strukturach danych, czy chociażby w dużej części tabele zoptymalizowane do trzymania w pamięci (Hekaton) w SQL Serverze. Natomiast w codziennym programowaniu jakie uprawia większość z nas, to za wielkiego pożytku z niemutowalnych struktur nie ma. Skoro nie jeteśmy pewni że ich na pewno potrzebujemy System.Collections.Generic zawsze będzie lepszym wyborem.

0

@neves:

Cały kompilator c# Roslyn, jest oparty na niemutowalnych strukturach danych

Ah tak, drzewa czerwono-zielone użyte następnie w Swifcie i Róście

Persistence, façades and Roslyn’s red-green trees

2

Jeśli nie chcę modyfikować kolekcji, to po prostu jej nie modyfikuję.

Równie dobrze mozesz w takim razie zrobić wszystkie metody publiczne w danej klasie. Jak nie chcesz ich wykorzystywac to ich nie wykorzystujesz. A nawet jak nie jesteś fanem FP to niemutowalne kolekcje mogą się wiele razy przydać, np. jakies mapy które zawierają mapowania, stałe listy z jakiiś wartościami itp, itd.

Z tej perspektywy System.Collections.Immutable w porównaniu do System.Collections.Generic wnoszą wyłącznie gorszą wydajność i

Brak potrzeby tworenie defensywnych kopii zwiększa wydajność.

2
var rawList = GetFromSomewhere();
var modifiedList = rawList;
modifiedList.Add(new Item());
modifiedList.RemoveAt(997);
var diff = CalculateDiff(rawData, modifiedData);

Bez immutable na wyniku można się przejechać. Ale też fakt, programiści nie tworzą przecież takich skomplikowanych algorytmów. Na co to komu potrzebne.:P

0
somekind napisał(a):

Bez immutable na wyniku można się przejechać. Ale też fakt, programiści nie tworzą przecież takich skomplikowanych algorytmów. Na co to komu potrzebne.:P

Z immutable też można się przejechać, tylko w inny sposób i wg mnie bardziej - bo w sposób zupełnie nieoczekiwany.

IList<int> lst = new List<int>().AsReadOnly();
lst.Add(42);
0
scibi_92 napisał(a):

Równie dobrze mozesz w takim razie zrobić wszystkie metody publiczne w danej klasie. Jak nie chcesz ich wykorzystywac to ich nie wykorzystujesz.

Tak bym to widział. To jest różnica między statycznym typowaniem a dynamicznym typowaniem: statyczne typowanie wprowadza niewygodę, ale za to gwarantuje, że będą zachodzić założenia, na mocy których można wnioskować o kodzie. (Tj. gwarantuje dopóki, dopóty się tych gwarancji nie wyrzuci przez okno używając refleksji, schodząc do poziomu bytecode, itp) Jak wiadomo, w matematyce im silniejsze założenia, tym więcej można wyciągnąć wniosków. Dlatego niewygody związane z dobrowolnym samoograniczeniem się do tego, co pozwala system typów mają swój zysk.

Przy czym to jest skala, a nie sytuacja binarna. Na jednym końcu skali będzie pewnie jakiś PHP z wyłączonymi wyjątkami w razie błędów (a może paradoksalnie C/C++ ze swoim undefined behavior?), a na drugim końcu skali będzie pewnie jakaś Agda. C# uplasuje się gdzieś po środku. Można go kopnąć mocno do poziomu zbliżonego do języków dynamicznych poprzez spamowanie refleksją, dynamic, castami z/do object i innymi takimi (ale to sami C#powcy uważają za zuo), albo do poziomu odrobinę bliższego Haskellowi poprzez rygorystyczne wymaganie niemutowalności (ale znowu, z mojego ograniczonego doświadczenia takie podejście irytuje C#powców).

KamilAdam napisał(a):
  • uzywasz Select/ManySelect? bo wtedy i tak tworzysz nową kolekcję i mutowalna nie przynosi zysku

Dlatego nie do końca rozumiem tego pytania. Na moje rozumienie niemutowalne kolekcje służą nie tyle temu, bym nie musiał modyfikować kolekcji (tak jak piszesz, tę potrzebę spełnia LINQ, dodałbym do tego jeszcze np. IEnumerable.Append, dzięki czemu nie muszę tworzyć kopii defensywnej, by dodać element bez mutowania kolekcji, więc ImmutableList.Add staje się "zbędny"). Niemutowalne kolekcje służą raczej temu, by nie było mi wolno modyfikować kolekcji.

Używanie System.Collections.Generic.List razem z Select jest podobne IMO do sytuacji opisanej wyżej: oznaczania wszystkich metod jako publicznych, a potem niewołania ich spoza danej klasy.

somekind napisał(a):
var rawList = GetFromSomewhere();
var modifiedList = rawList;
modifiedList.Add(new Item());
modifiedList.RemoveAt(997);
var diff = CalculateDiff(rawData, modifiedData);

Bez immutable na wyniku można się przejechać. Ale też fakt, programiści nie tworzą przecież takich skomplikowanych algorytmów. Na co to komu potrzebne.:P

No ale tu można dać kontrprzykład:

var invoices = new ImmutableList<Invoice>();
invoices.Add(invoiceToPay);
// później
foreach(var invoice in invoices)
{
    invoice.Pay(); // nic nie zapłacone!
}

Akurat ten przykład, jaki podałeś, to IMO nie jest problem ani z mutowalnością, ani z niemutowalnością, tylko z nieznajomością tego, do czego defaultuje dany język.

Nie pomaga to, że analogiczne konstrukcje w różnych językach działają zupełnie inaczej. var modifiedList = rawList; kopiuje referencję do mutowalnego obiektu w C# i każdy, kto programuje w C# musi o tym wiedzieć, dlatego taka linijka śmierdzi na kilometr (może już lepiej byłoby napisać typ explicite? ImmutableList<Cośtam> modifiedList = rawList;, na zasadzie "tak wiem co robię, nie popełniam tego szkolnego błędu, który czytelniku mojego kodu pewnie sądzisz, że popełniam"?). No ale analogiczna konstrukcja w C++, czyli auto modifiedList = rawList; (mam nadzieję, że nie pokręciłem składni, już pozapominałem sporo z C++) skopiuje listę w czasie O(n). Co nie wprowadzi błędu związanego z mutowalnością, ale dalej śmierdzi, tym razem z powodu niewydajności. No i wreszcie Haskellowy let modifiedList = rawList in cośtam już zachowa się zgodnie z oczekiwaniami:

let modifiedList = rawList
 in let modifiedList = newItem : modifiedList
     in let modifiedList = removeAt modifiedList 997 -- chyba trzeba napisać removeAt, chyba nie ma bibliotece standardowej?
         in let diff = calculateDiff rawList modifiedList -- prawidłowo

(chociaż chyba jest to dość nieidiomatyczny kod, a i moje wcięcia są też nieidiomatyczne)

3
Saalin napisał(a):
somekind napisał(a):

Bez immutable na wyniku można się przejechać. Ale też fakt, programiści nie tworzą przecież takich skomplikowanych algorytmów. Na co to komu potrzebne.:P

Z immutable też można się przejechać, tylko w inny sposób i wg mnie bardziej - bo w sposób zupełnie nieoczekiwany.

IList<int> lst = new List<int>().AsReadOnly();
lst.Add(42);

Ale to nie jest Immutable, ale ReadOnly :(

3
YetAnohterone napisał(a):

Akurat ten przykład, jaki podałeś, to IMO nie jest problem ani z mutowalnością, ani z niemutowalnością, tylko z nieznajomością tego, do czego defaultuje dany język.

Nie pomaga to, że analogiczne konstrukcje w różnych językach działają zupełnie inaczej. var modifiedList = rawList; kopiuje referencję do mutowalnego obiektu w C# i każdy, kto programuje w C# musi o tym wiedzieć, dlatego taka linijka śmierdzi na kilometr

Wiesz, to był uproszczony przykład tylko, często taki kod może wyglądać tak:

var rawList = GetFromSomewhere();
var modifiedList = ModifyList(rawList);
var diff = CalculateDiff(rawData, modifiedData);

I z Twojego punktu widzenia, wszystko będzie OK. A nie będzie.

Po prostu, mutowalność kolekcji jest problemem.

0
KamilAdam napisał(a):
Saalin napisał(a):
somekind napisał(a):

Bez immutable na wyniku można się przejechać. Ale też fakt, programiści nie tworzą przecież takich skomplikowanych algorytmów. Na co to komu potrzebne.:P

Z immutable też można się przejechać, tylko w inny sposób i wg mnie bardziej - bo w sposób zupełnie nieoczekiwany.

IList<int> lst = new List<int>().AsReadOnly();
lst.Add(42);

Ale to nie jest Immutable, ale ReadOnly :(

@somekind: @KamilAdam narzekacie, ale macie:

IList<int> lst = ImmutableList<int>.Empty;
lst.Add(42);
1
Saalin napisał(a):

@somekind: @KamilAdam narzekacie, ale macie:

IList<int> lst = ImmutableList<int>.Empty;
lst.Add(42);

No i działa to dokładnie tak jak się spodziewałem (jesli umiem czytać dokumentację M$) i tak jak działa to we wszystkich znanych mi językach mających niemutowalne kolekcje czyli add zwraca nową listę

A new immutable list with the object added.

Zero zaskoczenia dla mnie, po prostu mogę siąść i programować w C# :P

Dla porównania:
Scala List

Adds an element at the beginning of this list.
elem - the element to prepend.
returns - a list which contains x as first element and which continues with this list. Example:

0
KamilAdam napisał(a):

Zero zaskoczenia dla mnie, po prostu mogę siąść i programować w C# :P

Nie możesz. https://dotnetfiddle.net/8Mh13l

Unhandled exception. System.NotSupportedException: Specified method is not supported.
   at System.Collections.Immutable.ImmutableList`1.System.Collections.Generic.ICollection<T>.Add(T item)
   at Program.Main()
Command terminated by signal 6
0
Saalin napisał(a):
KamilAdam napisał(a):

Zero zaskoczenia dla mnie, po prostu mogę siąść i programować w C# :P

Nie możesz. https://dotnetfiddle.net/8Mh13l

No to dokumentacja mnie kłamie albo nie umiem jej czytać :(

https://docs.microsoft.com/en-us/dotnet/api/system.collections.immutable.immutablelist-1.add?view=net-6.0

3

i ten problem wynika z tego ze Api do kolekcji jest tak samo zrypane w Javie i C#. Powinny byc bazowe interfejsy readonly, i dodatkowe na mutowalne, tak jak w Kotlinie czy chyba tez Scali.

0

Nawet zwracany typ się różni.

Czekaj, czekaj, czekaj. Bo ja już tego nie ogarniam. Czyli wychodzi na to że ImmutableList ma dwie metody Add:

  • public System.Collections.Immutable.ImmutableList<T> Add (T value); źródło
  • public void Add (T item); źródło

Czyli dwie metody Add które nazywają się tak samo a różnią się tylko typem zwracanym? Z czego jedna zawsze rzuca wyjątek bo źle zaprojektowali interfejs?

przecież ten język to jest kpina. Cofam wszystko co kiedykolwiek dobrego powiedziałem lub pomyślałem o C#

Update teraz spojrzałem na kod

IList<int> lst = ImmutableList<int>.Empty;
lst.Add(42);

i doczytałem

This member is an explicit interface member implementation. It can be used only when the ImmutableList<T> instance is cast to an ICollection<T> interface.

@Saalin Czyli jawnie rzutujesz ImmutableList na bezsensowny dla niej interfejs mutowalnej listy i dziwisz że się wywala. I oczywiście świadczy to na niekorzyść mutowalnej list. Ładny fikołek logiczny. Nie mam więcej pytań

0
KamilAdam napisał(a):

@Saalin Czyli jawnie rzutujesz ImmutableList na bezsensowny dla niej interfejs mutowalnej listy i dziwisz że się wywala. I oczywiście świadczy to na niekorzyść mutowalnej list. Ładny fikołek logiczny. Nie mam więcej pytań

Jaki fikołek? To pokazuje, że można użyć ImmutableList tam, gdzie na wejściu jest IList<T>, a to ma z kolei metodę Add. Skąd mam wiedzieć co jest pod spodem? I jaki bezsensowny dla niej interfejs, skoro to jest interfejs, który ImmutableList<T> implementuje? Przecież nie ja zaimplementowałem ten interfejs tam.

1
Saalin napisał(a):

I jaki bezsensowny dla niej interfejs, skoro to jest interfejs, który ImmutableList<T> implementuje? Przecież nie ja zaimplementowałem ten interfejs tam.

To że nie ty implementowałeś, nie znaczy jeszcze że jest sensowny :P

3
KamilAdam napisał(a):

@Saalin Czyli jawnie rzutujesz ImmutableList na bezsensowny dla niej interfejs mutowalnej listy i dziwisz że się wywala. I oczywiście świadczy to na niekorzyść mutowalnej list. Ładny fikołek logiczny. Nie mam więcej pytań

Skoro ten interfejs jest dla niej bezsensowny, to niemutowalna lista nie powinna go implementować. IMO to jest zrypane C# i to solidnie

1
Saalin napisał(a):

Jaki fikołek? To pokazuje, że można użyć ImmutableList tam, gdzie na wejściu jest IList<T>

No dobrze, ale przecież jak używamy immutable, to nie po to, aby je miksować ze standardowymi.

Saalin napisał(a):

@somekind: @KamilAdam narzekacie, ale macie:

IList<int> lst = ImmutableList<int>.Empty;
lst.Add(42);

Jak sam wykazałeś, to zwraca błąd kompilacji, a nie daje dziwne wyniki po uruchomieniu.
Błędy kompilacji to nigdy nie jest problem, dziwne działanie nim jest.

4

Jaki błąd kompilacji?

Wyjątek leci podczas działania programu, nie błąd kompilacji.

2

no jest to zrąbane akurat
to nie dotyczy tylko Immutable, zadada Liskov jest tak samo zgwałcona w ReadOnly:

IList<int> list = new List<int>().AsReadOnly();
list.Add(42);

rzuca

System.NotSupportedException: „Collection is read-only.”

Wynika to z tego że najpierw były zwykłe kolekcje a dopiero w .NET 4.5 dołożyli kolekcje readonly, ale kolekcje readonly musiały implementować zwykłe żeby dało się łatwo na nie przejść bez zmieniania typów argumentów dosłownie wszędzie. Bez tego można by było z nich korzystać dopiero w nowo napisanym kodzie i nie można by było użyć bibliotek z wcześniejszych wersji frameworka nawet gdy wiemy że biblioteka nie narusza kolekcji tylko z niej czyta. Tak więc kompatybilność wsteczną postawiono wyżej niż robienie k... z logiki

Moim zdaniem dla spójności wyjątek rzucany dla Immutable powinien być Collection is immutable a nie Specified method is not supported..
To nie duży problem jeśli testujemy kod i testy chociaż raz wykonują każdą gałąź kodu (niekoniecznie nawet sprawdzając ich poprawność).

Lepszy wyjątek niż możliwość zmiany kolekcji, bo i tak się może stać gdy zamiast owrapować kolekcję przez AsReadOnly() użyjemy rzutowania:

var list = new List<int>() as IReadOnlyList<int>; // czasem można spotkać zwykłe rzutowanie na IReadOnly...
IList<int> mutable = (IList<int>)list; // a takie rzutowanie można cofnąć w dowolnym momencie
mutable.Add(42); // ok
1
YetAnohterone napisał(a):

Jaki błąd kompilacji?

Wyjątek leci podczas działania programu, nie błąd kompilacji.

Tak, masz racje, to runtime error.
Runtime error w bezsensownym kodzie, bo przecież używając świadomie ImmutableList takiego kodu się nie napisze, bo i po co? Równie dobrze, można by tam było null przypisać i narzekać.
Ja pokazałem kod, który się kompiluje, uruchamia i daje wyniki niezgodne z intuicją. Nadal będę się upierał, że to coś znacznie gorszego.

A, że te immutable kolekcje niepotrzebnie są obciążone wsteczną kompatybilnością, to już wina Microsoftu. Natomiast co do zasady, bezpieczniej i bardziej intuicyjnie byłoby używać kolekcji niezmiennych. I nie jestem wcale fanem FP, po prostu taki kod jest wtedy bardziej przewidywalny.

0

Mniejsza o to - po prostu nie zrozumiałem co chcesz przekazać. Możesz napisać posta z przykładami pseudokodu? — @somekind dziś, 16:44

Zastrzegam, że może się okazać, że bzdury piszę.

Niejasno sobie przypominam, że w Pythonie obowiązuje konwencja, że tylko metody / funkcje czyste z punktu widzenia niemutowalności mają prawo zwracać obiekt, który zmieniają.

Zgodne z konwencją, imperatywne:

l = [1, 2, 3, 4]
modify_list(l)  # zwraca none czyli pythonowy odpowiednik voida, nie można zrobić modified_l = modify_list(l)
print(l) # drukuje [5, 6, 7, 8]

Zgodne z konwencją, funkcyjne:

l = [1, 2, 3, 4]
modified_l = modify_list(l)
print(l) # drukuje [1, 2, 3, 4]
print(modified_l) #drukuje [5, 6, 7, 8]

Niezgodne z konwencją, mylące, choć technicznie poprawne:

l = [1, 2, 3, 4]
modified_l = modify_list(l) # źle (niezgodnie z konwencją) napisana funkcja modify_list
print(l) # drukuje [5, 6, 7, 8]
print(modified_l) # drukuje [5, 6, 7, 8]

Ma to swój sens moim zdaniem: trudno popełnić błąd w rodzaju przypadkowej mutacji jakiejś referencji bo się uważa, że metoda, która tak naprawdę mutuje, jest czysta i na odwrót trudno przypadkowo nie dokonać jakiejś zmiany, bo potraktowało się czystą metodę jakby mutowała.

Widać, że przy tym podejściu tylko czyste funkcje / metody mogą być dotchainowane. Też ma to sens moim zdaniem: przyjęty w programowaniu imperatywnym separator to średnik (w Pythonie nowa linia), nie wiem czemu upierać się przy zastępowaniu średnika kropką?

Ale - jak mi się zdaje - C#powcy (i Javowcy także) bardzo lubią dotchainować. Niejasno mi się przypominało, że Fowler bodajże dał temu szumną nazwę "flow api", ale może bzdurę palnąłem.

Dlatego za brzydkie jest uważane pisanie:

var foo = new Foo();
foo.Bar = 5;
foo.Baz = 6;

Zamiast tego za estetyczniejsze jest uważane:

var foo = new Foo()
    .WithBar(5)
    .WithBaz(6);

Przy czym WithBar oraz WithBaz oczywiście działają imperatywnie:

public class Foo
{
    public int Bar {get; private set;}
    
    public Foo WithBar(int bar)
    {
        this.Bar = bar;
        return this;
    }
}

Jaki z tego zysk, nie wiem. Widać natomiast, że różni się to od konwencji pythonowej, co utrudnia odróżnienie na pierwszy rzut oka metody czystej od nieczystej. Czy WithBar mutuje this, czy nie mutuje? Nie widać od razu.

Wracając do twojego przykładu:

var rawList = GetFromSomewhere();
var modifiedList = ModifyList(rawList);
var diff = CalculateDiff(rawData, modifiedData);

Czemu ModifyList zwraca listę, skoro jednocześnie mutuje swój argument? Gdyby konwencja pythonowa przyjęła się w C#, to trudno byłoby popełnić ten błąd, o którym piszesz: wiadomo, że modifyList zwraca listę, więc nie mutuje argumentu. Ale C#owcy wolą móc zastępować średniki kropkami, czy też, jak w tym wypadku, choćby nawiasami. Ważniejsze jest, by móc napisać:

var list = ModifyList(GetFromSomewhere());

zamiast:

var list = GetFromSomewhere();
ModifyList(list);

niż by była jasność, co tak naprawdę robi ModifyList.

Zresztą i w C# można się przed tym bronić. Wystarczy stosować inną konwencję: jeśli metoda mutuje, niech przyjmuje jako swój argument mutowalny interfejs, a jeśli nie mutuje, niech przyjmuje niemutowalny interfejs. Mamy ModifyList(List<Cośtam> l)? Wiadomo, że mutuje. Mamy ModifyList(ReadOnlyCollection<Cośtam> l)? Nie mutuje. Trochę porządku i wszystko wiadomo.


Nadal wydaje mi się, że mutowanie bywa wygodne. Np. stan gry. Gdzieś głęboko obsługujemy jakiś atak. Ale ten atak jest typu Fire, w związku z czym mamy 20% szans, że przeciwnik otrzyma status ailment Burned. Nie wygodnie byłoby dodać odpowiedniej postaci w grze tego statusu imperatywnie? hurtCharacter.Effects.Add(new Burned());? No niby można zwrócić nową postać, ale w ostateczności to:

hurtCharacter.Effects.Add(new Burned()); // GameState mutowalny, GameState.Characters mutowalne, Character mutowalny, Character.Effects mutowalne

jest po prostu wygodniejsze, niż to:

return gameState with { // GameState niemutowalny
    Characters = gameState.Characters.SetItem( // Characters to ImmutableDictionary
        key: hurtCharacter.Id, // Bug jeśli pokręcę id!
        value: hurtCharacter with { // Character niemutowalny
            Effects = hurtCharacter.Effects.Add(new Burned()); // // Character.Effects to ImmutableList
        }
    )
}

(Chętnie dam się przekonać, że się mylę)

1
YetAnohterone napisał(a):
hurtCharacter.Effects.Add(new Burned()); // GameState mutowalny, GameState.Characters mutowalne, Character mutowalny, Character.Effects mutowalne

jest po prostu wygodniejsze, niż to:

return gameState with { // GameState niemutowalny
    Characters = gameState.Characters.SetItem( // Characters to ImmutableDictionary
        key: hurtCharacter.Id, // Bug jeśli pokręcę id!
        value: hurtCharacter with { // Character niemutowalny
            Effects = hurtCharacter.Effects.Add(new Burned()); // // Character.Effects to ImmutableList
        }
    )
}

(Chętnie dam się przekonać, że się mylę)

Jest to znany problem w przypadku niemutowalnosci i ma też rozwiązanie - optyki (lenses) https://hackage.haskell.org/package/lens

1

Odnośnie tej gry, jeszcze jedno:

Jeśli modyfikuję game state, to raczej (poza przetwarzaniem wielowątkowym) nie wyobrażam sobie sytuacji, bym miał nie chcieć, by ta modyfikacja nie rozpropagowała się od razu po całym kodzie.

Niemutowalny game state: Ryzyko, że gdzieś jest trzymana jakaś referencja, która nigdy się nie dowie, że jakaś postać ma status ailment Burned, i bugi. Mutowalny game state: Każdy fragment kodu od razu wie, że postać jest Burned. Gdzie jest większe ryzyko bugów?

1
YetAnohterone napisał(a):

Odnośnie tej gry, jeszcze jedno:

Jeśli modyfikuję game state, to raczej (poza przetwarzaniem wielowątkowym) nie wyobrażam sobie sytuacji, bym miał nie chcieć, by ta modyfikacja nie rozpropagowała się od razu po całym kodzie.

w zwykłym przypadku nie, ale niemutowalne obiekty mogą pomóc w symulacji i to zrównoleglonej. Możesz śmiało przekazać swój obiekt do wielu wątków i np rozegrać równoległą symulację partii szachów na n kroków do przodu, wszystko bez kopiowania obiektów i bez locków. Możesz też łatwo cofnąć się w grze w czasie i nie musisz samemu wyliczać delty żeby oszczędzać pamięć.

mutowalne obiekty są wolniejsze i pożerają więcej pamięci niż tradycyjne jeśli chcemy zwyczajne modyfikować je w miejscu, zwłaszcza jeśli te zmiany są częste i jeśli musimy je później propagować. wszystko zależy od danych, jak duży obiekt modyfikujemy i jak często. widzę że w c# 6 to poprawili ale wcześniej dosłownie każda operacja na mutowalnych kolekcjach była wolniejsza niż skopiowanie średniej wielkości kolekcji i wprowadzenie zmian.

w powyższym przykładzie stanu gracza w tradycyjnej grze nigdy bym się nie pokusił o zastosowanie niemutowalnych obiektów

1
YetAnohterone napisał(a):

Ma to swój sens moim zdaniem: trudno popełnić błąd w rodzaju przypadkowej mutacji jakiejś referencji bo się uważa, że metoda, która tak naprawdę mutuje, jest czysta i na odwrót trudno przypadkowo nie dokonać jakiejś zmiany, bo potraktowało się czystą metodę jakby mutowała.

Tak, konwencje są dobre - o ile wszyscy się do nich stosują. :)

Dlatego za brzydkie jest uważane pisanie:

var foo = new Foo();
foo.Bar = 5;
foo.Baz = 6;

To jest brzydkie, ale z innego powodu. Skoro już i tak mamy publiczne właściwości, to lepiej użyć inicjalizatora obiektu.

Zamiast tego za estetyczniejsze jest uważane:

var foo = new Foo()
    .WithBar(5)
    .WithBaz(6);

Przy czym WithBar oraz WithBaz oczywiście działają imperatywnie:

public class Foo
{
    public int Bar {get; private set;}
    
    public Foo WithBar(int bar)
    {
        this.Bar = bar;
        return this;
    }
}

Jaki z tego zysk, nie wiem. Widać natomiast, że różni się to od konwencji pythonowej, co utrudnia odróżnienie na pierwszy rzut oka metody czystej od nieczystej. Czy WithBar mutuje this, czy nie mutuje? Nie widać od razu.

Nie wiem jak to w Javie wygląda, ale w C# nigdy nie spotkałem się z czymś takim. Nie przeszłoby to też review w żadnym z projektów, w których pracowałem. Zysku nie ma żadnego, sensu też nie widzę.
Co innego wzorzec builder, który jest nieco podobny, ale wtedy łańcuch metod kończy się jakimś Build(), i zwracana jest instancja docelowego obiektu, a nie buildera.

Czemu ModifyList zwraca listę, skoro jednocześnie mutuje swój argument?

No właśnie! Czemu?
Bo język na to pozwala. I to jest właśnie ten problem, na który chciałem zwrócić uwagę. :)

Gdyby konwencja pythonowa przyjęła się w C#, to trudno byłoby popełnić ten błąd, o którym piszesz: wiadomo, że modifyList zwraca listę, więc nie mutuje argumentu.

Ale przecież autor tej metody miał taki zamiar. Po prostu język pozwolił mu się pomylić.

Ale C#owcy wolą móc zastępować średniki kropkami, czy też, jak w tym wypadku, choćby nawiasami.

???
Przecież nawiasy są niezbędne do wywołania funkcji, w ogóle nie rozumiem tego argumentu.

Ważniejsze jest, by móc napisać:

var list = ModifyList(GetFromSomewhere());

zamiast:

var list = GetFromSomewhere();
ModifyList(list);

niż by była jasność, co tak naprawdę robi ModifyList.

Kompletnie nie o to chodziło w moim przykładzie.
Ja chciałem pokazać, że używanie mutowalnych kolekcji prowadzi do prostych błędów wynikających z ich nieintuicyjności. To robi mój kod.

1 użytkowników online, w tym zalogowanych: 0, gości: 1