Obiekty niemutowalne w praktycznym zastosowaniu

Rafał Łomotowski

Stosowanie obiektów niemutowalnych jest dobrą praktyką w codziennym programowaniu aplikacji. Dzięki nim śledzenie działania aplikacji i występowania błędów jest prostsze. Chcesz wiedzieć, jak stworzyć obiekty niemutowalne/niezmienne w Javie? Zastanawiasz się, dlaczego są one tak przydatne? Czytaj dalej.

Obiekt niemutowalny, czyli jaki?

W programowaniu obiekt jest niemutowalny, gdy jego stan jest niezmienialny po jego utworzeniu. W praktyce oznacza to, że nie można ani zapisać, ani zaktualizować żadnego z jego pól. Dobrym przykładem tego rodzaju obiektów jest String lub Integer, ponieważ ich zawartość nigdy się nie zmienia. Dla każdej aktualizacji stanu trzeba utworzyć nowy obiekt.

Niezmienne obiekty oferują kilka przydatnych korzyści. Jakich?

  • Tego rodzaju obiekty są bezpieczne wątkowo i nie musisz się martwić o aplikacje działające w środowiskach współbieżnych lub wielowątkowych. Pomaga to zaoszczędzić czas podczas debugowania aplikacji.
  • Kolejną korzyścią jest działanie w skomplikowanej domenie potrzeb biznesowych. W przypadku wielu operacji biznesowych użycie obiektów niezmiennych pomaga monitorować, w jaki sposób operacje te są ze sobą połączone oraz jak oddziałują na stan aplikacji.
  • Gdy korzystasz z pamięci podręcznej (cache), klasa niezmienna jest również bardzo dobrą gwarancją niezmiennego stanu. Niezmienny obiekt jest też świetną rzeczą do wykorzystania jako klucz do mapy.

Jak stworzyć klasę niemutowalną w Javie?

Aby utworzyć taką klasę, musisz przestrzegać kilku zasad.

  • Klasa musi być finalna – aby nie podlegała dziedziczeniu.
  • Wszystkie pola muszą być finalne i prywatne.
  • Nie twórz „setterów” dla pól, które dałyby bezpośredni dostęp do ich zmiany.
  • Wszystkie pola powinny być inicjowane z pomocą konstruktora lub wzorca „builder” (pomocny może być tu Lombok).
  • Uwaga! Jeśli pola są modyfikowalnymi obiektami złożonymi (np. HashMap), podstawowe gettery lub konstruktory (jak te z Lomboka) nie wystarczą.

Instrukcja krok po kroku

Poniżej znajdziesz przykład, jak stworzyć klasę niezmienną. Zobaczysz też korzyści z jej użycia. Spójrz:

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;

import java.math.BigDecimal;

@Getter
@Builder
@EqualsAndHashCode
@RequiredArgsConstructor
public final class PlaneModel {
    private final String model;
    private final BigDecimal weight;
    private final Integer amountOfSeats;
}
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Slf4j
public class AircraftFleet {
    private final Map<PlaneModel, Integer> createdPlanes = new HashMap<>();

    public void addCreatedPlane(PlaneModel planeModel) {
        Integer numberOfPlanes = getNumberOfPlanesByModel(planeModel);
            createdPlanes.put(planeModel, numberOfPlanes + 1);
    }

    public Integer getNumberOfPlanesByModel(PlaneModel planeModel) {
        return Optional.ofNullable(createdPlanes.get(planeModel))
                .orElse(0);
    }

    public void showCreatedPlanes() {
        for (Map.Entry<PlaneModel, Integer> entries : createdPlanes.entrySet()) {
            PlaneModel planeModel = entries.getKey();
            Integer numberOfPlanes = entries.getValue();

            log.info(String.format("Model: %s , weight: %s , number of seats: %d , number of planes: %d",
                    planeModel.getModel(), planeModel.getWeight(), planeModel.getAmountOfSeats(), numberOfPlanes));
        }
        System.out.println("");
    }
}

Jak możesz zauważyć, AircraftFleet nie posiada Lombokej adnotacji @Getter, ponieważ skutkowałoby to umożliwieniem zmiany stanu mapy createdPlanes. To z kolei prowadziłoby do nieoczekiwanego i niechcianego rezultatu. Jeśli chcesz jednak nadal dawać użytkownikom możliwość zwracania bieżącego stanu, musisz zawsze zwracać jego kopię wewnątrz gettera, aby nie przekazywać wewnętrznej referencji poza klasę. Możemy również zwrócić jego widok tylko do odczytu za pomocą metody Collections.nmodifiableMap().

Również w przypadku tworzenia AircraftFleet z początkowym stanem należy zastosować podobne podejście wewnątrz konstruktora.

@Slf4j
public class AircraftFleet {
    private final Map<PlaneModel, Integer> createdPlanes;

    public AircraftFleet(Map<PlaneModel, Integer> initialPlanes) {
        this.createdPlanes = new HashMap<>(initialPlanes);
    }

    public Map<PlaneModel, Integer> getPlanes() {
        return new HashMap<>(createdPlanes); // or Collections.unmodifiableMap(createdPlanes);
    }

    // rest of the code
  • Zobacz, jak możesz wykorzystać niemutowalne obiekty:
@Slf4j
public class Application {

    public static void main(String[] args) {
        String modelOfFirstPlane = "Airbus A310";
        BigDecimal weightOfFirstPlane = BigDecimal.valueOf(141);
        Integer amountOfSeatsInFirstPlane = 200;

        String modelOfSecondPlane = "Boeing 737";
        BigDecimal weightOfSecondPlane = BigDecimal.valueOf(105);
        Integer amountOfSeatsInSecondPlane = 120;

        PlaneModel firstPlaneModel = PlaneModel.builder()
                .model(modelOfFirstPlane)
                .weight(weightOfFirstPlane)
                .amountOfSeats(amountOfSeatsInFirstPlane)
                .build();

        PlaneModel secondPlaneModel = PlaneModel.builder()
                .model(modelOfSecondPlane)
                .weight(weightOfSecondPlane)
                .amountOfSeats(amountOfSeatsInSecondPlane)
                .build();

        PlaneModel sameAsSecondPlaneModel = PlaneModel.builder()
                .model(modelOfSecondPlane)
                .weight(weightOfSecondPlane)
                .amountOfSeats(amountOfSeatsInSecondPlane)
                .build();        

        AircraftFleet aircraftFleet = new AircraftFleet();
        aircraftFleet.addCreatedPlane(firstPlaneModel);

        log.info("Add first plane. Before attempt to change name of model:");
        aircraftFleet.showCreatedPlanes();

        modelOfFirstPlane = "Modified Model of " + modelOfFirstPlane;

        log.info("After attempt to change name of model:");
        aircraftFleet.showCreatedPlanes();

        log.info("Add second plane model:");
        aircraftFleet.addCreatedPlane(secondPlaneModel);
        aircraftFleet.showCreatedPlanes();

        log.info("Add next plane of second model");
        aircraftFleet.addCreatedPlane(sameAsSecondPlaneModel);
        aircraftFleet.showCreatedPlanes();
    }
}

Jak widać, stworzyłeś/-łaś klasę niemutowalną o nazwie PlaneModel. Będzie ona używana jako złożony klucz w mapie, dzięki czemu możesz zebrać liczbę samolotów per model.

@EqualsAndHashCode
@RequiredArgsConstructor
public final class PlaneModel {
  • Dodatkowo użyliśmy adnotacji z zależności Lombok, aby utworzyć kontrakt między metodami equals a hashcode, który jest podstawą do użycia obiektu jako klucza w mapie. Gdyby kontrakt nie został dotrzymany, mogłoby to prowadzić do niespójności kluczy i mielibyśmy identyczne modele samolotów jako oddzielny zestaw ich liczby.

Tak przedstawia się rezultat:

Add the first plane. This what we can see before the attempt to change name of the model:
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

After the attempt to change the name of model:
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

Add the second plane model:
Model: Boeing 737 , weight: 105 , number of seats: 120 , number of planes: 1
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

Add the next plane of second model
Model: Boeing 737 , weight: 105 , number of seats: 120 , number of planes: 2
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1


Process finished with exit code 0
  • Spójrz na pierwszą część:
Add the first plane. Before attempt to change name of model:
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

Dodany został pierwszy model PlaneModel i widzimy, że liczba samolotów takiego modelu równa jest „1”.

  • Następnie zmieniona została nazwa modelu:
 modelOfFirstPlane = "Modified Model of " + modelOfFirstPlane;
  • Widzisz, że nic się nie zmieniło. Niezmienność klasy String spowodowała utworzenie nowego obiektu String, więc ostatecznie oryginalny String nie uległ zmianie. Zobacz:
After attempt to change name of model:
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1
  • Pora, aby dodać drugi model:
aircraftFleet.addCreatedPlane(secondPlaneModel);
aircraftFleet.showCreatedPlanes();

Rezultat? Spójrz:

Add the second plane model:
Model: Boeing 737 , weight: 105 , number of seats: 120 , number of planes: 1
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

Jak widać, masz po jednym samolocie z każdego modelu.

  • Na koniec pora, aby dodać drugą jednostkę tego modelu. Jak to zrobić? Tak, jak poniżej:
aircraftFleet.addCreatedPlane(sameAsSecondPlaneModel);
aircraftFleet.showCreatedPlanes();

Końcowy efekt prezentuje się następująco:

Add the next plane of second model
Model: Boeing 737 , weight: 105 , number of seats: 120 , number of planes: 2
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

Możesz wyciągnąć wnioski, że niezmienność PlaneModel i kontraktu equals oraz hashCode działa doskonale. Dlaczego? Ponieważ masz jedną jednostkę dla pierwszego modelu i dwie jednostki dla drugiego modelu, nawet gdy są dwa utworzone obiekty o tej samej zawartości. Żadna sytuacja nie spowodowała utworzenia trzeciego oddzielonego obiektu PlaneModel.

Obiekty niemutowalne w cache’u – najważniejsze korzyści

Chcesz użyć pamięci podręcznej w swojej aplikacji? Dobrym sposobem jest utworzenie niezmiennego obiektu jako klucza pamięci podręcznej i użycie kontraktu między equals a hashcode, aby uniknąć niespójności w przechowywaniu danych. Przykład? Użyj adnotacji @Cacheable z implementacji EhCache w Spring Boot. Poniżej znajdziesz konfigurację słownika chache. Spójrz:

import net.sf.ehcache.config.CacheConfiguration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.ehcache.EhCacheCacheManager;
import org.springframework.context.annotation.Bean;

@EnableCaching
@Configuration
public class ApplicationCacheConfiguration {

    public static final String DICTIONARY_CACHE_NAME = "DICTIONARY";

    private static final long TIME_TO_LIVE_SECONDS = 600;
    private static final long MAX_ENTRIES = 50;

    @Primary
    @Bean
    CacheManager cacheManager() {
        return new EhCacheCacheManager(createCacheManager());
    }

    private net.sf.ehcache.CacheManager createCacheManager() {
        net.sf.ehcache.config.Configuration config = new net.sf.ehcache.config.Configuration();
        config.addCache(getDictionaryCacheConfiguration());
        return net.sf.ehcache.CacheManager.newInstance(config);
    }

    private CacheConfiguration getDictionaryCacheConfiguration() {
        CacheConfiguration cacheConfiguration = new CacheConfiguration();
        cacheConfiguration.setName(DICTIONARY_CACHE_NAME);
        cacheConfiguration.setTimeToLiveSeconds(TIME_TO_LIVE_SECONDS);
        cacheConfiguration.setMaxEntriesLocalHeap(MAX_ENTRIES);
        return cacheConfiguration;
    }
}
  • Stwórz niemutowalna klasę jako unikatowy klucz słownika wraz z adnotacją @EqualsAndHashCode:
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;

@Getter
@EqualsAndHashCode
@RequiredArgsConstructor
public final class DictionaryId {
    private final String pageName;
    private final Integer line;
}
  • Następnie utwórz mechanizm cache, używając DictionaryId jako klucza:
@Repository
@RequiredArgsConstructor
class DictionaryRepositoryImpl implements DictionaryRepository {

    private final DictionaryJpaRepository dictionaryJpaRepository;

    @Override
    @Cacheable(value = ApplicationCacheConfiguration.DICTIONARY_CACHE_NAME)
    public String getDictionary(DictionaryId dictionaryId) {
        return dictionaryJpaRepository.getDictionary(dictionaryId); // Getting dictionary from Database
    }
}

Pamięć podręczną dla słowników jest gotowa i możesz mieć pewność, że dwa różne obiekty o tej samej zawartości klucza zwrócą te same dane z pamięci podręcznej.

  • Co by się jednak stało, gdyby klucz był mutowalny oraz nie przestrzegał kontraktu?
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;

@Getter
@AllArgsConstructor
public final class DictionaryId {
    private String pageName;
    private final Integer line;

    public void updatePageName(String pageName) {
        this.pageName = pageName;
    }
}
  • Załóżmy, że pobrałeś/-łaś i zapisałeś/-łaś słownik w pamięci podręcznej cache. O tak:
DictionaryId dictionaryId = new DictionaryId("FirstPage", 5);
String dictionaryValue = dictionaryRepository.getDictionary(dictionaryId);
  • Załóżmy również, że przypadkowo i nieświadomie zaktualizowałeś/-łaś klucz w logice biznesowej. Spójrz:
dictionaryId.updatePageName("FirstPageNew");

Jesteś teraz narażony/-na na brak wartości słownika podczas ponownego pobierania z pamięci podręcznej. Dlaczego? Ponieważ nasz klucz stał się niespójny. Mechanizm cacheable będzie próbował pobrać kolejny rekord z bazy danych, a to byłoby nieprzewidziane.

  • O czym jeszcze warto pamiętać? Jeśli klasa Twojego zwróconego obiektu z Cache nie była niezmienna, mogła zwrócić różne odpowiedzi przy każdym wywołaniu pamięci podręcznej. Przykład? Pierwsza osoba wykonuje żądanie z określonym kluczem, aby pobrać słownik, i modyfikuje tę wartość słownika. Następnie druga osoba z innym żądaniem i tym samym kluczem zobaczy inną wartość. Takie sytuacje mogą wystąpić w zapisywaniu do pamięci Cache, na przykład w implementacji EhCache. Gdybyśmy używali rozproszonej pamięci podręcznej, takiej jak Hazelcast, nie byłoby problemu ze zwracaną wartością, ale zależy to od konfiguracji.

Wady obiektów niemutowalnych

  • Każda aktualizacja obiektu prowadzi do tworzenia nowego, a w następstwie do zwiększonego zużycia pamięci (obecny mechanizm GC radzi sobie z tym jednak całkiem dobrze).
  • Wszystkie pola powinny być inicjowane na początku życia obiektu, ale zawsze zamiast konstruktora możesz zastosować wzorzec projektowy Builder.

Podsumowanie

Stosowanie obiektów niemutowalnych jest dobrą praktyką w codziennym programowaniu aplikacji. Sprawia, że śledzenie działania aplikacji i występowania błędów jest prostsze.

Poznaj mageek of j‑labs i daj się zadziwić, jak może wyglądać praca z j‑People!

Skontaktuj się z nami