Lecture from: 19.11.2024 | Video: Videos ETHZ

Visibility Modifiers

Continuation…

Protected Members

Protected members bridge the gap between public and private access, specifically in the context of inheritance. They offer a way for a superclass to grant access to its members to subclasses while restricting access from unrelated classes, even within the same package.

public class Animal {
    protected String name; // protected: subclasses can access
 
    //... constructor
}
 
public class Cat extends Animal {
 
  public Cat(String n){
    super(n); // Sets name via the constructor
    // name = n; 
    // valid here because name is protected in Animal and we are inheriting from Animal
  }
 
    public void eat() {
        System.out.println(name + " eats tuna!"); // OK now because name is protected
 
    }
}

Explanation:

  1. If Animal and Cat were in different packages and name were declared with default (package-private) access in Animal, the line System.out.println(name + " eats tuna!"); in Cat would cause a compiler error.
  2. However, because name is declared as protected in Animal, any class that inherits from Animal (like Cat) can directly access name as if it were a member of its own class. This holds true even if Cat resides in a different package from Animal.

Overriding and Visibility

A fundamental rule of overriding in Java is that you can increase the visibility of an overridden method but you can never decrease it. The visibility order is: public > protected > default > private.

Why this rule exists: This restriction is crucial to maintain the integrity of the “is-a” relationship established by inheritance and to ensure that polymorphism functions correctly. We’ll explore the detailed reasoning behind this rule in the next section.

Overriding and Visibility: Detailed Explanation

This section delves into the rationale behind the visibility restrictions when overriding methods in Java.

Key Concepts:

  • Subtyping and the Liskov Substitution Principle: Subtyping means that a subclass (B) “is a” more specialized type of its superclass (A). The Liskov Substitution Principle (LSP) formalizes this by stating that you should be able to use a B object wherever an A object is expected, without altering the correctness of the program.

  • Polymorphism (Dynamic Dispatch): Polymorphism allows you to call a method on a reference of type A, and at runtime, the JVM automatically determines the actual object type (which could be B or another subtype of A) and executes the appropriate overridden version of the method.

The Problem with Decreasing Visibility:

Imagine if you could reduce the visibility of an overridden method:

public class A {
    public void doSomething() { ... }
}
 
public class B extends A {
    @Override private void doSomething() { ... } // Hypothetical: Decreasing visibility
}
 
// Client code:
A myObject = new B(); // LSP: Using B where A is expected
myObject.doSomething(); // Compile-time error!

This code would cause a compile-time error. The client code, holding a reference of type A, expects doSomething() to be accessible because it’s public in A. However, B has made it private, so it’s no longer accessible through an A reference. This violates the LSP and breaks polymorphism.

Why Increasing Visibility is Allowed:

If B increases the visibility (e.g., protected in A, public in B), the client code works without issue. The client code, expecting at least the visibility declared in A, can still call the method in B because it’s now even more accessible. The LSP and polymorphism are preserved.

In Summary: The visibility rule for overriding guarantees that subclass objects behave compatibly with the superclass interface. This is essential for polymorphism and subtyping to work as intended.

Subtyping

Subtyping, intimately tied to inheritance, is built upon the “is-a” relationship. When class B extends class A, B is a subtype of A. This translates to the principle of substitutability: a B object can be used wherever an A object is expected. This automatic conversion is known as an implicit upcast.

Example: Subtyping in Action

Animal a1 = new Animal();
Animal a2 = new Cat(); // Cat is a subtype of Animal (implicit upcast)
a2 = new Dog();       // Dog is also a subtype of Animal
 
Animal[] myPets = new Animal[3]; // Array to store Animal objects
myPets[0] = new Husky("Awena"); // Implicit upcast to Animal
myPets[1] = new Cat("Bruschetta"); // Implicit upcast to Animal
myPets[2] = new Cat("Chebyshev"); // Implicit upcast to Animal
 
 
 
// In methods as well
 
public void adopt(Dog d) {  // Any sub type of Dog can be passed here
 
}
 
DogShelter shelter = new DogShelter(); //...
Husky h = new Husky(); //...
Wolfdog w = new Wolfdog(); //...
shelter.adopt(h); // OK! – Husky is subtype of Dog
shelter.adopt(w); // OK! – Wolfdog is subtype of Dog

Static and Dynamic Typing in Java

Java employs both static typing (checked at compile time) and dynamic typing (determined at runtime). This dual approach is critical for type safety and enables polymorphism.

Static vs. Dynamic Types

  • Static Type (Compile-Time Type): The type explicitly declared for a variable in your code. The compiler uses this type for type checking.

  • Dynamic Type (Runtime Type): The actual type of the object a variable refers to during program execution. This can be the same as or a subtype of the static type.

Example: Illustrating Static and Dynamic Types

Animal animal = new Dog(); 
Animal animal2 = new Cat();

Here, animal has a static type of Animal and a dynamic type of Dog. Similarly, animal2 has a static type of Animal but a dynamic type of Cat.

Subtyping and Type Compatibility

A crucial rule in Java is that a variable’s dynamic type must be a subtype of (or identical to) its static type. This enforces type safety during execution.

Assignment Rules
1. Direct Assignment (No Casting):

Subtype objects can be directly assigned to supertype variables.

Dog dog = new Dog();
Animal animal = dog; // Valid: Dog is a subtype of Animal
2. Assignment with Explicit Downcasting:

Assigning a supertype object to a subtype variable requires an explicit cast using (Subtype) variable. This can potentially cause a ClassCastException at runtime if the dynamic type is incompatible.

Animal animal = new Animal();
Dog dog = (Dog) animal;  // Compiles, but may throw ClassCastException at runtime
 
Animal anotherAnimal = new Cat();
Dog anotherDog = (Dog) anotherAnimal; // Throws ClassCastException at runtime!
  • Important Note on Casting: Casting doesn’t alter the object’s actual type; it instructs the compiler to treat the reference as if it were of the target type. The JVM will still verify at runtime that the dynamic type is compatible with the cast.
3. Inconvertible Types:

Casting between unrelated types is forbidden and results in a compile-time error.

Cat cat = new Cat();
Dog dog = (Dog) cat; // Compiler error: Cat and Dog are not related
String s = (String) cat; // Compiler error

Implications for Polymorphism

The interplay of static and dynamic typing is what makes polymorphism possible. When a method is called on a variable, the specific version executed is determined by the object’s dynamic type at runtime, allowing different subclasses to offer specialized method implementations.

Example: Polymorphism in Action

Animal animal = new Dog();
animal.makeSound(); // Calls Dog's makeSound() method
 
animal = new Cat();
animal.makeSound(); // Calls Cat's makeSound() method

Type Casts, instanceof, and Dynamic Binding

These concepts are fundamental to understanding Java’s type system, particularly with inheritance and polymorphism.

Type Casts and the instanceof Operator

  • Upcasting (Implicit): Assigning a subtype reference to a supertype variable (automatic and safe).
  • Downcasting (Explicit): Assigning a supertype reference to a subtype variable (requires (Subtype) variable and might throw ClassCastException).
  • instanceof Operator: Checks if an object’s dynamic type is compatible with a specific type (returns true for instances or subtypes, false otherwise).

Why Downcasting is Necessary and Potentially Risky:

When working with collections or variables of a supertype, you might need to access subtype-specific members. Downcasting, guarded by instanceof, provides a way to do this safely.

Animal animal = getSomeAnimal(); // Might return a Dog, Cat, etc.
 
if (animal instanceof Dog) {
    Dog dog = (Dog) animal; // Safe downcast
    dog.bark();
} else if (animal instanceof Cat) {
    Cat cat = (Cat) animal;
    cat.meow();
}
 
// Example of a compile-time error with inconvertible types:
Cat cat = new Cat();
Dog dog = (Dog) cat;  // Compiler error
 

Type Cast Safety and instanceof:

The JVM checks the dynamic type during downcasting. If the types are incompatible, a ClassCastException is thrown. instanceof helps prevent this by checking compatibility beforehand.

Further Examples of instanceof:

String str = "Hello";
if (str instanceof Object) { ... } // True (all classes are subtypes of Object)
 
Cat cat = new Cat();
if (cat instanceof Animal) { ... } // True
if (cat instanceof Dog) { ... }    // False (Cat and Dog are sibling classes)

TLDR: Type Casts

Dynamic Binding (Dynamic Dispatch)

Dynamic binding is the mechanism behind polymorphism. It determines at runtime which version of an overridden method to execute based on the object’s dynamic type, not the variable’s static type.

How Dynamic Binding Works:

  1. Compile Time (Static Binding): The compiler checks method signature accessibility based on the static type.

  2. Runtime (Dynamic Binding): If the method is overridden, the JVM uses the object’s dynamic type to choose the correct overridden version at runtime. This occurs every time the method is called.

Example: Dynamic Binding in Action

class Animal { ... public void makeSound()... }
class Dog extends Animal { @Override public void makeSound()... }
class Cat extends Animal { @Override public void makeSound()...}
 
Animal[] animals = { new Dog(), new Cat() };
for (Animal a : animals) { a.makeSound(); } // Dynamic binding!
//Output: Woof!, Meow!
Dynamic Binding and Method Arguments: A Crucial Detail

Dynamic binding applies only to the object on which a method is called, not to the method’s arguments. Argument types are determined at compile time using their static types.

Dynamic vs. Static Binding
  • Dynamic Binding: Runtime, flexible, enables polymorphism (overridden methods).
  • Static Binding: Compile-time, efficient (method call resolution is upfront), used for non-overridden methods (including private, static, and final).

Polymorphism

Polymorphism, meaning “many forms,” is a powerful tool in object-oriented programming. It allows you to write code that can work with objects of different types in a flexible and adaptable way. A polymorphic program can be used with various object types, adjusting its behavior accordingly.

Types of Polymorphism in Java:

  • Method Overloading: Having multiple methods in the same class with the same name but different parameter lists (signatures). This is static polymorphism resolved at compile time.

  • OOP Polymorphism (Subtyping and Dynamic Binding): Using inheritance, subtyping, and dynamic binding to allow objects of different classes to be treated as objects of a common superclass. This is dynamic polymorphism resolved at runtime.

  • Generics: A way to write type-safe code that can work with different types without explicit casting (we will cover this later).

OOP Polymorphism with Parameters and Arrays

OOP Polymorphism and Method Parameters

If a method expects a parameter of type T, you can pass an object of any subtype of T due to subtyping. This allows methods to operate on a range of related object types.

public static void printInfo(Animal a) { // Accepts any Animal or its subtype
    // ... code to process the Animal object ...
    System.out.println(a);
    a.eat(); //Polymorphic behavior due to Dynamic Binding
}

This printInfo method can be called with an Animal, Dog, Cat, or any other subclass of Animal. Dynamic binding ensures that the correct version of overridden methods (like toString() or eat()) is called at runtime based on the object’s actual type.

OOP Polymorphism and Arrays

An array of type T can also hold objects of any subtype of T. This is a key aspect of polymorphism in collections.

Animal[] myPets = new Animal[3];
myPets[0] = new Husky("Awena");   // Husky is a subtype of Animal
myPets[1] = new Cat("Bruschetta"); // Cat is a subtype of Animal
myPets[2] = new Cat("Chebyshev");  // Cat is a subtype of Animal

Dynamic Binding with Arrays: When you call a method on an element of a polymorphic array (like myPets[i].makeSound()), dynamic binding is in play. The correct overridden version of the method is executed based on the actual type of the element at that index.

Example and Question: Polymorphism with Arrays and Overriding

public class Animal {
    public void eat() { System.out.print("Yummy!"); }
}
public class Cat extends Animal {
    @Override public void eat() { System.out.print("Tuna, yay! "); super.eat(); }
}
public class Dog extends Animal {
    @Override public void eat() { super.eat(); System.out.println(" Sausage!"); }
}
 
//...in main method...
Animal[] myPets = new Animal[2];
myPets[0] = new Dog();
myPets[1] = new Cat();
 
for (int i = 0; i < 2; i++) {
    myPets[i].eat();
}
//Output: Yummy! Sausage!\nTuna, yay! Yummy!
 

Explanation: Even though myPets is declared as Animal[], the dynamic type of each element determines which eat() method is called. This illustrates dynamic binding and polymorphism.

Methods and Static Type

The Role of the Static Type in Method Calls

In Java, the static type of a variable determines which methods are accessible at compile time. The compiler doesn’t consider the dynamic type when checking for method availability.

Example:

Dog dog = new Dog();
dog.bark(); // Valid: Dog has a bark() method
 
Animal animal = dog;
animal.bark(); // Compile-time error: Animal doesn't have a bark() method

Dynamic Type Irrelevance for Method Resolution (Initially):

The dynamic type of an object is not considered during the initial method resolution at compile time. Only the declared static type matters. Even if a reference of a supertype points to an object of a subtype that has a specific method, you cannot directly call that method using the supertype reference.

Workaround: Type Casts: To access subtype-specific methods, you must first downcast the reference to the appropriate subtype after confirming its dynamic type using instanceof.

Example:

Animal animal = new Dog();
if (animal instanceof Dog) {
    Dog dog = (Dog) animal;
    dog.bark(); // Now valid because of the downcast
}
 
//OR... 
((Dog)animal).bark();
 

Dynamic Binding and Method Resolution

Method Resolution: The Complete Story

Method resolution is the process Java uses to determine which method to call at both compile time and runtime.

1. Compile-Time (Static Binding):

The compiler verifies:

  • Existence and Accessibility: Does a method with the matching signature (name and parameters) exist and is it accessible based on the static type of the variable and visibility rules?
  • Overloading Resolution (Signature Matching): If multiple methods with the same name (overloads) exist, the compiler chooses the most specific matching signature based on the static types of the arguments.

2. Runtime (Dynamic Binding):

If the chosen method is overridden in a subclass, the JVM at runtime performs dynamic binding (dynamic dispatch):

  • Dynamic Type Check: The actual type (dynamic type) of the object at runtime determines which overridden version of the method is executed.
  • Method Execution: The appropriate overridden version is called.

Example: Method Resolution in Action

Dog d = new Dog();
Animal a = d;
 
d.eat();     // Valid: Dog has eat() (might override Animal's eat())
a.eat();     // Valid: Animal has eat()
d.bark();    // Valid: Dog has bark()
a.bark();    // Error: Animal has no bark() (compile-time error)
 
// Override Example:
a.eat();  // At runtime, *Dog's* eat() is called if it's overridden
Schematics of Method Resolution

This diagram represents a class hierarchy (X, Y, Z, where Y extends X and Z extends Y). Each class has a method f with possible overloads (f(A), f(B)), where B is a subtype of A, and a method g with possible overloads (g(A), g(C)).

  • Horizontal (Compile Time): The compiler uses the static type to check if a method with the given signature exists.
  • Vertical (Runtime): If overrides exist, the dynamic type decides which version is executed.
Method Resolution Details: Overload and Override

1. Overload Resolution (Compile Time):

The compiler follows these steps (simplified):

  • Exact Match: Does a method exist with the exact parameter types?
  • Implicit Conversions/Upcasting: Can the arguments be implicitly converted (e.g., int to double, or Cat to Animal) to match a method signature?
  • Boxing/Unboxing: Can arguments be boxed or unboxed to match a signature (e.g., int to Integer)?

Key point: The compiler tries to find the most specific matching overload.

Example (Overloading):

public class OverloadExample {
 
    void method(int x) { System.out.println("int version"); }
    void method(double x) { System.out.println("double version"); }
    void method(Integer x) { System.out.println("Integer version"); }
    void method(Object x) { System.out.println("Object version"); }
 
    public static void main(String[] args) {
        OverloadExample obj = new OverloadExample();
        int i = 5;
        double d = 5.0;
        Integer integer = 6;
 
        obj.method(i);       // Output: int version
        obj.method(d);       // Output: double version
        obj.method(integer); // Output: Integer version
        obj.method("Hello");// Output: Object Version
 
        //Tricky case with Autoboxing and Widening
        obj.method(5.5f);    // Output: double version (float is implicitly widened to double)
 
    }
}

2. Override Resolution (Runtime):

  • The dynamic type of the object on which the method is called determines which overridden version is executed.
  • The dynamic types of the arguments do not affect which overridden version is chosen. Only the static types of arguments are used (already resolved during overload resolution at compile time).

Example (Overriding):

Animal animal = new Dog();
animal.makeSound(); // Calls Dog's makeSound() if it's overridden
 
(Un)Boxing: Pitfalls in Overload Resolution

Autoboxing and unboxing can introduce subtleties into overload resolution.

Example:

public class BoxingOverload {
    void method(int x) { }
    void method(double x) { }
    void method(Integer x) { }
    void method(Double x) { }
    void method(Object x) { }
 
    public static void main(String[] args) {
      BoxingOverload obj = new BoxingOverload();
      int i = 5;
      double d = 5.0;
      Integer integer = 6;
 
      obj.method(i); // Calls method(int)
      obj.method(d); // Calls method(double)
      obj.method(integer); // Calls method(Integer)
 
      obj.method(5.5f); // Calls method(double) due to float widening to double
 
      Integer wrapperI = i;  // Boxing
      obj.method(wrapperI); // Calls method(Integer)
      Double wrapperD = d; //Boxing
      obj.method(wrapperD); // Calls method(Double)
    }
}

Recommendation: Be mindful when using wrapper types and autoboxing, as they can make overload resolution less predictable. Favor using primitive types when possible to avoid unintended behavior.

Method Binding Examples

The provided example diagrams (Schematisches Beispiel 1 and 2 in our slides) illustrate the complex interplay of static and dynamic typing, overloading, and overriding. Try tracing through the examples yourself using the rules described earlier.

Continue here: 18 Object Class, Comparing Objects (Override Equals), Shadowing, Inheritance Design Principles, Files, Cursor and Tokens