Chapter TWO
Utilizing Java Object-Oriented Approach - Part 2


Exam Objectives

Understand variable scopes, apply encapsulation, and create immutable objects. Use local variable type inference.
Implement inheritance, including abstract and sealed types as well as record classes. Override methods, including that of the Object class. Implement polymorphism and differentiate between object type and reference type. Perform reference type casting, identify object types using the instanceof operator, and pattern matching with the instanceof operator and the switch construct.
Create and use interfaces, identify functional interfaces, and utilize private, static, and default interface methods.

Chapter Content


Variables

Variable Scopes

We can think of a variable’s scope as its visibility, where it can be seen and accessed in our code. Properly managing variable scope helps us write cleaner, more maintainable code and avoid bugs related to accessing variables in the wrong context.

At the highest level, a variable’s scope is determined by where it is declared. In Java, there are five main scopes to be aware of:

Here’s a diagram to visualize it:

┌───────────────────────────────────────────────┐
│ Class                                         │
│ ┌───────────────────────────────────────────┐ │
│ │ Static/Class Variables                    │ │
│ │ ┌───────────────────────────────────────┐ │ │
│ │ │ Instance Variables                    │ │ │
│ │ │ ┌───────────────────────────────────┐ │ │ │
│ │ │ │ Method                            │ │ │ │
│ │ │ │ ┌───────────────────────────────┐ │ │ │ │
│ │ │ │ │ Method Parameters             │ │ │ │ │
│ │ │ │ │ Other Local Variables         │ │ │ │ │
│ │ │ │ │ ┌───────────────────────────┐ │ │ │ │ │
│ │ │ │ │ │ Block                     │ │ │ │ │ │
│ │ │ │ │ │ ┌───────────────────────┐ │ │ │ │ │ │
│ │ │ │ │ │ │ Block Variables       │ │ │ │ │ │ │
│ │ │ │ │ │ └───────────────────────┘ │ │ │ │ │ │
│ │ │ │ │ └───────────────────────────┘ │ │ │ │ │
│ │ │ │ └───────────────────────────────┘ │ │ │ │
│ │ │ └───────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────┘ │
└───────────────────────────────────────────────┘

Local variables are declared inside the method where they are defined, while block variables and are only accessible within the block where they are defined. They come into scope at their declaration and go out of scope at the end of the enclosing method/block:

void myMethod() {
    int x = 1;
    if (x > 0) { 
        int y = 2;
        System.out.println(x + y); // x and y both in scope here
    }
    System.out.println(x); // Only x is in scope here
    System.out.println(y); // Compile error! y is out of scope
}

As you can see, y is only visible within the if block where it was declared. Attempting to access it outside that block results in a compile error.

If you declare a variable inside a loop, you can’t access it outside the loop. Even if it’s all in the same method, the scope still ends at the loop’s closing }. For example:

void myLoopingMethod() {
    for (int i = 0; i < 10; i++) { 
        System.out.println(i);
    }
    System.out.println(i); // Compile error! i is out of scope
}

Similarly, variables declared in a for-loop initializer, such as int i above, are scoped only to the loop body, not the entire enclosing method.

This concept applies to other blocks like if/else too. A variable declared inside an if is not visible in the corresponding else:

void myIfElseMethod(int x) {
    if (x > 0) {
        int y = 1; 
    } else {
        System.out.println(y); // Compile error! y not in scope
    }
}

Then we have method parameters. These are also considered local variables, but with a scope that covers the entire method body. They come into scope when the method is called and go out of scope when the method completes.

Parameters are local to the method, no other methods can see them, even if the method is currently executing:

void methodA(int x) {
    methodB();
    System.out.println(x); // x is in scope
}

void methodB() {
    System.out.println(x); // Compile error! x is not in scope
}

Fields, or instance variables, are variables declared at the class level, outside any method. They come into scope when the object is instantiated and remain in scope as long as the object is in memory:

class MyClass {
    private int x; // Instance variable (field)

    void myMethod() {
        System.out.println(x); // x is in scope here
    }
}

Since instance variables belong to an object instance, they cannot be accessed from static contexts, but they can be accessed by any instance method in the class.

A common misconception is that instance variables are garbage collected as soon as the method that uses them finishes, which is not the case. An object’s fields stay in memory until the object itself is eligible for garbage collection, which may be long after a particular method call completes.

Also, remember that if the variable or its class is declared private, then only the declaring class can access it. But if they have public, protected, or default (package) access, other classes can potentially access them too.

Finally, class variables, or static fields, are static variables declared at the class level. They come into scope when the class is loaded and stay in scope until the program ends. There is only one copy of a class variable shared across all instances of the class.

Class variables belong to the class itself, not a specific object instance. And unlike instance variables, class variables can be accessed from both static and instance contexts:

class MyClass {
    private static int x; // Class variable

    void myMethod() {
        System.out.println(x); // x is in scope 
    }

    static void myStaticMethod() {
        System.out.println(x); // x is also in scope
    }
}

Class variables can be accessed from anywhere in your program, even without creating an instance of the class. But they are still subject to access controls like private and public.

An interesting case is when you have two variables with the same name but different scopes:

class MyClass {
    private int x; // Instance variable 
    
    void myMethod() {
        int x = 1; // Local variable
        System.out.println(x); // Prints 1 (local variable)
        System.out.println(this.x); // Prints 0 (instance variable) 
    }
}

In this situation, the local variable shadows the instance variable within its scope. To access the instance variable, we have to use the this keyword. We’ll talk about this later in the chapter, but, as you can see, properly limiting scope is not about improving performance, but about organizing our code and controlling access to variables.

Variable Declarations

When you start learning Java, it’s easy to think that fields and local variables are pretty much the same thing. After all, they’re both just variables, right? You declare them, give them a type and a name, maybe assign them a value, and then use them in your code. What’s the big deal?

Well, as it turns out, there are some pretty important differences between fields and local variables in Java.

Fields are declared directly inside a class, but outside any method or constructor. They’re part of the class’s state, and each instance of the class gets its own copy of these fields.

Local variables, on the other hand, are declared inside a method or constructor. They only exist for the duration of that method or constructor call, and they’re not accessible from outside. Once the method has finished executing, the local variables disappear.

Here’s an example:

public class MyClass {
    private int myField; // This is a field

    public void myMethod() {
        int myLocalVar = 25; // This is a local variable
        // Do something with myLocalVar...
    } // myLocalVar no longer exists after this point
}

Now, you might be thinking, “Okay, so fields are in the class, and local variables are in methods. But can’t I just use them interchangeably otherwise?” Well, not quite. There are some key differences in how they behave.

For one thing, fields automatically get default values if you don’t explicitly initialize them. For numeric types (like int, long, float, double) the default is 0. For boolean, it’s false. For reference types (like String or any object), it’s null.

On the other hand, local variables don’t get any default values. If you try to use a local variable before initializing it, you’ll get a compile error. In other words, the Java compiler wants you to be explicit about your intentions with local variables:

public void myMethod() {
    int uninitialized;
    System.out.println(uninitialized); // Compile error!
}

So Java requires you to initialize a local variable before you use it. But when exactly do you need to do this initialization? The rule is simple: the initialization must happen on every possible execution path before the first use of the variable:

int myVar;
if (someCondition) {
    myVar = 1;
} else {
    myVar = 2;
}
System.out.println(myVar); // This is fine

int myOtherVar;
if (someCondition) {
    myOtherVar = 1;
}
System.out.println(myOtherVar); // Compile error! Not initialized on the else path.

In the first example, myVar is guaranteed to be initialized before it’s used, regardless of which path the if/else takes. But in the second example, if someCondition is false, myOtherVar will not be initialized before its first use, hence the compile error.

In any case, fields or local variables, Java lets you declare several variables of the same type in a single line, separated by commas:

int a, b, c;

But this doesn’t mean that these variables share the same value. They’re completely independent variables that just happen to be declared together. You can assign them different values:

int a = 1, b = 2, c = 3;

In fact, you don’t have to assign them all values right away. It’s totally fine to do this:

int a, b, c;
a = 1;
b = 2;
// c remains uninitialized for now

Just remember that you can’t use c until you initialize it with a value, or you’ll get a compile error.

Now, what about when you want to declare multiple variables of different types? Well, you can’t do that in a single line like you can with variables of the same type. You’ll have to declare each one separately:

int a = 1;
String b = "hello";
// This won't compile: int a = 1, String b = "hello";

Another difference between local variables and fields is in how you use final. Marking a field as final means it must be initialized when the object is constructed, and then it can never be changed again. With a local variable, final just means you can only assign it a value once. But that assignment doesn’t have to happen when the variable is declared:

public class MyClass {
    private final int myFinalField = 42; // Must initialize here

    public void myMethod(int arg) {
        final int myFinalVar; // Okay to initialize later
        if (arg > 0) {
            myFinalVar = arg;
        } else {
            myFinalVar = 0;
        }
        // Can't assign to myFinalVar again after this point
    }
}

The assignment must occur before the variable’s first use, and can only happen once. This is often useful when you want to assign a value conditionally, like in the above example. Or when you want to assign a value in a loop but ensure it doesn’t change after the loop:

final int myFinalVar;
for (int i = 0; i < 10; i++) {
    // Some calculation...
    myFinalVar = result;
    // Can't assign to myFinalVar again after this point
}

However, when working with references and objects, if you make a local variable final, you can change the properties of the object it references. final only prevents you from assigning a new value to the variable itself. If the variable is a reference to an object, you can still modify that object:

final StringBuilder sb = new StringBuilder();
sb.append("Hello"); // This is fine
sb = new StringBuilder(); // This won't compile

In this example, we can call methods on sb that modify the StringBuilder object, but we can’t assign a new StringBuilder instance to sb.

Variable Type Inference

Java 10 and later versions introduced a new feature, var. It lets you declare a local variable without specifying its type:

var myVar = 42;

This is called local variable type inference. The compiler looks at the value you’re assigning to the variable and figures out the appropriate type for you. In this case, it infers that myVar should be an int.

Traditionally, declaring local variables could often lead to verbose and repetitive code. For example:

HashMap<Integer, String> map = new HashMap<>();
List<String> list = new ArrayList<>();
AtomicInteger counter = new AtomicInteger(0);

In each case, the type is mentioned twice, once on the left-hand side and once on the right-hand side. This is where the var keyword comes into play.

By using var, the above code can be rewritten as:

var map = new HashMap<Integer, String>();
var list = new ArrayList<String>();
var counter = new AtomicInteger(0);

The types of map, list, and counter are inferred by the compiler based on the initializer expressions. This makes the code more concise and readable, while still maintaining type safety.

It’s important to note that var behaves like a keyword in its context of use, even though it is technically a reserved type name for local variable type inference. This means code that uses var as a variable, method, or package name will not be impacted.

var is restricted to local variables within methods, constructors, or initializer blocks. It cannot be used to declare instance variables (fields) or class (static) variables. This restriction ensures that the type of class and instance variables is always clear from the class’s API, not just from its implementation:

public class MyClass {
   var myVar = "Hello"; // This will not compile
}

Similar to instance and class variables, var cannot be used to declare method parameters. Method signatures are part of the class’s public API and need to explicitly state their parameter types for clarity and to ensure contract stability:

public void myMethod(var param) { // This will not compile
   // ...
}

Apart from that, var can be used in other situations. For example in for loop indexes:

var numbers = Arrays.asList(1, 2, 3, 4, 5);
for (var num : numbers) {
    System.out.println(num);
}

// Or

for (var i = 1; i <= 10; i++) {
    System.out.println(i);
}

In try-with-resources statements:

try (var stream = Files.lines(Path.of("file.txt"))) {
    stream.forEach(System.out::println);
}

Or for the parameters of implicitly typed lambda expressions:

Function<Integer, String> toString = (var i) -> String.valueOf(i);

Keep in mind that in a lambda expression, either all parameters need to be declared with var, or none of them. Mixing var with manifest types or inferred types is not allowed.

However, be careful with var, it’s not always the best choice. Sometimes explicitly declaring the type can make your code more readable and maintainable. You can only use var when you’re initializing the variable right there in the declaration:

var myVar; // This won't compile
var myOtherVar = someMethodThatReturnsAnObject(); // Fine, as long as the method return type is clear

Similarly, var cannot be used when initializing a variable with a null value without specifying its type because the compiler cannot infer the type of the variable:

// This will not compile because the type cannot be inferred
var myVar = null;

However, once var has been used to declare a variable with a concrete type, it can be reassigned a null value:

var myString = "Hello, World!"; // Inferred as String
myString = null; // This is allowed

Finally, when using var with array initializers, explicit instantiation is required. You cannot use shorthand syntax because the type cannot be inferred:

var numbers = new int[] {1, 2, 3}; // This works
// var numbers = {1, 2, 3}; // This will not compile

Inheritance

Introducing Inheritance

Inheritance is one of the core concepts in object-oriented programming. It allows you to define a new class based on an existing class. The new class inherits the attributes and methods of the existing class, allowing you to reuse code and build hierarchical relationships between your classes.

Do you remember the Cookie class from the beginning of the previous chapter?

public class Cookie {
    // Attributes
    String flavor; 
    int size;
                     
    // Behavior (Method)
    public void eat() {
        System.out.println("That was yummy!");
    }
}

How would you define a chocolate chip cookie class?

Well, chocolate chip cookies have a flavor, number of chips, and can be eaten like normal cookies. But they also have additional properties like number of chips per cookie. So our initial naive ChocolateChipCookie class might look like:

public class ChocolateChipCookie {

  String flavor;  
  int size;

  void eat() {
    System.out.println("That was yummy!"); 
  }

  int chips;

}

We’ve duplicated the cookie attributes and methods! It is not a good design.

This is where the concept of inheritance enters in OOP.

All varieties of cookies share common properties like having a flavor and being edible. We can represent this with a parent Cookie class that contains flavor, size, and an eat() method.

Child classes, like ChocolateChipCookie, can then inherit these common cookie elements from the parent Cookie class. This way, we can create many specific varieties that inherit shared cookie properties. The child classes can still define their own specialized attributes, like the number of chocolate chips, but reuse the inherited parent code.

In Java, you use the extends keyword to create a subclass that inherits from a superclass. This is how the ChocolateChipCookie class can be defined using inheritance:

public class ChocolateChipCookie extends Cookie {

  int chips;

  public void addChips(int chipsPerCookie) {
    this.chips += chipsPerCookie;
  }

}  

Here, ChocolateChipCookie is a subclass of Cookie. It inherits the flavor and size fields and the eat() method. The subclass can declare its own methods, like how ChocolateChipCookie declares the addChips() method.

However, a subclass cannot directly access private members of its superclass. Subclasses can only access protected and public members of the superclass directly. To access private fields, the superclass must provide public or protected accessors.

One important thing to know is that in Java, a class can only extend from one class due to the design choice to avoid the complexity and ambiguity associated with multiple inheritance. In other words, multiple inheritance, where a class can extend more than one class, can lead to:

  1. Diamond Problem: This is a well-known complication where a class inherits from two classes that have a common base class. This scenario creates ambiguity in the inheritance hierarchy when two parent classes have methods with the same signature, as the system might not be able to determine which version of the inherited method to use.

  2. Increased Complexity: Allowing multiple inheritance can make the design and maintenance of a program more complex. Understanding the flow of methods and variables becomes harder, especially in large codebases.

Some important class modifiers related to inheritance are final, abstract, and sealed.

Final classes cannot be subclassed. If you try to extend a final class, you’ll get a compile error. Using the cookie example, if the Cookie class were declared as final:

public final class Cookie {
    // ...
}

The declaration of the ChocolateChip class will generate a compilation error.

Making a class final ensures that its implementation cannot be changed by subclassing. However, contrary to a common misconception, final classes are not more efficient at runtime just because they are final. The final modifier is about inheritance, not performance.

Abstract classes cannot be instantiated, only subclassed. They are incomplete on their own and need to be extended to be used. Abstract classes often contain abstract methods that have no implementation in the abstract class and must be implemented by concrete subclasses. Trying to create an instance of an abstract class with new will result in a compile error.

Sealed classes provide a middle ground between final and non-final classes. Sealed classes can be extended, but only by classes explicitly permitted to do so in the sealed class declaration. This gives you fine-grained control over inheritance. Subclasses of sealed classes must themselves be declared sealed, non-sealed or final. Sealed classes restrict but don’t completely prohibit inheritance like final classes do.

Let’s review in more detail abstract and sealed classes.

Abstract Classes

An abstract class is a class that cannot be instantiated, meaning you cannot create new instances of an abstract class. It serves as a base for subclasses:

abstract class Cookie {
    abstract void flavor(); 
}

You must use the abstract keyword to declare a class or a method as abstract. An abstract class may or may not include abstract methods.

Abstract methods are declared without an implementation (without braces, and followed by a semicolon):

abstract void flavor();

Abstract methods are similar to regular methods in the sense that you declare them with or without parameters, with a return value or void, and any access modifier like public, protected, or default. The only difference is that abstract methods do not have any implementation, they cannot have a body, therefore, they end with a semicolon (;) and not with brackets ({}).

To use an abstract class, you have to inherit it from another class using the extends keyword. Let’s see an example:

class OatmealRaisinCookie extends Cookie {
    void flavor() {
        System.out.println("Oatmeal and raisin flavor");
    }
}

When inheriting from an abstract class, the subclass usually provides implementations for all of the abstract methods in its parent class. If it doesn’t, then the subclass must also be declared abstract:

abstract class Cookie {
    abstract void flavor();
    
    public void bake() {
        System.out.println("Cookie is baking");
    }
}

abstract class OatmealRaisinCookie extends Cookie {
    // Abstract method which makes the class abstract
    // (Otherwise it will not compile)
    abstract void flavor();
    
    // Even if it defines concrete method(s)
    public void addRaisins() {
        System.out.println("Adding raisins");
    }
}

Why?

Because an abstract class is (or is intended to be) incomplete. Creating an object from an incomplete class would be wrong. An abstract class needs to be extended to be used, very much like a template.

Abstract classes are helpful to share code among closely related classes. Most importantly, abstract classes can define methods that the subclasses must implement, establishing a contract or a protocol that subclasses must follow.

It’s good to think of concrete classes as specializations of abstract classes. The same way a compact car is a specialization of the general concept of a car, abstract classes are the general concept and concrete classes are a specific implementation of that concept.

Concrete classes have to implement all abstract methods but they can also define their own new methods. Not all methods have to be abstract in a concrete class, just the ones declared as abstract in the parent abstract class. Here’s an example to illustrate this:

abstract class Cookie {
    abstract void flavor();
    
    public void bake() {
        System.out.println("Cookie is baking");
    }
}

class ChocolateCookie extends Cookie {
    // Implementing the abstract method
    void flavor() {
        System.out.println("Chocolate flavor");
    }
    
    // Defining its own new method
    public void addChocolateChips() {
        System.out.println("Adding chocolate chips");
    }
}

class OatmealRaisinCookie extends Cookie {
    // Implementing the abstract method
    void flavor() {
        System.out.println("Oatmeal and raisin flavor");
    }
    
    // Defining its own new method
    public void addRaisins() {
        System.out.println("Adding raisins");
    }
}

In this example, Cookie is an abstract class with one abstract method flavor() and one concrete method bake().

The ChocolateCookie and OatmealRaisinCookie classes are concrete classes that extend the Cookie abstract class. They both implement the abstract method flavor() that they inherited from Cookie. Again, this is mandatory, otherwise they would have to be declared as abstract as well.

But ChocolateCookie and OatmealRaisinCookie also define their own new methods, addChocolateChips() and addRaisins() respectively. These methods are specific to each type of cookie and are not related to the abstract class.

When you create instances of ChocolateCookie and OatmealRaisinCookie, you can call all their methods:

ChocolateCookie chocolateCookie = new ChocolateCookie();
chocolateCookie.flavor();          // Output: Chocolate flavor
chocolateCookie.addChocolateChips();  // Output: Adding chocolate chips
chocolateCookie.bake();            // Output: Cookie is baking

OatmealRaisinCookie oatmealRaisinCookie = new OatmealRaisinCookie();
oatmealRaisinCookie.flavor();      // Output: Oatmeal and raisin flavor
oatmealRaisinCookie.addRaisins();  // Output: Adding raisins
oatmealRaisinCookie.bake();        // Output: Oatmeal Raisin Cookie is baking at a lower temperature

Abstract classes can have constructors. You need them to initialize attributes and execute any logic that needs to run when an instance of the concrete (sub)class is created. An abstract class is a class, and like any other class, it can have attributes and those attributes might need to be initialized when an instance (of the concrete class) is created. Here’s an example:

abstract class Cookie {
    protected String name;
    
    public Cookie(String name) {
        this.name = name;
        System.out.println("Cookie constructor is called");
    }
    
    abstract void flavor();
    
    public void bake() {
        System.out.println(name + " is baking");
    }
}

In this updated example, the Cookie abstract class now has a constructor that takes a name parameter. It initializes the name attribute of the cookie. The name attribute is declared as protected, which means it is accessible to subclasses.

This way, the concrete classes ChocolateCookie and OatmealRaisinCookie can call the constructor of the abstract Cookie class using super(), passing in the specific name for each type of cookie. We’ll see how to use super() later in this chapter.

When you think of abstract classes as a contract or a template that subclasses must follow and complete to ensure a common behavior, the following rules make sense:

In summary, here are the rules for correctly declaring abstract classes and methods:

Now, before talking about sealed classes, let’s review the topic of interfaces.

Interfaces

When it comes to object-oriented programming, in addition to classes, Java provides one powerful tool, interfaces. An interface in Java is essentially a contract that defines a set of methods a class must implement. It’s similar to a menu at a restaurant. The menu lists the dishes available but doesn’t provide details on how they’re prepared. When you order a dish from the menu, the kitchen (the class) provides a specific implementation of that dish (the method).

So, what exactly is an interface and how does it differ from a regular class or even an abstract class?

An interface in Java is a reference type, similar to a class, that can contain only constants, method signatures, default methods, static methods, and nested types. Interfaces cannot be instantiated, they can only be implemented by classes or extended by other interfaces.

To declare an interface, you use the interface keyword instead of the class keyword. Here’s an example:

public interface Drawable {
    void draw();
}

Any class that implements the Drawable interface must provide an implementation for the draw() method.

At first glance, interfaces might seem very similar to abstract classes. After all, both can contain abstract methods, methods without a body. However, there are some key differences:

So while there is some overlap, interfaces and abstract classes serve different purposes and are not interchangeable.

To use an interface, a class must implement it. The implements keyword is used to implement an interface:

public class Circle implements Drawable {
    public void draw() {
        System.out.println("Drawing a circle");
    }
}

If a class implements an interface but does not implement all the methods, it must be declared as abstract.

public abstract class Shape implements Drawable {
    // Class content
}

All methods in an interface are implicitly public and abstract. You don’t need to use the public or abstract keyword when declaring methods in an interface.

All variables declared in an interface are implicitly public, static, and final.

So this:

public interface MyInterface {
    int NUMBER = 10;
    void method();
}

It’s equivalent to this:

public interface MyInterface {
    public static final int NUMBER = 10;
    public abstract void method();
}

It’s important to note that because interface methods are abstract, they cannot be declared as private, protected, final, or static (with the exception of static methods, which we’ll cover later).

An interface can extend another interface, similar to how a class can extend another class. The extends keyword is used for this:

public interface Moveable {
    void move();
}

public interface Drawable extends Moveable {
    void draw();
}

In this case, any class that implements Drawable must provide implementations for both draw() and move().

A class can only extend from one class. However, a class can implement multiple interfaces. This is a way to achieve a form of multiple inheritance in Java:

public interface Moveable {
    void move();
}

public interface Drawable {
    void draw();
}

public class Circle implements Drawable, Moveable {
    public void draw() {
        System.out.println("Drawing a circle");
    }

    public void move() {
        System.out.println("Moving a circle");
    }
}

This doesn’t violate Java’s single inheritance rule because interfaces don’t contain any implementation. If a class implements two interfaces that have the same method, it’s not a problem. The class simply provides one implementation of the method, solving the ambiguity and complexity problems:

public interface A {
    void method();
}

public interface B {
    void method();
}

public class C implements A, B {
    public void method() {
        System.out.println("Method implementation");
    }
}

Also, interfaces can have default methods. These are methods with a body that provide a default implementation if a class doesn’t override them:

public interface Drawable {
    void draw();
    default void print() {
        System.out.println("Printing...");
    }
}

Classes that implement Drawable can, but are not required to, override the print() method.

If a class implements two interfaces and both have the same default method, the class must override the method. If it wants to call the default method from one of the interfaces, it can do so using the super keyword:

public interface A {
    default void method() {
        System.out.println("A's method");
    }
}

public interface B {
    default void method() {
        System.out.println("B's method");
    }
}

public class C implements A, B {
    public void method() {
        A.super.method();
    }
}

Interfaces can also have static methods, similar to static methods in classes:

public interface Drawable {
    static void staticMethod() {
        System.out.println("Static method");
    }
}

Static methods in interfaces are not inherited by classes or interfaces that extend the interface.

For the above example, you would use the Drawable interface to call staticMethod like this:

Drawable.staticMethod();

In addition to default and static methods, interfaces can also have private methods. These are helpful for sharing code between default methods in the interface:

public interface Drawable {
    default void print() {
        printLine();
        System.out.println("Printing...");
    }

    private void printLine() {
        System.out.println("---");
    }
}

Private methods in interfaces cannot be accessed by classes that implement the interface.

Sealed Classes

Imagine a royal family with a strict rule: only certain people can become future kings or queens, and this rule is unchangeable. In Java, sealed classes are like this royal family. They allow a class to strictly control which other classes can extend it, just like the royal family controls who can be in line for the throne.

So if a class is sealed, does that mean it’s completely locked down and no one can extend it at all? Not quite. A sealed class simply restricts who can extend it, but it’s not completely off limits. You get to specify a set of permitted subclasses.

This feature is useful for several reasons:

To create a sealed class, you use the sealed modifier on the class declaration, along with the permits clause to specify the permitted subclasses:

public sealed class Vehicle permits Car, Truck, Motorcycle {
    public void startEngine() {
        System.out.println("Starting the vehicle's engine.");
    }
}

final class Car extends Vehicle {
    @Override
    public void startEngine() {
        System.out.println("Starting the car's engine.");
    }
}

final class Truck extends Vehicle {
    @Override
    public void startEngine() {
        System.out.println("Starting the truck's engine.");
    }
}

final class Motorcycle extends Vehicle {
    @Override
    public void startEngine() {
        System.out.println("Starting the motorcycle's engine." );
    }
}

The sealed modifier indicates that the class is sealed. The permits clause lists the classes that are allowed to extend the sealed class.

Sealed classes and their subclasses must be declared in the same package (or named module) as their direct subclasses. This ensures a close relationship between the sealed class and its permitted subclasses.

Every class that directly extends a sealed class must specify exactly one of the following three modifiers: final, sealed, or non-sealed:

If you don’t specify one of these modifiers on a direct subclass of a sealed class, you’ll get a compilation error. The compiler enforces this to ensure the hierarchy is well-defined.

Marking a subclass as non-sealed simply means it’s open for extension. It doesn’t require you to actually create new subclasses. Accidentally using non-sealed when you don’t add more subclasses won’t break anything, but it does signal to other developers that your intent is to allow the class to be extended.

The permits clause is optional if the sealed class and its direct subclasses are declared within the same file or the subclasses are nested within the sealed class. The compiler can infer the permitted subclasses in these cases, so you can omit the explicit listing.

Here’s an example where the permits clause is omitted:

// Beverage.java
public sealed class Beverage {
    void pour();
}

final class Coffee implements Beverage {
    public void pour() {
        System.out.println("Pouring coffee");
    } 
}

final class Tea implements Beverage {
    public void pour() {
        System.out.println("Pouring tea");
    }
}

Since Coffee and Tea are declared in the same file as the sealed Beverage class (Beverage.java), the permits clause can be inferred by the compiler.

So sealed classes can only be used within the same file? No, sealed classes and their subclasses can be in different files, as long as they are in the same package or module. The same-file restriction is only relevant for omitting the permits clause.

And to answer another common question: “If I seal a class, I can’t use it in another package, can I?” You can use a sealed class from another package, but you can’t declare its subclasses in a different package. The usage is not restricted, only the extension.

In any case, once a class is sealed, the set of permitted subclasses is fixed. You can’t add new subclasses outside of what’s specified in the permits clause. If you need to extend the hierarchy later, you’d have to modify the sealed class to permit additional subclasses. This requires recompiling the sealed class and its existing subclasses.

If you’re wondering if there’s a limit to how many subclasses a sealed class can permit, the answer is no, there’s no hard limit on the number of subclasses you can permit. However, the intent of sealed classes is to have a finite and manageable set of subclasses. Allowing hundreds of subclasses would go against that spirit and likely indicate a design issue. Stick to a reasonable number that makes sense for your use case.

Sealing is not limited to just classes. You can seal interfaces too.

Interfaces can be sealed to limit the classes that implement them or the interfaces that extend them. Here’s an example:

public sealed interface Shape permits Circle, Rectangle, Triangle, Polygon {
    double getArea();
}

final class Circle implements Shape {
    public double getArea() {
        // Implementation of getArea() for circles
    }
}

final class Rectangle implements Shape {
    public double getArea() {
        // Implementation of getArea() for rectangles
    }
}

final class Triangle implements Shape {
    public double getArea() {
        // Implementation of getArea() for triangles
    }
}

sealed interface Polygon extends Shape permits RegularPolygon, IrregularPolygon {
    int getNumberOfSides();
}

final class RegularPolygon implements Polygon {
    public double getArea() {
        // Implementation of getArea() for regular polygons
    }
    
    public int getNumberOfSides() {
        // Implementation of getNumberOfSides() for regular polygons
    }
}

final class IrregularPolygon implements Polygon {
    public double getArea() {
        // Implementation of getArea() for irregular polygons
    }
    
    public int getNumberOfSides() {
        // Implementation of getNumberOfSides() for irregular polygons
    }
}

In this example, the Shape interface is sealed and permits four classes to implement it: Circle, Rectangle, Triangle, and Polygon. This means that only these four classes can directly implement the Shape interface.

But the Polygon interface is also sealed and extends the Shape interface. It permits two classes to implement it: RegularPolygon and IrregularPolygon. This demonstrates how sealing can be used to control which interfaces can extend a sealed interface.

By sealing the Polygon interface, we restrict the classes that can implement it to just RegularPolygon and IrregularPolygon. No other class can directly implement Polygon. However, since Polygon extends Shape, the RegularPolygon and IrregularPolygon classes indirectly implement Shape as well.

This allows for a well-defined and constrained inheritance structure.

The above also applies to classes, you can change the Shape interface to a class and make the necessary modifications to the other classes to achieve a similar hierarchical structure.

To summarize, here are the key rules for sealed classes:

  1. Sealed classes are declared with the sealed and permits modifiers.

  2. Sealed classes must be declared in the same package or named module as their direct subclasses.

  3. Direct subclasses of sealed classes must be marked final, sealed, or non-sealed.

  4. The permits clause is optional if the sealed class and its direct subclasses are declared within the same file or the subclasses are nested within the sealed class.

  5. Interfaces can be sealed to limit the classes that implement them or the interfaces that extend them.

The this Reference

When you’re writing code in Java, you’ll often see the keyword this sprinkled around in your methods and constructors. But what exactly is this, and why do we use it?

this is a reference to the current instance of a class. In other words, when you’re inside a method or constructor of a class, this refers to the specific object that the method or constructor belongs to. Here’s a simple example:

public class Person {
    private String name;
    
    public Person(String name) {
        this.name = name;
    }
}

In the constructor, we use this.name to specify that we’re talking about the name field of this particular Person object, not some other name variable.

But wait, you might be thinking, “So, this is just another variable I can change, right?” Well, not exactly. this is a final reference, which means you can’t assign it to something else. It always points to the current object instance.

this cannot be used anywhere in the code, like in static methods, It is only relevant within the context of an instance method or constructor. Static methods belong to the class itself, not a specific instance, so this doesn’t have any meaning there.

So, do you have to use this every time you refer to an attribute or method, no matter what? Not necessarily. If there’s no ambiguity, you can often omit this. However, there are times when using this can make your code clearer and avoid confusion. For example:

public class Person {
    private String name;
    
    public Person(String name) {
        this.name = name;
    }
    
    public void introduce(Person other) {
        System.out.println("Hi " + other.name + ", I'm " + this.name);
    }
}

Here, using this.name makes it clear that we’re referring to the name of the current Person instance, not the other Person.

Here are a few situations where this is necessary:

Speaking of constructors, you cannot use this to call a constructor from anywhere in my class. You can only use this to call another constructor from within a constructor, and it must be the first statement:

public class Person {
    private String name;
    private int age;
    
    public Person(String name) {
        this(name, 0);
    }
    
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

This is useful when you have multiple constructors and you want to avoid duplicating code.

One rule, however, is that if you’re using this to invoke another constructor, it must be the first statement in the constructor. This rule ensures that another constructor is called before executing any code in the constructor that contains the this call, preventing the use of uninitialized fields or the duplication of initialization code. For example, the following will not compile:

public class Person {
    private String name;
    private int age;
    
    public Person(String name) {
        System.out.println("Person(String) Constructor Called");
        // The following line will cause a compilation error
        this(name, 0); // ERROR: Constructor call must be the first statement in a constructor
    }
    
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

Also, remember that this doesn’t refer to the class itself. this refers to the current instance. Each instance gets its own this reference. It can’t be null.

This also means that this is used for instance members. Static fields and methods belong to the class itself, not a specific instance, so this isn’t applicable.

Also, when you use this inside a method, you’re referring to the object instance that the method belongs to, not the method itself.

Finally, passing this as an argument is useful when you want to give another method access to the current instance. For example, you might pass this to a method of another class so that it can call back to the originating object:

public class Person {
    private String name;
    
    public Person(String name) {
        this.name = name;
    }
    
    public void introduceYourselfTo(IntroductionService service) {
        service.introduce(this);
    }
    
    public String getName() {
        return name;
    }
}

public class IntroductionService {
    public void introduce(Person person) {
        System.out.println("Hello, my name is " + person.getName());
    }
}

In this example, we have two classes: Person and IntroductionService.

The Person class has a method called introduceYourselfTo, which takes an IntroductionService as a parameter. Inside this method, this (referring to the current Person instance) is passed as an argument to the introduce method of the IntroductionService.

The IntroductionService class has an introduce method that takes a Person as a parameter. This method can then access the Person’s getName() method to print out the introduction.

Here’s how you might use these classes:

Person alice = new Person("Steve");
IntroductionService service = new IntroductionService();
alice.introduceYourselfTo(service);

And this is the output:

Hello, my name is Steve

The super Reference

So the this keyword is used to refer to the current instance of the class. But what if you want to refer to the superclass from which your current class inherits? That’s where super comes in.

The super keyword acts as a reference to the parent class (superclass) of the current class. It allows access to the superclass’s members (fields, methods, and constructors).

The main purpose of super is to differentiate between members of the superclass and members of the current class when they have the same name. By prefixing super to a member name, you specify that you wish to use the superclass’s version of that member, rather than the current class’s version.

The syntax for using super is straightforward:

super.memberName

Here, memberName can be a field, method, or constructor of the superclass.

Overriding in Java is a feature that allows a subclass or child class to provide a specific implementation of a method that is already provided by one of its superclasses or parent classes.

When you override a method in a subclass, you’re not erasing or replacing the original method in the superclass. The superclass method is still there, but when you call the method on an object of the subclass, the overridden version in the subclass is executed instead. So, when overriding a method in a subclass, you might want to call the original implementation of the method from the superclass.

In that case, you can use super to invoke the superclass’s version of the method:

@Override
public void someMethod() {
    super.someMethod(); // Calls the superclass's implementation
    // Additional code specific to the subclass
}

Another common use case for super is when you want to invoke the constructor of the superclass from the constructor of the current class. Just like with this, you must call super() as the first statement in the constructor:

public class SubClass extends SuperClass {
    public SubClass() {
        super(); // Invokes the superclass constructor
        // Other initialization code
    }
}

Otherwise, you’ll get a compilation error.

If your superclass doesn’t have a default (no-argument) constructor, you’ll need to explicitly call a parameterized constructor using super(arguments). You cannot use super without specifying the required arguments.

Consider this example:

// Superclass without a default constructor
public class Person {
    private String name;
    private int age;

    // Constructor that requires parameters
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // Getter methods for name and age
    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

// Subclass that extends Person
public class Student extends Person {
    private String studentID;

    // Since Person does not have a default constructor, we must explicitly call a parameterized constructor
    public Student(String name, int age, String studentID) {
        super(name, age); // Calls the superclass constructor with arguments
        this.studentID = studentID;
    }

    // Getter method for studentID
    public String getStudentID() {
        return studentID;
    }
}

In the Student constructor, super(name, age); is used to explicitly call the parameterized constructor of the Person class. This is necessary because Person does not have a no-argument constructor. If this super call was omitted, the code would not compile, as Java would attempt to call a default constructor in the Person class, which does not exist in this case.

Now, you might be wondering, if I use super, does that mean I can’t use this in the same method? The answer is no. You can use both this and super in the same method, as they serve different purposes. this refers to the current instance, while super refers to the superclass.

However, it’s important to keep in mind is that super cannot be used to directly access private members (fields or methods) of the superclass. Private members are only accessible within the same class. If you need to access them, you’ll have to rely on public or protected methods provided by the superclass.

Finally, it’s also worth noting that while super is primarily used to call methods or access fields from the immediate parent class, it indirectly allows for interaction with the broader inheritance hierarchy. In particular, if the immediate parent class inherits methods from its ancestors (grandparent classes and beyond), super can indirectly access these methods as well. This is because the inherited methods from the parent class, which super can call, may themselves call methods from their ancestors within the inheritance chain. However, direct invocation of methods or access to fields from grandparent classes or higher, using super, is not possible. To access such methods directly, you would typically rely on the inherited methods that encapsulate this functionality within your immediate superclass.

Consider the following example, which extends the previous example by adding a new class, GraduateStudent, which inherits from Student, and a grandparent class, Human, from which Person inherits:

// Grandparent class
public class Human {
    private String nationality;

    public Human(String nationality) {
        this.nationality = nationality;
    }

    protected void sayHello() {
        System.out.println("Hello from Human!");
    }
}

// Parent class
public class Person extends Human {
    private String name;
    private int age;

    public Person(String name, int age, String nationality) {
        super(nationality); // Calls the Human constructor
        this.name = name;
        this.age = age;
    }

    // Overriding the sayHello method
    @Override
    protected void sayHello() {
        super.sayHello(); // Calls Human's sayHello
        System.out.println("Hello from Person!");
    }
}

// Current class
public class Student extends Person {
    private String studentID;

    public Student(String name, int age, String nationality, String studentID) {
        super(name, age, nationality); // Calls the Person constructor
        this.studentID = studentID;
    }

    // Overriding the sayHello method again
    @Override
    protected void sayHello() {
        super.sayHello(); // Calls Person's sayHello, which in turn calls Human's sayHello
        System.out.println("Hello from Student!");
    }
}

// New Subclass that extends Student
public class GraduateStudent extends Student {
    private String researchTopic;

    public GraduateStudent(String name, int age, String nationality, String studentID, String researchTopic) {
        super(name, age, nationality, studentID); // Calls the Student constructor
        this.researchTopic = researchTopic;
    }

    public void introduce() {
        super.sayHello(); // Calls Student's sayHello, which in turn calls Person's, and then Human's sayHello
        System.out.println("I am a graduate student working on " + researchTopic + ".");
    }
}

In this example, the GraduateStudent class uses super.sayHello() in its introduce method. This calls the sayHello method from the Student class, which itself overrides Person’s sayHello method. The Person class’s sayHello method then calls Human’s sayHello method. This demonstrates how super can be used to indirectly access methods up the inheritance chain, from the Human class to the GraduateStudent class, even though direct access to Human’s methods from GraduateStudent using super is not possible.

Now let’s talk more about overriding and polymorphism.

Polymorphism

Introducing Polymorphism

Polymorphism is one of the pillars of object-oriented programming, and it’s a powerful concept in Java. In simple terms, polymorphism allows you to treat objects of different subclasses as if they were objects of the same superclass. It’s like having a single remote control that can operate multiple types of devices, a TV, a stereo, and a DVD player. Just as the remote control sends signals to each device that performs different functions depending on the device it’s communicating with, in Java, you can use a single reference type to interact with objects of different classes, allowing them to perform their own unique behaviors through a common interface.

However, polymorphism doesn’t mean that methods can arbitrarily change their behavior. Instead, it allows subclasses to provide their own implementations of methods defined in the superclass, a concept known as method overriding.

As mentioned before, when you override a method in a subclass, you’re not erasing or replacing the original method in the superclass. The superclass method is still there, but when you call the method on an object of the subclass, the overridden version in the subclass is executed instead. It’s important to note that overriding is not the same as hiding members, which we’ll discuss later.

To properly override a method, the method in the subclass must have the same:

As the method in the superclass. Here’s an example:

class Animal {
    public void makeSound() {
        System.out.println("The animal makes a sound");
    }
}

class Pig extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Oink");
    }
}

class Duck extends Animal {
    @Override
    public void makeSound() {
        System.out.println("Quack");
    }
}

And a diagram to visualize this hierarchy:

┌──────────────────────────────────────────┐
│            Animal makeSound()            │
└──────────────────────┬───────────────────┘
                       │
           ┌───────────┴─────────┐
           │                     │
┌──────────┴────────┐  ┌─────────┴─────────┐
│    Pig (Oink)     │  │   Duck (Quack)    │
└───────────────────┘  └───────────────────┘

The Animal class has a method called makeSound(). The Pig and Duck classes, which extend Animal, override the makeSound() method to provide their own implementations. Now, let’s see polymorphism in action:

Animal animal1 = new Pig();
Animal animal2 = new Duck();

animal1.makeSound(); // Output: Oink
animal2.makeSound(); // Output: Quack

Here, we create two variables of type Animal, but we assign them objects of the Pig and Duck classes. When we call the makeSound() method on each variable, the appropriate overridden method in the respective subclass is called. This is the power of polymorphism, the ability to treat objects of different subclasses as objects of a common superclass.

It’s important to understand that overriding is not the same as overloading. Overloading refers to having multiple methods with the same name but different parameter lists within the same class. Overriding, on the other hand, is about providing a different implementation of a method in a subclass.

Another common misconception is that overriding applies to all members of a class, including variables. However, that’s not true. Overriding specifically applies to methods. When you declare a variable with the same name in a subclass, you’re actually hiding the variable from the superclass, not overriding it.

Let’s explore some rules related to overriding.

Overriding Rules

There are several rules you need to follow when overriding methods from a superclass:

Rule #1: Method Signatures
The first and most important rule is that the method signature must match exactly between the superclass and subclass. This means the name, parameters, and return type need to be identical (with one exception we’ll discuss later). You can’t change the parameters or return type however you want:

// Superclass
class Cookie {
    // Define a method 'eat' in the superclass
    public String eat() {
        return "Eating a plain cookie";
    }
}

// Subclass
class ChocolateChipCookie extends Cookie {
    // Override the 'eat' method in the subclass
    @Override
    public String eat() {
        return "Eating a chocolate chip cookie";
    }
}

In this example:

Why does the method signature have to stay the same? Well, think of it like a contract between the superclass and subclass. The superclass is defining a specific method that subclasses can override if needed. If you change the signature, you’re breaking that contract. The subclass method would no longer be a true override of the superclass method.

Rule #2: Access Modifiers
When overriding a method, you can make the access modifier more lenient, but not more restrictive. For example, you could override a protected method in the superclass and make it public in the subclass. But you can’t do the opposite, like changing a public method to private:

// Superclass
class Cookie {
    // Define a method with 'protected' access modifier in the superclass
    protected String recipe() {
        return "Default cookie recipe";
    }
}

// Subclass
class ChocolateChipCookie extends Cookie {
    // Override the 'recipe' method in the subclass and change the access modifier to 'public'
    @Override
    public String recipe() {
        return "Chocolate chip cookie recipe";
    }
}

In this example:

This often confuses people. They think: “Since it’s my subclass, shouldn’t I be able to limit access to the method if I want?” However, this goes against the idea that a subclass should always work wherever its superclass is used. If you make the method more restricted in the subclass, you disrupt this compatibility.

Rule #3: Checked Exceptions
We’ll review exceptions in more detail in a later chapter, but if the superclass method declares any checked exceptions in its throws clause, the overridden method in the subclass can only declare exceptions that are the same or more specific. It can’t add any new checked exceptions that aren’t a subclass of those declared by the superclass method:

class BakingException extends Exception {
    public BakingException(String message) {
        super(message);
    }
}

class OverBakingException extends BakingException {
    public OverBakingException(String message) {
        super(message);
    }
}

// Superclass
class Cookie {
    // Define a method that declares throwing a general BakingException
    public String bake() throws BakingException {
        return "Cookie is baked";
    }
}

// Subclass
class ChocolateChipCookie extends Cookie {
    // Override the 'bake' method, declaring a more specific exception, OverBakingException
    @Override
    public String bake() throws OverBakingException {
        return "Chocolate chip cookie is baked";
    }
}

In this example:

People often think they are allowed to throw any checked exception they want in an overridden method, especially if the superclass doesn’t declare any. But that’s not the case. Again, it comes down to the contract defined by the superclass method. The subclass can’t suddenly introduce new checked exceptions that the caller wasn’t expecting to handle.

Rule #4: Covariant Return Types
Here’s the one exception to the rule about method signatures. An overridden method is allowed to have a covariant return type. That means the return type can be a subclass of the return type declared in the superclass method. It doesn’t have complete freedom to return just anything loosely related though.

For example, if the superclass method returns a Number, the subclass could return an Integer, since Integer is a subclass of Number. However, it cannot return a String, despite any perceived loose relation to the original Number. The return types need that direct hierarchical relationship.

Here’s an example to illustrate this rule:

class Cookie {
    // A method in the superclass that returns an instance of Cookie
    public Cookie getCookie() {
        return new Cookie();
    }
}

class ChocolateChipCookie extends Cookie {
    // An overriding method with a covariant return type
    // It returns ChocolateChipCookie, a subclass of Cookie
    @Override
    public ChocolateChipCookie getCookie() {
        return new ChocolateChipCookie();
    }
}

In this example:

All right.

Have you notice the @Override annotation in all these examples?

The @Override annotation explicitly marks methods that are intended to override a superclass method. But what’s the point of using it? Is it just for clarity, or does it have a real purpose?

While it’s true that @Override can make your code more readable by clearly indicating overridden methods, it provides a safeguard against accidental errors. Consider this scenario:

class Cookie {
    public String recipe() {
        return "Default cookie recipe";
    }
}

class ChocolateChipCookie extends Cookie {
    @Override
    public String recipes() { // Oops, typo in the method name!
        return "Chocolate chip cookie recipe";
    }
}

In this case, the subclass intended to override recipe, but accidentally introduced a typo, naming it recipes instead. Without the @Override annotation, this would compile just fine. The subclass would simply have two separate methods: the inherited recipe and the new recipes.

But with @Override, the compiler will catch the mistake and produce an error, indicating that recipes does not override any method. The annotation forces the compiler to verify that the method truly overrides a superclass method, providing an extra layer of safety.

Now, what happens if you redeclare a private method from the superclass in a subclass? Is that considered overriding? The answer is no. Private methods are not inherited at all, so there’s nothing to override.

If you redeclare a private method in the subclass, it’s essentially a completely separate method that just happens to have the same name. It doesn’t interact with the superclass method in any way. For example:

class Cookie {
    private String recipe() {
        return "Default cookie recipe";
    }
}

class ChocolateChipCookie extends Cookie {
    private String recipe() {
        return "Chocolate chip cookie recipe";
    }
}

In this case, Cookie and ChocolateChipCookie each have their own separate recipe. Calling recipe on a ChocolateChipCookie instance will always invoke the subclass version, never the superclass one.

Another source of confusion is the difference between hiding and overriding static methods. When you redeclare a static method in a subclass, it’s called hiding, not overriding. The subclass method hides the superclass method, but doesn’t actually override it.

The key difference is that overriding is a runtime concept, while hiding is a compile-time concept. With overriding, the specific method invoked is determined by the actual object type at runtime. But with hiding, the method invoked is determined by the reference type at compile-time.

Here’s an example to illustrate this:

class Cookie {
    public static String bake() {
        return "Cookie is baked";
    }
}

class ChocolateChipCookie extends Cookie {
    public static String bake() {
        return "Chocolate chip cookie is baked";
    }
}

Now, consider the following code:

Cookie obj1 = new Cookie();
System.out.println(obj1.bake());  // Output: "Cookie is baked"

ChocolateChipCookie obj2 = new ChocolateChipCookie();
System.out.println(obj2.bake());  // Output: "Chocolate chip cookie is baked"

Cookie obj3 = new ChocolateChipCookie();
System.out.println(obj3.bake());  // Output: "Cookie is baked"

In the last case, even though obj3 is actually a ChocolateChipCookie instance at runtime, the reference type is Cookie. So it invokes the hidden Cookie method, not the overridden ChocolateChipCookie one.

Similar to static methods, variables can be hidden in subclasses. If a subclass declares a variable with the same name as a variable in the superclass, it hides the superclass variable within the scope of the subclass.

Here’s an example:

class Cookie {
    protected int size = 10;
}

class ChocolateChipCookie extends Cookie {
    private int size = 20;
}

In this case, the size variable in ChocolateChipCookie hides the size variable from Cookie. Any reference to size within ChocolateChipCookie will access the subclass variable, not the superclass one.

But here’s the tricky part. The hidden superclass variable doesn’t go away. It’s still there, and can be accessed through a superclass reference. Consider this:

Cookie cookie = new ChocolateChipCookie();
System.out.println(cookie.size);  // Output: 10

Even though cookie is actually a ChocolateChipCookie instance, the variable is declared as type Cookie. So it accesses the hidden Cookie variable, not the ChocolateChipCookie one.

This can lead to a lot of confusion and subtle bugs. In general, it’s best to avoid hiding variables altogether. If you need to override a superclass variable, consider using a getter/setter method instead, which can be properly overridden.

Finally (pun intended), let’s talk about the final keyword. When applied to a method, final prevents that method from being overridden in subclasses. It essentially locks the method, ensuring that its implementation remains constant throughout the hierarchy.

A common misconception is that final methods can’t be accessed by subclasses at all. That’s not true. Subclasses can still call and use final methods; they just can’t override them.

For example:

class Cookie {
    public final void bake(int temp) {
        System.out.println("Baking at " + temp);
    }
}

class ChocolateChipCookie extends Cookie {
    // Attempting to override bake() will cause a compile error
    // @Override
    // public void bake(int temp) { ... }
    
    public void extras() {
        bake(350);  // Calling the final bake() method is allowed
    }
}

The bake() method in Cookie is final, so ChocolateChipCookie can’t override it. But it can still call bake() whenever needed.

So when should you use final methods? Only when you have a critical reason to prevent overriding. Overuse of final can make your code rigid and hard to extend. In most cases, it’s better to leave methods open for overriding, as it promotes flexibility and reusability.

Accessing Java Objects

In the previous chapter, you learned that when declaring a field or a variable, one thing is the reference type, and another thing is the object type.

Taking this into account, there are three main ways to access an object in Java:

  1. Using a reference with the same type as the object

  2. Using a reference that is a superclass of the object’s type

  3. Using a reference that defines an interface the object’s class implements or inherits

Let’s dive into each of these in more detail.

Using a Reference with the Same Type as the Object.

The most straightforward way to access an object is by using a reference variable that matches the object’s type exactly.

Consider this class:

class Dog {    
    public void bark() {
        System.out.println("Woof!");
    }
}

And this code:

Dog myDog = new Dog();
myDog.bark(); // Can access all public methods of Dog

Here, myDog is a reference variable of type Dog, and it’s referring to a Dog object. With this setup, we can access any public method or variable defined in the Dog class directly through the myDog reference.

If you’re wondering if polymorphism is happening when a reference type and object type are the same, the answer is yes. Even with matching types, polymorphism is still at play under the hood. The reference type determines what methods you can call, but the actual object type determines which implementation of those methods gets used at runtime.

Using a Reference that is a Superclass of the Object.

Things get a bit more interesting when we bring inheritance into the picture. In Java, it’s perfectly valid to have a reference variable with a type that is a superclass of the actual object type.

Consider this class and its subclass:

class Animal {
    public void eat() {
        System.out.println("Animal is eating.");
    }
}

class Dog extends Animal {
    public void eat() {
        System.out.println("Dog is eating.");
    }
    
    public void bark() {
        System.out.println("Woof!");
    }
}

We can have something like this:

Animal myAnimal = new Dog();

Here, we have a reference of type Animal referring to a Dog object. Since Dog extends Animal, this is allowed. But what does this mean for accessing the object’s functionality?

When you have a superclass reference to a subclass object, you can access any methods defined in the superclass, but not methods that are unique to the subclass. So in the above example, we could call myAnimal.eat() as eat() is defined in Animal, but we couldn’t call myAnimal.bark() as bark() is only defined in Dog. The reference type restricts you to the methods that type defines. However, Java does give us a way around this: casting.

If you’re certain your superclass reference is pointing to a specific subclass object, you can cast the reference to that subclass type and then call the subclass methods:

Dog myDog = (Dog) myAnimal; // Casting from Animal to Dog
myDog.bark(); // Now we can call Dog-specific methods

Casting essentially says, “I know this seems to be an Animal, but trust me, it’s really a Dog.” Of course, you need to be careful, if you try to cast to the wrong subclass, you’ll get a ClassCastException at runtime.

We’ll continue looking at casting in the next section, but in summary, superclass references give you flexibility (you can use a Dog anywhere an Animal is expected) but they restrict direct access to subclass-specific functionality. This is a key aspect of polymorphism in Java.

Using a Reference that Defines an Interface the Object Implements.

The third way to access an object in Java is through an interface reference. If a class implements an interface, you can refer to instances of that class using a reference variable of the interface type.

Consider this interface and its implementations:

interface Pet {
    void play();
}

class Dog implements Pet {
    public void play() {
        System.out.println("Dog is playing!");
    }
    
    public void bark() {
        System.out.println("Woof!");
    }
}

class Cat implements Pet {
    public void play() {
        System.out.println("Cat is playing!");
    }
    
    public void meow() {
        System.out.println("Meow!");
    }
}

This way, we can have something like this:

Pet myPet = new Dog();

In this example, Dog implements the Pet interface, so we can create a Pet reference and point it to a Dog object.

Now, you might be thinking, does creating an interface reference to an object mean I can only use the methods defined in the interface? And the answer is yes. When you have an interface reference, you can only directly call methods that are defined in that interface, even if the actual object has other methods available.

myPet.play(); // Valid, play() is defined in Pet
myPet.bark(); // Not valid, bark() is not part of Pet

This might seem limiting, but it’s actually a powerful feature. By programming to an interface, you can write more flexible, maintainable code. You can change the actual object type (for example, from Dog to Cat) without having to change any code that uses the interface reference:

Pet myPet = new Dog();
myPet.play(); // Output: Dog is playing!
        
myPet = new Cat();
myPet.play(); // Output: Cat is playing!

The key point in this example is that the myPet reference doesn’t care whether it’s dealing with a Dog or a Cat. It just knows it’s working with some Pet. We can change the actual object type from Dog to Cat, and the play method still works without any changes.

But what if you need to access methods that are specific to the actual object type? Just like with superclass references, you can use casting:

Dog myDog = (Dog) myPet; // Casting from Pet to Dog
myDog.bark(); // Now we can call Dog-specific methods

Again, you need to be certain that your interface reference is actually pointing to a Dog object before you perform this casting, or you’ll get a runtime exception.

And remember, interfaces do not have instances, you can’t create an object of an interface type directly. However, any object of a class that implements the interface can be referred to using the interface type. In that sense, the object is-a form of the interface type.

It’s also worth remembering that a single class can implement multiple interfaces. If a class implements multiple interfaces, you can use a reference of any of those interface types to refer to instances of the class:

interface Trainable {
    void doTrick();
}

class Dog implements Pet, Trainable {
    // Implement methods from both interfaces
}

Pet myPet = new Dog();
Trainable myStudent = (Trainable) myPet;

In this example, a single Dog object can be referred to as both a Pet and as a Trainable, because Dog implements both interfaces.

So, interface references provide a way to write more abstract, flexible code. They allow you to focus on a specific set of behaviors that an object can perform, regardless of its actual class type. This is a fundamental principle of object-oriented design.

One final note, remember that interfaces are not part of an object’s inheritance hierarchy. They are a separate construct. So, while a Dog object can be referred to as Pet, a Pet reference is not a superclass of Dog. It’s a distinct type of relationship.

Type Casting

To understand type casting, you can think of variables as actors. Each variable has a specific role to play, determined by its data type. But sometimes, just like in a movie, a variable needs to take on a new role temporarily to fit the needs of a particular scene in your code. This is where type casting comes in.

For primitive types, type casting allows you to assign a value of one primitive data type to another type. In the case of objects, it allows you to treat an object of one class as an object of another class, as long as there is an inheritance relationship between the two classes.

So, does casting an object change its actual type? Not exactly. When you cast an object, you’re not altering its underlying type, instead, you’re merely treating it as a different type temporarily for a specific context. It’s like an actor putting on a costume for a scene. Underneath, they’re still the same person, but they’re playing a different role for that moment. Once the cast is over, the variable reverts back to its original type. It’s like an actor taking off the costume after the scene is done. They’re back to being themselves.

Now, you might be wondering, can you cast any type to any other type? After all, it’s all just data, right? Well, not quite. Java is a strongly-typed language, which means it has strict rules about type compatibility. You can’t arbitrarily cast between unrelated types, like trying to cast an int to a String. The compiler will give you an error if you try to do something like that.

The rules for type casting in Java are as follows:

  1. Casting a reference from a subtype to a supertype doesn’t require an explicit cast.

  2. Casting a reference from a supertype to a subtype requires an explicit cast.

  3. At runtime, an invalid cast of a reference to an incompatible type results in a ClassCastException being thrown.

  4. The compiler disallows casts to unrelated types.

Let’s break these down one by one.

The first rule says that casting a reference from a subtype to a supertype doesn’t require an explicit cast. This is known as upcasting. If you have a class hierarchy where class B extends class A, you can assign a reference of type B to a variable of type A without an explicit cast:

class A {}
class B extends A {}

B b = new B();
A a = b; // upcasting, no explicit cast needed

Upcasting is safe because a subclass always contains all the features of its superclass. So treating a subclass object as a superclass object will never cause a problem.

The second rule says that casting a reference from a supertype to a subtype requires an explicit cast. This is known as downcasting. If you have a variable of the supertype and you want to treat it as the subtype, you need to explicitly cast it:

A a = new B(); // upcasting
B b = (B) a; // downcasting, explicit cast needed

Downcasting is necessary when you want to access methods or variables that are specific to the subclass and not available in the superclass.

However, downcasting comes with a risk. What if the object being referenced is not actually an instance of the subclass you’re trying to cast it to? This leads us to the third rule.

At runtime, an invalid cast of a reference to an incompatible type results in a ClassCastException being thrown:

A a = new A();
B b = (B) a; // Compiles but throws ClassCastException at runtime

In this example, a is referring to an instance of class A, not class B. When we try to cast it to B, it compiles without error because the compiler allows the possibility that a might be referring to a B object. But at runtime, when the cast is actually attempted, Java realizes that a is not in fact a B, and it throws a ClassCastException.

This is an important point: casting doesn’t magically transform an object into something it’s not. If you try to cast an object to an incompatible type, it will result in a runtime exception. Explicit casting basically tells the compiler, “Trust me, I know what I’m doing.” But if you’re wrong, Java will let you know at runtime.

However, the fourth rule states that the compiler disallows casts to unrelated types. If you try to cast between classes that are not in the same inheritance hierarchy, the compiler will give you an error:

class A {}
class C {}

A a = new A();
C c = (C) a; // Compilation error

Classes A and C are not related through inheritance, so the compiler knows that it’s impossible for an A object to ever be a C object. It won’t even let this code compile.

So, if casting doesn’t work, is it a compile-time problem or a runtime problem? It can be either, depending on the situation. If you try to cast to an unrelated type, it’s a compile-time error. If you try to cast to a related type but the object is not actually an instance of that type, it’s a runtime exception.

Now, you might be thinking, isn’t all this casting dangerous? Doesn’t it basically bypass Java’s type checking system? Not exactly. Java’s type system is still in effect, and the compiler won’t let you do anything too unsafe. Explicit casting is a way of telling the compiler that you have additional knowledge about the type of an object, but it’s still checked at runtime.

That said, it’s generally a good idea to avoid excessive casting, especially downcasting. If you find yourself downcasting a lot, it might be a sign that your class hierarchy needs to be redesigned.

So when is casting actually useful? Upcasting is very common and is an important part of polymorphism in Java. It allows you to treat a more specific type as a more general type, which is safe and often necessary.

For example, let’s say you have a method that takes a parameter of type List. You can pass in an ArrayList, a LinkedList, or any other subclass of List, and it will work fine due to upcasting.

void processNames(List<String> names) {
    // code here
}

ArrayList<String> nameList = new ArrayList<>();
processNames(nameList); // upcasting from ArrayList to List

Downcasting is less common and should be used more sparingly. It’s necessary when you have a reference to a superclass but you need to access methods or variables that are only available in a subclass.

class Shape {
    void draw() { /* ... */ }
}

class Circle extends Shape {
    void drawCircle() { /* ... */ }
}

Shape shape = new Circle();
shape.draw(); // Fine, draw() is defined in Shape
((Circle)shape).drawCircle(); // Downcast to access drawCircle()

In this case, the downcast is safe because we know that shape is actually referring to a Circle object.

In summary, type casting in Java allows you to temporarily treat an object as a different type, either a superclass (upcasting) or a subclass (downcasting), as long as there is an inheritance relationship. Upcasting is safe and common, while downcasting requires an explicit cast and should be used carefully. The compiler checks for invalid casts to unrelated types, while invalid casts to related types result in a runtime exception. And always remember, underneath the cast, the object itself doesn’t change, it’s just being viewed through a different lens.

But to be extra safe, you can use the instanceof operator to check the type before casting. Let’s talk about it next.

The instanceof Operator

In Java, the instanceof operator is used to test whether an object is an instance of a particular class or implements a specific interface. It returns a boolean value: true if the object is an instance of the class/interface, false otherwise.

The syntax for using instanceof is:

objectReference instanceof ClassName/InterfaceName  

For example:

Object obj = "Hello";
if(obj instanceof String) {
    System.out.println("obj is a String");
}

This will print "obj is a String" since the object referenced by obj is an instance of the String class.

It’s important to note that using instanceof does not actually change the object or its type in any way. It simply checks the object against the specified class or interface and returns a boolean result. instanceof cannot be used with primitive types like int or double, it only works with object references.

Passing the instanceof test for a class indicates that the object is an instance of either that class itself or one of its subclasses. All objects in Java inherit from the Object class, so instanceof Object will always return true:

String str = "abc";
if(str instanceof Object) {
    System.out.println("This will always print");
}

An exception to this rule is when the reference is null:

String str = null;
if(str instanceof String) {
    System.out.println("This will never be executed");
}

instanceof can also check if an object implements a particular interface. If a class implements an interface either directly or through inheritance, instanceof will return true for that interface:

interface Trainable {
    void doTrick();
}

interface Pet extends Trainable {
    void play();
}

class Dog implements Pet {
    // Implement methods from both interfaces
}

Pet dog = new Dog();
if(dog instanceof Pet) {
    System.out.println("A Dog is a Pet");
}
if(dog instanceof Trainable) {
    System.out.println("A Dog is a Trainable");
}

Both of these print statements will execute, since Dog directly implements Pet, and Pet extends Trainable.

One common use case for instanceof is to safely downcast an object before calling a subclass-specific method. Remember, a downcast is when you cast a reference from a superclass type to a subclass type:

Object obj = getSomeObject();
if(obj instanceof String) {
    String str = (String) obj;
    System.out.println(str.toUpperCase());
}

Here we first check if obj is actually a String before downcasting and calling the String specific toUpperCase() method. The explicit cast (String) is required even though we already confirmed the type with instanceof.

However, we can use pattern matching for the instanceof operator to streamline the process of checking and casting object types.

So instead of an explicit cast, you can combine the type check and cast in a single operation using the following syntax:

if (objectReference instanceof ClassName variableName) {
    // Use variableName here, which is automatically cast to ClassName
}

This syntax checks whether objectReference is an instance of ClassName. If it is, objectReference is cast to ClassName, and the cast object is assigned to variableName within the scope of the if statement. If the check fails, no exception is thrown. The code within the block simply doesn’t execute, and the pattern variable remains inaccessible. This eliminates the need for an explicit cast and reduces boilerplate code.

Here’s the previous downcast example rewritten to use pattern matching:

Object obj = getSomeObject();
if(obj instanceof String str) {
    System.out.println(str.toUpperCase());
}

In this example, str is the pattern variable that is automatically cast to String if obj is an instance of String. Pattern variables are implicitly initialized upon a successful match. No additional casting is required.

Pattern variables have a limited scope. They are only accessible where their matching is guaranteed. str in the above example is not available outside the if block. This design choice ensures that pattern variables are only used in contexts where their types are assured, eliminating a common source of errors.

However, this does not always mean that the scope is the if block where they are defined. When using pattern matching with instanceof, if the condition is true, meaning the object is an instance of the specified type, the pattern variable is indeed scoped to and accessible within the block that follows the condition. However, consider this example, where the pattern matching is used with a negation:

Object obj = getSomeObject();
if (!(obj instanceof String str)) {
    // The pattern variable str is NOT accessible here
    return "";
}
// But, because the execution only reaches this point if str IS an instance of String,
// the pattern variable str is accessible here.
return str.toUpperCase();

In this example, the if statement checks if obj is not an instance of String. If obj is not a String, the method returns false immediately, and the pattern variable str is not accessible within the if block because the condition for its instantiation (obj being an instance of String) is false.

However, immediately after this if block, the code execution continues only if obj is indeed an instance of String, which means str was successfully matched and is now accessible and usable outside of, but directly after, the if block that contains the pattern matching. This is a specific scenario where the flow of the program ensures that the pattern variable str is instantiated and can be used safely because the method would have exited early if the condition were false.

You can also use a pattern variable this way:

Object obj = getSomeObject();
if(obj instanceof String str && str.length() > 3) {
    System.out.println(str.toUpperCase());
}

Because, being the conditional-AND operator (&&) short-circuiting, the program can reach the str.length() > 3 expression only if the instanceof expression returns true.

However, you can’t use an OR operator (||):

Object obj = getSomeObject();
if(obj instanceof String str || str.length() > 3) { // Error
    System.out.println(str.toUpperCase());
}

This will result in an error because the str.length() > 3 expression may execute when obj is not an instance of String, leading to an attempt to access str when it may not have been initialized.

Also, pattern matching with instanceof is designed for one type at a time. It simplifies the process for a single type check and cast but doesn’t extend to multiple types simultaneously:

Object obj = getSomeObject();

if (obj instanceof String str) {
    // obj is a String, use str here
    System.out.println("String length: " + str.length());
} else if (obj instanceof Integer intVal) {
    // obj is an Integer, use intVal here
    System.out.println("Integer value: " + intVal);
} else if (obj instanceof List<?> list) {
    // obj is a List, use list here
    System.out.println("List size: " + list.size());
}

In this example, obj is checked against multiple types: String, Integer, and List. Depending on the actual type of obj, the corresponding block of code executes. Within each block, the object obj is automatically cast to the type being checked, and you can use the cast object directly without an explicit cast.

This approach keeps your code clean and type-safe, allowing for more readable and maintainable code when dealing with multiple possible types for a single object reference.

It’s generally good practice to use instanceof sparingly and prefer polymorphism where possible. Frequent instanceof checks can be a sign of poor object-oriented design. But it does have valid uses for safely downcasting, reflective code, and some equality comparisons.

Finally, here are two other key facts about instanceof:

Encapsulation

What is Encapsulation?

Encapsulation is one of the fundamental principles of object-oriented programming in Java. It involves bundling data (attributes) and methods (behavior) that operate on that data within a single unit (like a class) and restricting access to the inner workings of the class from the outside.

Encapsulation in Java can be thought of like a vending machine. Just as you interact with a vending machine using the provided buttons to select your snack or drink, without needing to understand or access the internal mechanisms that actually dispense the item, encapsulation allows you to interact with an object through its public methods, while the internal state and implementation details remain hidden and protected from external interference.

The main purpose of encapsulation is to protect the data from unauthorized access and modification, and to separate the interface of a class (how it can be used) from the implementation (how it actually works internally). By encapsulating the internal state of an object, we ensure that it cannot be put into an invalid or inconsistent state by external code.

Some programmers might wonder, “Can’t I just make everything public to simplify the coding process? Why bother hiding class internals?”

While this approach may seem simpler in the short term, it quickly leads to inflexible, fragile, and hard-to-maintain code. Encapsulation helps manage complexity by reducing interdependencies between different parts of a program. When a class is well encapsulated, changes to its internal implementation do not affect the rest of the codebase, allowing for easier maintenance, refactoring, and updating of the class without causing ripple effects throughout the program.

So how exactly do we implement encapsulation in Java? The primary mechanism is through the use of access modifiers on class members.

Remember, there are four access modifiers that determine the visibility and accessibility of classes, fields, and methods:

You can apply these modifiers to classes, attributes, and methods according to the following table:

Access Modifier Class/Interface Class Attribute Class Method Interface Attribute Interface Method
public
private      
protected      
default

And here’s the summary of the rules of access modifiers:

Access Modifier Same Class Subclass (Same Package) Subclass (Different Package) Another Class (Same Package) Another Class (Different Package)
public
private        
protected  
default    

To encapsulate a class, we typically:

  1. Declare the fields (instance variables) of the class as private. This prevents direct access to the fields from outside the class.

  2. Provide public getter methods to retrieve the values of the fields, and setter methods to modify them, if needed. These methods provide controlled access to the fields and allow for adding validation, logging, or any other logic when the field values are accessed or modified.

Here’s an example of a well-encapsulated BankAccount class:

public class BankAccount {
    private String accountNumber;
    private double balance;

    public String getAccountNumber() {
        return accountNumber;
    }

    public double getBalance() {
        return balance;
    }

    public void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        } else {
            throw new IllegalArgumentException("Deposit amount must be positive.");
        }
    }

    public void withdraw(double amount) {
        if (amount > balance) {
            throw new IllegalArgumentException("Insufficient funds.");
        } else if (amount < 0) {
            throw new IllegalArgumentException("Withdrawal amount must be positive.");
        } else {
            balance -= amount;
        }
    }
}

And a diagram to visualize it:

┌─────────────────────────────────────────┐
│              BankAccount                │
├─────────────────────────────────────────┤
│ - accountNumber: String                 │
│ - balance: double                       │
├─────────────────────────────────────────┤
│ + getAccountNumber(): String            │
│ + getBalance(): double                  │
│ + deposit(amount: double): void         │
│ + withdraw(amount: double): boolean     │
└─────────────────────────────────────────┘

In this example, the accountNumber and balance fields are declared private, so they cannot be directly accessed or modified from outside the BankAccount class. The public getAccountNumber() and getBalance() methods allow for controlled retrieval of these field values, while the deposit() and withdraw() methods enable controlled modification of the balance field with added validation logic.

Now, you might wonder, “If I use getters and setters for all my fields, does that automatically mean my class is well-encapsulated?”

Not necessarily. While using getters and setters is a common way to encapsulate fields, simply having these methods does not guarantee good encapsulation. Encapsulation is about more than just hiding data. It’s about ensuring that the internal state of an object is always valid and consistent. Getters and setters are just one tool for achieving this.

For example, consider this Rectangle class:

public class Rectangle {
    private double width;
    private double height;

    public double getWidth() {
        return width;
    }

    public void setWidth(double width) {
        this.width = width;
    }

    public double getHeight() {
        return height;
    }

    public void setHeight(double height) {
        this.height = height;
    }

    public double getArea() {
        return width * height;
    }
}

While this class uses getters and setters, it’s not really well-encapsulated. The width and height can be set to any value, including negative numbers, which doesn’t make sense for a rectangle. A better approach would be to validate the input in the setters:

public void setWidth(double width) {
    if (width > 0) {
        this.width = width;
    } else {
        throw new IllegalArgumentException("Width must be positive.");
    }
}

public void setHeight(double height) {
    if (height > 0) {
        this.height = height;
    } else {
        throw new IllegalArgumentException("Height must be positive.");
    }
}

By adding this validation logic, we ensure that the internal state of the Rectangle object is always valid, thus achieving better encapsulation.

In summary, encapsulation is about managing complexity, protecting data integrity, and separating the interface of a class from its implementation. It is achieved primarily through the use of access modifiers, with private fields and public getters and setters being a common pattern. However, good encapsulation goes beyond just using getters and setters; it requires carefully designing the public interface of a class and ensuring that its internal state is always valid and consistent.

Immutable Objects

In object-oriented programming, immutability is the ability to create objects whose state cannot be changed after they are created.

Immutable objects in Java are like a printed book: once the content is published (or the object is created), it cannot be altered. Just as you can’t change the words on a printed page without creating a new book, you can’t modify an immutable object without creating a new instance with the desired changes.

So what makes an object immutable in Java? It’s not as simple as just omitting setter methods. There are several key requirements:

  1. Mark the class as final or make all of the constructors private. This prevents subclassing, which could otherwise allow mutability to sneak in.

  2. Mark all the instance variables private and final. This ensures the state can’t be modified directly from outside the class. But is this alone sufficient for immutability?

  3. Don’t define any setter methods. Any method that modifies state, even indirectly, breaks immutability.

  4. Don’t allow referenced mutable objects to be modified. If your class holds a reference to a mutable object (like a Date or a Collection), you must ensure that reference can’t be used to change the object’s state.

  5. Use a constructor to set all properties of the object, making a defensive copy if needed. Once an immutable object is constructed, its state can never change. The constructor must establish the invariants.

Let’s dive deeper into each of these requirements.

Marking the class as final prevents it from being subclassed. If we allowed subclassing, a subclass could add mutable state or override methods to be mutable, breaking the immutability contract.

public final class ImmutableExample {
    // class definition here
}

Alternatively, we can make the constructors private and control instantiation through factory methods:

public class ImmutableExample {
    private ImmutableExample() {
        // private constructor
    }
    
    public static ImmutableExample create() {
        return new ImmutableExample();
    }
}

But making a class final doesn’t automatically make it immutable. We also need to ensure all its fields are private and final:

public final class ImmutableExample {
    private final int value;
    
    public ImmutableExample(int value) {
        this.value = value;
    }
    
    public int getValue() {
        return value;
    }
}

By making the fields private, we prevent direct access from outside the class. And by making them final, we ensure they can only be set once, in the constructor.

But even with private final fields, immutability can still be violated if the class has methods that change state:

public final class NotActuallyImmutable {
    private final int value;
    
    public NotActuallyImmutable(int value) {
        this.value = value; 
    }
    
    public void setValue(int value) {
        this.value = value; // Mutates state - not okay!
    }
}

To be truly immutable, a class must not have any setter methods or any other methods that change its fields after construction.

However, immutability goes beyond just the immediate state of the object. An immutable object’s state includes the state of any other objects it holds references to.

Consider this class:

public final class NotImmutable {
    private final Date start;
    
    public NotImmutable(Date start) {
        this.start = start;
    }
    
    public Date getStart() {
        return start;
    }
}

At first glance, it might seem immutable, the start field is private and final, and there are no setters. But the Date class is mutable. Someone could do this:

NotImmutable example = new NotImmutable(new Date());
example.getStart().setTime(0); // Mutates the internal state of example!

To fix this, we need to make a defensive copy of the Date in the constructor:

public final class ActuallyImmutable {
    private final Date start;
    
    public ActuallyImmutable(Date start) {
        this.start = new Date(start.getTime()); // Defensive copy
    }
    
    public Date getStart() {
        return new Date(start.getTime()); // Defensive copy
    }
}

Now the state of the ActuallyImmutable instance cannot be changed through the reference it holds.

The same principle applies to collections and arrays, if an immutable class holds a reference to a mutable collection or array, it must defensively copy it and provide no way for the internal collection to be modified.

Proper use of constructors is also key to immutability. An immutable object’s state should be fully defined by the arguments passed to its constructor. And the constructor must establish all invariants of the object.

This means that an immutable class shouldn’t have a no-arg constructor, because then its state wouldn’t be fully defined at the end of construction. All properties should be set via constructor arguments.

Here’s an example of an immutable class with a collection:

public final class ImmutableCollection {
    private final List<String> strings;
    
    public ImmutableCollection(List<String> strings) {
        this.strings = List.copyOf(strings); // Immutable copy
    }
    
    public List<String> getStrings() {
        return strings;
    }
}

By following these rules, making the class and fields final, providing no mutator methods, defensively copying mutable components, and setting all state in the constructor, we can create truly immutable objects in Java.

Immutable objects have many advantages, especially in concurrent contexts. Because their state never changes, they are inherently thread-safe. They can be freely shared between threads without synchronization.

They are also simpler to reason about, because you know their state will always remain the same. And they can serve as building blocks for more complex thread-safe structures.

However, immutability does come with some costs. Immutable objects can be more expensive to create, because they often require making defensive copies. And if you need to make any changes, you have to create a new instance, which can be costly for large objects.

Key Points

Practice Questions

1. What is the result of compiling and executing the following code?

void myMethod() {
    int x = 1;
    if (x > 0) { 
        int y = 2;
        System.out.println(x + y);
    }
    System.out.println(x);
    System.out.println(y);
}

A) The code compiles and outputs 3 followed by 1.
B) The code compiles and outputs 3 followed by 1 and an undefined value for y.
C) The code does not compile because y is accessed outside of its scope.
D) The code compiles but throws a runtime exception when trying to print y.

2. Which of the following variable declarations statements are valid? (Choose all that apply.)

A) double x, double y;
B) int i = 0, String s = "hello";
C) float f1 = 3.14f, f2 = 6.28f;
D) char a = 'A', b, c = 'C';

3. Which of the following statements are true regarding the use of var in Java? (Choose all that apply.)

A) var can be used to declare both local variables within methods and instance variables within classes.
B) The use of var is restricted to local variables within methods, constructors, or initializer blocks.
C) var can be used to declare method parameters.
D) var enhances readability by inferring types where it’s clear from the context, but it’s not allowed in method signatures to maintain clarity.
E) var can be used to declare class (static) variables.

4. Which of the following statements correctly describe the use of inheritance in Java? (Choose all that apply.)

A) Subclasses can only access protected and public members of their superclass directly.
B) In Java, a class can extend multiple classes to achieve multiple inheritance.
C) The extends keyword is used in Java to create a subclass that inherits from a superclass.
D) A subclass in Java can directly access private members of its superclass.

5. Consider the following code snippet:

abstract class Animal {
    abstract void eat();
}

class Dog extends Animal {
    void eat() {
        System.out.println("Dog eats");
    }
}

class Cat extends Animal {
    void eat() {
        System.out.println("Cat eats");
    }
}

public class Test {
    public static void main(String[] args) {
        Animal myAnimal = new Dog();
        myAnimal.eat();
    }
}

Which of the following statements is true regarding the above code? Choose all that apply.

A) The code will compile and print "Dog eats" when executed.
B) The Animal class can be instantiated.
C) Removing the eat method from the Dog class will cause a compilation error.
D) The Cat class is necessary for the code to compile and run.

6. Consider the following interfaces:

interface Walkable {
    int distance = 10;
    void walk();
}

interface Runnable {
    void run();
    default void getSpeed() {
        System.out.println("Default speed");
    }
}

class Person implements Walkable, Runnable {
    public void walk() {
        System.out.println("Walking...");
    }
    public void run() {
        System.out.println("Running...");
    }
}

Which of the following statements is true?

A) The Person class must override the getSpeed method.
B) The distance variable in the Walkable interface is implicitly public, static, and final.
C) A Person object can call the getSpeed method without any implementation in the Person class.
D) The Runnable interface causes a compilation error due to a naming conflict with java.lang.Runnable.

7. Consider the following code snippet related to sealed classes:

sealed abstract class Shape permits Circle, Square {
    abstract double area();
}

final class Circle extends Shape {
    private final double radius;

    Circle(double radius) {
        this.radius = radius;
    }

    public double area() {
        return Math.PI * radius * radius;
    }
}

non-sealed class Square extends Shape {
    private final double side;

    Square(double side) {
        this.side = side;
    }

    public double area() {
        return side * side;
    }
}

public class TestShapes {
    public static void main(String[] args) {
        Shape shape = new Circle(10);
        System.out.println("Area: " + shape.area());
    }
}

Which of the following statements is true?

A) The Shape class is correctly defined as a sealed class, allowing only specified classes to extend it.
B) The Square class does not correctly extend the Shape class because it is not marked as final.
C) The Circle class can be further extended by other classes.
D) The area method in the Shape class must provide a default implementation.

8. Consider the following class:

public class Widget {
    private int size;

    public Widget() {
        this(10); // Line 5
    }

    public Widget(int size) {
        this.size = size;
    }

    public void resize(int size) {
        if (size > this.size) {
            this.size = size; // Line 14
            updateWidget();
        }
    }

    private void updateWidget() {
        System.out.println("Widget updated to size " + this.size);
    }

    public static void main(String[] args) {
        Widget widget = new Widget();
        widget.resize(15);
    }
}

In line 114, what does the this keyword represent in the context of the Widget class?

A) A reference to the static context of the class, allowing access to static methods and fields.
B) A special variable that stores the return value of a method.
C) An optional keyword that can always be omitted without affecting the functionality of the code.
D) A reference to the current object, whose instance variable is being called.

9. Consider the following classes:

class Animal {
    String name;

    Animal(String name) {
        this.name = name;
    }

    protected void eat() {
        System.out.println("Animal eats");
    }
}

class Dog extends Animal {
    Dog(String name) {
        super(name);
    }

    @Override
    protected void eat() {
        super.eat();
        System.out.println(name + " (Dog) eats");
    }
}

public class TestAnimal {
    public static void main(String[] args) {
        Animal myDog = new Dog("Buddy");
        myDog.eat();
    }
}

Which of the following statements are true regarding the use of super in the above code? (Choose all that apply.)

A) The super keyword is used in the Dog constructor to call the superclass constructor.
B) The eat method in the Dog class uses super to invoke the superclass’s eat method.
C) Removing the super.eat(); call in the Dog class’s eat method will prevent the Dog class from compiling.
D) The super keyword can be used to access static methods from the superclass.

10. Consider the following classes:

class Vehicle {
    public void drive(int speed) {
        System.out.println("Vehicle driving at speed: " + speed);
    }
}

class Car extends Vehicle {
    @Override
    public void drive(long speed) {
        System.out.println("Car driving at speed: " + speed);
    }
}

public class TestDrive {
    public static void main(String[] args) {
        Vehicle myCar = new Car();
        myCar.drive(60);
    }
}

What is the result of compiling and executing the above code?

A) It compiles and prints "Car driving at speed: 60".
B) It does not compile because the drive method cannot be called using a Vehicle reference.
C) It does not compile because the drive method in the Car class does not properly override the drive method in the Vehicle class.
D) It compiles and prints "Vehicle driving at speed: 60" because the drive method in the Car class is an overload, not an override.

11. Consider the following code snippet:

class Fruit {
    public void flavor() {
        System.out.println("Fruit flavor");
    }
}

class Apple extends Fruit {
    @Override
    public void flavor() {
        System.out.println("Apple flavor");
    }

    public void color() {
        System.out.println("Red");
    }
}

public class TestFruit {
    public static void main(String[] args) {
        Fruit myFruit = new Apple();
        myFruit.flavor();
        // myFruit.color();
    }
}

If the commented line // myFruit.color(); is uncommented, what will be the result of compiling and executing the above code?

A) It compiles and prints "Apple flavor" followed by "Red".
B) It compiles and prints "Fruit flavor".
C) It compiles but throws a runtime exception when attempting to call color().
D) It does not compile because Apple is not a valid type of Fruit.
E) It does not compile because the color method is not defined in the Fruit class.

12. Consider the following code snippet:

class Animal {}

class Dog extends Animal {
    public void bark() {
        System.out.println("Woof");
    }
}

class Cat extends Animal {
    public void meow() {
        System.out.println("Meow");
    }
}

public class TestCasting {
    public static void main(String[] args) {
        Animal animal = new Dog();
        ((Dog)animal).bark();

        Animal anotherAnimal = new Animal();
        // Line 1
    }
}

Which of the following lines of code, if inserted independently at Line 1, will compile without causing a runtime exception? (Choose all that apply.)

A) ((Dog)anotherAnimal).bark();
B) if (anotherAnimal instanceof Dog) ((Dog)anotherAnimal).bark();
C) ((Cat)animal).meow();
D) if (anotherAnimal instanceof Cat) ((Cat)anotherAnimal).meow();

13. Consider the following code snippet:

public class AdvancedPatternMatching {
    public static void process(Object input) {
        if (input instanceof String s && s.contains("Java")) {
            System.out.println("String with Java: " + s);
        } else if (input instanceof Integer i && i > 10) {
            System.out.println("Integer greater than 10: " + i);
        }
    }

    public static void main(String[] args) {
        process("Hello Java!");
        process(15);
        process("Just a string");
        process(5);
    }
}

Given the above code, which statement accurately describes its execution result?

A) It compiles and prints "String with Java: Hello Java!" followed by "Integer greater than 10: 15".
B) It compiles but only prints "String with Java: Hello Java!" because integers are not supported with pattern matching.
C) It does not compile because pattern matching in instanceof cannot be combined with logical operators like &&.
D) It compiles but prints all four lines due to incorrect use of pattern matching that always evaluates to true.

14. Consider the encapsulation practices in the following class structure:

package store;

public class Product {
    private String name;
    private double price;
    private int stock;

    public Product(String name, double price, int stock) {
        setName(name);
        setPrice(price);
        setStock(stock);
    }

    public String getName() {
        return name;
    }

    private void setName(String name) {
        this.name = name;
    }

    public double getPrice() {
        return price;
    }

    private void setPrice(double price) {
        if (price >= 0) {
            this.price = price;
        }
    }

    public int getStock() {
        return stock;
    }

    private void setStock(int stock) {
        if (stock >= 0) {
            this.stock = stock;
        }
    }
}

Which statement is true regarding the encapsulation of the Product class?

A) Making the setName, setPrice, and setStock methods public would enhance the class’s encapsulation.
B) The class is not encapsulated because the Product class’s fields are private.
C) Encapsulation is weakened because the constructor allows direct setting of fields without validation.
D) The Product class should have package-private getters to improve encapsulation.
E) The class is properly encapsulated by providing public getters for all fields and private setters with validation, ensuring control over the state of its objects.

15. Consider the following classes defined in the same package:

class Account {
    private double balance;
    
    Account(double initialBalance) {
        if (initialBalance > 0) {
            balance = initialBalance;
        }
    }
    
    void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }
    
    protected double getBalance() {
        return balance;
    }
}

public class SavingsAccount extends Account {
    private double interestRate;
    
    public SavingsAccount(double initialBalance, double interestRate) {
        super(initialBalance);
        this.interestRate = interestRate;
    }
    
    public void applyInterest() {
        double interest = getBalance() * interestRate / 100;
        deposit(interest);
    }
}

Which statement(s) about encapsulation principles and the use of access modifiers accurately describes the code above? Choose all tha apply.

A) The SavingsAccount class cannot access the balance field directly due to its private access modifier in the Account class.
B) The getBalance method should be public to allow SavingsAccount to access the account balance.
C) The deposit method in the Account class should be marked as final to prevent overriding.
D) The interestRate field in the SavingsAccount class violates encapsulation principles by being private.
E) The Account class correctly encapsulates the balance field, and SavingsAccount adheres to encapsulation by accessing balance through getBalance and deposit.

16. Consider the following class:

public final class Contact {
    private final String name;
    private final String email;
    private final Address address;

    public Contact(String name, String email, Address address) {
        this.name = name;
        this.email = email;
        this.address = new Address(address.getStreet(), address.getCity());
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }

    public Address getAddress() {
        return new Address(address.getStreet(), address.getCity());
    }

    public static class Address {
        private final String street;
        private final String city;

        public Address(String street, String city) {
            this.street = street;
            this.city = city;
        }

        public String getStreet() {
            return street;
        }

        public String getCity() {
            return city;
        }
    }
}

Given the above implementation, which statement accurately describes the Contact object?

A) The Contact object is mutable because the Address class is not final.
B) The Contact object is immutable, but only because it does not provide setters.
C) The Contact object is immutable, and it properly prevents leakage of mutable internal state through defensive copying.
D) The Contact object is mutable because the Address object can be changed via the getAddress method.
E) The Contact object is immutable but fails to prevent access to its mutable internal state.

Do you like what you read? Would you consider?


Do you have a problem or something to say?

Report an issue with the book

Contact me