This program has
two immutable value classes, which are classes
whose instances represent values. One class represents a point on the plane with
integer coordinates, and the second class adds a bit of color to the puzzle. The
main program creates and prints an instance of the second class. What does the
program print?
class Point { private final int x, y; private final String name; // Cached at construction time Point(int x, int y) { this.x = x; this.y = y; name = makeName(); } protected String makeName() { return "[" + x + "," + y + "]"; } public final String toString() { return name; } } public class ColorPoint extends Point { private final String color; ColorPoint(int x, int y, String color) { super(x, y); this.color = color; } protected String makeName() { return super.makeName() + ":" + color; } public static void main(String[] args) { System.out.println(new ColorPoint(4, 2, "purple")); } }
Solution 51: What's the Point?
The main method creates and prints a
ColorPoint instance. The println method invokes the
toString method of the ColorPoint instance, which is defined
in Point. The toString method simply returns the value of the
name field, which is initialized in the Point constructor by
calling the makeName method. For a Point instance, the
makeName method returns a string of the form [x,y]. For a
ColorPoint instance, makeName is overridden to return a string
of the form [x,y]:color. In this case, x is 4, y is 2, and the color is
purple, so the program prints [4,2]:purple, right? No. If you ran the
program, you found that it prints [4,2]:null. What is the matter with
the program?
The program suffers from a problem with the order of instance
initialization. To understand the problem, we will trace the program execution
in detail. Here is an annotated program listing to guide us:
class Point { protected final int x, y; private final String name; Point(int x, int y) { this.x = x; this.y = y; name = makeName(); // 3. Invoke subclass method } protected String makeName() { return "[" + x + "," + y + "]"; } public final String toString() { return name; } } public class ColorPoint extends Point { private final String color; ColorPoint(int x, int y, String color) { super(x, y); // 2. Chain to Point constructor this.color = color; // 5. Initialize blank final-Too late } protected String makeName() { // 4. Executes before subclass constructor body! return super.makeName() + ":" + color; } public static void main(String[] args) { // 1. Invoke subclass constructor System.out.println(new ColorPoint(4, 2, "purple")); } }
In the explanation that follows, the numbers in parentheses
refer to the numbers in the comments in the annotated listing. First, the
program creates a ColorPoint instance by invoking the
ColorPoint constructor (1). This constructor starts by chaining to the
superclass constructor, as all constructors do (2). The superclass constructor
assigns 4 to the x field of the object under construction and 2 to its
y field. Then the constructor invokes makeName, which is
overridden by the subclass (3).
The makeName method in ColorPoint (4)
executes before the body of the ColorPoint constructor, and therein
lies the heart of the problem. The makeName method first invokes
super.makeName, which returns [4,2] as expected. Then the
method appends the string ":" and the value of the color
field, converted to a string. But what is the value of the color field
at this point? It has yet to be initialized, so it still contains its default
value of null. Therefore, the makeName method returns the
string "[4,2]:null". The superclass constructor assigns this value to
the name field (3) and returns control to the subclass constructor.
The subclass constructor then assigns the value
"purple" to the color field (5), but it is too late. The
color field has already been used to initialize the name field
in the superclass to an incorrect value. The subclass constructor returns, and
the newly created ColorPoint instance is passed to the println
method, which duly invokes its toString method. This method returns the
contents of its name field, "[4,2]:null", so that is what the
program prints.
This puzzle illustrates that it is
possible to observe the value of a final instance field before its value has
been assigned, when it still contains the default value for its type. In
a sense, this puzzle is the instance analog of Puzzle 49, which observed the value of
a final static field before its value had been assigned. In both cases, the
puzzle resulted from a circularity in initialization. In Puzzle 49, it was class
initialization; in this puzzle, it is instance initialization. Both cases have
the potential for enormous confusion. There is one point where the analogy breaks down: Circular class
initialization is a necessary evil, but circular
instance initialization can and should always be avoided.
The problem arises whenever a constructor calls a method that
has been overridden in its subclass. A method invoked in this way always runs
before the instance has been initialized, when its declared fields still have
their default values. To avoid this problem, never
call overridable methods from constructors, either directly or indirectly
[EJ Item 15]. This prohibition extends to instance initializers and the bodies
of the pseudoconstructors readObject and
clone. (These methods are called pseudoconstructors because they create
objects without invoking a constructor.)
You can fix the problem by initializing the field name
lazily, when it is first used, rather than eagerly, when the Point
instance is created. With this change, the program prints [4,2]:purple
as expected:
class Point { protected final int x, y; private String name; // Lazily initialized Point(int x, int y) { this.x = x; this.y = y; // name initialization removed } protected String makeName() { return "[" + x + "," + y + "]"; } // Lazily computes and caches name on first use public final synchronized String toString() { if (name == null) name = makeName(); return name; } }
Although lazy initialization fixes the problem, it is a bad
idea to have one value class extend another, adding a field that affects
equals comparisons. You can't provide value-based equals
methods on both the superclass and subclass without violating the general
contract for Object.equals or eliminating the possibility of meaningful
comparisons between superclass and subclass instances [EJ Item 7].
The circular instance initialization problem is a can of worms
for language designers. C++ addresses the problem by changing the type of the
object from the superclass type to the subclass type during construction.
With this solution, the original program in this puzzle would print
[4,2]. We're not aware of any popular language that addresses this
issue satisfactorily. Perhaps it is worth considering making circular instance
initialization illegal by throwing an unchecked exception when a superclass
constructor calls a subclass method.
To summarize, you must never call an overridable method from a
constructor under any circumstances. The resulting circularities in instance
initialization can be fatal. The solution to this problem is lazy initialization
[EJ Items 13, 48].
No comments:
Post a Comment
Your comments are welcome!