Skip to content
academia.sh

Lesson 02 / 13

Access Modifiers

Crossing four visibility levels with four call positions, ten of sixteen pairs compile; the whole decision is made at compile time and the member stays right where it is at runtime — all four fields sit in the class file, and only a flag carries the distinction. Protected level also opens up the package.

Contents

The previous lesson measured the moment an object is constructed, and at its end a modifier came out of nowhere: because a method marked private was never inherited by the subclass, the constructor trap never arose there. That single word switched a decision’s side, but the word itself was never measured.

This lesson measures it. Its question is: who decides which side a member is seen from, and when is that decision made? The answer is more definite here than this course tends to give. Visibility is the most extreme case among the eight decisions the course measures: the whole decision belongs to the declared type and to compile time — runtime contributes nothing at all. The measurement shows this from two directions — by counting pairs that compile and pairs that do not, then by looking at where the member sits at runtime.

Whose Question Is Visibility

Java recognizes four visibility levels, and three of them have a name: private, protected, public. The fourth has no name — it is the level in effect when no modifier is written at all, and it is called package-private. This namelessness is not a detail: leaving a member unmarked is not failing to make a choice, it is choosing to leave it open to the package.

Levels have no meaning on their own; meaning is born only together with where access is attempted from. This is why the measurement is a crossing: each of the four levels is tried from four separate positions. The positions are — inside the class declaring the member, an unrelated class in the same package, a subclass in another package, an unrelated class in another package. Sixteen pairs come out, and each pair reduces to a single question: does this source compile?

The Object-Oriented Python and Types course established encapsulation as a contract at the name level; visibility there was an agreement, and is not repeated here. The distinction here is structural: the decision is made by the language’s own compiler, and rejection is not a warning, it is a source that does not compile.

The Measurement Core

The measurement runs no program at all. For every pair, a source set is produced, the compiler is invoked from within the program, and only the exit status is read.

  • CI7 — The measurement’s oracle is the rig itself: in every scenario the member is declared in exactly one place and read from exactly one place; there is no other reason for a compile failure.
  • CI8 — Every message the compiler produces is swallowed; the only thing measured is exit status. Why a source was rejected is read from the rig, not from the text.
  • CI9 — The same member is used in every scenario: Base‘s label field. The only thing that changes across levels is that field’s modifier.
// Compile.java — compiles a source set; the result is only compiled or not compiled
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 Compile {
    static boolean compiles(Map<String, String> files) throws Exception {
        Path d = Files.createTempDirectory("access");
        List<String> arg = new ArrayList<>(List.of("-d", d.resolve("out").toString()));
        for (var g : files.entrySet()) {
            Path p = d.resolve(g.getKey());
            Files.createDirectories(p.getParent());
            Files.writeString(p, g.getValue());
            arg.add(p.toString());
        }
        PrintWriter sink = new PrintWriter(Writer.nullWriter());
        return ToolProvider.findFirst("javac").orElseThrow()
                .run(sink, sink, arg.toArray(String[]::new)) == 0;
    }

    static List<String> fieldFlags(String source, String className) throws Exception {
        Path d = Files.createTempDirectory("flags");
        Path f = d.resolve(className + ".java");
        Files.writeString(f, source);
        PrintWriter sink = new PrintWriter(Writer.nullWriter());
        if (ToolProvider.findFirst("javac").orElseThrow()
                .run(sink, sink, "-d", d.toString(), f.toString()) != 0)
            throw new IllegalStateException("did not compile: " + className);
        List<String> lines = new ArrayList<>();
        for (FieldModel fm : ClassFile.of().parse(d.resolve(className + ".class")).fields()) {
            var flags = fm.flags();
            lines.add(fm.fieldName().stringValue() + " " + (flags.has(AccessFlag.PRIVATE) ? "ACC_PRIVATE"
                    : flags.has(AccessFlag.PROTECTED) ? "ACC_PROTECTED"
                    : flags.has(AccessFlag.PUBLIC) ? "ACC_PUBLIC" : "(no flag)"));
        }
        return lines;
    }
}

How Many of Sixteen Pairs Compile

// Access.java — four visibility levels x four call positions
import java.util.*;

public class Access {
    static final String[] LEVELS = { "private", "", "protected", "public" };
    static final String[] LEVEL_NAMES = { "private", "(default)", "protected", "public" };
    static final String FORMAT = "%-13s %-13s %-13s %-13s %s%n";

    static String base(String level) {
        return "package home;\npublic class Base {\n"
                + "    " + level + " String label = \"base field\";\n"
                + "    public String own() { return label; }\n}\n";
    }

    static Map<String, String> scenario(String level, int position) {
        Map<String, String> d = new LinkedHashMap<>();
        d.put("home/Base.java", base(level));
        switch (position) {
            case 1 -> d.put("home/Neighbor.java",
                    "package home;\nclass Neighbor { String read(Base u) { return u.label; } }\n");
            case 2 -> d.put("away/OutsideSub.java", "package away;\nimport home.Base;\n"
                    + "public class OutsideSub extends Base { public String read() { return this.label; } }\n");
            case 3 -> d.put("away/Outsider.java", "package away;\nimport home.Base;\n"
                    + "class Outsider { String read(Base u) { return u.label; } }\n");
            default -> { }
        }
        return d;
    }

    public static void main(String[] args) throws Exception {
        System.out.printf(FORMAT, "level", "same class", "same package", "subclass", "outside pkg");
        int compiledCount = 0;
        for (int i = 0; i < LEVELS.length; i++) {
            String[] results = new String[4];
            for (int j = 0; j < 4; j++) {
                boolean ok = Compile.compiles(scenario(LEVELS[i], j));
                results[j] = ok ? "compiled" : "not compiled";
                if (ok) compiledCount++;
            }
            System.out.printf(FORMAT, LEVEL_NAMES[i], results[0], results[1], results[2], results[3]);
        }
        System.out.println("compiled count of 16 pairs: " + compiledCount);
    }
}
level         same class    same package  subclass      outside pkg
private       compiled      not compiled  not compiled  not compiled
(default)     compiled      compiled      not compiled  not compiled
protected     compiled      compiled      compiled      not compiled
public        compiled      compiled      compiled      compiled
compiled count of 16 pairs: 10

Ten of sixteen pairs compile, six do not. The table’s shape is a staircase: every row opens what the previous one opened and adds one more position on top. Levels, for this reason, are not four separate rules, they are four nested rings; moving a member from one level to the next opens a new door and closes none.

The first column is the same across all four rows. A class sees its own member at every level; even private closes nothing inside the class itself. Visibility does not hide a member from its owner, it bounds the calling side.

The only difference between the first and second row is the absence of a single word, and that absence lets in every class in the same package. An unmarked field can be read from the Neighbor class; Neighbor carries no inheritance relationship to Base at all, it merely sits in the same package. In this table, a package is not an organizational unit, it is an access boundary.

What the last row says is that the fourth column fills for the first time. public is the only level opening outside the package declaring the member; the remaining three are bound to the package to some degree or other.

All of these decisions are made in one single place: the compiler. In none of the sixteen scenarios did a program run. For six of the pairs there is not even a class file — the defect shows up not as an error in a running program, but as a program that never came to exist. Among the eight decisions this course measures, visibility is the extreme where the runtime type’s share is zero.

A Bounding Measurement: Protected Level Also Opens the Package

One row in the table resists the first reading. protected is mostly read as “open only to subclasses”; yet the third row’s second column says compiled. A class that is not a subclass, and merely sits in the same package, can read the protected field.

This single measurement does more than bound a rule — it also reshapes what protected is. To see the detail, the same protected field is tried from five separate places.

  • CI10 — In all five trials, the Base class and the label field are byte-for-byte identical; what changes is only the trying class’s package, its inheritance relationship, and the declared type of the reference it uses.
// Protection.java — what protected level actually opens
import java.util.*;

public class Protection {
    static final String BASE = "package home;\npublic class Base {\n"
            + "    protected String label = \"base field\";\n}\n";
    static final String PREFIX = "package away;\nimport home.Base;\n";

    static final String[][] TRIALS = {
        { "same package, unrelated class", "home/Neighbor.java",
          "package home;\nclass Neighbor { String read(Base u) { return u.label; } }\n" },
        { "outside package, subclass, via this", "away/A.java",
          PREFIX + "public class A extends Base { String read() { return this.label; } }\n" },
        { "outside package, subclass, via own type", "away/B.java",
          PREFIX + "public class B extends Base { String read(B b) { return b.label; } }\n" },
        { "outside package, subclass, via Base type", "away/C.java",
          PREFIX + "public class C extends Base { String read(Base u) { return u.label; } }\n" },
        { "outside package, unrelated class", "away/D.java",
          PREFIX + "class D { String read(Base u) { return u.label; } }\n" },
    };

    public static void main(String[] args) throws Exception {
        System.out.printf("%-44s %s%n", "access site", "result");
        for (String[] d : TRIALS) {
            Map<String, String> k = new LinkedHashMap<>();
            k.put("home/Base.java", BASE);
            k.put(d[1], d[2]);
            System.out.printf("%-44s %s%n", d[0], Compile.compiles(k) ? "compiled" : "not compiled");
        }
    }
}
access site                                  result
same package, unrelated class                compiled
outside package, subclass, via this          compiled
outside package, subclass, via own type      compiled
outside package, subclass, via Base type     not compiled
outside package, unrelated class             not compiled

Three of five trials compile. The first row is the bounding measurement itself: a protected member is open to the whole package, and inheritance is not required for that. The sentence “protected means open only to subclasses” is disproved by the measurement.

The difference between the third and fourth rows is the most overlooked side of protected. Both classes are Base‘s subclass, both sit in an outside package, both try to read the same field. The distinction is in which reference the read is made through: B uses a reference of its own type and compiles, C uses a reference whose declared type is Base and does not compile. The object can be the same object; the decision is made by looking not at the object, but at the reference’s declared type.

This is the lesson’s opening claim in its sharpest form. Same field, same subclass, same line shape — and two separate outcomes. The only thing producing the difference is the type the compiler sees while resolving that name. A subclass in an outside package can use a protected member through its own line of inheritance, not as a general tool of the base type.

Where the Member Sits

Do accesses rejected at compile time have any runtime counterpart at all? The question is answered by looking at what state the member is in inside the class file.

// Flag.java — what four levels leave in the class file
public class Flag {
    static final String SOURCE = """
        class Member {
            private   String hidden  = "";
                      String shared  = "";
            protected String guarded = "";
            public    String open    = "";
        }
        """;

    public static void main(String[] args) throws Exception {
        var fields = Compile.fieldFlags(SOURCE, "Member");
        System.out.println("field count in class file: " + fields.size());
        for (String s : fields) System.out.println("  " + s);
    }
}
field count in class file: 4
  hidden ACC_PRIVATE
  shared (no flag)
  guarded ACC_PROTECTED
  open ACC_PUBLIC

All four of the four fields are in the class file. The private-declared field has not been deleted, hidden, or moved anywhere else; it sits in the same list, in the same form, as the others. The only thing separating them is a flag.

Package level’s counterpart shows here too. Each of the other three levels has a name in the class file; the unmarked field carries no flag at all. The namelessness in the source stays nameless in the class file too: package level is the absence of any of the three flags being set.

This is where the lesson’s second half comes from. Visibility is not a hiding mechanism; it does not put a member where no one can reach it. What it does is tell the compiler which sources to reject. Encapsulation is not a barrier, it is a contract — and that contract’s enforcement shows up not in a running program, but in a program that never gets born at all.

In Whose Compile Does the Defect Show

Narrowing a level is changing a single word in its owner’s source. Who this change hits can be measured: the same Base class is compiled at two levels, first alone, then together with a caller in an outside package.

// Cost.java — when a level is narrowed, whose compile does the defect break
import java.util.*;

public class Cost {
    static final String CALLER = "package away;\nimport home.Base;\n"
            + "class Outsider { String read(Base u) { return u.label; } }\n";

    static String base(String level) {
        return "package home;\npublic class Base {\n    " + level
                + " String label = \"base field\";\n}\n";
    }

    public static void main(String[] args) throws Exception {
        for (String level : new String[] { "public", "protected" }) {
            Map<String, String> both = new LinkedHashMap<>();
            both.put("home/Base.java", base(level));
            both.put("away/Outsider.java", CALLER);
            System.out.printf("%-10s | Base alone: %-13s | with caller: %s%n", level,
                    Compile.compiles(Map.of("home/Base.java", base(level))) ? "compiled" : "not compiled",
                    Compile.compiles(both) ? "compiled" : "not compiled");
        }
    }
}
public     | Base alone: compiled      | with caller: compiled
protected  | Base alone: compiled      | with caller: not compiled

The middle column says compiled on both rows. Base‘s source is flawless at protected level too; it compiles on its own, a class file is produced, no warning is raised. Nothing visible shows on the owner’s side.

The right column, though, splits. The same Outsider class, the same line, two separate outcomes — and nothing changed in Outsider’s own source. The defect is born not in the compile of the class that made the change, but in the compile of the class looking at it.

This is this lesson’s answer to the “where does the cost show” question every lesson in this course asks. Widening visibility is retroactively safe: it opens a new door and closes none that were open. Narrowing produces a break somewhere the owner cannot see. The cost of declaring a member public is the cost of later taking that back.

Summary

  • Crossing four visibility levels with four call positions, ten of sixteen pairs compile; the table is shaped like four nested rings, each level adding one position atop the last.
  • Visibility does not hide a member from its owner — the first column compiles at all four levels — what is bounded is the calling side; the unmarked level is not a non-choice either, it lets in unrelated classes in the same package and turns the package into an access boundary.
  • Protected level also opens up the package: a neighbor class that is not a subclass can read a protected field, so “open only to subclasses” is the wrong reading.
  • A subclass in an outside package can reach a protected member through a reference of its own type, not through a reference whose declared type is the base class: the decision looks not at the object, but at the reference’s declared type.
  • All four of the four fields sit in the class file; only a flag carries the distinction, and package level carries no flag. Visibility is not hiding, it is a rule handed to the compiler.
  • When a level is narrowed, the owner’s source still compiles; the defect is born in the caller’s compile. Widening is retroactively safe, narrowing breaks somewhere else.

Next Step

For two lessons the same pair has been used, and each measured one side of the decision: the runtime type chose the method called from the constructor, the declared type decided accessibility. The two were never set side by side. The next lesson is this course’s core measurement: eight separate decisions are read on a single reference, and how many of them came from which side is counted. On the same line, d.name() and d.label give two opposing answers — redeclaring fields under the same name is not overriding, it is hiding, and a hidden field does not vanish. In the same lesson, what the @Override mark catches at compile time, and why the decision switches sides when a signature changes, are measured.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close