Why this matters
Some data should not change after it is made. A list of allowed file types, a table of tax rates, or the list of days in a week is the same for every user. If a part of the program can change that data by mistake, the bug may appear far away from the line that caused it, and it may appear only on some runs.
An immutable collection is a collection that cannot be changed after it is created. You can read it, but you cannot add, remove, or replace items. Java and C++ handle this differently. Java gives you ready-made immutable collections and throws an error at run time when code tries to change one. C++ has no built-in immutable collection, but a const container refuses changes at compile time, which means the program does not build.
This lesson covers both. It does not cover iterators, which are the subject of the previous lesson on iterator invalidation, or generics, which come later in the module.
Words you need
A mutable collection can be changed after it is made. A java.util.ArrayList is mutable. An immutable collection cannot be changed. The word means "not able to change".
A view is a collection that shows the items of another collection without copying them. If the other collection changes, the view shows the change. A copy is a new collection with its own items. Later changes to the original do not affect a copy.
A const object in C++ is an object that the program has marked as read-only. The word comes from "constant". The keyword is const, and it goes before the type. const std::vector<int> nums{4, 8, 15}; makes nums read-only.
A reference in C++ is another name for an existing object. A reference is written with &. const std::vector<int>& view is a name for a vector that cannot be changed through that name.
A compile error is a problem the compiler reports before the program can run. The program is not built, so no code runs. A run-time error is a problem found while the program runs, and it stops the program at that line.
Java: List.of and Map.of
Java has factory methods that make immutable collections. List.of makes an immutable list, and Map.of makes an immutable map. Both are part of the standard library and have been available since Java 9. You call them with the items you want, and you get back a collection that refuses every change.
Worked example 1: changing a List.of list
This program makes a list of three colours and then tries to add one and change one.
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> colors = List.of("red", "green", "blue");
System.out.println(colors.get(1));
try {
colors.add("pink");
} catch (UnsupportedOperationException e) {
System.out.println("add failed: " + e.getClass().getName());
}
try {
colors.set(0, "black");
} catch (UnsupportedOperationException e) {
System.out.println("set failed: " + e.getClass().getName());
}
System.out.println(colors);
}
}Trace it by hand. get(1) reads the middle item, so the first line prints green. The add call tries to make the list longer. The list refuses, and Java throws UnsupportedOperationException. The catch block prints the name of the exception. The set call tries to replace the first item, and it also throws. The final line prints the list, which has not changed.
The real output from Java 25 on this machine is:
green
add failed: java.lang.UnsupportedOperationException
set failed: java.lang.UnsupportedOperationException
[red, green, blue]Reading is allowed and changing is not. Both add and set are changes, so both are refused. This is a run-time error. The compiler does not know that colors is immutable, because the type is still the general List type. The error appears only when the bad call runs.
If you do not catch the exception, the program stops with a message like this:
Exception in thread "main" java.lang.UnsupportedOperationExceptionWorked example 2: List.of refuses null
List.of also refuses null as an item. A null is Java's way to mean "no object here". The list does not allow it, because the method is designed to make a list that is safe to read everywhere.
import java.util.List;
public class Main {
public static void main(String[] args) {
try {
List<String> items = List.of("a", null, "c");
System.out.println(items);
} catch (NullPointerException e) {
System.out.println("NullPointerException thrown");
}
try {
List<String> found = List.of("a", "b");
System.out.println(found.contains(null));
} catch (NullPointerException e) {
System.out.println("contains(null) threw NullPointerException");
}
}
}NullPointerException thrown
contains(null) threw NullPointerExceptionThe first call fails while the list is being built, so no list is made. The second call fails too, even though the list has no null in it. The method contains(null) is refused, because the immutable list does not allow null as a question. If you need a list that might hold null, use Collections.unmodifiableList or Arrays.asList, which are covered in the lesson on Java collections.
Worked example 3: Map.of
Map.of makes an immutable map. A map stores pairs: a key and a value. The key is used to look up the value. The method takes the keys and values in turn.
import java.util.Map;
public class Main {
public static void main(String[] args) {
Map<String, Integer> prices = Map.of("apple", 3, "pear", 5);
System.out.println(prices.get("apple"));
System.out.println(prices.get("kiwi"));
try {
prices.put("kiwi", 7);
} catch (UnsupportedOperationException e) {
System.out.println("put failed: " + e.getClass().getName());
}
try {
Map<String, Integer> dup = Map.of("a", 1, "a", 2);
System.out.println(dup);
} catch (IllegalArgumentException e) {
System.out.println("IllegalArgumentException: " + e.getMessage());
}
}
}3
null
put failed: java.lang.UnsupportedOperationException
IllegalArgumentException: duplicate key: aThree things happen here. The first get finds the key apple and returns 3. The second get looks for kiwi, which is not in the map, and returns null. A map returns null for a missing key, so the program does not crash. The put call is refused with UnsupportedOperationException, the same as a list. The last part gives the map two entries with the same key. Map.of refuses this at the moment it is built, and throws IllegalArgumentException with the message duplicate key: a.
Notice that Map.of does not say in what order the entries are kept. Do not write code that depends on the order of a map made with Map.of. If you need a fixed order, use TreeMap, which sorts its keys.
Worked example 4: a view is not a copy
This is the most common surprise with immutable collections in Java. Collections.unmodifiableList makes a read-only view of a list. The view can be read, but it cannot be changed through the view. It still shows the items of the original list, and if the original changes, the view changes too.
List.copyOf makes a real copy that does not depend on the original.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> backing = new ArrayList<>(List.of("x", "y"));
List<String> view = Collections.unmodifiableList(backing);
List<String> copy = List.copyOf(backing);
backing.add("z");
System.out.println("view: " + view);
System.out.println("copy: " + copy);
try {
view.add("w");
} catch (UnsupportedOperationException e) {
System.out.println("view.add failed");
}
try {
List.of("a").sort(null);
} catch (UnsupportedOperationException e) {
System.out.println("sort on List.of failed");
}
}
}view: [x, y, z]
copy: [x, y]
view.add failed
sort on List.of failedThe backing list is mutable, so add("z") works. The view shows z because it is a window onto backing. The copy does not show z, because List.copyOf took its own snapshot before the add. Adding through the view is refused. The last example shows that List.of also refuses sort, because sorting changes the order of the items.
The lesson is simple. If you want a collection that nobody else can change, make a copy. If you only want a read-only window, a view is enough, but remember that the owner can still change what the view shows.
Worked example 5: a defensive copy in a class
A defensive copy is a copy that a class makes of a list it receives, so that the caller cannot change the class's data later. Here is a class that keeps a list of tags. The constructor copies the list it is given.
import java.util.ArrayList;
import java.util.List;
public class Main {
private final List<String> tags;
Main(List<String> input) {
this.tags = List.copyOf(input);
}
public static void main(String[] args) {
List<String> source = new ArrayList<>(List.of("java", "c++"));
Main m = new Main(source);
source.add("go");
System.out.println("source: " + source);
System.out.println("tags: " + m.tags);
try {
m.tags.add("rust");
} catch (UnsupportedOperationException e) {
System.out.println("tags.add failed");
}
}
}source: [java, c++, go]
tags: [java, c++]
tags.add failedThe caller added go to source after the object was built. The object's tags do not show it, because the copy was taken in the constructor. The field is also final, which means the field cannot point at a different list, and the list it points at cannot change. Together these make the class's data stable.
What List.of does not protect
List.of makes the list itself read-only. It does not make the items inside the list read-only. If an item is a mutable object, the object can still change.
import java.util.List;
public class Main {
public static void main(String[] args) {
StringBuilder note = new StringBuilder("hello");
List<StringBuilder> notes = List.of(note);
note.append(" world");
System.out.println(notes);
System.out.println(notes.get(0) == note);
}
}[hello world]
trueThe list cannot be changed, but the StringBuilder in it can. Changing note changes the item that the list holds, and the list shows the change. Immutability is a property of the container, not a promise about the items. For a full protection, the items should be immutable too, for example String values, numbers, or records with no setters.
C++: no built-in immutable container
C++ has no List.of and no immutable map type in the standard library. Instead, the language uses const. A const container cannot be changed through its own name. The compiler checks this for you, so a program that tries to change a const vector does not build.
C++ also gives you a way to write a function that reads a container without changing it. You pass the container as a const reference. The function can read the items but cannot change them.
Worked example 6: reading a const vector
This program makes a const vector, reads it in several ways, and passes it to a function that takes a read-only view.
#include <iostream>
#include <map>
#include <span>
#include <string>
#include <vector>
int total(std::span<const int> values) {
int sum = 0;
for (int v : values) sum += v;
return sum;
}
int main() {
const std::vector<int> nums{4, 8, 15};
std::cout << "size " << nums.size() << ", first " << nums.front() << "\n";
for (const int& n : nums) std::cout << n << " ";
std::cout << "\n";
std::cout << "total " << total(nums) << "\n";
std::vector<int> editable{1, 2, 3};
const std::vector<int>& view = editable;
editable.push_back(4);
std::cout << "view size " << view.size() << "\n";
const std::map<std::string, int> prices{{"apple", 3}, {"pear", 5}};
std::cout << "pear " << prices.at("pear") << "\n";
return 0;
}Compile it with g++ -std=c++20 -Wall, then run it. The output is:
size 3, first 4
4 8 15
total 27
view size 4
pear 5Walk through it. The first line uses size and front, which only read the vector, so they work on a const vector. The loop uses const int&, which means each item is read by reference and not changed. The function total takes a std::span<const int>. A span is a light view of a sequence of items, made in C++20. It does not copy the items. The const in span<const int> means the function cannot change the items it sees. A non-const vector can be passed to it, because the conversion from a vector to a read-only span is allowed.
The last part of the program makes a const map and uses at to read a value. The at function reads a key and gives back its value. It is a read-only function, so it works on a const map.
Worked example 7: a change to a const vector does not build
This program tries to add an item to a const vector.
#include <vector>
int main() {
const std::vector<int> nums{4, 8, 15};
nums.push_back(16);
}Compile it with g++ -std=c++20 -Wall -c. The compiler stops with an error. The first lines it prints are:
bad_push.cpp: In function ‘int main()’:
bad_push.cpp:5:19: error: passing ‘const std::vector<int>’ as ‘this’ argument discards qualifiers [-fpermissive]
5 | nums.push_back(16);
| ~~~~~~~~~~~~~~^~~~The message says that push_back would discard the const qualifier. A qualifier is a keyword such as const that adds a rule to a type. The compiler is saying that push_back needs to change the vector, and the vector was marked read-only. The program does not run at all. This is the strongest protection C++ has, because the mistake is found before the program is ever started.
The message also shows -fpermissive. That flag is the name of the rule in GCC. It tells you the error is one GCC could downgrade to a warning, but the default settings treat it as an error. Do not add -fpermissive to get past this error. It hides the real problem.
Worked example 8: a const map does not allow operator brackets
A std::map has an operator, written [], that reads a value by key. If the key does not exist, the operator creates a new entry with a default value. That creates a change, so [] cannot be used on a const map.
#include <map>
#include <string>
int main() {
const std::map<std::string, int> prices{{"apple", 3}};
int x = prices["kiwi"];
return x;
}The compiler reports:
bad_index.cpp: In function ‘int main()’:
bad_index.cpp:6:26: error: passing ‘const std::map<std::__cxx11::basic_string<char>, int>’ as ‘this’ argument discards qualifiers [-fpermissive]The fix is at, as in worked example 6. The at function does not create an entry. If the key is missing, it throws an exception. The program below catches that exception:
#include <iostream>
#include <map>
#include <stdexcept>
#include <string>
int main() {
const std::map<std::string, int> prices{{"apple", 3}, {"pear", 5}};
try {
std::cout << prices.at("kiwi") << "\n";
} catch (const std::out_of_range& e) {
std::cout << "out_of_range: " << e.what() << "\n";
}
}out_of_range: map::atThe message map::at names the function that failed. This is a run-time error, unlike the compile error from []. The difference is in the design: at promises to read only, and it reports a missing key by throwing an exception. The [] operator is allowed on a non-const map, and it inserts a default value when the key is missing.
Worked example 9: a const reference is a read-only name, not a copy
A const reference lets you read a container through another name, and the container can still be changed through its original name. This is shown in worked example 6, where view is a const reference to editable. The code added 4 through editable and then printed view size 4. The reference shows the change because it is a second name for the same vector.
This is the C++ version of the Java view from worked example 4. The rule is the same in both languages. A read-only name does not stop the owner from changing the thing. If you need a copy that nobody else can change, make a copy in C++ too. Copy the value into a new const object:
const std::vector<int> frozen = editable;The frozen variable is a separate vector. Later changes to editable do not affect it.
Worked example 10: the error from a span
A std::span<const int> lets a function read the items it is given. Inside the function, the items cannot be changed. This program tries to do that anyway:
#include <span>
#include <vector>
void clear_all(std::span<const int> values) {
values[0] = 0;
}
int main() {
std::vector<int> v{1, 2};
clear_all(v);
}The compiler stops with this error:
bad_span.cpp:5:15: error: assignment of read-only location ‘values.std::span<const int>::operator[](0)’The error is in the function, not in main. The const in the function's parameter type is the rule, and the compiler checks it where the change is written. The function can be fixed by removing the assignment, or by changing the parameter to std::span<int> if the function is meant to change the items.
The const_cast trap
C++ has a keyword, const_cast, that removes const from a reference or pointer. It is almost always a mistake. If the object was created as const, changing it through a const_cast is undefined behaviour. The compiler may have put that object in memory that it treats as read-only, or it may use the old value in code it has already optimised. The result can look correct on one run and wrong on the next.
const_cast is only safe when the object was created without const and you are removing a const that someone else put on a reference to it. Learn to use const correctly in the first place, and do not reach for const_cast to get past a compile error.
What happens when code tries to change an immutable collection
The two languages report the problem at different times. This table compares the common cases.
| Collection | Attempt | When it is caught | What you see |
|---|---|---|---|
Java List.of | add or set | At run time | UnsupportedOperationException |
Java List.of | contains(null) | At run time | NullPointerException |
C++ const std::vector | push_back | At compile time | discards qualifiers error |
C++ const std::map | [] | At compile time | discards qualifiers error |
C++ const_cast on a const object | write | Not checked | Undefined behaviour |
The Java rule can be broken by code that runs only in some cases. A rarely used branch can call add and fail only after a user does something unusual. The C++ rule catches the mistake for every path in the function, because the compiler reads all of the code. The C++ rule does not catch a const_cast, which is why it is the most dangerous option in the table.
Common mistakes
Mistake 1: thinking a view is a copy
Worked example 4 shows that Collections.unmodifiableList shows later changes to the original list. A class that returns such a view can still change its data through the owner. Use List.copyOf when you need a snapshot.
Mistake 2: thinking List.of protects the items
The section on what List.of does not protect shows that a mutable item stays mutable. Use immutable items when the whole structure must not change.
Mistake 3: passing null to List.of
Running List.of("a", null, "c") without a catch stops the program with java.lang.NullPointerException. The first line of the real trace is:
Exception in thread "main" java.lang.NullPointerExceptionThe method refuses null as an item. Replace the null with a value that means "missing", such as an empty string, or use a different collection that allows null.
Mistake 4: relying on the order of Map.of
Map.of does not promise an order. Your code should not depend on the order that keySet or entrySet returns. Use a TreeMap when the order matters.
Mistake 5: using [] on a const C++ map
error: passing ‘const std::map<...>’ as ‘this’ argument discards qualifiersUse at for a read-only lookup. The at function throws std::out_of_range when the key is missing, so catch it, or check the key first with find or contains.
Mistake 6: removing const to make an error go away
Adding const_cast or removing a const from a declaration may make the error go away. It does not fix the design problem. The code that changes the data should either be given a non-const object, or the code should be changed so that it does not change the data at all.
Practice
Exercise 1: predict the Java result
What does this program print? Does it stop with an error?
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> letters = List.of("a", "b");
System.out.println(letters.get(0));
letters.add("c");
System.out.println(letters);
}
}Show the answer
The program prints a, because get(0) reads the first item. Then add("c") throws UnsupportedOperationException, so the second println never runs. The real output is:
a
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:159)
at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:164)
at Main.main(Main.java:7)Exercise 2: make a defensive copy in Java
Write a class Team with a constructor that takes a List<String> of member names. The class must keep its own immutable copy, so that later changes to the list the caller passed do not change the team. Then write a main method that shows the copy does not change.
Show the answer
Use List.copyOf in the constructor and store the result in a final field:
import java.util.ArrayList;
import java.util.List;
public class Main {
static class Team {
private final List<String> members;
Team(List<String> names) {
this.members = List.copyOf(names);
}
List<String> members() {
return members;
}
}
public static void main(String[] args) {
List<String> names = new ArrayList<>(List.of("Ana", "Ben"));
Team team = new Team(names);
names.add("Cara");
System.out.println("names: " + names);
System.out.println("team: " + team.members());
}
}The output is:
names: [Ana, Ben, Cara]
team: [Ana, Ben]The members method returns the immutable list, so a caller can read it but cannot change the team's data through it.
Exercise 3: fix a const error in C++
This program does not build. Find the line that causes the error, and change the program so that it builds and prints the sum of the vector. Do not remove const from nums.
#include <iostream>
#include <vector>
int main() {
const std::vector<int> nums{4, 8, 15};
nums.push_back(16);
int sum = 0;
for (int n : nums) sum += n;
std::cout << sum << "\n";
}Show the answer
The line nums.push_back(16); causes the error, because push_back changes the vector and nums is const. The fix is to build a new vector that holds the extra value, and keep nums as it was. For example:
#include <iostream>
#include <vector>
int main() {
const std::vector<int> nums{4, 8, 15};
std::vector<int> more = nums;
more.push_back(16);
int sum = 0;
for (int n : more) sum += n;
std::cout << sum << "\n";
}The program prints 43. The original nums is unchanged, and more is the mutable copy that receives the new item.
Exercise 4: a missing key in C++
Write a C++ program with a const std::map<std::string, int> that holds {"apple", 3}. Look up the key "kiwi" with at, and catch the exception, printing the message from what. Then say why the program would not build if you used [] instead.
Show the answer
The program catches std::out_of_range, and prints its message:
#include <iostream>
#include <map>
#include <stdexcept>
#include <string>
int main() {
const std::map<std::string, int> prices{{"apple", 3}};
try {
std::cout << prices.at("kiwi") << "\n";
} catch (const std::out_of_range& e) {
std::cout << "out_of_range: " << e.what() << "\n";
}
}The output is out_of_range: map::at. The [] operator would not build on a const map, because it can insert a new entry when the key is missing. The compiler sees a change to a read-only object and reports discards qualifiers.
Exercise 5: a view that changes
A Java program makes an ArrayList named scores with three values. It makes an unmodifiable view named shown from scores, then adds a fourth value to scores. What does shown print, and can it be changed through shown?
Show the answer
The view shows the fourth value, because it is a window onto scores, and scores was changed. The view itself cannot be changed. Calling shown.add throws UnsupportedOperationException. The owner of scores can still change what the view shows, and that is the reason to use List.copyOf when the snapshot must stay fixed.
Summary
- An immutable collection cannot be changed after it is made, and the protection is only as strong as the language allows.
- In Java,
List.ofandMap.ofrefuse changes at run time withUnsupportedOperationException, andList.ofalso refusesnull. Collections.unmodifiableListis a view that shows later changes to the original, whileList.copyOfmakes a real copy.- In C++, a
constcontainer refuses changes at compile time, so the mistake is found before the program runs. - A
constreference or view does not stop the owner from changing the data, andconst_caston a const object is undefined behaviour.