Embercode
✕
Object-Oriented ProgrammingLesson 9 of 17

40 min read

Immutable objects and records

Why it matters

Most bugs in object-oriented code come from one object being changed by a part of the program that did not expect it. A shopping cart total changes while a receipt is being printed. A list of team members changes while a loop is walking through it. The fix is often to stop the change from happening at all.

An immutable object is an object whose values cannot change after it has been created. You build it once, with all its values, and then every part of the program can read it safely. If you want a different value, you make a new object. The old one stays exactly as it was.

This lesson covers two ways to get immutable objects. In Java you use final fields and, for simple data, the record keyword. In C++ you use const objects, const member functions and value semantics, which means copies are real, separate values. The lesson also shows the copy-on-change pattern, where a change returns a new object. Sorting out how to make equal objects match and how to print them is covered in the next lesson, Operator overloading and equals. Validating input when an object is built is covered in Invariants and validation.

The idea: a price tag that cannot be edited

Start with a plain example. A price tag is printed and stuck on a shelf. If the shop wants a new price, it prints a new tag and replaces the old one. Nobody takes a pen and changes the number on the tag that is already on the shelf. Customers who read the old tag still have a correct piece of paper.

An immutable object works like that tag. Here is the same idea written as a small C++ class. It stores a price in cents, so 12.50 dollars is stored as 1250.

C++ · editable

Output:

USD 12.50
USD 9.99

Look at what the class does not have. It has no function that sets cents_ to a new number. The data members are private, so code outside the class cannot touch them. The only way to get a different amount is withCents, and that function returns a new Money object. The variable price still holds 1250 after the call.

The word for this habit is copy-on-change. A "change" copies the object and gives the copy the new value. Copy-on-change is slower than editing in place, because it creates a new object each time. For small objects like this one, the cost is tiny. Later in this lesson there is a note on when the cost matters.

Immutability in Java: final fields

Java gives two tools for this. The first is the final keyword on a field. A final field can be given a value once, in the constructor. After that the compiler refuses any assignment to it.

class Money {
    private final String currency;
    private final long cents;
 
    Money(String currency, long cents) {
        this.currency = currency;
        this.cents = cents;
    }
 
    long cents() {
        return cents;
    }
 
    Money withCents(long newCents) {
        return new Money(currency, newCents);
    }
}

If any method tries cents = 5;, the compiler stops with an error. Each field is set in the constructor and never again.

Writing these classes by hand takes many lines. Each field needs a final line, a constructor parameter, a line in the constructor, and a getter. Java 16 added the record keyword to remove this work for classes that only hold data.

A record is a class where the compiler writes the constructor, the getters, equals, hashCode and toString for you. Each record component is a final field. Here is the same Money class as a record.

record Money(String currency, long cents) {
    Money withCents(long newCents) {
        return new Money(currency, newCents);
    }
}

That is the whole class. The line record Money(String currency, long cents) creates a constructor that takes two values and stores them. It creates methods named currency() and cents() that return the stored values. It creates toString, which prints the record in a readable form. You will see the output of this in the worked examples.

Records are a good fit for plain data, such as a point, a price, a date range or a line on an invoice. They are not a good fit for an object that must hold changing state, such as a game character with health that goes down. You can still write a normal Java class for those.

C++: const objects and value semantics

C++ does not have records. It has two features that together give the same effect: const and value semantics.

The first feature is const on a variable. A const variable cannot be changed after it is created.

const Money price{"USD", 1250};

The compiler now rejects any code that tries to change price. This is the C++ version of a final local variable in Java.

The second feature is const on a member function. A member function is a function that belongs to a class, such as cents() above. Adding const after the parentheses makes a promise that the function does not change the object it is called on. The compiler checks the promise. A const function can read the data members, but it cannot assign to them.

That is why the getters in the Money class are written as long cents() const. Without the const after the parentheses, the compiler would refuse to call cents() on a const Money. You will see the exact error in the Common mistakes section. The & in const std::string& on the currency getter means the getter returns a reference, a name for the stored string, not a copy.

The third feature is value semantics. This is the most important difference between C++ and Java, so it needs a clear example. In C++, a variable of a class type holds the whole object. Assigning one variable to another makes a full copy. Here is a small program.

C++ · editable

Output:

a.size = 10
b.size = 99

Changing b left a alone. In Java the same idea looks different. A Java variable that holds an object holds a reference, which is a pointer to the object somewhere in memory. Two Java variables can refer to the same object, so changing the object through one variable is visible through the other. Java programmers call this aliasing. Value semantics removes aliasing for the objects you store directly in variables, which is why C++ code can often be immutable with less effort.

Value semantics has a cost. Copies take time, and a large object such as a std::vector with a million elements takes a long time to copy. C++ gives you move semantics to avoid most of that cost when the old object is not needed any more. A move transfers the internal parts of an object to the new one and leaves the old one empty. This lesson uses std::move only to hand values to constructors and push_back.

Example 1: Money with copy-on-change

The first worked example adds a second money value to the first. The class has a plus method. It does not change either input. It returns a new object that holds the total.

C++ · editable

Output:

price: USD 12.50
total: USD 16.25
discounted: USD 9.99
original still: USD 12.50
copy still: USD 12.50

Read the code line by line. The variable price is const, so the compiler guarantees that nothing changes it. The call price.plus(Money{"USD", 375}) reads cents_ from price, adds 375 to it, and builds a new object. The total variable holds the new object. price is unchanged.

The copy variable is a full copy made in main. It is not connected to price in any way. If someone later added a method that changed price, copy would keep the value 1250. Value semantics is what makes this safe.

The method withCents follows the same pattern. The name starts with with because it returns a copy of the object with one value replaced. Many C++ and Java codebases use with for this purpose. The name is a convention, not a rule the compiler checks.

Notice what the plus method does with other.cents_. It reads a private member of another Money object. That is allowed in C++, because the access rule is per class and not per object. A Money may read the private data of another Money.

Example 2: an order that holds a list

The first example stored only numbers. The second stores a list of item names inside the object. This is harder, because a list can be large and the copy-on-change step copies the list too.

The Order class below has an id and a std::vector of item names. The method withItem returns a new order with one more item. The old order stays the same.

C++ · editable

Output:

A-1: pen
A-1: pen notebook
A-1: pen notebook ruler
items in first: 1

The constructor takes its arguments by value and moves them into the members. The std::move calls say "this value is no longer needed here, so take its parts instead of copying them." You do not need to understand move fully yet. For this lesson it is enough to know that the constructor stores the list without copying it a second time.

Inside withItem, the line std::vector<std::string> next = items_; makes a full copy of the list. The copy is then extended with push_back. The original items_ is never touched, because withItem is const. If you wrote items_.push_back(item) inside a const function, the compiler would stop you.

The getter items() returns a const reference to the list. A reference is a name for an existing object, not a copy. The const in the return type means the caller can read the list but cannot change it. The caller can still copy it into their own variable, which is safe.

The last output line shows that first still holds one item after two changes. Each change produced a new order.

Example 3: a Java record and a C++ class that check their input

Immutable objects are often used to hold values that must always be valid. A negative price should not exist anywhere in the program. The best place to check it is in the constructor, because every object goes through the constructor. This idea is developed in the Invariants and validation lesson. Here it is shown in both languages, so you can see the same design twice.

In Java, a record can have a compact constructor. It is a constructor with no parameter list. It runs before the fields are stored, so it can check the values and throw an error if they are wrong.

public class Main {
    record Money(String currency, long cents) {
        Money {
            if (cents < 0) {
                throw new IllegalArgumentException("cents must not be negative: " + cents);
            }
        }
    }
 
    public static void main(String[] args) {
        System.out.println(new Money("USD", 1250));
        try {
            new Money("USD", -5);
        } catch (IllegalArgumentException e) {
            System.out.println("error: " + e.getMessage());
        }
    }
}

Output:

Money[currency=USD, cents=1250]
error: cents must not be negative: -5

The first line of output comes from the toString method that the record created for you. It lists the component names and their values. The second line comes from the check in the compact constructor.

The same rule in C++ is written in the constructor body.

C++ · editable

Output:

USD 1250
error: cents must not be negative: -5

Both versions do the same job. The C++ version has no toString, so it prints fields by hand, as the first two examples did. That is the main difference you will notice. Java records give you printing, equality and hashing for free. C++ needs you to write them, which the next lesson explains.

A checklist for an immutable class

Use this list when you write a class that should never change after it is built. Each item is checked against the examples above.

  1. Every data member is private.
  2. Every data member is set in the constructor, and the class has no setter.
  3. A method that would change a value returns a new object instead, as withCents and withItem do.
  4. A list or other container passed in is copied when it is stored, so the caller cannot change it later.
  5. A getter returns a const reference or a copy, never a writable reference.
  6. Every method that only reads data is marked const in C++.

Records in Java cover items 1, 2 and 6 for you, and you still need to write item 3 and item 4 yourself.

Common mistakes

Forgetting const on a getter

A getter is a member function that returns the value of a data member. In C++ the getter must be marked const if it can be called on a const object. Here is a class where it is not.

#include <string>
#include <utility>
 
class Money {
public:
    Money(std::string currency, long cents)
        : currency_{std::move(currency)}, cents_{cents} {}
 
    long cents() { return cents_; }
 
private:
    std::string currency_;
    long cents_;
};
 
int main() {
    const Money price{"USD", 1250};
    return static_cast<int>(price.cents());
}

The compiler reports:

missing_const.cpp: In function 'int main()':
missing_const.cpp:18:40: error: passing 'const Money' as 'this' argument discards qualifiers [-fpermissive]
   18 |     return static_cast<int>(price.cents());
      |                             ~~~~~~~~~~~^~
missing_const.cpp:9:10: note:   in call to 'long int Money::cents()'
    9 |     long cents() { return cents_; }
      |          ^~~~~

The phrase "discards qualifiers" means the object is const, but the function promises nothing about leaving it alone. The fix is to add const after the parentheses: long cents() const { return cents_; }.

Putting a const data member in a class you want to sort or erase from

A const data member can be set once, in the constructor. This sounds like immutability, but it stops the compiler from writing the assignment operator that std::vector needs. When an element is removed from the middle of a vector, the vector moves the later elements back one place, and that needs assignment.

#include <vector>
 
struct Config {
    const int limit;
};
 
int main() {
    std::vector<Config> configs{Config{1}, Config{2}};
    configs.erase(configs.begin());
    return 0;
}

The compiler reports:

const_member_error.cpp:9:18:   required from here
    9 |     configs.erase(configs.begin());
const_member_error.cpp:3:8: error: non-static const member 'const int Config::limit', cannot use default assignment operator

The fix is to keep the object immutable through its interface instead. Make the data member private and not const, give it no setter, and let only the constructor and with methods create new values. That keeps the object immutable while still allowing it to be assigned and stored in containers.

Returning a reference to a private list

A getter that returns a non-const reference lets the caller change the list from outside the class. The object looks immutable, but it is not.

#include <iostream>
#include <string>
#include <utility>
#include <vector>
 
class Team {
public:
    explicit Team(std::vector<std::string> members) : members_{std::move(members)} {}
 
    std::vector<std::string>& members() { return members_; }
 
    void show() const {
        for (const auto& m : members_) std::cout << m << " ";
        std::cout << "\n";
    }
 
private:
    std::vector<std::string> members_;
};
 
int main() {
    Team team{{"Asha", "Ravi"}};
    team.members().push_back("Meena");
    team.show();
}

Output:

Asha Ravi Meena 

The Team object was built with two names and then changed to three names from outside. The fix is to return const std::vector<std::string>& from the getter, as Order::items() does in example 2.

Using final in Java and believing the object is immutable

In Java, final makes the variable that holds a reference unchangeable. It does not make the object it points to unchangeable. A final List can still have items added to it.

import java.util.ArrayList;
import java.util.List;
 
public class Main {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        final List<String> fixed = list;
        fixed.add("pen");
        System.out.println(list);
    }
}

Output:

[pen]

The variable fixed cannot be pointed at a different list, but the list it points to is still a normal mutable list. Records have the same trap. A record that holds a List will show the list's changes through its toString and equals, as the next example shows.

Keeping a reference to a list the caller still owns

A Java record stores whatever list it is given. If the caller keeps the list and changes it later, the record changes too. The record's fields are final, but the list is shared.

import java.util.ArrayList;
import java.util.List;
 
public class Main {
    record Order(String id, List<String> items) {}
 
    public static void main(String[] args) {
        List<String> source = new ArrayList<>(List.of("pen"));
        Order leaky = new Order("B-1", source);
        source.add("ruler");
        System.out.println("leaky order after outside change: " + leaky);
    }
}

Output:

leaky order after outside change: Order[id=B-1, items=[pen, ruler]]

The fix is to copy the list in the constructor in a compact constructor: items = List.copyOf(items);. List.copyOf returns a list that cannot be changed and does not share memory with the list it was copied from. This is the Java way to get the same result that C++ gives by default when a vector is passed by value.

Mixing up with and set

A name such as setCents suggests that the object will be changed. If the method returns a new object and leaves the old one alone, the caller may expect a change that never happens. Use with for methods that return a new object, and use no set method at all for an immutable class.

Practice

Exercise 1. Add a Counter class in C++ with one int value. Give it a const method named withIncrement that returns a new Counter with the value one higher. Then write a main that creates a counter with value 0, makes two increments, and prints all three values. The original counter must still print 0.

Show the answer

#include <iostream>
 
class Counter {
public:
    explicit Counter(int value) : value_{value} {}
 
    int value() const { return value_; }
 
    Counter withIncrement() const { return Counter{value_ + 1}; }
 
private:
    int value_;
};
 
int main() {
    Counter zero{0};
    Counter one = zero.withIncrement();
    Counter two = one.withIncrement();
    std::cout << zero.value() << " " << one.value() << " " << two.value() << "\n";
}

Output: 0 1 2. The method is const because it does not change value_. It builds a new Counter from the current value plus one.

Exercise 2. Write a Java record Point with two int components, x and y. Add a method moveBy(int dx, int dy) that returns a new point. Start with new Point(1, 2), call moveBy(1, 1), and print both the original and the new point.

Show the answer

public class Main {
    record Point(int x, int y) {
        Point moveBy(int dx, int dy) {
            return new Point(x + dx, y + dy);
        }
    }
 
    public static void main(String[] args) {
        Point p = new Point(1, 2);
        System.out.println(p.moveBy(1, 1));
        System.out.println(p);
    }
}

Output:

Point[x=2, y=3]
Point[x=1, y=2]

The record's components are final, so moveBy cannot assign to x or y. It builds a new record with the sums.

Exercise 3. The C++ Config struct below cannot be erased from a vector. Explain why, then change it so the erase call compiles and the field can be read only from outside.

#include <vector>
 
struct Config {
    const int limit;
};
 
int main() {
    std::vector<Config> configs{Config{1}, Config{2}};
    configs.erase(configs.begin());
}

Show the answer

The const data member makes the default copy and move assignment operators deleted. erase moves later elements back one place, so it needs assignment. One fix keeps the member private, makes it not const, and provides only a getter:

class Config {
public:
    explicit Config(int limit) : limit_{limit} {}
    int limit() const { return limit_; }
 
private:
    int limit_;
};

With this version, the class has no setter, so no code outside the class can change limit_. The vector can move elements freely.

Exercise 4. A Java class has a final List<String> field. A caller says that the class is immutable because the field is final. Describe a sequence of two calls that proves the caller wrong, and state the fix.

Show the answer

Create the object with a list source, then call source.add("x") on the list the caller still holds. The field still points at the same list, so the object shows the new item. Another example is calling a getter that returns the list and then calling add on the result. The fix is to store a copy made with List.copyOf in the constructor, so the caller's list can no longer reach the object's list.

Exercise 5. Predict the output of this C++ program, then run it to check.

#include <iostream>
 
struct Box {
    int size;
};
 
int main() {
    Box a{10};
    Box b = a;
    b.size = 99;
    std::cout << a.size << " " << b.size << "\n";
}

Show the answer

Output: 10 99. The assignment Box b = a; makes a full copy, so changing b.size does not change a.size. This is value semantics.

Summary

  • An immutable object keeps the same values for its whole life. To change a value, build a new object.
  • In Java, final fields and record give immutability for plain data. A final list field still allows changes to the list, so copy collections in the constructor.
  • In C++, a const variable and const member functions let the compiler enforce immutability, and value semantics makes copies independent.
  • Copy-on-change methods are often named with and return a new object with one value replaced.
  • A const data member makes assignment impossible, which breaks sorting and erasing. Keep the member private and not const, and rely on the class interface instead.
  • Do not return non-const references to private data, because the caller can change them.
Practice this topic: coding problems for Object-Oriented Programming →

Stuck? Ask the tutor

Ask about anything in this lesson. Answers are short and use simple words.

Sign in to ask the tutor.

← Invariants and validation