mattone
dopo mattoneTHE BOOK SERIES
IT/EN
← Java guide

Java 25 · 7/39

7. Defining classes and objects

Java 25 · Complete guide · Draft under review

This guide retains the book’s draft status. Editorial review and comprehension checks with independent readers remain to be completed.

Search the whole guide →

From the model to the program

In chapter 2 we imagined a library made up of books, readers and loans. Until now these were mainly names that helped us reason. How does one of those ideas become a Java program? Let us start with a book that knows its title and can count how many times it has been borrowed. It is a small example: it will let us see a class, create an object and call a method without confusing the three operations.

A class is a declaration: it describes the data and operations that objects created according to that definition will have. An object, or instance, is a concrete entity in the program. We can declare the Book class once and create several Book objects, each with its own title and counter. The distinction already appeared in the definition in chapter 2; now we see it in code.

public class Book {
    private final String title;
    private int loans;

    public Book(String title) {
        this.title = title;
    }

    public void recordLoan() {
        loans++;
    }

    public String description() {
        return title + " - loans: " + loans;
    }

    public static void main(String[] args) {
        Book first = new Book("Java, Brick by Brick");
        Book second = first;
        first.recordLoan();
        System.out.println(second.description());
        System.out.println(first == second);
    }
}

Save the file as Book.java. In the folder containing it, javac --release 25 -d build Book.java produces the compiled class and java -cp build Book runs it. The two printed lines are Java, Brick by Brick - loans: 1 and true. If the result surprises you, return to the line Book second = first: it is the heart of the example.

Fields and methods

Inside Book, title and loans are fields: they hold data associated with each instance. The title is assigned when the book is created and, thanks to final, is not reassigned later. The counter instead changes when we record a loan. The word private prevents external code from accessing these fields directly; in the next chapter we will see why this helps maintain valid rules, and why it is not enough on its own.

recordLoan and description are methods. A method gives an operation a name and contains the instructions needed to perform it. The first does not return a value, so it declares void. The second returns a String: the return statement delivers the description to the point where the method was called. In second.description(), the dot selects a method of the object to which second refers. The string describes the state at the time of the call; if we recorded another loan, a new call would produce different text.

The text in parentheses in a method declaration is the list of formal parameters. In the constructor Book(String title), the formal parameter is the local name that receives the value passed by new Book("Java, Brick by Brick"); that string is the argument of the call. Distinguishing the two names helps us read the code: the parameter belongs to the method declaration, the argument to the individual call.

Further reading – A variable number of arguments. A parameter written as String... titles allows a method to be called with zero or more strings. Inside the method, titles is used like an array: titles.length tells us how many values arrived. The variable parameter must be last in the list and there can only be one. It is useful when the number of values really changes; it is not a way to avoid designing a coherent set of data. The method specification defines this syntax.

The main block is also a method, but it declares static: the java command can use it as an entry point without us first having to explicitly create a Book. In contrast, recordLoan works on the counter of one particular book. We cannot call it as Book.recordLoan() because it would not know which object to update. The distinction between a class member and an instance member matters more than the keyword to memorize.

Reading a whole method declaration

Take public String description(). public indicates that the method can also be called by code using the class; String indicates the type of the returned value; description is the name; the empty parentheses say that the call requires no arguments. The body begins with { and ends with }. If we promise a result of type String, every normal path that completes the method must produce a compatible value through return. Writing only return; would not be enough. In public void recordLoan(), however, void says there is no value to deliver.

In the declaration Book(String title), a type is followed by the parameter name. In the call new Book("Java, Brick by Brick"), the concrete argument appears. A method can receive several parameters separated by commas, for example lendTo(String reader, int days). Each argument must be compatible with the parameter in the same position. The name days does not force the caller to pass a sensible number: if negative values have no meaning, the method must check them.

The number of arguments required by a method is called its arity. With String... arguments we declare a method with a variable number of arguments: the caller can pass zero or more strings. Inside the method the variable is used like an array, but the caller can write the arguments separately. The advantage is real when the number of values changes; the difficulty is that a long, anonymous list does not say what role each element plays. For a loan, an object gathering the reader and duration data is often clearer than many positional arguments.

Important note – A field is not a local variable. loans belongs to each Book instance. If you create two books, each has its own counter. A variable declared inside the body of description, on the other hand, is created for that call and is not a field shared with later calls. The fact that both are written with a type and a name does not make them interchangeable.

From an isolated method to a complete class

Let us start from a method's general form, then place it in the class: reading the declaration is more useful than memorizing a list of symbols. In Java a method declaration is found in the body of a class, an interface or another structure that can contain it: we cannot put description() by itself, outside every type, as we would with a free function in other languages. Fields, constructors and methods can coexist inside the body of Book. The outer braces delimit the class; the inner ones delimit the individual method's body. When the compiler encounters a statement, its place in the source therefore helps determine which names it can use.

A class is a common model, but instance field values are not common to all objects. If we write Book manual = new Book("Java"); and then Book novel = new Book("The Path");, the description() method is defined only once in the source, while each instance's title field has a different value. For each declaration it is useful to ask whether it describes what every book owns or what the whole class shares. The first case normally leads to an instance field or method; the second may justify static. The distinction does not depend on the number of variables we use to reach the object: two variables can refer to the same book.

Important note – var has a specific place. In Book fields we write the type, for example private int loans;: var is not used to declare a field. In a method, however, we can write var copy = new Book("Java");, because the initializer allows the local variable's type to be inferred. var absent = null; does not compile: from null alone the compiler cannot infer the required type. If we want to represent the absence of a book, we explicitly declare Book absent = null; and then decide whether that value is permitted by the contract. Gathering fields at the beginning of the class is a convention that often helps readability, because it makes members easier to find; Java does not impose that order, and project consistency matters more than a fixed position.

The value returned by a method is distinct from the messages the method prints. description() delivers a String to the caller: the caller can print it, save it, compare it or pass it to another method. A method that internally executed System.out.println(...) would instead produce an effect on the output stream, but would not automatically give the caller the text to reuse. In a first draft of a program the two forms look equivalent because we see the same line on the screen; as soon as we need to compose a page, run an automated test or show the result in a graphical interface, the distinction becomes decisive.

To understand – Arguments and parameters are not the same names. In lendTo("Ada", 14), "Ada" and 14 are the arguments of the call. In lendTo(String reader, int days), reader and days are the formal parameters available in the body. The caller may have obtained those values from variables with completely different names. Before reading the body, pair each argument with the parameter in the same position and ask whether the type and meaning are compatible.

A method with a variable number of arguments keeps the same logic. If printTitles(String... titles) receives three titles, inside the body titles is an array of length three; if it receives none, the length is zero. The ellipsis form also allows a call with an existing array. Its advantage is a convenient call for a homogeneous series of values, not the elimination of type rules. For example, printTitles("Java", 7) does not become valid because the method accepts a variable number of arguments: 7 is not a String.

In the historical DemoMultipleElements example, the loop for (String argument : arg) printed each received element. Read according to the rule just seen, the method has no magic for reading unlimited arguments: it receives an array and traverses it. If the call is printTitles("Java", "JVM"), the loop performs two iterations and prints two lines; with printTitles() it never enters the loop body. If a mandatory parameter is needed before the variable part, a declaration such as printTitles(String prefix, String... titles) expresses this. The ellipsis must stay on the last parameter, so the compiler knows which arguments to gather into the array. For a list to keep or transform in several steps, passing an explicit collection may be clearer.

A book whose pages we can turn

Imagine a Book with a page count and a current page. Its methods describe the object’s actions, rather than just formulas returning numbers. Let us give those actions a clear rule: pages are numbered from 1 to pageCount; when we reach the last page, turnForward() does not go beyond it, and when we are at the first, turnBackward() does not go down to zero. The contract lets us predict behavior before looking at the code details.

The complete source declares two fields and three public operations. pageCount is fixed by the constructor; currentPage can change. The word final on the first field prevents a new assignment after construction, while the absence of final on the second allows us to move forward and backward. The constructor rejects a book with zero pages: it would have no page 1 to start from.

public final class PageableBook {
    private final int pageCount;
    private int currentPage;

    public PageableBook(int pageCount) {
        if (pageCount <= 0) {
            throw new IllegalArgumentException("at least one page is required");
        }
        this.pageCount = pageCount;
        this.currentPage = 1;
    }

    public int currentPage() {
        return currentPage;
    }

    public void turnForward() {
        if (currentPage < pageCount) {
            currentPage++;
        }
    }

    public void turnBackward() {
        if (currentPage > 1) {
            currentPage--;
        }
    }

    public static void main(String[] args) {
        PageableBook book = new PageableBook(3);
        System.out.println(book.currentPage());
        book.turnForward();
        System.out.println(book.currentPage());
        book.turnBackward();
        System.out.println(book.currentPage());
    }
}

The three printed lines are 1, 2 and 1. The first println observes the initial state; the second print follows a forward step; the third follows the return to the first page. If you call turnBackward() once more, the number stays at 1. If you repeat turnForward() up to and beyond the last page, it stays at 3. This behavior is a choice of our model, not an automatic property of all Java books. We could have reported an attempt to leave the bounds with an exception, as long as the contract said so. In chapter 8 we will see how these constraints become part of encapsulation.

The class contains a main method only to make it executable as an exercise. Page-turning operations do not depend on main and can be used by another program that creates a PageableBook. This separation is useful: the entry point serves the demonstration, while fields and methods describe the object's responsibility. If we had written all the calculations directly in main, we would have printed the same three lines without learning to model a book.

Constructing an instance

The line new Book("Java, Brick by Brick") creates an instance and invokes the constructor Book(String title). A constructor has the same name as the class and declares no return type, not even void. Here it receives the title and assigns it to the field. The variable first holds the reference to the created object, not a copy of all its fields. Chapter 8 will explore validation, alternative constructors and invariants; for now it is enough to see how the class declaration becomes a usable instance.

In the constructor the two names title indicate different things. this.title is the current object's field; the title on the right is the received parameter. this is the reference by which a method or constructor indicates its own instance. When there is no ambiguity, as in loans++, Java lets us omit it. It is not a second hidden object: it is an explicit way to say “this book”.

Names, scope and this: following the lookup

Imagine a Point class with fields x and y and a method move(int x, int y). Inside the method, the simple names x and y indicate the parameters, because these are the nearest names in their scope. Writing x = x; would assign the parameter to itself: the field would remain unchanged. The statements this.x = x; and this.y = y; instead indicate the current object's fields on the left, and the parameters on the right. This is the same rule already seen in the Book constructor, applied to two pieces of data.

A variable declared inside an inner block is visible where the scope rules permit it, until the end of that block. A parameter is available in the body of the method that declares it. A field is accessible from the class's methods, according to access and context rules. These are boundaries of the name in the source: they do not say that an object's memory is released as soon as a brace closes. If a reference to the object is kept elsewhere, the object may remain reachable.

We can write this.description() from a Book instance method or, if there is no ambiguity, description(). In both cases we ask the same object for the operation. this can also be passed as an argument or returned by a method when there is a precise reason to deliver a reference to the current instance. We cannot, however, use this in the body of a static method, because that method is not associated with a particular instance. The class specification distinguishes these contexts.

Consider an experiment in which a field and a local variable are both called temporary. It is useful because it forces us to read the names carefully. In the DemoScope program, the field starts at 5, the local variable at 3 and the parameter value receives 2. Before running it, try following the three assignments and writing down the three numbers that will be printed.

public class DemoScope {
    private int temporary = 5;

    public void add(int value) {
        int temporary = 3;
        temporary = temporary + value;
        System.out.println("local: " + temporary);

        this.temporary = this.temporary + value;
        System.out.println("field: " + this.temporary);

        this.temporary = this.temporary + temporary;
        System.out.println("final field: " + this.temporary);
    }

    public static void main(String[] args) {
        new DemoScope().add(2);
    }
}

The first assignment uses the simple name temporary, which inside the body of add indicates the local variable: 3 plus 2 gives 5. The field is still 5. The second assignment explicitly names the field with this.temporary: 5 plus 2 gives 7. The third adds the local value 5 to the field 7 and obtains 12. The output is therefore local: 5, field: 7, final field: 12. The words before the numbers are not decorative: they prevent us from mistakenly attributing a print to the wrong data.

If we remove this. from the second assignment, we do not get a shorter way to update the field. We update the local variable again and change the experiment's result. The presence of a field does not make it impossible to use the same name for a local variable, but shadowing increases the reading effort. Sometimes it is natural, as in this.title = title in a constructor; in other cases choosing different names is clearer. The experiment teaches the language rule and, at the same time, why it is worth not exploiting it without a reason.

The field continues to exist in the object's state after add has ended. The local variable temporary, however, is not accessible from a later call: each execution of the method has its own local variables. If we invoke add(2) a second time on the same instance, the local variable starts again at 3 while the field starts at 12, the result of the previous call. If we create a new DemoScope, its field starts at 5. This variant distinguishes a name's scope from the lifetime of the object's state, two ideas we must keep distinct.

Figure 7.1 represents the program's most important point. After the assignment, first and second refer to the same object; calling a method through one of the two names can therefore produce a result visible through the other.

Two references to the same book
Figure 7.1 – first and second indicate the same Book instance. The figure is a logical model of references: it does not prescribe the physical memory area in which the JVM must place the data.

first == second is true because the two references indicate the same instance. This test concerns identity. Two books created with two different new expressions can have the same title but be distinct objects; in chapter 10 we will use equals to define content equality when needed. Do not automatically replace == with equals without asking what question you want to ask.

A reference variable can contain null, meaning “no object indicated”. Book absent = null; is a valid declaration; absent.description() fails during execution because there is no book on which to call the method. A reference-type field not explicitly initialized receives the initial value null; a local variable, however, must be assigned before being read. In a stack-and-heap drawing, we first distinguish the two initialization rules: the position drawn for the data must not hide them. In chapter 12 we will see how to read the exception produced by accessing null.

An array can hold several references: Book[] shelf = {first, second}; contains two elements, but in this example both indicate the same book. The array's length is two; the number of distinct Book objects indicated by its elements is one. For index use and array form you can return to chapter 4.

Four declarations, four questions

Compare int copies = 2;, Book book;, Book[] shelf; and Book[] shelves = new Book[3];. The first variable directly contains a primitive value. The second is a reference-type variable, but the local declaration alone does not create a Book and does not yet let us read book: first we must assign it a value. The third declares an array reference without creating the array. Only the fourth constructs an array with three slots; each element of type Book starts with null, because array elements receive their type's initial values. None of those three slots already contains a book. To obtain three books we need three creations, or assignments of references to books created elsewhere.

Figure 7.2 separates the primitive value from the reference that allows us to reach an object. The two declarations can look similar in the source, but answer different questions when we read a variable's value.

Primitive and reference
Figure 7.2 – int contains a numeric value; Book contains a reference. The model concerns observable values and does not establish a mandatory physical placement in the JVM.

This reading corrects a misunderstanding encouraged by some old memory drawings. The type int has a size defined by the language, but this does not mean that the local variable copies must physically be in an area called the heap, nor that a reference variable is a pointer that can be manipulated as in C. The specification describes values and observable behavior; the JVM chooses how to represent them. To reason about the program we need only three questions: what value the variable has, which object it can reach through that value, and which operations its type allows us to write. A figure drawing arrows between names and objects answers the second question; it is not a photograph of memory.

The syntax new Book("Java").description() uses an object without assigning its reference to a named variable. The object exists and the method can be called; we simply do not keep a reference at that point with which to call it again after the expression. In contrast, new Book("Java") alone as a statement creates an object our code does not use: it can make sense if the constructor produces a documented effect, but for the Book model it would be an unclear choice. We do not infer from this observation when the JVM will reclaim the memory: automatic memory management does not promise a collection instant visible to the program.

The historical example also used a newly constructed Integer object to turn a number into text. The idea of immediately calling a method on the expression remains valid, but today, to obtain the text for 10, writing Integer.toString(10) or String.valueOf(10) is more direct: we do not need to explicitly create a wrapper. In this case the method belongs to the class and receives the numeric value as an argument. When we return to wrappers in chapter 10, we will see why their old constructors are not the usual route and why an Integer is still a different object from an int.

Important note – Initial values and definite assignment. An int loans field starts at 0 and a Book linked field at null if they have no explicit initializer. A local variable, however, must have a value assigned on every path leading to its reading. The distinction reveals an oversight during compilation; it is not an invitation to leave fields without a deliberate choice of initial value.

The array itself is an object, distinct from any objects contained in its elements. If we write Book[] firstShelf = new Book[2]; Book[] secondShelf = firstShelf;, we have one array reachable through two variables. Assigning firstShelf[0] = new Book("A") makes that book visible through secondShelf[0] too. If instead we write secondShelf = new Book[2], the second variable starts referring to another array; the first continues to contain the book. This is the same reference rule applied to a container. We can therefore have two levels of sharing: two variables referring to the same array, or two distinct arrays containing references to the same Book.

An array created with new Book[2] does not call the Book constructor twice. Its elements start at null and every book must be constructed explicitly, unless references to existing books are assigned. In contrast, new int[2] immediately contains two primitive values initialized to zero. The distinction does not make object arrays “less real” than primitive arrays: it clarifies that the element type determines what is in each position. A drawing of array references must show the variable, the array object and the objects its elements may refer to separately.

To verify – Count objects, not names. After Book a = new Book("A"); Book b = a; Book[] shelf = {a, b};, how many reference variables have we declared? How many Book objects have been constructed? And how many elements does the array contain? The answers are three variables (a, b, shelf), one book and two array elements. The array object has also been created. If you change only b = new Book("B"), the second element of shelf continues to indicate the first book: assigning to b does not automatically rewrite the array contents.

Figure 7.3 shows the array as an object with two slots. Both can contain references to the same book, and the slot count does not tell us how many distinct books have been created.

Array with two references to the same book
Figure 7.3 – Two array elements can indicate a single Book object. Constructing the array alone leaves its two elements at null; the arrows represent a situation after assignments.

A stack shows us that several operations can belong to the same object. We develop the example in chapter 8: push adds a number and pop returns the last one added. This is called LIFO, from last in, first out. Before reading how it is built, you can already describe its responsibility: it must know how many elements it contains and must not pop from an empty stack. The same stack will help us read a chain of calls in chapter 12.

Important note – Do not confuse variable and object. Assigning second = first copies the reference value. It does not duplicate the instance and does not invoke the constructor. If you later write second = null, the object may still be reachable through first: the assignment changes a name, it does not immediately destroy the object.

A reference passed to a method

The distinction becomes even more useful when we pass a Book to another method. Java passes argument values: for a reference-type parameter, the copied value is the reference to the object. If the method calls book.recordLoan(), it changes the state of the same instance visible to the caller. If instead it assigns book = new Book("Another title"), it changes only its own local parameter; the caller's variable continues to indicate the original book. Simply saying “the object is passed by reference” hides this distinction and leads to wrong predictions.

With an int the test is more intuitive: the method receives a numeric value; assigning another number to the parameter does not change the original variable. With Book, the method still receives a value, but that value allows it to reach and modify a shared object. Both statements are true: parameter reassignment stays local; object mutation can be observed outside the method.

A call observed from both sides

Consider a method lend(Book received) that calls received.recordLoan() and then assigns a new Book to received. From the caller's side, the variable first continues to indicate the original book. Its counter has increased because the method reached the same object through a copy of the reference. The new assignment, however, changes only the local variable received; it does not travel back along the call to replace the value of first. In terms of observable behavior, the method can mutate the object reached, but cannot reassign the local variable the caller used as its argument.

To check the prediction without a memory figure, note down two columns. Before the call, first and received indicate the same book only after the parameter is passed; the counter is zero. After recordLoan(), that single book's counter is one. After received = new Book("Another"), received indicates a second book, while first still indicates the first, with counter one. If the method returned received and the caller wrote first = lend(first), it would be a new explicit assignment in the caller that changed first. Argument passing alone did not do so.

To understand – The word “reference”. A reference is a value that allows an object to be reached; it is not the object itself and Java does not offer C pointer arithmetic on it. Copying the reference value explains why two variables can observe the same mutation. The distinction suffices to solve many call exercises without attributing a fixed physical stack-and-heap layout to the specification.

Getters and setters have a contract

Consider a Car class with fields such as color and license plate and methods getLicensePlate() and setLicensePlate(...). A getter returns information, a setter accepts a request for modification. Having both for every field, however, is not a mandatory rule. If the license plate identifies the car and must not change after creation, a public setter would break the very invariant we wanted to protect. If the color can change, a method paint(String newColor) can describe the action better than a generic setColor.

A method that changes state must also decide what to do with an invalid argument. For an empty license plate we can reject the call, or use a representation that cannot express that value. It is not enough to move the assignment from car.licensePlate = ... to car.setLicensePlate(...) if the setter accepts any text without checking. In chapter 8 we will see how to define the object's rules and maintain them in both constructors and methods.

State, identity and equality

Our Book's observable state includes at least its title and the number of recorded loans. Method behavior can depend on these values: description() changes after recordLoan(). Not every field a class could contain must enter its notion of equality. An access counter used only for diagnostics, for example, can change without changing the book's meaning for the catalog. The choice depends on the class contract, not a universal formula saying “two objects are equal if all fields match”.

The operator == on two references answers the identity question: do they indicate the same instance? The equals method instead answers according to the contract defined by the class. The implementation inherited from Object uses identity; some classes, such as String, redefine equals to compare content. This is why new String("Java").equals(new String("Java")) is true, while comparing the two objects created with new using == is false. We do not use two identical literals to demonstrate this: string interning would make the example misleading. When we define equality for our types, we must also respect the hashCode contract.

Key concept – Meaning before comparison. A loan may be “the same loan” because it has an identifier, even if its state has changed after the return. Two editions with the same title and year, on the other hand, may be considered equal as values. Before writing equals, choose the domain question it must answer.

Class members and the entry point

All Book objects have their own title. If, however, we need a constant identical for the whole class, we can declare it with static final. An example is static final int MAXIMUM_LOANS = 20;: the name and value belong to the class and are referred to as Book.MAXIMUM_LOANS, if visibility permits. A mutable static field, on the other hand, contains state shared among all instances: using it for a single book's loan count would be a mistake, because every book would update the same counter.

An uppercase name with words separated by underscores follows the convention used for constants. It helps the reader recognize a shared value that cannot be reassigned, but does not replace the modifiers: MAXIMUM_LOANS written in uppercase without final could still be reassigned. Similarly, static final does not make a mutable object reached through the field deeply immutable. For a numeric limit such as 20, however, the value itself has no mutable internal parts. Distinguishing the naming convention, assignment rule and value mutability avoids three different promises hidden in the same word “constant”.

A static method can directly use other static members, but not an instance field without knowing which instance. It can still receive a Book as a parameter and call its methods: the restriction does not concern objects in general, it concerns the absence of an implicit this. This distinction explains both why main is traditionally static and why main can create and use objects.

The traditional entry point is public static void main(String[] args). The java command starts the program from the class we indicate; if several classes declare a suitable main method, we choose which one to start by naming that class. The array args contains the words passed after the class name, as we verified in chapter 3. main does not turn the whole application into an object: it is a startup method. Later we will also encounter the compact form introduced in Java 25, but the traditional form remains important for reading existing projects.

Reading main as an ordinary method with a special role

If you run java -cp build Book Ada 3, the words after Book become elements of the array args: args[0] contains "Ada" and args[1] contains "3". The second element is still text; to obtain a number, a program would need to convert it and handle potentially invalid input. If you start the program without arguments, the array is empty and reading args[0] would cause an error. Before using it, we therefore check args.length. This small step connects the entry-point signature to the array rules already studied and shows that a parameter does not create data the caller has not supplied.

static does not mean that the method works “before every object” or cannot create instances. It means the method is associated with the class and can be called without a Book receiver. Inside main we can write new Book(...), keep the reference and invoke recordLoan(). We can also directly call another static method of the class. If we tried reading title without indicating a book, the compiler would stop us: which of the possible instances should it take the title from? In this way the compiler message reinforces the class/instance model rather than seeming an arbitrary rule about keywords.

The name args is conventional, but is not a reserved word. We can call the parameter arguments; what matters is the type and entry-point form. In the best-known declaration, String[] indicates an array of strings and void indicates that the method does not deliver a value to its caller. Printing a line and returning a value are still different operations: main can produce messages with System.out, but its signature does not declare a program result. A process exit code follows another mechanism and must not be confused with the return of a method returning int.

System: output, errors and properties

The line System.out.println(...) contains two steps. System.out is a static field of the System class providing an output stream; println is a method of the stream object. It is therefore incorrect to call println a static method of System. System.err is another stream, conventionally used for error messages. Even if both appear in the same terminal, tools and scripts can direct them to different places. We will use System.exit(int) only when the program must terminate the process with an exit code; calling it from an ordinary library method would surprise the library's user.

The static method System.getProperty("java.version") returns a runtime property, or null if the requested key is not defined. Other useful keys are os.name for the system name, user.dir for the working directory and line.separator for the line separator. Values vary between machines: a list of Windows 2000 paths would describe only the machine and era in which it was produced, not an output to expect today. Try printing only java.version and user.dir, then compare them with java --version and the directory from which you launched the program.

Important note – A property is not an environment variable. System properties are key-value pairs accessible through the Java API. Environment variables are supplied to the process by the operating system and are read with System.getenv. Some information may look similar, but the two mechanisms are not identical. The System documentation lists standard methods and properties.

Reading a property without turning it into a promise

System properties describe the environment in which a program runs; a list of values collected from one machine soon becomes a dated snapshot. If we write String version = System.getProperty("java.version");, the variable receives text describing the runtime version with which the program is actually running. Compiling with --release 25 and launching with another runtime are distinct operations: the property concerns execution. System.getProperty("user.dir") describes the process's working directory, which can differ from the folder containing the source or .class file. It is easy to verify this by launching the same program from two different directories with a suitable classpath.

A second experiment connects System.out and System.err. Print "result" on the first and "problem" on the second. In a terminal you may see both lines, but do not conclude they are the same stream: the environment can separate them, as many execution and testing tools do. Choosing the stream is part of the message's meaning. Standard output is suitable for a result another program might read; error output for a diagnosis that must not be confused with that result. In our Book we print a description on out because it is part of the example's normal outcome.

Further reading – Keys and values. A property is looked up through a textual key. System.getProperty("java.version") requests the version of the running Java environment; if a key is absent, getProperty returns null. Before calling a method on the resulting string, we therefore decide how to handle absence. We can also use the form with a default value, such as System.getProperty("application.port", "8080"). The result is still a string: using it as a numeric port requires conversion and a check of the allowed range. We test this by reading a known property and an absent key, checking both the value and the case to handle. Querying the environment in this way avoids treating installation-dependent values as universal.

A form for simple data: records

Our Book has behavior that changes state. Sometimes, however, we only need to group data we want to describe transparently, for example the title and year of a particular edition. For this Java offers records, special classes that declare their components in the header. A record such as record Edition(String title, int year) { } provides a constructor, the methods title() and year() and methods for comparison and textual representation. We do not have to write them all by hand for a simple aggregate.

record Edition(String title, int year) { }

public class DemoRecord {
    public static void main(String[] args) {
        Edition first = new Edition("Java, Brick by Brick", 2026);
        System.out.println(first.title() + " (" + first.year() + ")");
    }
}

The file is called DemoRecord.java and prints Java, Brick by Brick (2026). The record documentation describes the generated members. Component fields are final, but this does not automatically make every object a component refers to deeply immutable. We will return to defensive copies and validation in chapter 8. Records have been stable since Java 16; they require no preview in Java 25.

Deciding when a record expresses the model

For Edition, title and year describe a value we are interested in reading and comparing. Two instances constructed with the same components are equal according to the record's generated methods; two references to distinct instances remain different according to ==. This is the same distinction we will explore in chapter 10, but here it shows why a record is convenient when the value's meaning corresponds to its components. If the book's identity instead depended on a catalog code assigned by the application, or if we needed to continually record and modify loans, an ordinary class with an explicit contract might describe the problem better.

The brevity of the declaration does not remove the responsibility to check the data. new Edition(null, -3) can syntactically be constructed with the record shown: the compiler does not know that a missing title and a negative year make no sense for our domain. We can add a constructor that validates the components. In the next chapter we will see how the same invariant question applies to every class form. The criterion for choosing a record, then, is not “this definition occupies one line”; it is “the exposed components describe exactly the value I want to model”.

A record can contain an array-type component, for example record Shelf(Book[] books) {}. The component field cannot be reassigned from outside, but the array passed to the constructor and the one returned by the accessor can still expose their elements to changes. Furthermore, comparing two arrays does not automatically become an element-by-element comparison because they are inside a record. This is a concrete reason not to call every record “immutable” without examining what it holds. The question about references and objects is the same one we have built from the beginning of the chapter.

An even smaller program in Java 25

Perhaps you have wondered why the book's first program required a class, public, static and an argument array just to print a line. Java 25 also offers a compact source file: we can write a main method without an explicit class declaration. The materials' CompactHello.java file contains:

void main() {
    IO.println("Hello from Java 25!");
}

The command java CompactHello.java prints Hello from Java 25!. In this case the language implicitly declares a class for the file and the command launches an instance main method. IO.println is a shortcut for textual output available in Java 25; our System.out.println remains valid. This form is useful for a short experiment, but does not eliminate classes from the language and is not the form chosen for the Book model, where name, state and methods have a precise teaching role. The official guide to compact files describes their behavior.

Java version – Two stable forms. Records have been permanent since Java 16; compact source files and instance main methods have been permanent since Java 25. A reader with an earlier JDK must use the form supported by their release. The official summary of language changes records the introduction and status of features.

To verify

Before running the Book program, predict what would change if the line Book second = first; became Book second = new Book("Java, Brick by Brick");. Would the two variables still indicate the same instance? What description would second print after first.recordLoan()? Then try the modification and explain the result without using the word “copy” ambiguously. Activity C07 takes up this test with a case to solve independently.

Try the examples

Running the programs requires JDK 25. Download the individual Java files linked in the chapter or the complete example project, which includes instructions and a launcher script. The explanations also compare expected output: predict it before running the program.

Massimiliano Tarquini · CC BY-NC 4.0

Back to the top ↑