Skip to content
academia.sh

Lesson 08 / 13

Records and Enumerations

A one-line record declaration produces ten members in the class file, and the produced class cannot be extended, meaning its declared type and runtime type can never come apart. In an enum, a constant with its own body written takes its own class; a record's produced equality, in turn, protects only shallowly.

Contents

The previous lesson had the compiler produce classes never written in the source, and give them hidden fields and hidden constructor parameters. What everything produced shared was taking on a binding job a programmer would otherwise write by hand — but what was produced stayed invisible: none of it was a surface callable from the source.

This lesson puts that same production ability to a visible job. In Java, a data carrier’s entire surface — its field readers, equality, hash value, and string conversion — can be produced from a single line; that line’s name is the record declaration. An enumeration, defining a fixed set, does a similar production. There are three questions: how many members does one line produce, which side do the produced members leave the decision to, and how far does the produced equality protect?

The Measurement Core

The measurement compiles a source, reads the field and method names in the class file it produces, reports whether the class can be extended, and also returns whether a given source compiles at all.

  • CI36 — The oracle is the source itself: the record declaration’s body is empty, so every member appearing in the class file is one the compiler produced.
  • CI37 — The member list is read from the class file; the count is based on the file produced, not the source’s spelling.
  • CI38 — Compile trials run in their own temporary directories, and the result is read through a single exit code.
  • CI39 — In the equality measurement, two objects are constructed separately; identity is compared only with ==, and no identity number is printed.
  • CI40 — Hash value is invoked only with the question of whether two objects’ hashes are equal to each other, and that question is asked only for components producing a value-based hash.
// Gauge.java — members in the class file and the extension trial
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.lang.reflect.AccessFlag;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

class Gauge {
    record ClassInfo(boolean isFinal, List<String> fields, List<String> methods) {}

    static Path newDir() throws Exception { return Files.createTempDirectory("gauge"); }

    static boolean compile(Path d, String name, String source) throws Exception {
        Path f = d.resolve(name + ".java");
        Files.writeString(f, source);
        PrintWriter sink = new PrintWriter(Writer.nullWriter());
        return ToolProvider.findFirst("javac").orElseThrow()
                .run(sink, sink, "-d", d.toString(), "-cp", d.toString(), f.toString()) == 0;
    }

    static ClassInfo read(String source, String name) throws Exception {
        Path d = newDir();
        if (!compile(d, name, source)) throw new IllegalStateException("did not compile: " + name);
        ClassModel cm = ClassFile.of().parse(d.resolve(name + ".class"));
        List<String> fields = new ArrayList<>();
        for (FieldModel f : cm.fields())
            fields.add(f.fieldName().stringValue()
                    + (f.flags().has(AccessFlag.FINAL) ? " [final]" : ""));
        List<String> methods = new ArrayList<>();
        for (MethodModel m : cm.methods()) methods.add(m.methodName().stringValue());
        return new ClassInfo(cm.flags().has(AccessFlag.FINAL), fields, methods);
    }
}

One Line, Ten Members

The measured source is a single line, and its body is empty. The course’s concrete axis continues here too: a three-field inventory record — name, count, and weight in grams.

// Produced.java — how many members come out of a one-line record declaration
import java.util.TreeSet;

public class Produced {
    static final String MANUAL = """
        class Manual {
            private final String name; private final int count; private final long weight;
            Manual(String name, int count, long weight) {
                this.name = name; this.count = count; this.weight = weight;
            }
            public String name() { return name; }
            public int count() { return count; }
            public long weight() { return weight; }
            @Override public boolean equals(Object o) {
                return o instanceof Manual e
                        && name.equals(e.name) && count == e.count && weight == e.weight;
            }
            @Override public int hashCode() {
                return java.util.Objects.hash(name, count, weight);
            }
            @Override public String toString() {
                return "Manual[name=" + name + ", count=" + count + ", weight=" + weight + "]";
            }
        }
        """;

    public static void main(String[] args) throws Exception {
        String source = "record Entry(String name, int count, long weight) { }";
        Gauge.ClassInfo k = Gauge.read(source, "Entry");
        System.out.println("source: " + source);
        System.out.println("fields (" + k.fields().size() + "): " + k.fields());
        System.out.println("methods (" + k.methods().size() + "): " + k.methods());
        System.out.println("class final: " + k.isFinal());
        System.out.println();
        Gauge.ClassInfo e = Gauge.read(MANUAL, "Manual");
        System.out.println("source lines: record 1, hand-written " + MANUAL.strip().lines().count());
        System.out.println("member count : record " + (k.fields().size() + k.methods().size())
                + ", hand-written " + (e.fields().size() + e.methods().size()));
        System.out.println("method names equal: "
                + new TreeSet<>(k.methods()).equals(new TreeSet<>(e.methods())));
        System.out.println();
        System.out.printf("%-34s -> does it compile: %s%n", "extending the record",
                Gauge.compile(Gauge.newDir(), "T", source + " class T extends Entry { }"));
        System.out.printf("%-34s -> does it compile: %s%n", "record implementing an interface",
                Gauge.compile(Gauge.newDir(), "T", "interface T { }"
                        + " record Entry(String name) implements T { }"));
        System.out.printf("%-34s -> does it compile: %s%n", "reassigning its field",
                Gauge.compile(Gauge.newDir(), "T",
                        "record Entry(String name) { } class T { void f(Entry k) { k.name = \"x\"; } }"));
    }
}
source: record Entry(String name, int count, long weight) { }
fields (3): [name [final], count [final], weight [final]]
methods (7): [<init>, toString, hashCode, equals, name, count, weight]
class final: true

source lines: record 1, hand-written 19
member count : record 10, hand-written 10
method names equal: true

extending the record               -> does it compile: false
record implementing an interface   -> does it compile: true
reassigning its field              -> does it compile: false

A single declaration line was written in the source; the class file carries three fields and seven methods, ten members in total. All three fields carry the final flag. Of the seven methods, three are component readers (name, count, weight), three are the surface Java expects from every object (toString, hashCode, equals), and one is the canonical constructor taking the components. This is where it differs from the previous lesson’s hidden fields: these are not hidden, they are a callable surface.

The middle three lines measure what this surface costs when written by hand. A class building the same ten members by hand is 19 lines, and it also leaves 10 members in its class file; the two classes’ method names are identical. A record brings no new mechanism — what turns up at the class-file level is an ordinary class. What it brings is that the mistakes possible in writing these ten members by hand disappear: when a component is added, it is possible to forget it in a hand-written equality method; not so in the produced one.

The Java Fundamentals course measured this same thing for enumerations too, and showed that a declaration writing only two names in the source carries fields and methods absent from the source in its class file. The two forms use the same mechanism; what differs is what they produce, not whether they produce.

The three trials in the bottom section show one more side of what is produced. A record cannot be extended: the class carries the final flag, and a class extending it does not compile. Implementing an interface, though, is free. Reassigning its field does not compile either, because all the fields are final — the restriction the previous lesson measured is enforced here without ever being written in the source.

Which Side Do the Produced Members Leave the Decision To

A record’s not being extendable brings the course’s question to an odd place. Since a record type cannot have a subtype, a name declared with that type will always have the same runtime type. Does the question, then, lose its meaning for the produced members?

// Side.java — which side do the produced members leave the decision to
import java.util.*;

public class Side {
    interface Carrier { }
    record Entry(String name, int count) implements Carrier { }

    enum Operation {
        ADD { @Override int apply(int a) { return a + 1; } },
        KEEP;
        int apply(int a) { return a; }
    }

    public static void main(String[] args) {
        Carrier t = new Entry("bolt", 12);
        Object n = t;
        System.out.println("declared type Object, toString      : " + n);
        Entry a = new Entry("bolt", 12), b = new Entry("bolt", 12);
        System.out.println("a == b                               : " + (a == b));
        System.out.println("a.equals(b)                          : " + a.equals(b));
        System.out.println("hash codes equal                     : " + (a.hashCode() == b.hashCode()));
        Map<Entry, String> m = new HashMap<>();
        m.put(a, "first");
        System.out.println("read with key b                      : " + m.get(b));
        System.out.println();
        for (Operation i : Operation.values())
            System.out.printf("%-5s class is Operation itself: %-5s | declaring type: %s | apply(1)=%d%n",
                    i, i.getClass() == Operation.class, i.getDeclaringClass().getSimpleName(), i.apply(1));
        System.out.println("Operation.ADD and valueOf(\"ADD\") same object: "
                + (Operation.ADD == Operation.valueOf("ADD")));
    }
}
declared type Object, toString      : Entry[name=bolt, count=12]
a == b                               : false
a.equals(b)                          : true
hash codes equal                     : true
read with key b                      : first

ADD   class is Operation itself: false | declaring type: Operation | apply(1)=2
KEEP  class is Operation itself: true  | declaring type: Operation | apply(1)=1
Operation.ADD and valueOf("ADD") same object: true

The first line answers the question. The declared type is Object, and Object’s own toString method does not give what the record gives; the printed text, though, is exactly the form the record produces. The produced toString is an overriding, and like every overriding, leaves the decision to the runtime type. Because a record cannot be extended, that type is the only known type — but the decision still comes from that side. Not being extendable does not change which side the decision comes from; it makes the answer knowable in advance.

The next four lines show what the produced equality is. a and b are two separately constructed objects, and == gives false: identity is separate. equals, though, gives true, because the produced implementation compares the components. The hash values are equal too, and the observable consequence of this is the last line: a map entry added with a can be read with b. These three lines, where identity and equality come apart, are the definition of how a data carrier should behave — and not a single character of it was written in the source.

For an enumeration, the same question is asked from the opposite end. ADD and KEEP share the same declared type, but ADD‘s runtime class is not the enumeration itself: a constant with its own body written takes its own class. For KEEP, the two are the same. The two constants give separate answers to the same method call — 2 and 1 — and what gives the answer, again, is the runtime type. The declaring type stays Operation for both; this shows that, at the point where the declared type and the runtime type come apart, which set a constant belongs to is not lost. The last line, in turn, measures the set’s being fixed: a constant found by name is the same object as the one found with ==, because there is exactly one of every constant.

Why the two forms sit side by side in this lesson is read from here. A record is written for carriers whose count is unknown, and leaves each one’s identity to its value; an enumeration defines a set whose count is known, and keeps each one’s identity in itself. In one, equals can be true while == is false; in the other, the two always answer together. What they share is the source of their produced members: both declarations have the compiler produce a surface, and the produced surface’s decision comes from the runtime type.

A Bounding Measurement: A Record Is Shallowly Immutable

All the produced fields were final, and the produced equality compared components. Read together, these two facts make a record look “immutable.” The boundary sits exactly here.

// Shallow.java — a record is shallowly immutable
import java.util.Arrays;

public class Shallow {
    record Batch(String name, int[] counts) { }

    record Safe(String name, int[] counts) {
        Safe { counts = counts.clone(); }
    }

    public static void main(String[] args) {
        int[] outside = {1, 2, 3};
        Batch p = new Batch("bolt", outside);
        Safe g = new Safe("bolt", outside);
        System.out.println("after construction  : " + Arrays.toString(p.counts())
                + " | cloning: " + Arrays.toString(g.counts()));
        outside[0] = 99;
        System.out.println("written from outside: " + Arrays.toString(p.counts())
                + " | cloning: " + Arrays.toString(g.counts()));
        System.out.println();
        Batch q = new Batch("bolt", new int[] {99, 2, 3});
        System.out.println("name fields equal   : " + p.name().equals(q.name()));
        System.out.println("array content equal : " + Arrays.equals(p.counts(), q.counts()));
        System.out.println("p.equals(q)         : " + p.equals(q));
    }
}
after construction  : [1, 2, 3] | cloning: [1, 2, 3]
written from outside: [99, 2, 3] | cloning: [1, 2, 3]

name fields equal   : true
array content equal : true
p.equals(q)         : false

After p was constructed, none of its members were ever written to; even so, its counts component changed. The side making the change never even saw the record — it wrote to the array it had in hand, and the record was holding that very same array. As measured in the previous lesson, a final field protects only the binding; the object at the end of that binding sits outside the restriction. A record applies that restriction per component, so its protection is only as deep as the components’ type — it is shallow.

The bottom three lines show the same boundary on the equality side. p and q now carry the same name and an array with the same content; both comparisons give true. p.equals(q), even so, is false. The produced equality compares every component by that component’s own equality; an array’s own equality is identity. Inside the same record type, one component looks at value, the other at identity, and nowhere in the source is this distinction written.

Both defects have their remedy in the same place. The Safe record clones the array in its canonical constructor and is unaffected by the outside change; the output’s second line shows this. What the compiler produces is a starting point, not a guarantee: the produced members inherit the components’ behavior, and fixing it is left to whoever writes it.

Summary

  • A one-line record declaration with an empty body produces three fields and seven methods in the class file; all three fields carry the final flag.
  • The seven produced methods are three component readers, three shared-surface methods (toString, hashCode, equals), and the canonical constructor taking the components.
  • A record cannot be extended, can implement an interface, and reassigning its field stops compilation.
  • The produced members leave the decision to the runtime type: even when the declared type is Object, the printed text is the form the record produces. Not being extendable does not change which side the decision comes from; it makes the answer knowable in advance.
  • For two separately constructed records, == is false, equals is true, and their hash values are equal; in an enumeration, a constant with its own body written takes its own class and gives a separate answer to the same call.
  • A record’s protection is shallow: an array component changes from outside, and the produced equality looks at that component’s identity, not its value; cloning in the canonical constructor fixes both.

Next Step

Throughout this topic, the same question was measured: did the declared type give the decision, or the runtime type? In the last two lessons, a third side joined the question — the compiler itself. It produced the hidden fields, the constructor parameters, and a data carrier’s entire surface; everything it produced sat in the class file and could be read at runtime. So what happens to another piece of information the compiler holds — type information itself? Which type of element a container holds is written in the source, and the compiler checks against that writing. The next topic measures how much of that information survives at runtime: can two containers written with two separate type parameters be told apart at runtime, and once the side doing the checking is gone, who pays the cost of writing the wrong thing?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close