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.
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.
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
.
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 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:
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.
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.
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:
An abstract class cannot be final. The final
modifier prevents a class from being subclassed, and this contradicts the essence of an abstract class, that it must be inherited to be used. So no, marking an abstract class as final
would not make it more secure, it would make it useless.
Abstract methods cannot be final either, for the same reason, they have to be overridden in a subclass.
Abstract methods cannot be native or synchronized, for slightly different reasons. A native method is implemented in another language like C++ in the JVM, so it would already have an implementation. We’ll talk about synchronized methods in a later chapter, but the synchronized
modifier is used to coordinate multithreaded access, and for that, the method needs a body, an implementation.
An abstract method cannot be private
. It doesn’t make sense that a method that must be implemented by a subclass in another class, cannot be seen by that class. So no, private
abstract methods cannot exist.
Finally, an abstract method cannot be static either. Static methods belong to the class itself, not to any instance. An abstract method is only useful when a subclass implements it, which means an instance uses it.
In summary, here are the rules for correctly declaring abstract classes and methods:
If a class contains one or more abstract methods, the class must be declared as abstract.
An abstract class can have both abstract and non-abstract (concrete) methods.
An abstract class can extend another abstract or concrete class and an abstract class can be extended by another abstract or concrete class.
A subclass can override a concrete method in a superclass and declare it as abstract.
An abstract subclass can override some or none of the abstract methods in its superclass, but a first concrete subclass must implement all of them.
Now, before talking about sealed classes, let’s review the topic of 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:
An abstract class can have instance variables and constructors, while an interface cannot.
An abstract class can have non-abstract methods, while all methods in an interface are implicitly abstract (with the exception of default and static methods, which we’ll cover later).
A class can extend only one abstract class, but it can implement multiple interfaces.
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.
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
:
final
: The subclass cannot be extended further. This is the most restrictive option.sealed
: The subclass is also sealed and must specify its own permitted subclasses.non-sealed
: The subclass is open for extension by unknown subclasses. This is the most permissive option.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:
Sealed classes are declared with the sealed
and permits
modifiers.
Sealed classes must be declared in the same package or named module as their direct subclasses.
Direct subclasses of sealed classes must be marked final
, sealed
, or non-sealed
.
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.
Interfaces can be sealed to limit the classes that implement them or the interfaces that extend them.
this
ReferenceWhen 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
super
ReferenceSo 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 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.
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:
The Cookie
class defines a method named eat
that returns a String
.
The ChocolateChipCookie
class, which extends Cookie
, overrides the eat
method. The overriding method in ChocolateChipCookie
has the same name, return type, and parameter list (in this case, none) as the method in Cookie
.
When an instance of ChocolateChipCookie
calls eat
, the overridden version of the method is executed, returning "Eating a chocolate chip cookie"
.
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:
The Cookie
class defines a recipe
method with a protected
access modifier. This means the recipe
method can only be accessed within its own class, subclasses, or within the same package.
The ChocolateChipCookie
class, which extends Cookie
, overrides the recipe
method, changing the access modifier of the overriding method to public
, which is less restrictive than protected
.
Trying to access the recipe
method directly from a Cookie
instance would lead to a compile-time error, due to the protected
access control. However, accessing the recipe
method through an instance of ChocolateChipCookie
is possible because it’s public
.
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:
BakingException
is a checked exception that represents a general baking error.
OverBakingException
is a more specific checked exception, indicating the cookie has been overbaked, and it extends BakingException
.
The Cookie
class has a bake
method that declares it might throw a BakingException
.
The ChocolateChipCookie
class overrides the bake
method and declares it might throw an OverBakingException
, which is a subclass of BakingException
.
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:
The Cookie
class has a method getCookie
that returns an instance of Cookie
.
The ChocolateChipCookie
class extends Cookie
and overrides the getCookie
method. The return type of the overridden method is ChocolateChipCookie
, which is a subclass of Cookie
. This change in return type is an example of using covariant return types.
When getCookie
is called on an instance of ChocolateChipCookie
, it returns an instance of ChocolateChipCookie
, demonstrating the overridden method with a covariant return type in action.
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.
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:
Using a reference with the same type as the object
Using a reference that is a superclass of the object’s type
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.
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:
Casting a reference from a subtype to a supertype doesn’t require an explicit cast.
Casting a reference from a supertype to a subtype requires an explicit cast.
At runtime, an invalid cast of a reference to an incompatible type results in a ClassCastException
being thrown.
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.
instanceof
OperatorIn 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
:
Child classes are considered instances of their parent classes, but parent classes are not considered instances of their child classes.
It can check for implementations of interfaces, but cannot distinguish between direct implementation in a class vs inherited implementation from a parent class.
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:
private
: Only accessible within the same class.
default
(package-private): Accessible within the same class and from any other class in the same package.
protected
: Accessible within the same class, from any other class in the same package, and from subclasses (even in different packages).
public
: Accessible from anywhere.
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:
Declare the fields (instance variables) of the class as private
. This prevents direct access to the fields from outside the class.
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.
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:
Mark the class as final
or make all of the constructors private
. This prevents subclassing, which could otherwise allow mutability to sneak in.
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?
Don’t define any setter methods. Any method that modifies state, even indirectly, breaks immutability.
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.
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.
Variable scope refers to the visibility and accessibility of a variable in code. The four main scopes in Java are local variables, method parameters, fields (instance variables), and class variables (static fields).
Local variables are declared inside a method or block and are only accessible within that block. They come into scope at their declaration and go out of scope at the end of the enclosing block.
Method parameters are also considered local variables, 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.
Fields, or instance variables, are variables declared at the class level. They come into scope when the object is instantiated and remain in scope as long as the object is in memory.
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.
Fields automatically get default values if not explicitly initialized, while local variables must be explicitly initialized before use.
The var
keyword allows for local variable type inference. The compiler infers the type based on the initializer expression.
Inheritance allows a new class to be based on an existing class, inheriting its attributes and methods. The extends
keyword is used to create a subclass.
Abstract classes cannot be instantiated and are intended to be subclassed. They can contain abstract methods, which have no implementation in the abstract class and must be implemented by concrete subclasses.
Interfaces define a contract of methods that a class must implement. The implements
keyword is used to implement an interface. A class can implement multiple interfaces.
Sealed classes restrict which other classes can extend them. Permitted subclasses are specified using the permits
keyword. Subclasses of a sealed class must be declared final
, sealed
, or non-sealed
.
The this
keyword is a reference to the current instance of a class. It’s used to disambiguate between local variables and instance variables, to pass the current instance as a method argument, and to call another constructor from within a constructor.
The super
keyword is a reference to the parent class (superclass) of the current class. It’s used to access the superclass’s members and to invoke the superclass constructor from the subclass constructor.
Polymorphism allows you to treat objects of different subclasses as if they were objects of the same superclass.
Method overriding is a key concept in polymorphism, where a subclass provides its own implementation of a method defined in the superclass.
To properly override a method, the method in the subclass must have the same name, return type, and parameter list as the method in the superclass.
When overriding methods, you can make the access modifier more lenient in the subclass, but not more restrictive. The overridden method can also only declare exceptions that are the same or more specific than those declared by the superclass method.
An overridden method is allowed to have a covariant return type, meaning the return type can be a subclass of the return type declared in the superclass method.
The @Override
annotation explicitly marks methods that are intended to override a superclass method and provides a safeguard against accidental errors.
Redeclaring a private method from the superclass in a subclass is not considered overriding. Private methods are not inherited.
Redeclaring a static method in a subclass is called hiding, not overriding. The subclass method hides the superclass method, but doesn’t actually override it.
Variables can also be hidden in subclasses if a subclass declares a variable with the same name as a variable in the superclass.
There are three main ways to access an object in Java: using a reference with the same type as the object, using a reference that is a superclass of the object’s type, and using a reference that defines an interface the object’s class implements or inherits.
Type casting allows you to assign a value of one primitive data type to another type or treat an object of one class as an object of another class, as long as there is an inheritance relationship between the two classes.
Casting a reference from a subtype to a supertype (upcasting) doesn’t require an explicit cast, while casting a reference from a supertype to a subtype (downcasting) requires an explicit cast.
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.
Pattern matching for the instanceof
operator allows you to combine the type check and cast in a single operation, reducing boilerplate code.
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?