JAVA / NESTED CLASSES AND THE OBJECT CONTRACT
toString and messages that actually help debugging
Write toString methods and exception messages that let you diagnose a failure from a single log line, without a debugger or a second run.
What you will learn
- Override toString with the type, the id, and the state that changes
- Read Outer$Inner@6d06d69c as class name plus identity hash, never a memory address
- Print a neighbour's id, not the neighbour, so toString cannot recurse forever
- Put the rejected value and the accepted range in every exception message
Understanding toString and messages that actually help debugging
Every class inherits Object.toString, and what it returns is getClass().getName() plus '@' plus Integer.toHexString(hashCode()) - for a nested class that reads Order$Line@6d06d69c, and for an anonymous one Order$1@6d06d69c. Java calls that method on your behalf in far more places than the code makes obvious: concatenation with +, println, String.valueOf, the {} placeholders of a logging framework, assertion failure text, the rendering of every element inside a collection's own toString, and the variable pane of a debugger. So the inherited version is not a neutral default; it is a decision to discard every fact about the object at the exact moment something went wrong.
The model that keeps toString useful is to write for a person who has the log line and nothing else: no debugger, no reproduction, possibly not even the source. That fixes what goes in - the type, the identifying field such as an id or key, and the state that varies while the bug is happening - and what stays out, namely anything the reader cannot act on. Unlike equals and hashCode, no library depends on the value, which is why a vague toString survives for years: nothing breaks, you just learn less from each failure. Keep the shape mechanical, Type[a=1, b=2], because you will end up grepping for it.
Two things reliably break a toString. If it prints a referenced object rather than that object's identifier, any back-reference makes rendering recurse until the stack is exhausted, and the error usually surfaces inside the logging call, far from the class that caused it; printing parent.id instead of parent ends the walk after one hop. If it touches a lazily initialised field, a database proxy, or a 100000-element list, it can throw or become the most expensive part of the log call, so bound the output and make sure the method cannot fail. The same reasoning applies to the messages beside it: 'invalid port' only repeats what the caller already suspected, while 'port out of range: 70000 (expected 1..65535)' names the rejected value and the rule it broke, which is usually the whole diagnosis.
import java.util.Objects;
public class ToStringDemo {
static final class Sensor {
private final String id;
private final double celsius;
private final boolean calibrated;
Sensor(String id, double celsius, boolean calibrated) {
this.id = id;
this.celsius = celsius;
this.calibrated = calibrated;
}
@Override
public String toString() {
return "Sensor[id=" + id + ", celsius=" + celsius
+ ", calibrated=" + calibrated + "]";
}
}
// Same data, no override: this is what the JVM prints for you.
static final class Raw {
private final String id;
Raw(String id) {
this.id = id;
}
}
public static void main(String[] args) {
Sensor s = new Sensor("t-14", 21.5, false);
System.out.println(s);
System.out.println(mask(new Raw("t-14").toString()));
Sensor missing = null;
System.out.println("concat with null: " + missing);
System.out.println("with fallback: " + Objects.toString(missing, "<no sensor>"));
try {
publish(s);
} catch (IllegalStateException e) {
System.out.println("caught: " + e.getMessage());
}
}
static void publish(Sensor s) {
if (!s.calibrated) {
throw new IllegalStateException("refusing to publish uncalibrated reading: " + s);
}
}
// Hides the identity hash so the demo prints the same text on every run.
static String mask(String text) {
return text.replaceAll("@[0-9a-f]+", "@<identityHash>");
}
}A useful toString is a one-line, human-facing statement of which object this is and what state it is in, and the same standard applies to every exception message you write.
Worked examples
A back-reference turns toString into a StackOverflowError
Shows why toString must print a neighbour's identifier rather than the neighbour itself.
public class CycleDemo {
static final class Node {
private final String name;
private Node parent;
private Node child;
Node(String name) {
this.name = name;
}
@Override
public String toString() {
return "Node[name=" + name + ", parent=" + parent + ", child=" + child + "]";
}
}
static final class Fixed {
private final String name;
private Fixed parent;
Fixed(String name) {
this.name = name;
}
@Override
public String toString() {
return "Fixed[name=" + name
+ ", parent=" + (parent == null ? "<none>" : parent.name) + "]";
}
}
public static void main(String[] args) {
Node root = new Node("root");
Node leaf = new Node("leaf");
root.child = leaf;
leaf.parent = root;
try {
System.out.println(root);
} catch (StackOverflowError e) {
System.out.println("printing root failed: " + e.getClass().getName());
}
Fixed fixedRoot = new Fixed("root");
Fixed fixedLeaf = new Fixed("leaf");
fixedLeaf.parent = fixedRoot;
System.out.println(fixedLeaf);
System.out.println(fixedRoot);
}
}Example explained
Line 1root.toString() interpolates child, whose toString interpolates parent, which is root again, so each round trip adds frames until the stack is gone.
Line 2Nothing is printed before the failure because println only receives a String after toString has returned.
Line 3The failure is a StackOverflowError, not an Exception, so a catch (Exception e) wrapped around the logging call would not stop it.
Line 4Fixed prints parent.name instead of parent, so rendering visits one extra object and then stops.
Containers and arrays inherit your mistakes
Shows that collection and array printing is built from element toString, and that arrays never override it.
import java.util.Arrays;
import java.util.List;
import java.util.Map;
public class Printing {
record Point(int x, int y) { }
public static void main(String[] args) {
Point[] points = { new Point(1, 2), new Point(3, 4) };
System.out.println(mask(points.toString()));
System.out.println(Arrays.toString(points));
System.out.println(List.of(points));
System.out.println(Map.of("origin", new Point(0, 0)));
int[][] grid = { { 1, 2 }, { 3, 4 } };
System.out.println(mask(Arrays.toString(grid)));
System.out.println(Arrays.deepToString(grid));
}
static String mask(String text) {
return text.replaceAll("@[0-9a-f]+", "@<hash>");
}
}Example explained
Line 1An array never overrides toString, so points.toString() gives the inherited form, where [LPrinting$Point; is the binary name for 'array of Point nested in Printing'.
Line 2Arrays.toString and the List and Map versions all delegate to the elements, so one missing override degrades every container that holds the object.
Line 3The record's generated toString lists every component and uses the simple name Point, not the binary name Printing$Point.
Line 4Arrays.toString on int[][] prints the inner arrays with the inherited form; deepToString is the version that recurses into them.
Messages that name the value and the rule
Shows the difference between a message you can act on and one that only says something went wrong.
public class Messages {
static int parsePort(String raw) {
int port;
try {
port = Integer.parseInt(raw.trim());
} catch (NumberFormatException e) {
throw new IllegalArgumentException("port must be an integer, got '" + raw + "'", e);
}
if (port < 1 || port > 65535) {
throw new IllegalArgumentException(
"port out of range: " + port + " (expected 1..65535)");
}
return port;
}
public static void main(String[] args) {
System.out.println(parsePort(" 8080 "));
for (String bad : new String[] { "8 080", "70000" }) {
try {
parsePort(bad);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage() + " | cause: " + e.getCause());
}
}
}
}Example explained
Line 1The raw input is quoted in the message, which is what makes the stray space inside 8 080 visible instead of invisible.
Line 2The range message names the rejected number and the accepted interval, so nobody has to open the source to learn the limit.
Line 3Passing e as the cause keeps NumberFormatException's own message and stack trace attached to the thrown IllegalArgumentException.
Line 4The second failure prints cause: null because that constructor was called without a cause, which is fine here since no lower-level exception existed.
Important notes
The hex suffix in Printing$Point@6d06d69c is Integer.toHexString(hashCode()), not an address; once hashCode is overridden, two equal objects print the same suffix, so it never proves two references are the same object.
Records (Java 16 and later) generate a toString covering every component, which is usually what you want; override it when a component holds a secret or an unbounded collection.
Common mistakes
Printing a referenced object instead of its id: with a parent and child pointing at each other, the log line you needed becomes a StackOverflowError thrown inside the logging call, where it looks like a logging bug.
Treating the format as an API by parsing values back out of toString, or comparing two toString results instead of the fields; the day someone adds a field, the calling code still compiles and quietly does the wrong thing.
Dumping everything: a toString containing a password, a token, an e-mail address, or a 50000-entry list copies that into every log index that ever saw the line, and can make one log call the slowest part of the request.
Try it yourself
Change, predict, then run
Write an Order class with fields id, customerEmail and List<String> items, and a toString that prints the id, the item count plus only the first two item names, and just the domain part of the e-mail. Print an order with five items and check the line stays around 80 characters and reveals no address.
Open the Java workspaceCheck your understanding
A production log contains the single line com.acme.Job$Handler@6d06d69c and nothing else about the failure. What does that line actually tell you?
- It is the object's memory address, so you can at least tell this handler apart from the other handlers in the same log.
- Handler's own toString threw an exception, so the JVM printed the inherited form as a fallback.
- It is Object.toString: the binary class name plus hashCode() in hex, so none of the job's state was recorded and Handler needs its own toString.
- Handler is an anonymous class, and anonymous classes cannot override toString, so this is the best output available.
Show answer
Object.toString returns getClass().getName() plus '@' plus Integer.toHexString(hashCode()), which is exactly why the line carries a class name and a number and no state. Option 0 is the usual misreading: the hex is a hash, not an address, it differs between runs, two distinct objects can share it, and if Handler overrode hashCode then every equal handler prints the same suffix. A toString that throws propagates the exception instead of falling back, and an anonymous class would appear as Job$1 and can override toString like any other class.