Niemutowalność w przypadku String
wynika przede wszystkim ze sposobu ich implementacji:
String a = "test";
String b = "test";
W takim wypadku a == b
zwróci true
, ponieważ "pod spodem" znajduje się wzorzec flyweight. Bez ograniczenia zmiany stanu wywołanie przykładowo a.toUpperCase()
spowodowało by też zmianę w b
ponieważ obie zmienne wskazują na dokładnie ten sam obiekt. Omijamy to poprzez np. b = new String("test");
.
Wzorzec flyweight jest zastosowany z dwóch powodów:
- wydajność - kiedyś naprawdę liczono RAM i możliwość współdzielenia pamięci była rzeczą dobrą. Współdzielenie pamięci jest fajne o ile to co w niej zapisano nie zmienia się. Dodatkowo można np. cachować wyliczony hashCode co znacząco wpływa na wydajność użycia kolekcji korzystających z tej wartości.
- optymalizacja - kompilator "wie", że ma do czynienia z obiektem niezmiennym i może zastosować pewne optymalizacje.
Kolejnym elementem jest sposób w jaki startuje JVM. Klasy ładowane się "po nazwie", a ta jest przechowywana w postaci stringa. Zapewnienie niezmienności nazwy jest tu całkiem dobrym pomysłem.
Z takiego też punktu należy też uniemożliwić zmianę stringów przechowujących np. adres bazy danych, loginy czy hasła.
Podsumowując niemutowalność klasy String
wpisuje się w koncepcję języka, który ma zabezpieczyć programistę przed różnymi błędami. Tu wynikającymi z możliwości zmiany zawartości pamięci.
Niemutowalność w bardziej ogólnym podejściu pozwala na wprowadzenie wielu ciekawych rozwiązań na poziomie zarówno języka jak i architektury aplikacji. Java jest tu dość słabym przykładem, bo jednak nie ma domyślnej niemutowalności. Dużo lepszym przykładem jest chociażby Scala ze swoimi kolekcjami, które można obrabiać skalując w dowolny sposób ilość jednostek obliczeniowych (rdzeni, procków czy też całych serwerów), bo wiadomo, że w środku obliczeń nic się nie zmieni.
Jednak najlepszy przykład na to dlaczego niemutowalność jest OK, miałem okazję poznać na LambdaDays. Otóż niezależnie czy dana pamięć jest współdzielona czy też nie, to w przypadku gdy wystąpi wyjątek jeżeli masz gwarancję, że dany fragment jest niezmienny możesz:
- powtórzyć obliczenia, bo wiesz co masz na początku - uzyskujesz coś w stylu transakcji.
- jego awaria na pewno nie ma wpływu na całość aplikacji, zatem można spokojnie "zresetować" dany fragment pamięci.
- wynikające z 2. możesz zastosować znacznie "mocniejsze" scenariusze failover np. przełączyć rozmowę telefoniczną "w locie" na działający BTS. W najgorszym wypadku klient "zgubi" jakiś niewielki fragment danych, ale jako, że całość idzie w sekwencji zatem mechanizm kontroli poradzi sobie z tym problemem.
W dodatku nie ma znaczenia czy środowisko jest tu wielo czy jedno procesorowe. Po prostu masz gwarancję, że nic ci nie zmieni danego kawałka danych tym samym możesz "w ciemno" usuwać wąskie gardła w stylu "synchronizatorów". Nie są potrzebne w takiej ilości.