Skip to content
academia.sh

Lesson 07 / 13

Inner and Local Classes

A single source file produces six class files, and four of those carry hidden fields never written in the source. An inner class binds to the enclosing instance through a field and reads that field live; a local class copies the variable it captures through a constructor parameter, which is why that variable can never be reassigned.

Contents

The previous lesson’s measurement left a restriction vanishing without ever being written into a class file: a final local variable’s restriction stopped only at the compiler. If that word disappears once compilation ends, what is it for? There is a place in Java where the compiler wants that very same word on its own, without it being written — and there, its counterpart becomes visible.

That place is where a class is written inside another class. In Java, a class can be defined in another class’s body, in a method’s body, or directly inside a new expression. In the source, all of these sit nested, in a single file; a class file, though, cannot be nested. There are three questions: how many separate files does this nesting open into, how do the produced files bind to the outside, and which side does that binding form leave the decision to?

The Measurement Core

The measurement compiles a source and reads every class file produced. For each file, two things are counted: its fields — those written in the source together with the hidden ones the compiler adds — and the constructor’s signature.

  • CI31 — The oracle is the source itself: we know which field we wrote, so any extra field appearing in the list is one the compiler added. A hidden field is told apart not by guessing but by its flag in the class file.
  • CI32 — All class files are produced from a single source file; the count covers every file in that directory.
  • CI33 — In the capture measurement, the object is produced before the outer field’s value is changed, so a later reading of that value is proof of a live read.
  • CI34 — In the compile trials, a single line changes, and every trial compiles in its own temporary directory.
  • CI35 — No environment-dependent data is read; what is counted is file count, field count, and constructor parameters.
// Gauge.java — the class files produced, the synthetic fields they carry, and constructor signatures
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 {
    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 List<String> classFiles(Path d) throws Exception {
        try (var s = Files.list(d)) {
            return s.map(p -> p.getFileName().toString())
                    .filter(n -> n.endsWith(".class")).sorted().toList();
        }
    }

    static String fields(Path d, String file) throws Exception {
        ClassModel cm = ClassFile.of().parse(d.resolve(file));
        List<String> a = new ArrayList<>();
        for (FieldModel f : cm.fields())
            a.add(f.fieldName().stringValue()
                    + (f.flags().has(AccessFlag.SYNTHETIC) ? " [synthetic]" : ""));
        return a.isEmpty() ? "-" : String.join(", ", a);
    }

    static String constructor(Path d, String file) throws Exception {
        ClassModel cm = ClassFile.of().parse(d.resolve(file));
        for (MethodModel m : cm.methods())
            if (m.methodName().stringValue().equals("<init>")) {
                List<String> p = new ArrayList<>();
                for (var a : m.methodTypeSymbol().parameterList()) p.add(a.displayName());
                return "(" + String.join(", ", p) + ")";
            }
        return "-";
    }
}

One Source File, Six Class Files

The measured source holds a single outer class and places five separate forms inside it: an inner class, a static nested class, a local class defined in an instance method, a local class defined in a static method, and an anonymous class.

// Production.java — how many files does nested class writing produce
import java.nio.file.Path;

public class Production {
    static final String SOURCE = """
        class Outer {
            int outerField = 1;
            class Inner { int read() { return outerField; } }
            static class Nest { int read() { return 2; } }
            Runnable instanceLocal() {
                int y = 3;
                class Local implements Runnable { public void run() { use(outerField + y); } }
                return new Local();
            }
            static Runnable staticLocal() {
                int y = 4;
                class SLocal implements Runnable { public void run() { use(y); } }
                return new SLocal();
            }
            Runnable anonymous() {
                int y = 5;
                return new Runnable() { public void run() { use(outerField + y); } };
            }
            static void use(int n) { }
        }
        """;

    public static void main(String[] args) throws Exception {
        Path d = Gauge.newDir();
        Gauge.compile(d, "Outer", SOURCE);
        System.out.printf("%-20s %-40s %s%n", "class file", "fields", "constructor sig");
        for (String f : Gauge.classFiles(d))
            System.out.printf("%-20s %-40s %s%n", f, Gauge.fields(d, f), Gauge.constructor(d, f));
        System.out.println("files produced: " + Gauge.classFiles(d).size());
    }
}
class file           fields                                   constructor sig
Outer$1.class        val$y [synthetic], this$0 [synthetic]    (Outer, int)
Outer$1Local.class   val$y [synthetic], this$0 [synthetic]    (Outer, int)
Outer$1SLocal.class  val$y [synthetic]                        (int)
Outer$Inner.class    this$0 [synthetic]                       (Outer)
Outer$Nest.class     -                                        ()
Outer.class          outerField                               ()
files produced: 6

A single source file gave off six class files. Nesting does not exist at the class-file level; every form gets its own file, and the names are derived from the outer class’s name. The anonymous class had no name at all in the source, and it still has a file.

The second column carries the real measurement. The only field written in the source is outerField, and it appears only in Outer’s file. The four fields showing up in the other files are not written in the source: this$0 and val$y. Four of the five forms carry at least one hidden field; the only form that does not is the static nested class.

The third column shows how the hidden fields get filled. Outer$Inner’s constructor takes an Outer parameter; no constructor was ever written for this class in the source, and whoever writes new supplies no parameter either. Outer$1Local and Outer$1 — the local class defined in an instance method, and the anonymous class — take two parameters: the enclosing instance and the captured number. Outer$1SLocal takes only the number, because there is no enclosing instance in a static method. The compiler has added both the field and the parameter filling it; writing something nested is not a matter of appearance, it is a binding mechanism.

The table splits the five forms into three groups by this mechanism. Those carrying both the enclosing instance and the captured value: the local class defined in an instance method, and the anonymous class. Those carrying only one: the inner class carrying the enclosing instance, and the local class in a static method carrying only the captured value. Those carrying neither: the static nested class. In the source, these five forms are written in a very similar way — all are a class declaration, and the difference between them is a word or a position — but they turn into three separate structures in the class file. Choosing a form is not choosing a spelling convenience.

Shared Field, Copied Local

The hidden fields carry two separate things, and the two do not behave the same way. this$0 is a reference; val$y is a copy of a value. To see the difference, both are read through the same object.

// Capture.java — the outer field is shared, the captured local is copied
public class Capture {
    int field = 1;

    Runnable produce() {
        int local = 1;
        class Trace implements Runnable {
            @Override public void run() { System.out.println("  field=" + field + " local=" + local); }
        }
        Runnable r = new Trace();
        field = 2;
        return r;
    }

    static final String BODY = """
        class Trial {
            int field = 1;
            Runnable f() {
                int local = 1;
                class Trace implements Runnable { public void run() { use(field + local); } }
                Runnable r = new Trace();
                %s
                return r;
            }
            static void use(int n) { }
        }
        """;

    public static void main(String[] args) throws Exception {
        Capture y = new Capture();
        Runnable r = y.produce();
        System.out.println("after the object was produced, field set to 2, local gone");
        r.run();
        y.field = 3;
        System.out.println("field written to 3 from outside");
        r.run();
        System.out.println();
        for (String[] s : new String[][] {{"field = 2;", "reassigning the outer field"},
                                          {"local = 2;", "reassigning the captured local"}})
            System.out.printf("%-32s -> does it compile: %s%n", s[1],
                    Gauge.compile(Gauge.newDir(), "Trial", BODY.formatted(s[0])));
    }
}
after the object was produced, field set to 2, local gone
  field=2 local=1
field written to 3 from outside
  field=3 local=1

reassigning the outer field      -> does it compile: true
reassigning the captured local   -> does it compile: false

At the moment the Trace object was produced, field was 1 and local was 1. After the object was produced, field changed twice, and both changes showed up in the value read: first 2, then 3. local, in turn, stayed 1 on both reads. Both names are written in the source the same way, with no qualifier at all — and yet one tracks the outside value, the other reads a copy frozen at the moment of production.

The difference comes from what the hidden fields carry. this$0 points to the outer object, and the field is fetched from that object on every read; so a change from outside is visible. val$y holds the number itself, filled in once, in the constructor; no link to the outside variable remains at all. A method’s local variable disappears once the method ends anyway — the object, though, keeps living, so there is nothing left to track.

The last two lines show how the compiler protects this copy. The version reassigning the outer field compiles; the version reassigning the captured local does not compile. The word final is not written anywhere in the source; the compiler places the restriction on its own, because the only case where the copy could disagree with the original is the original changing. The previous lesson’s measurement — “final on a local is never written to the class file” — finds its counterpart here: that restriction leaves no flag behind, but it protects a copy.

Which Outer Instance — A Third Source

The decision’s side splits in two in this section. What the captured value will be is settled the moment the constructor is called, and never asked again. Which outer object gets read, though, is asked again on every read — and the answer comes from neither the declared type nor the runtime type.

// Binding.java — which outer instance it is bound to is decided at runtime
public class Binding {
    static class Outer {
        String name;
        Outer(String name) { this.name = name; }
        class Inner { String read() { return name; } }
    }

    public static void main(String[] args) throws Exception {
        Outer a = new Outer("first");
        Outer b = new Outer("second");
        Outer.Inner x = a.new Inner();
        Outer.Inner y = b.new Inner();
        System.out.println("are x and y the same class: " + (x.getClass() == y.getClass()));
        System.out.println("x.read()              : " + x.read());
        System.out.println("y.read()              : " + y.read());
        System.out.println();
        for (String[] s : new String[][] {
                {"I make() { return new D().new I(); }", "with an outer instance"},
                {"static I make() { return new I(); }",  "without an outer instance"}})
            System.out.printf("%-26s -> does it compile: %s%n", s[1],
                    Gauge.compile(Gauge.newDir(), "D",
                            "class D { class I { } " + s[0] + " }"));
    }
}
are x and y the same class: true
x.read()              : first
y.read()              : second

with an outer instance     -> does it compile: true
without an outer instance  -> does it compile: false

x and y are the same class — the output’s first line confirms this — and their declared types are the same too. Yet the same method call gives two separate results. Neither of the two sources this course has measured so far can answer this: the two objects’ runtime type is one, and their declared type is one. What decides the result is the hidden field’s value — which outer object got written into this$0.

This does not add a third answer to the course’s question; it sharpens it. Where an overridden method has the object’s class give the answer, here it is a field of the object that gives it; both belong to runtime, but one is bound to type and the other to data. The practical consequence of this difference is: in a program with no subclass ever added, the first source never changes, while the second is settled fresh on every single new.

The bottom two lines show this field’s necessity. An inner class can only be produced through an outer instance; a new written from a context with no outer instance stops compilation. In a place where the hidden field cannot be filled, the object cannot even be constructed.

A Bounding Measurement: Where the Hidden Field Is Absent

The claim’s boundary sits in two rows of the first table. Outer$Nest carries no field at all, and its constructor takes no parameter; Outer$1SLocal, in turn, carries no this$0, only val$y.

A static nested class, despite being written in the outer class’s body, forms no bond with an outer instance at all. The nesting in the source is only for namespace and visibility; the class produced is an independent class sitting at the same level as the outer class. This is why the question “which outer object” has no runtime answer here — the question is never asked at all. The same reasoning applies to a local class defined in a static method: since there is no outer instance while that method is running, there is no reference to carry either, but the captured local variable still gets copied there too.

Two consequences follow from this. First, what decides whether a hidden field exists is not where the class is written, it is what it accesses from outside: a form that never touches the enclosing instance carries no reference. Second, the measurement can become unobservable. When an inner class is written that never reads the outer field, this$0 still gets added, but no trace of it shows in behavior; the difference between the two forms only surfaces once the outer object keeps living and can no longer be released for exactly this reason. A programmer unaware that a link was formed only notices, indirectly, that the object they thought was discarded is still being held.

Summary

  • A single source file produces six class files; nesting does not exist at the class-file level, even the anonymous class has its own file, and four of the five forms carry a hidden field never written in the source: this$0 holding the enclosing instance, val$y holding the captured value.
  • Two inner-class instances of the same class and the same declared type give two separate answers to the same call; what gives the answer is not the type, it is the enclosing instance written into the this$0 field.
  • What fills the hidden fields is the constructor’s hidden parameters: the inner class’s constructor carries the signature (Outer), the local class in an instance method (Outer, int), the one in a static method (int).
  • The outer field is shared and read live — after the object was produced, the values 2 and 3 were both read; the captured local is copied and stayed 1 on both reads.
  • Even with no final written in the source, reassigning the captured variable stops compilation; the restriction keeps the copy from disagreeing with the original.
  • A static nested class carries no hidden field, and a local class in a static method carries no this$0: what decides whether a hidden field exists is not where it is written, it is what it accesses from outside.

Next Step

In this lesson, the compiler produced classes never written in the source, and gave them fields and constructor parameters never written in the source either. What everything produced shared was taking on a binding job a programmer would otherwise have had to write by hand. There is a far more visible use of that same production ability: starting from a one-line declaration, letting the compiler produce a data carrier’s entire surface — its field readers, its equality, its hash value, and its string conversion. The next lesson counts these members and asks which side each one’s decision is left to; it then measures how far the produced equality protects, meaning how a mutable part inside a data carrier can be changed from outside.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close