Lecture from: 22.11.2024 | Video: Videos ETHZ

We continued with a bit of polymorphism which I added to the previous lecture note instead…

The Object Class

In Java, every class implicitly inherits from the java.lang.Object class. This means that Object sits at the top of the Java class hierarchy. It acts as the ultimate superclass for all other classes, providing a set of common methods that every object in Java inherits.

Key Properties of Object:

  • No Superclass: The Object class itself does not inherit from any other class.
  • Implicit Inheritance: You don’t need to explicitly write extends Object in your class definitions; it’s done automatically by the compiler.
  • Universal Reference: A reference variable of type Object can hold a reference to an object of any class.
public class MyClass { ... } // Implicitly extends Object
 
Object obj1 = new MyClass();
Object obj2 = "A String"; // String also inherits from Object
Object obj3 = 10; // Autoboxing: int is boxed into Integer, which inherits from Object

Methods of the Object Class

The Object class defines several important methods:

MethodDescription
toString()Returns a string representation of the object. Useful for debugging and printing.
equals(Object other)Compares the current object with another object for equality.
getClass()Returns the runtime class of the object.
hashCode()Returns a hash code value for the object.
notify(), notifyAll(), wait()Methods for thread synchronization.

Using Object Variables

Because Object is the superclass of all classes, you can use Object variables to store references to objects of any type. However, this comes with limitations.

Example: Object Variables and Method Access

public class Animal { ... }
//... in another class, in main method
Object o1 = new Animal();
Object o2 = "Hello there!";
Object o3 = new Scanner(System.in); // Scanner also inherits from Object
 
String s = o1.toString(); // OK: toString() is defined in Object
int len = o2.length();   // Compiler Error: length() is not defined in Object
double d = o3.nextDouble(); // Compiler Error: nextDouble() is not defined in Object

Explanation: You can only call methods that are defined in the Object class directly on an Object variable. To access methods specific to the object’s actual type (like length() for String or nextDouble() for Scanner), you need to downcast the Object reference to the correct type after checking with instanceof.

Example: An isNull() Method Using Object

public static boolean isNull(Object o) {
    return o == null; // This works because null can be assigned to any reference type
}

This method can check for null for any object type because Object is the universal supertype.

Comparing Objects: The Right Way and the Wrong Way

Incorrect Comparison with ==:

The == operator compares references (memory addresses), not the content of objects. For objects, this is rarely the desired behavior.

String name1 = "Paula";
String name2 = "Paula";  // In this special case with string literals, 
// name1 and name2 may or may not refer to the same object in memory
 
if (name1 == name2) { ... }  
// Might be true or false depending whether literals were defined or not before!
 
//In this case, probably True
// because of how string literals are managed by the JVM (string pool)
 
//BUT generally, not true!
 
String name3 = new String("Paula");
String name4 = new String("Paula");
if(name3 == name4){...} //False! Different memory location
 
String name5 = name1;
 
if(name1 == name5){...} // True! Both refer to the same memory location

Correct Comparison with equals():

The equals() method, inherited from Object, is designed to compare the content or state of objects. However, the default implementation in Object simply behaves like == (reference comparison). Therefore, you need to override equals() in your classes to provide meaningful content comparison.

String name1 = "Paula";
String name2 = "Paula";
 
if (name1.equals(name2)) { ... } // True: Strings override equals() for content comparison

Overriding equals() and toString()

Overriding equals() for Custom Classes

The equals() method in Object doesn’t know how to compare the internal state of your custom objects. You must override (!!!) it to provide a proper comparison.

https://stackoverflow.com/questions/8180430/how-to-override-equals-method-in-java

Important Considerations when Overriding equals():

  1. Signature: The signature must be public boolean equals(Object other). Changing the parameter type (e.g., to Rational other) is not an override and will cause unexpected behavior.

  2. Type Check and Cast: Inside your overridden equals(), you first need to check if the other object is of the correct type using instanceof, and then downcast it to your custom type.

  3. Null Check: Check if other is null before attempting any operations on it.

  4. Comparison Logic: Implement your logic to compare the relevant fields of your objects for equality.

Example: Correct equals() Override for Rational Class

public class Rational {
    private int n; // numerator
    private int d; // denominator
 
    // ... constructor ...
 
    @Override
    public boolean equals(Object other) {
        if (other == null || !(other instanceof Rational)) {
            return false; // Not a Rational or null, not equal
        }
 
        Rational otherRational = (Rational) other; // Safe cast
 
        //Simplified (doesn't account for zero denominators)
        return this.n * otherRational.d == this.d * otherRational.n; 
    }
}

Common Pitfalls:

  • Incorrect Parameter Type: Declaring equals(Rational other) instead of equals(Object other) creates an overload, not an override. The Object version will still be used by other methods expecting the Object version (e.g., collections).

  • Missing Type Check and Cast: Attempting to access members of your custom class directly on the Object other parameter will result in compiler errors. You must check the type and downcast.

  • Forgetting Null Check: Dereferencing a null object will throw a NullPointerException.

Overriding toString()

The toString() method in Object returns a string representing the object’s memory address, which is often not useful. Override toString() to provide a more informative string representation of your object’s state.

public class Rational {
//...
 @Override
    public String toString() {
        return n + "/" + d;
    }
}

By overriding toString(), you can control how your objects are displayed when printed or used in string concatenation. Using the @Override annotation is good practice, as it helps catch errors if you accidentally misspell the method name or use an incorrect signature.

Shadowing

Shadowing occurs when a variable declared in a subclass has the same name as a variable declared in its superclass. The subclass variable shadows or hides the superclass variable. This is different from overriding methods!

Example: Shadowing Attributes

class A {
    int x;
    int a;
}
 
class B extends A {
    boolean x; // Shadows A's x
    double b;
}
 
//...in main method...
B b = new B();
b.x = true;    // Sets B's x
((A) b).x = 5; // Sets A's x – Accessing the shadowed variable via an upcast

Accessing Shadowed Attributes:

To access a shadowed attribute of the superclass, you can use an explicit cast to the superclass type:

B b = new B();
System.out.println(b.x);        // Accesses B's x (boolean)
System.out.println(((A) b).x); // Accesses A's x (int) – Explicit cast to A

Why Shadowing is Discouraged

Shadowing can make your code harder to read and understand because the same variable name refers to different things depending on the context. It’s generally considered bad practice and should be avoided if possible. Use distinct names for variables in subclasses to improve clarity



Attributes vs. Methods in Inheritance

It’s essential to understand the difference between how attributes and methods behave in inheritance.

FeatureMethodsAttributes
ModificationOverriding (changes behavior)Shadowing (hides, but both exist)
Runtime ResolutionDynamic Binding (dynamic type decides)Static Binding (static type decides)

Example: Attributes vs. Methods

class A {
    String s = "in A";
    String myS() { return s; }
}
 
class B extends A {
    String s = "in B"; // Shadows A's s
    @Override String myS() { return s; } // Overrides A's myS()
}
 
//...in main method...
B b = new B();
 
System.out.println(b.s);       // Output: in B (accesses B's s)
System.out.println(b.myS());    // Output: in B (calls B's myS(), which accesses B's s)
System.out.println(((A) b).s);  // Output: in A (accesses A's s due to the cast)
System.out.println(((A) b).myS()); 
// Output: in B (calls B's myS() due to dynamic binding, even with the cast to A)

Key Differences Explained:

  • Method Overriding (Dynamic Binding): b.myS() calls B’s version of myS() even when the static type is A. Dynamic binding ensures the correct version based on the runtime type is executed.

  • Attribute Shadowing (Static Binding): b.s accesses B’s s. ((A) b).s accesses A’s s because the cast changes how the compiler resolves the attribute (the static type matters). No overriding occurs for attributes.

final Keyword

The final keyword in Java can be applied to variables, methods, and classes, with different implications in each case.

final Variables

  • Primitive Types: A final primitive variable’s value cannot be changed after initialization. It becomes a constant.
final double pi = 3.14159;
pi = 3.14 // Compiler error: cannot assign a value to final variable pi
  • Reference Types: A final reference variable cannot be reassigned to point to a different object. However, the state of the object it refers to can be modified.
final Cat myCat = new Cat("Whiskers");
myCat = new Cat("Mittens"); // Compiler error: cannot assign a value to final variable myCat
myCat.setName("Mittens");  // OK: Modifying the object's state is allowed

final Methods

A final method cannot be overridden by subclasses. This is useful when you want to ensure that a particular method’s behavior is preserved across the inheritance hierarchy.

class Animal {
    final void makeSound() { ... }
}
 
class Dog extends Animal {
    @Override void makeSound() { ... } // Compiler error: cannot override final method
}

final Classes

A final class cannot be extended (subclassed). This is often used for security or performance reasons, or when a class’s design is not intended for extension. Examples are the String or Math classes.

final class MyFinalClass { ... }
 
class Subclass extends MyFinalClass { ... } 
// Compiler error: cannot inherit from final MyFinalClass

Abstract Classes and Methods

Abstract Classes: An abstract class cannot be instantiated directly. It serves as a blueprint for subclasses, defining common methods and attributes but leaving some implementations incomplete (abstract). Use the abstract keyword before the class declaration.

Abstract Methods: An abstract method has no implementation (no method body). It’s declared with the abstract keyword and a semicolon instead of a body. Subclasses must provide concrete implementations for all abstract methods inherited from the abstract class.

Example: Abstract Class and Method

abstract class Shape { // Abstract class
 
    abstract double area();    // Abstract method (no implementation)
    abstract double perimeter();
 
    double ratio(){
        return perimeter()/area(); //Common behavior
    }
 
}
 
class Circle extends Shape {
    double radius;
 
    @Override double area() { return Math.PI * radius * radius; }
    @Override double perimeter(){return Math.PI * 2 * radius;}
 
 
    // ... other methods ...
}
 
class Square extends Shape{
    double length;
    @Override double area(){ return length * length;}
 
    @Override double perimeter(){return length * 4;}
}
 
//...in another class, main method...
Shape s = new Shape(); // Compiler Error: cannot instantiate Shape (abstract class)
Shape s1 = new Circle(); //OK
Shape s2 = new Square(); //OK

When to Use Abstract Classes:

  • Common Interface: To define a common interface or contract that subclasses must adhere to.
  • Partial Implementation: To provide a base implementation with some common methods while requiring subclasses to implement specific behavior.
  • Preventing Direct Instantiation: When it doesn’t make sense to create instances of the base class itself.

The AbstractList class in Java’s Collections Framework is a prime example of an abstract class. ArrayList and LinkedList extend AbstractList and provide concrete implementations for the abstract methods.

Designing Inheritance Hierarchies: is-a vs. has-a

When designing inheritance hierarchies, it’s crucial to choose the right relationships between classes. The two most common relationships are “is-a” and “has-a.”

is-a Relationship (Inheritance)

The is-a relationship represents inheritance (subtyping). It means that a subclass is a specialized type of its superclass. A Dog is a Animal, a Circle is a Shape. This is the core principle behind using extends.

Liskov Substitution Principle (LSP): A fundamental principle for sound inheritance hierarchies is the Liskov Substitution Principle. It states that objects of a subclass should be able to be used wherever objects of the superclass are expected, without altering the correctness of the program.

Violating the LSP: Examples

If you violate the LSP, your inheritance hierarchy is likely flawed. Here are some examples of violations:

  • Square and Rectangle: A Square is not a true subtype of a Rectangle in the OOP sense (mathematically, a square IS a rectangle), even though every square is a rectangle. If you have a method that operates on a Rectangle and expects to be able to change the width and height independently, a Square object would break this expectation because changing one dimension must also change the other.
  • Immutable and Mutable Collections: An immutable collection (one that cannot be changed after creation) is not a perfect subtype of a mutable collection. Methods like add() or remove() don’t make sense for an immutable collection and would violate the LSP if inherited.

has-a Relationship (Composition)

The has-a relationship represents composition or aggregation. It indicates that a class contains or is composed of another class. A Circle has a center point (Point2D), a car has an engine.

Composition/Aggregation vs. Inheritance:

When modeling a “has-a” relationship, composition is usually preferred over inheritance. Instead of extending the other class, create a reference attribute of the other class’s type within your class.

Example: Composition Instead of Inheritance

class Point2D {
    int x;
    int y;
}
 
class Circle {  // Composition: Circle HAS-A Point2D
    Point2D center;
    double radius;
}

Benefits of Composition:

  • Flexibility: Composition offers more flexibility than inheritance because you can change the contained objects at runtime.
  • Encapsulation: Composition promotes better encapsulation by hiding the internal implementation details.
  • Avoids LSP Violations: Composition helps avoid the LSP problems that can arise from inappropriate inheritance relationships.

When to Choose Inheritance or Composition

RelationshipKeywordDescription
is-aextendsSubclass is a specialized type of superclass. Must adhere to the Liskov Substitution Principle.
has-a(no keyword)Class contains or is composed of another class. Model this with a reference attribute. Often preferred to inheritance when representing “has-a” relationships to avoid LSP issues.

Examples of Inheritance and Composition Choices:

ScenarioInheritance or Composition?Explanation
2D and 3D pointsInheritance (Point3D extends Point2D)A 3D point is a 2D point with an additional z-coordinate.
2D points and circlesComposition (Circle has a Point2D)A circle is not a point; it has a center point.
Squares and rectanglesComplex, depends on use case. Usually CompositionA square is a rectangle mathematically but might violate the LSP depending on the context
Mutable and immutable setsComposition preferredAn immutable set has a underlying data structure but significantly restricts functionality. Inheritance would likely violate the LSP.

Inheritance Summary and Further Concepts

Overview of Inheritance Concepts

  • Inheritance (extends): Creates the “is-a” relationship, establishing subtyping.
  • Attributes: Inherited, but can be shadowed (discouraged).
  • Methods: Inherited and can be overridden (@Override) to change behavior.
  • Dynamic Binding: At runtime, the actual object type (dynamic type) determines which overridden method is called.
  • Static Binding: At compile time, the declared type (static type) determines which members are accessible.
  • Sichtbarkeit(visibility): public, protected, default, and private control access to members. Visibility can be increased but not decreased when overriding.
  • Konstruktoren (constructors): Not inherited, but the superclass constructor must be called using super().
  • Object Class: All classes inherit from Object, which provides toString(), equals(), and other essential methods.
  • final Keyword: Prevents overriding (methods), extending (classes), and modification (variables).
  • abstract Keyword: Declares abstract classes (cannot be instantiated) and abstract methods (no implementation, must be overridden by subclasses).

What if You Don’t Want Certain Behaviors?

Java provides modifiers to control inheritance behavior:

  • Prevent overriding a method: Declare the method final.
  • Prevent inheriting from a class: Declare the class final.
  • Prevent instantiating a class: Declare the class abstract or provide only private constructors.

Working with Files

Files are the primary means of storing persistent data — data that exists beyond the lifetime of a program’s execution.

Persistent Data and Files

Data stored in variables within a program is lost when the program terminates. To make data persistent, we store it in files.

Example: Processing Temperatures from a File

Consider a file containing daily temperature readings. Our goal is to write a program that calculates the difference between consecutive temperature measurements.

The file contains a series of temperatures. The program reads these temperatures and outputs the change between each consecutive pair.

Working with Files in Java: The File Class

The java.io.File class provides an abstraction for interacting with files and directories in Java. It’s important to note that a File object represents a file handle (a way to refer to a file), not the actual file’s contents.

Using the File Class

  1. Import: import java.io.File;
  2. Create a File Object: File file = new File("example.txt"); This creates a File object, not the file itself. The file might or might not already exist at this point.

Important Note: Creating a File object doesn’t automatically create a file on disk. It creates a representation of a potential file path.

Methods of the File Class

The File class offers methods to work with files and directories. Here are some key methods:

MethodDescription
exists()Returns true if the file/directory represented by the File object exists.
canRead()Returns true if the file can be read.
getName()Returns the name of the file/directory.
length()Returns the file size in bytes.
delete()Deletes the file/directory.
renameTo(File dest)Renames the file/directory.

Example: Using File Methods

import java.io.File;
 
File file = new File("example.txt");
 
if (file.exists() && file.length() > 1000) {  // Check if file exists and is larger than 1000 bytes
    file.delete(); // Delete if it's too large
}

This code snippet demonstrates using the File class to create a file handle and then potentially deleting the file if it exists and exceeds a certain size.

Reading from Files: The Scanner Class

We can use the familiar java.util.Scanner class to read data from files.

Using Scanner with Files

  1. Import: import java.util.Scanner;

  2. Create Scanner for File Input:

    File file = new File("input.txt");
    Scanner scanner = new Scanner(file); // Creating a Scanner to read from the specified file.

    Alternative (One-liner):

    Scanner scanner = new Scanner(new File("input.txt"));

    Example: Reading an Integer from a File

    import java.io.File;
    import java.util.Scanner;
     
    File file = new File("input.txt");
    Scanner scanner = new Scanner(file);
    int number = scanner.nextInt(); // Reading an integer from the file

Handling FileNotFoundException

Creating a Scanner to read from a file can throw a FileNotFoundException if the specified file doesn’t exist. We need to handle this potential exception.

What “throws FileNotFoundException” Means

The throws FileNotFoundException clause in the Scanner(File source) constructor documentation indicates that this constructor might throw a FileNotFoundException. Our code must handle this possibility.

Handling Exceptions: A Preview

Some operations in Java can lead to runtime errors called exceptions. When a method encounters a situation it can’t handle (like a missing file), it throws an exception.

Two Options for Handling Exceptions:

  1. Catching the Exception: Using a try-catch block to handle the exception directly where it might occur. (Covered in detail later)

  2. Declaring that the Method Can Throw: Adding a throws clause to the method signature to indicate that the method might throw a particular exception. This delegates the responsibility of handling the exception to the caller of the method.

Using throws to Declare Exceptions

A throws declaration in a method signature signifies that the method might throw the specified exception. The caller of this method must either handle the exception or propagate it further up the call stack using another throws declaration.

Example: Reading from a File with throws

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;
 
public class Test {
    public static void main(String[] args) throws FileNotFoundException {  // throws declaration
        Scanner f = new Scanner(new File("input.txt"));
        int number = f.nextInt();
    }
}
 

In this example, the main method declares that it can throw a FileNotFoundException. If the file “input.txt” is not found, the Scanner constructor throws the exception, and the program terminates (because the main method didn’t catch the exception). Ultimately, the operating system will catch it and terminate the program.

Why Haven’t We Seen throws Before?

Not all input sources for Scanner can throw exceptions. For example, Scanner(System.in) doesn’t require a throws declaration. Also, some exception types (like NullPointerException or ArrayIndexOutOfBoundsException) do not need to be explicitly declared. These are called unchecked exceptions, in contrast to checked exceptions like FileNotFoundException. The compiler forces you to handle checked exceptions, but not unchecked exceptions.

Input Cursor and Tokens

When reading from a file using Scanner, the input is treated as a stream of characters. A newline character (\n) marks the end of a line but is otherwise just another character in the stream.

Scanner breaks the input stream into tokens—sequences of characters separated by whitespace (spaces, tabs, newlines). An input cursor keeps track of the current position within the input stream.

Consuming Tokens

When you call methods like nextInt(), nextDouble(), or next(), Scanner reads the next token from the input stream:

  1. Whitespace Skipping: Leading whitespace is ignored.
  2. Token Reading: The next token (up to the next whitespace) is read and returned.
  3. Cursor Advancement: The input cursor moves to the end of the consumed token.
  4. File Unchanged: The original file remains unmodified.

Handling Scanner Exceptions

Scanner can throw various exceptions:

  • NoSuchElementException: Thrown when trying to read past the end of the input. This happens when you call next...() methods but there are no more tokens available.

  • InputMismatchException: Thrown when the next token cannot be converted to the requested type (e.g., trying to read “1.23” with nextInt()).

Preventing Scanner Exceptions

Scanner provides methods to check for the presence and type of the next token without actually consuming the token. These methods are crucial for preventing exceptions:

MethodDescription
hasNext()Returns true if there is another token in the input.
hasNextInt()Returns true if there is another token and it can be read as an int.
hasNextDouble()Returns true if there is another token and it can be read as a double.

The hasNext() Method

Scanner in = new Scanner(new File("input.txt"));
 
while (in.hasNext()) { // Loops as long as there are more tokens
    String token = in.next(); // Safe to call next() because hasNext() returned true
    System.out.println("next token is: " + token);
}

This loop safely reads all tokens from the file, regardless of the number of tokens, because hasNext() checks for the presence of the next token before attempting to read it.

Back to the Temperature Example

Let’s return to the temperature processing example. Recall that we want to read temperatures from a file and calculate the differences between consecutive readings.

First Attempt: A Fixed Number of Readings
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;
 
public class Temperatures {
    public static void main(String[] args) throws FileNotFoundException {
        Scanner in = new Scanner(new File("temps.txt"));
 
        double previous = in.nextDouble();  // Read the first temperature
        for (int i = 1; i <= 7; i++) {      // Read the next seven temperatures
            double next = in.nextDouble();
            System.out.println("change: " + (next - previous));
            previous = next;
        }
    }
}

Problem: This code only works if there are exactly eight temperatures in the file. It doesn’t handle files with more or fewer readings. With fewer readings, it throws a NoSuchElementException. With more readings, it simply ignores everything after the 8th value.

Second Attempt: Using hasNext()
double previous = in.nextDouble();
while (in.hasNext()) { // While there are more tokens in the file...
    double next = in.nextDouble();
    System.out.println("change: " + (next - previous));
    previous = next;
}

Problem: Still throws InputMismatchException if there are non-numeric tokens in the file. It also throws a NoSuchElementException if the file starts with a non-numeric token because in.nextDouble() is called before checking if a double exists at all.

Third Attempt: Using hasNextDouble()
double previous = in.nextDouble();
while (in.hasNextDouble()) { // While there's a double as next token...
    double next = in.nextDouble();
    System.out.println("change: " + (next - previous));
    previous = next;
}

Problem: This works only if the file is non-empty and starts with a valid double and contains only double values. It throws a NoSuchElementException if the file is empty or does not begin with a valid double.

Fourth Attempt: Handling the First Token
double previous = 0;  // Initialize previous
if (in.hasNextDouble()) {  // Check if the first token is a double
    previous = in.nextDouble();
}
while (in.hasNextDouble()) {
    double next = in.nextDouble();
    System.out.println("change: " + (next - previous));
    previous = next;
}

Problem: Now, the code handles empty files and files that don’t start with a number. However, if the file contains non-numeric tokens after the first number, the code will still throw an InputMismatchException. It will read the first valid double if it exists, but won’t handle mixed data types correctly within the file.

But given that we usually expect a file with numbers this should suffice as a “good” enough solution.

Continue here: 19 Files, File Output, Exceptions, Checked vs Unchecked, Throwing, Exception Handling Best Practices