Skip to content
academia.sh

Lesson 03 / 10

Packages and Imports

An import is not a loading directive, it is a shorthand rule: class files produced when the same program is written with an import versus a fully qualified name are byte-for-byte identical, and a static import adds no step either; a package name, by contrast, changes the file, and two names cannot fit the same shorthand.

Contents

The previous lesson measured when a class runs and in what order: the entry point was a single signature satisfying four conditions at once, class-level initialization ran only once and on first use, and members not written in the source sat in the class file. But how the virtual machine finds that class was never asked.

The first lesson let a detail slip by: the class file carries the type it will call by name, and that name was in its full form. Yet writing List in the source is enough. The gap between the short name and the full name has to close somewhere — this lesson measures where. The question is: do the import form and the fully qualified form produce separate class files, or the same one?

Namespace, Fully Qualified Name, Import

In Java, a class’s real name is not the short name we write. Classes are placed into packages, and a class’s fully qualified name is the combination of the package name and the short name. A package is a namespace that prevents names from clashing: two Proxy classes in two separate packages are separate classes, because their full names are separate.

An import is a declaration that saves us from writing this full name every time. Its name is misleading: nothing gets “imported,” no file gets loaded, no code gets copied. The declaration only tells the compiler this — when I write List in this file, I mean java.util.List.

Some names need no such declaration at all. Classes in the language’s core package — String, Object, Math, and the like — can be written by their short names in every source file; that package is open to every file by default. No import appearing in a source file, for this reason, does not mean “no external class is used.” A shorthand existing and a declaration being written are separate things, and this lesson asks what each leaves in the class file.

The JavaScript and Python curricula each established a module system and package structure in their own courses; the concepts there are not repeated here. What this lesson asks is separate and measurable: does name resolution leave a trace in the class file? In the same way, scope and shadowing, established in the Programming Fundamentals course, are not repeated either; what is measured here is at which stage a name arrives at its full form.

The Measurement Core

What is counted in this lesson is neither an instruction nor a member: what is compared is the class file’s raw bytes. The core compiles a source, reads the resulting file as is, and gives two pieces of helper information — the fully qualified name the file carries, and the line number recorded for a method.

  • LR11 — The compared sources carry the same line layout. A class file records a line number; if the layout is not held fixed, the comparison measures the line layout, not the name’s spelling. This is why the measured sources are written on a single line.
  • LR12 — The comparison is made over the class file’s whole bytes; no field is left out.
  • LR13 — A source that fails to compile is written as “not compiled”; the compiler’s error text is not printed, because what is measured is not the text itself, it is compilation’s result.
  • LR14 — The file name is produced from the short class name, and the directory from the package name; the measurement prints no real path.
  • LR15 — The measurement reads no environment-dependent data; the only things counted are byte count, line number, and compilation’s result.
// Gauge.java — the class file's raw bytes, the name it carries, and the line number it records
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.lang.classfile.instruction.LineNumber;
import java.nio.file.*;
import java.util.spi.ToolProvider;

class Gauge {
    static byte[] compile(String source, String className) throws Exception {
        Path d = Files.createTempDirectory("gauge");
        Path f = d.resolve(className.substring(className.lastIndexOf('.') + 1) + ".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);
        return Files.readAllBytes(d.resolve(className.replace('.', '/') + ".class"));
    }

    static String name(byte[] b) { return ClassFile.of().parse(b).thisClass().asInternalName(); }

    static int lineNumber(byte[] b) {
        for (MethodModel m : ClassFile.of().parse(b).methods())
            if (m.methodName().equalsString("length"))
                for (CodeElement e : m.code().orElseThrow())
                    if (e instanceof LineNumber ln) return ln.line();
        return -1;
    }

    static String attempt(String source, String className) {
        try { compile(source, className); return "compiled"; }
        catch (Exception e) { return "not compiled"; }
    }
}

An Import Leaves No Trace in the Class File

Four forms of the same class are compiled. The first uses an import, the second writes the fully qualified name, the third adds one more import that is never used, the fourth is identical to the first but moves the import to the next line. The first three are single-line, and this is deliberate — the fourth will soon say why it has to be.

// Main.java — does an import leave a trace in the class file
import java.util.Arrays;

public class Main {
    static String body(String prefix, String type) {
        return prefix + "class Counter { static int length(" + type + " l) { return l.size(); } }\n";
    }

    public static void main(String[] args) throws Exception {
        byte[] imported = Gauge.compile(body("import java.util.List; ", "List"), "Counter");
        byte[] qualified = Gauge.compile(body("", "java.util.List"), "Counter");
        byte[] extraImport = Gauge.compile(
                body("import java.util.List; import java.util.ArrayList; ", "List"), "Counter");
        byte[] importBelow = Gauge.compile("import java.util.List;\n" + body("", "List"), "Counter");
        System.out.printf("%-24s %4d bytes  line %d  name %s%n",
                "import form", imported.length, Gauge.lineNumber(imported), Gauge.name(imported));
        System.out.printf("%-24s %4d bytes  line %d  name %s%n",
                "fully qualified form", qualified.length, Gauge.lineNumber(qualified), Gauge.name(qualified));
        System.out.printf("%-24s %4d bytes  line %d%n",
                "with unused extra", extraImport.length, Gauge.lineNumber(extraImport));
        System.out.printf("%-24s %4d bytes  line %d%n",
                "import on next line", importBelow.length, Gauge.lineNumber(importBelow));
        System.out.println("first two byte-for-byte equal : " + Arrays.equals(imported, qualified));
        System.out.println("first and third equal         : " + Arrays.equals(imported, extraImport));
        System.out.println("first and fourth equal        : " + Arrays.equals(imported, importBelow));
    }
}
import form               307 bytes  line 1  name Counter
fully qualified form      307 bytes  line 1  name Counter
with unused extra         307 bytes  line 1
import on next line       307 bytes  line 2
first two byte-for-byte equal : true
first and third equal         : true
first and fourth equal        : false

The first two files are byte-for-byte identical. One of the sources writes List, the other writes java.util.List, and the two do not diverge by even a single byte. What this means is definite: an import leaves no trace in the class file at all. The class file already carries the fully qualified name — the short form is nothing but a rule letting the compiler find that full name. Name resolution ends at compile time; nothing called an import ever reaches runtime. The reverse of the same result also holds: writing the fully qualified form carries no extra cost, because what gets shortened is only the source text.

The third row gives the same result once more. When an entirely unused import is added, the file stays identical. An unused declaration is not a weight, because a used one is not a weight either: both cost zero steps.

The fourth row is the measurement’s own check. When the import is moved to the next line, the file stays the same size but is no longer byte-for-byte identical, and where the difference sits is visible: the recorded line number is 2 instead of 1. So the class file is not indifferent to everything about the source — it does record line layout. The claim “leaves no trace” cannot, for this reason, be a loose claim; it holds only once the layout is held fixed. An equality claim with no counterexample has not really said what it measured.

A Package Name Changes the File

If an import leaves no trace, does a package declaration leave none either? Both are lines sitting at the top of the file. The measurement gives a separate answer.

// Pack.java — does a package name change the class file
public class Pack {
    static final String BODY =
            "class Counter { static int length(java.util.List l) { return l.size(); } }\n";

    public static void main(String[] args) throws Exception {
        byte[] unpackaged = Gauge.compile(BODY, "Counter");
        byte[] packaged = Gauge.compile("package store; " + BODY, "store.Counter");
        System.out.println("unpackaged: " + unpackaged.length + " bytes, name " + Gauge.name(unpackaged));
        System.out.println("packaged  : " + packaged.length + " bytes, name " + Gauge.name(packaged));
    }
}
unpackaged: 307 bytes, name Counter
packaged  : 313 bytes, name store/Counter

Once the package declaration is added, the file grows by six bytes and the name it carries becomes store/Counter instead of Counter. The added prefix is exactly six characters. Since the class file carries the name as text, growth is exactly that text’s growth.

The distinction comes from here: an import is the convenience of whoever writes the source; a package is the class’s identity. The first is valid in one file and ends with that file; the second is part of the class’s name and travels with it everywhere. Two separate classes came out of the same body, because their fully qualified names are separate.

The measurement itself proves a second thing too. While looking for the compiled file, the core builds the path by turning the dots in the fully qualified name into slashes; since the store.Counter measurement was read successfully, the compiler must have written that file under a store directory. A package is not a naming convention: it is both part of the name and it decides where the class will be looked for. A declaration sitting as a single word in the source finds its counterpart as a directory in the file layout.

A Bounding Measurement: Zero Steps, Not Zero Rules

There is one more form of shorthand. Static import lets a class’s static member also be written by its short name: max instead of Math.max. Does this add zero steps too, and does the shorthand really cost nothing at all?

// StaticImport.java — static import and name clash
import java.util.Arrays;

public class StaticImport {
    static String body(String prefix, String call) {
        return prefix + "class Greatest { static int biggest(int a, int b) { return " + call + "(a, b); } }\n";
    }

    public static void main(String[] args) throws Exception {
        byte[] qualified = Gauge.compile(body("", "Math.max"), "Greatest");
        byte[] statically = Gauge.compile(body("import static java.lang.Math.max; ", "max"), "Greatest");
        System.out.println("Math.max form       : " + qualified.length + " bytes");
        System.out.println("static import form  : " + statically.length + " bytes");
        System.out.println("byte-for-byte equal : " + Arrays.equals(qualified, statically));
    }
}
Math.max form       : 288 bytes
static import form  : 288 bytes
byte-for-byte equal : true

Static import adds no step either; the two files are byte-for-byte identical. The name of the class owning the method is already written inside the call itself; the short form only erases that name from the source text.

Every measurement up to here has given zero. Does shorthand really have no cost at all? The cost is paid not at runtime, it is paid at compile time, and it is real. The five cases below write the same two classes — two classes sharing a short name, sitting in two separate packages — in five separate forms.

// Clash.java — under which condition is a name clash a compile error
public class Clash {
    public static void main(String[] args) {
        String[][] cases = {
            {"two exact names, even unused",
             "import java.net.Proxy; import java.lang.reflect.Proxy; class Clashing { }"},
            {"two wildcard imports, unused",
             "import java.net.*; import java.lang.reflect.*; class Clashing { }"},
            {"two wildcard imports, used",
             "import java.net.*; import java.lang.reflect.*; class Clashing { Proxy p; }"},
            {"exact name + wildcard, used",
             "import java.net.Proxy; import java.lang.reflect.*; class Clashing { Proxy p; }"},
            {"no import, two full names",
             "class Clashing { java.net.Proxy a; java.lang.reflect.Proxy b; }"},
        };
        for (String[] c : cases)
            System.out.printf("%-32s %s%n", c[0], Gauge.attempt(c[1], "Clashing"));
    }
}
two exact names, even unused     not compiled
two wildcard imports, unused     compiled
two wildcard imports, used       not compiled
exact name + wildcard, used      compiled
no import, two full names        compiled

Five rows give the rule of name resolution from end to end. First row: importing two classes by exact name is a compile error even if those names are never used — the shorthand table is built before the source itself, and one short name cannot point to two full ones. The second and third rows show the reverse: importing an entire package as a wildcard does not by itself cause a clash; the error only surfaces once the ambiguous name is used. A wildcard import is not a decision, it is a search list.

The fourth row states the order: an import written by exact name takes priority over a wildcard import, so no ambiguity arises. The fifth row draws the boundary — when both classes are written by their full names, they can be used side by side without a problem. So using the two together is not forbidden; fitting them into the same shorthand is.

The course’s measure is read once more from here. The first lesson showed a case where the added step was absent through constant folding; here there are three more cases where the added step is absent, and in all three, the side paying the cost is the compiler. A form costing nothing at runtime does not mean that form has no rules.

Summary

  • A class’s real name is its fully qualified name; a package is a namespace, and the class file carries this name as text.
  • The class files produced by the import form and the fully qualified form are byte-for-byte identical; an unused import does not change the file either. Name resolution ends at compile time.
  • A class file records the source’s line layout: when an import is moved to the next line, the file stays the same size but is no longer identical, because the recorded line number becomes 2 instead of 1.
  • A package declaration changes the file: the name it carries becomes store/Counter instead of Counter, and the file grows by exactly as many bytes as the added prefix.
  • Static import adds zero steps too; on the other hand, importing two classes sharing a short name by exact name is a compile error even if those names are never used. In a wildcard import, the error surfaces only once the ambiguous name is used, and an exact-name import takes priority.
  • Shorthand’s runtime cost is zero, its compile-time rule is real: one short name in one file can point to only one full name, but two classes can be used side by side by their full names.

Next Step

Throughout this topic, what got measured was always what the compiler adds: a step, a member, a name. In all three, the decision was made at compile time, and only the result reached runtime — which class was meant was already settled by the time the class file was written. If name resolution ends at compile time, when is the value itself decided? The next topic starts measuring this from the crossing between a primitive type and a wrapper: an integer assigned to a wrapper is a single equals sign in the source, and a call producing an object in the class file.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close