Introduction

An immutable object is one that does not change its state after its construction. Immutable classes are often useful in concurrent applications as immutable objects are inherently thread-safe. In this post, we will see on how to make a class immutable. We will see an example class that is not immutable. Then, by applying the steps or the rules outlined we will make it immutable.

Rules to make a class immutable

A class must satisfy all the below mentioned properties to be truly immutable.

  1. Don’t provide methods that modify the object’s state: These are the setter methods in a class that set the values of the instance variables.
  2. Ensure that the class cannot be extended: This is done so that a subclass does not modify the state of the object. This is typically done by making the class final.
  3. Make all the fields final
  4. Make all the fields private: This is to prevent the clients from accessing the internal mutable properties. It is okay to have primitive or immutable fields as public.
  5. Ensure exclusive access to any mutable components: If the class has any mutable instance variables, make defensive copies in the constructor and in the accessors(getters). We should not initialize such fields to a client-provided reference.

A ShoppingCart

The below class represents a shopping cart. Each item is represented by the Item class. The ShoppingCart has the quantity of each item represented as a map. It has getter and setter methods for the instance variable. It also has methods to return the Items as a Set (getItems) and to calculate the total price of the items in the shopping cart (calculatePrice).

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

public class ShoppingCartV1 {
    public Map<Item, Integer> itemToQuantity;

    public ShoppingCartV1() {
        this(new HashMap<>());
    }

    public ShoppingCartV1(Map<Item, Integer> itemToQuantity) {
        this.itemToQuantity = itemToQuantity;
    }

    public Map<Item, Integer> getItemToQuantity() {
        return itemToQuantity;
    }

    public void setItemToQuantity(Map<Item, Integer> itemToQuantity) {
        this.itemToQuantity = itemToQuantity;
    }

    public Set<Item> getItems() {
        return itemToQuantity.keySet();
    }

    public double calculatePrice() {
        return itemToQuantity.entrySet().stream()
                .mapToDouble(entry -> entry.getValue() * entry.getKey().getPrice())
                .sum();
    }
    @Override
    public int hashCode() {
        return Objects.hash(itemToQuantity);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        ShoppingCartV1 other = (ShoppingCartV1) obj;
        return Objects.equals(itemToQuantity, other.itemToQuantity);
    }

    @Override
    public String toString() {
        return itemToQuantity.entrySet().stream()
                .map(entry -> entry.getKey() + " - Quantity: " + entry.getValue())
                .collect(Collectors.joining("\n"));
    }
}

Item class:

import java.util.Objects;

public final class Item {
    private final String name;
    private final double price;

    public Item(String name, double price) {
        this.name = name;
        this.price = price;
    }

    public String getName() {
        return name;
    }

    public double getPrice() {
        return price;
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        Item other = (Item) obj;
        return Objects.equals(name, other.name); //We consider two items to be equal if they have the same name
    }

    @Override
    public String toString() {
        return name + " of price $" + price;
    }
}

The client code that uses the Shopping Cart is shown below. It first constructs the initial cart empty. Then, it passes the items and quantity (as a map) via the setter method. Then, it gets the itemToQuantity map and adds more items to it. To remove items, it removes the item from the items returned by getItems. (It could also remove it from the itemToQuantity map).

public static void main(String[] args) {
    ShoppingCartV1 shoppingCartv1 = new ShoppingCartV1();
    //Start off with initial items
    Map<Item, Integer> itemToQuantity = new HashMap<>();
    itemToQuantity.put(new Item("Item1", 10.5), 2);
    itemToQuantity.put(new Item("Item2", 15), 3);
    shoppingCartv1.setItemToQuantity(itemToQuantity);

    System.out.println("Total price: $" + shoppingCartv1.calculatePrice());

    //Add more items
    System.out.println("Adding Item3");
    shoppingCartv1.getItemToQuantity().put(new Item("Item3", 20.5), 3);
    System.out.println("Total price: $" + shoppingCartv1.calculatePrice());

    System.out.println();
    System.out.println("Current cart is");
    System.out.println(shoppingCartv1);
    System.out.println();

    //Remove item
    System.out.println("Removing Item1");
    shoppingCartv1.getItems().remove(new Item("Item1", 10.5));

    System.out.println("Current cart is");
    System.out.println(shoppingCartv1);   
}

The Item class is already immutable. We will now show a series of refactoring that makes the ShoppingCart class immutable.

Making the class final and the instance variables private

First, we will make the class final to prevent subclassing. Next, we will make the instance variable private.

public final class ShoppingCartV1 {
    private Map<Item, Integer> itemToQuantity;
    //rest of the code remains the same
}

Removing setter methods

We will remove the setItemToQuantity setter or mutator method. Now, the client will break with this change as it is calling setItemToQuantity. We will provide a new method to add an item to the shopping cart.

public ShoppingCartV2 addItemToCart(Item item, Integer quantity) {
    Map<Item, Integer> newItems = new HashMap<>(itemToQuantity); //copy existing items
    newItems.put(item, quantity); //add the new item
    return new ShoppingCartV2(newItems);
}

A big change here is that we are actually returning a new ShoppingCart instance for each change made to the shopping cart (let it be called ShoppingCartV2 to differentiate it from the mutable ShoppingCartV1). This follows a functional approach.

The client code will change to call the new method as

//Before
Map<Item, Integer> itemToQuantity = new HashMap<>();
itemToQuantity.put(new Item("Item1", 10.5), 2);
itemToQuantity.put(new Item("Item2", 15), 3);
shoppingCartv1.setItemToQuantity(itemToQuantity);

//After
shoppingCartv2 = shoppingCartv2.addItemToCart(new Item("Item1", 10.5), 2);
shoppingCartv2 = shoppingCartv2.addItemToCart(new Item("Item2", 15), 3);

It would be easier for the client if there was a method to add multiple items at once rather than calling addItemToCart for each time. So, we can add the below method.

public ShoppingCartV2 addItemsToCart(Map<Item, Integer> itemToQuantity) {
    Map<Item, Integer> newItems = new HashMap<>(itemToQuantity);
    newItems.putAll(itemToQuantity);
    return new ShoppingCartV2(newItems);
}

Ensuring exclusive access to any mutable components

There are two ways we can achieve this

Creating defensive copy

Defensive copying refers to the approach of returning a copy or a clone of a mutable data structure thereby not exposing the mutable object.

In our class, the only mutable component or field is the map that holds the item to quantity. Hence, we have to make defensive copies when we return it from the getter method and when the client initializes it via the constructor.

public ShoppingCartV2(Map<Item, Integer> itemToQuantity) {
    this.itemToQuantity = new HashMap<>(itemToQuantity); 
}

In the constructor, we no longer store the reference to the passed map directly. We create a new copy of the passed map.

Similarly, we have to change the getItems method to not return the actual KeySet, but return a copy of it. Similarly, we return a copy of the map in the getter.

public Set<Item> getItems() {
    return new HashSet<>(itemToQuantity.keySet());
}
public Map<Item, Integer> getItemToQuantity() {
    return new HashMap<>(itemToQuantity);
}

Returning an unmodifiable collection

An alternate to returning a defensive copy in the getItems and getItemToQuantity is that we can wrap the collection in an unmodifiable collection.

public Map<Item, Integer> getItemToQuantity() {
    return Collections.unmodifiableMap(itemToQuantity);
}
    
public Set<Item> getItems() {
    return Collections.unmodifiableSet(itemToQuantity.keySet());
}

The difference here is that if we are returning a copy (defensive copy), the client can still mutate the returned collection. But it won’t affect the shopping cart class internals as we have returned a copy. But, when returning an unmodifiable collection, if the client tries to mutate (add or remove) the collection, it will result in an java.lang.UnsupportedOperationException.

It is thus a design decision that we need to make on which approach to follow.

Since we no longer return the actual keyset, the client now cannot delete items. We thus add a deleteItem method.

public ShoppingCartV2 deleteItem(Item itemToDelete) {
    Map<Item, Integer> newItems = new HashMap<>(itemToQuantity);
    newItems.remove(itemToDelete);
    return new ShoppingCartV2(newItems);
}

Make the fields private

Since we have got rid of the setter methods, we can now make the instance variable final.

Putting it all together

The final ShoppingCart class and the client code will look as shown below.

ShoppingCart class:

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

public final class ShoppingCartV2 {
    private final Map<Item, Integer> itemToQuantity;

    public ShoppingCartV2() {
        this(new HashMap<>());
    }

    public ShoppingCartV2(Map<Item, Integer> itemToQuantity) {
        this.itemToQuantity = new HashMap<>(itemToQuantity);
    }

    public Map<Item, Integer> getItemToQuantity() {
        return Collections.unmodifiableMap(itemToQuantity); //or return new HashMap<>(itemToQuantity);
    }


    public ShoppingCartV2 addItemToCart(Item item, Integer quantity) {
        Map<Item, Integer> newItems = new HashMap<>(itemToQuantity);
        newItems.put(item, quantity);
        return new ShoppingCartV2(newItems);
    }

    public ShoppingCartV2 addItemsToCart(Map<Item, Integer> itemToQuantity) {
        Map<Item, Integer> newItems = new HashMap<>(itemToQuantity);
        newItems.putAll(itemToQuantity);
        return new ShoppingCartV2(newItems);
    }

    public ShoppingCartV2 deleteItem(Item itemToDelete) {
        Map<Item, Integer> newItems = new HashMap<>(itemToQuantity);
        newItems.remove(itemToDelete);
        return new ShoppingCartV2(newItems);
    }

    public Set<Item> getItems() {
        return Collections.unmodifiableSet(itemToQuantity.keySet()); //or return new HashSet<>(itemToQuantity.keySet());
    }

    public double calculatePrice() {
        return itemToQuantity.entrySet().stream()
                .mapToDouble(entry -> entry.getValue() * entry.getKey().getPrice())
                .sum();
    }
    @Override
    public int hashCode() {
        return Objects.hash(itemToQuantity);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null) {
            return false;
        }
        if (getClass() != obj.getClass()) {
            return false;
        }
        ShoppingCartV1 other = (ShoppingCartV1) obj;
        return Objects.equals(itemToQuantity, other.itemToQuantity);
    }

    @Override
    public String toString() {
        return itemToQuantity.entrySet().stream()
                .map(entry -> entry.getKey() + " - Quantity: " + entry.getValue())
                .collect(Collectors.joining("\n"));
    }
}

Client code:

public static void main(String[] args) {
    ShoppingCartV2 shoppingCartv2 = new ShoppingCartV2();

    shoppingCartv2 = shoppingCartv2.addItemToCart(new Item("Item1", 10.5), 2);
    shoppingCartv2 = shoppingCartv2.addItemToCart(new Item("Item2", 15), 3);

    System.out.println("Total price: $" + shoppingCartv2.calculatePrice());

    //Add more items
    System.out.println("Adding Item3");
    shoppingCartv2 = shoppingCartv2.addItemToCart(new Item("Item3", 20.5), 3);
    System.out.println("Total price: $" + shoppingCartv2.calculatePrice());

    System.out.println();
    System.out.println("Current cart is");
    System.out.println(shoppingCartv2);
    System.out.println();

    System.out.println("Removing Item1");
    shoppingCartv2 = shoppingCartv2.deleteItem(new Item("Item1", 10.5));

    System.out.println("Current cart is");
    System.out.println(shoppingCartv2);
}

We have followed all the rules mentioned to make the ShoppingCart class immutable. With these changes, the client will no longer be able to mutate the class’s internals.

References

  1. Minimise mutability from Effective Java (Item 15 in second edition and Item 17 in third edition)
  2. Effective Java – Make defensive copies when needed
  3. A Strategy for Defining Immutable Objects
  4. Journal Dev post on how to create immutable class in java
  5. How to create immutable class in Java