Java Quiz 2
Java Quiz 2
JAVA - Week 5
JAVA - Week 5
Polymorphism
Structural Polymorphism
Polymorphic Functions
Polymorphic Data Structures
Java Generics
Polymorphic Data Structures using Generics
Hiding a Type Variable
Extending Subtypes
Wildcards
Use of Bounded Wildcards
Reflection
Creating Class object
Using the Class object
Type Erasure
Incorrect Function Overloading
Polymorphism
Polymorphism is a fundamental concept in object-oriented programming (OOP) and is a powerful mechanism that
allows objects of different classes to be treated as if they were the same type. It usually refers to the effect of
dynamic dispatch, which allow different classes to provide their own implementation of the same method.
Structural Polymorphism
Structural polymorphism in Java is the ability of different objects to be treated as if they were the same type, even
if they are actually different. This is achieved through the use of inheritance, interfaces, and method overloading.
Inheritance allows a new class to be based on an existing class, inheriting its attributes and behaviors. This
means that objects of the new class can be treated as if they were objects of the original class, and can be used
interchangeably in many situations.
1 class Animal {
2 public void move() {
3 System.out.println("Animals can move");
4 }
5 }
6
7 class Dog extends Animal {
8 public void move() {
9 System.out.println("Dogs can walk and run");
10 }
11 }
12
13 public class Test{
14 public static void main(String[] args){
15 Animal animal = new Animal();
16 Animal dog = new Dog();
17 animal.move(); // Output: Animals can move
18 dog.move(); // Output: Dogs can walk and run
19
20 }
21 }
Interfaces provide a way to define a common set of methods that different classes can implement. This means
that objects of different classes that implement the same interface can be treated as if they were the same type,
even though they are different classes.
1 interface Shape {
2 public void draw();
3 }
4
5 class Circle implements Shape {
6 public void draw() {
7 System.out.println("Drawing a circle");
8 }
9 }
10
11 class Square implements Shape {
12 public void draw() {
13 System.out.println("Drawing a square");
14 }
15 }
16 public class Test{
17 public static void main(String[] args){
18 Shape shape1 = new Circle();
19 Shape shape2 = new Square();
20 shape1.draw(); // Output: Drawing a circle
21 shape2.draw(); // Output: Drawing a square
22
23 }
24 }
Method overloading allows multiple methods with the same name to be defined in a class, each with different
parameters. This means that different methods can be called with the same name, depending on the type and
number of parameters passed to them.
1 class Calculator {
2 public int add(int a, int b) {
3 return a + b;
4 }
5
6 public int add(int a, int b, int c) {
7 return a + b + c;
8 }
9 }
10 public class Test{
11 public static void main(String[] args){
12 Calculator calculator = new Calculator();
13 System.out.println(calculator.add(1, 2)); // Output: 3
14 System.out.println(calculator.add(1, 2, 3)); // Output: 6
15
16 }
17 }
Polymorphic Functions
Methods which depends on specific capabilities of an object are known as polymorphic functions. It can work with
any object as long as it possesses the capability that this method requires in order to work.
1 interface Shape {
2 public double area();
3 }
4
5 class Circle implements Shape {
6 private double radius;
7
8 public Circle(double radius) {
9 this.radius = radius;
10 }
11
12 public double area() {
13 return Math.PI * radius * radius;
14 }
15 }
16
17 class Rectangle implements Shape {
18 private double length;
19 private double width;
20
21 public Rectangle(double length, double width) {
22 this.length = length;
23 this.width = width;
24 }
25
26 public double area() {
27 return length * width;
28 }
29 }
30
31 public class Test {
32 public static void printArea(Shape shape) {
33 System.out.println("Area: " + shape.area());
34 }
35
36 public static void main(String[] args) {
37 Circle circle = new Circle(5);
38 Rectangle rectangle = new Rectangle(4, 6);
39
40 printArea(circle);
41 printArea(rectangle);
42 }
43 }
Here, the printArea method is a polymorphic method as it can work with objects of any type, as long as area
method is defined on that object.
Type Consistency
However, we need to impose certain restrictions in case of some methods. Let's take an example of a polymorphic
method to copy an array. It takes a source array and a target array and then copies elements from the source to
the target array.
Now, we need to ensure that the source array can be a subtype of target array but not the vice versa. Target array
should be type compatible with the source array.
A polymorphic data structure stores values of type Object which allows us to store arbitrary elements in that data
structure. A simple example is as follows:
This LinkedList stores data elements which are of type Object , which means it can store data of any type.
The potential issues that may arise as a result of utilizing polymorphic data structures are enumerated below:
Java Generics
Java generics is a feature that enables programmers to create flexible and reusable code. Generics allow classes,
interfaces, and methods to be written in a way that is independent of the data types used, while still ensuring type
safety at compile time.
This means that a single class or method can be used with different types of data, making code more efficient, and
easier to read and maintain. In essence, generics make it possible to write code that is more flexible and less
error-prone, without sacrificing performance or type safety.
We can define a type quantifier before return type between angle brackets (<>)
Let's say we want to create a method that counts the number of occurrences of a particular element in an array.
We could define a method like this:
This method works fine for arrays consisting of integers, but what if we want to count occurrences of different
type of objects such as Strings, double, etc. In that case, we will have to write separate methods for each data
type, but that would be repetitive and error-prone.
This is where, we can make use of generics, we can write a single method that works with any type of object by
using a generic type parameter.
1 public static <T> int countOccurrences(T[] array, T target) {
2 int count = 0;
3 for (int i = 0; i < array.length; i++) {
4 if (array[i].equals(target)) {
5 count++;
6 }
7 }
8 return count;
9 }
In this version of the method, the type parameter T represents any type of object. We use the equals method to
compare elements and count the occurrences.
Now, we can use this method as follows:
We can use the extends keyword, to put constraints on generic type parameters. Below is an example of a
generic method which copies elements from a source array into a target array.
1 public static <S extends T, T> void copyArray(S[] source, T[] target) {
2 int limit = Math.min(src.length,tgt.length);
3 for (int i = 0; i < limit; i++) {
4 target[i] = source[i];
5 }
6 }
7
This method defines two type parameters namely T and S , where S must extend T , thereby ensuring that the
source array will be compatible with the target array.
Here's an example of how we can implement the same LinkedList using Java Generics, in order to deal with the
problems that can occur using normal polymorphic data structures.
We can define new type variable, which hides the type variable that has already been defined. Like modifying the
add method like this will result in a new T which is different from the T defined at the class level. Quantifier <T>
masks the type parameter T of LinkedList.
Extending Subtypes
In Java, arrays typing is covariant, that means if Apple extends Fruit , then Apple[] extends Fruit[] too. This
can cause type errors during runtime as the following code becomes invalid:
Since fruitArr refers to an Apple array, it cannot store objects of type Fruit .
Generic classes are not covariant, that means LinkedList<Apple> is not compatible with LinkedList<Fruit> .
This means that we cannot create a general method like below to print a LinkedList of any type of fruits.
But as we have seen earlier we can define type variables in order to solve this issue
Wildcards
We can notice in the above example that the type variable T is not being used inside the method printList , so
instead we can make use of wildcards.
In Java, wildcards are a type of generic parameter that allows us to write more flexible and reusable code. They
provide a way to represent an unknown type or a type that is a subtype of a specified type. Wildcards are
represented using the ? symbol and can be used in three different forms: ? , ? extends , and ? super .
The first form ? represents an unknown type and can be used in situations where you don't care about the type
of the argument or variable. For example, the following method takes a list of unknown type:
1 public static void printList(LinkedList<?> list) {
2 for (Object obj : list) {
3 System.out.println(obj);
4 }
5 }
This method can accept a LinkedList of any type, but it can only read from it because the type is unknown.
The second form ? extends is used to represent a subtype of a specified type. For example, the following
method takes a list of objects that extend Number :
This method can accept a LinkedList of any type that extends Number , such as Integer , Double , or
BigDecimal .
The third form ? super is used to represent a supertype of a specified type. For example, the following method
takes a list of objects that are supertypes of String :
This method can accept a LinkedList of any type that is a supertype of String , such as Object .
We can define variables of wildcard type, but we need to be careful while assigning values.
We can use bounded wildcards, for various works for example copying a LinkedList from source to target .
1 public static <? extends T, T> void copy(LinkedList<?> source, LinkedList<T> target){
2 //...
3 }
Reflection
The feature of reflection in Java provides the ability to inspect and manipulate the behavior of classes, objects, and
their members at runtime, enabling us to examine the current state of a process.
Two components involved in reflection
Introspection
It allows a program to observe it's own current state
Intercession
It allows a program to modify or alter it's own state and interpretation
We can check whether an object is an instance of a particular class, by the following code:
However, we can only perform this check if we know beforehand which type we want to compare our object
against. When encountering such situations, we can make use of Introspection to determine the class to which an
object belongs.
Presented here is a straightforward function called checkEqual , which accepts two objects as arguments and
returns true if they are instances of the same class, and false otherwise.
We cannot make use of instanceof in cases like these, because we don't know which class to compare our object
against. We can import the reflection package for Introspection of our objects.
We can extract the class information of any object by using the method getClass() , which is available in the
reflection package. This gives us an object of type Class that encodes the class information of the object on
which the getClass() method was invoked.
1 import java.lang.reflect.*;
2 //...
3 public static boolean checkEqual(Object o1, Object o2){
4 if(o1.getClass() == o2.getClass()){
5 return true;
6 }else{
7 return false;
8 }
9 }
This method gets the class information for o1 and o2 , and then returns true if both of them are having the
same information and false otherwise.
To make complete use of Class objects, we should store them in a variable of type Class . We can create Class
objects primarily using two ways.
Getting the class information of a particular object
1 Class c1 = obj.getClass();
1 Class c2 = Class.forName("Fruit");
Using the Class object
It is possible to create new instances of the class to which the object obj belongs, using the following code.
We can use this Class object to get more information about the class such as constructors , methods and
fields . We have additional classes such as Constructor , Method and Field to introspect them further.
Now, in order to extract the information about constructor , method and field that are present in the class
that obj belongs to, we can make use of methods like getConstructors , getMethods and getFields .
We can store the results of these methods in an array of their respective types.
1 Class c = obj.getClass();
2 Constructor[] constructorArr = c.getConstructors();
3 Method[] methodArr = c.getMethods();
4 Field[] fieldArr = c.getFields();
These methods return the public constructors , methods and fields respectively. In order to get the public
and private details together, we can use methods like getDeclaredConstructors , getDeclaredMethods and
getDeclaredFields .
We can introspect them further for details such as parameters that a constructors takes, etc using the methods
present in their respective classes.
Similarly, we can invoke methods, set values of fields, and do many more things.
1 //...
2 Class c = obj.getClass();
3 Method[] method = c.getMethods();
4 method[0].invoke(obj, args);
5 //This invoke methods[0] on obj with arguments args
6 Field[] field = c.getFields();
7 field[2].set(obj, value1);
8 //This sets the value of field[2] in obj to value1
9 Object o = field[1].get(obj);
10 //This gets the value of field[1] from obj
Type Erasure
Java does not keep different versions of a generic class as separate types during runtime. Instead, at runtime all
type variables are promoted to Object or the upper bound if any.
Since, Java preserves no information about T during runtime, we cannot check if something is an instance of T.
1 if (o instanceof T){
2 //...
3 }
4 //This is incorrect
Now since all the versions of a particular generic class gets promoted to the same type during runtime, the
following code will return true .
1 o1 = new LinkedList<String>
2 o2 = new LinkedList<Date>
3 if(o1.getClass() == o2.getClass()){ //returns true
4 //This will get executed
5 }
Due to type erasure we have to be careful while overloading methods which involves parameters related to type
T . We cannot write two methods like this:
Both these functions will have the same method signature after type erasure.
Type Erasure convert LinkedList<T> to LinkedList<Object> but basic types like int , double , char , etc are
not compatible with Object , therefore we cannot use these types in place of generic types.
Therefore, we have wrapper classes for each type which is compatible with Object .
byte → Byte
short → Short
int → Integer
long → Long
float → Float
double → Double
boolean → Boolean
char → Character
We can convert between basic types and their corresponding Wrapper class as follows:
1 int x = 10;
2 Integer wrap_x = Integer(x);
3 int unwrap_x = wrap_x.intValue();
There are similar methods like these for other types like byteValue , doubleValue , and so on.
Autoboxing
Java implicitly converts values between basic types and their corresponding wrapper types.
1 int x = 10;
2 Integer wrap_x = x;
3 int unwrap_x = wrap_x;
Week 5 Home Week 7
Circular Array: Fixed-size array where elements wrap around when reaching the end.
Linked List: Dynamic structure where each node links to the next.
Circular Array:
Linked List:
Multiple Implementations
However, changing dateQueue to a flexible type requires updating all references in the code, which can be error-prone.
Here, Switching implementations require only updating the instantiation as compared to previous implementation.
Real-Life Analogy
When Java was first introduced, it came with several standalone data structures that were widely useful. These
included:
Vector
Stack
Hashtable
BitSet
However, these early data structures lacked a unified framework. This absence of a common interface led to several
challenges:
1. Code Maintenance: Switching from one data structure to another often required substantial changes throughout
the codebase.
2. Inconsistency: Each data structure had its own methods, syntax, and constructors, with no standardization.
3. Usability Issues: Developers had to remember the unique functionalities of each collection, making it harder to
achieve consistency and reusability in their code.
The fragmented nature of these collections underscored the need for a unified framework to standardize collection
operations, improve usability, and promote code reusability.
To address these issues, Java introduced the Collection Framework, a unified architecture for working with data
structures. This framework provides:
Examples: PriorityQueue
Adding Elements:
Iteration:
Containment Checks:
boolean equals(Object other) - Compares the collection with another object for equality.
boolean addAll(Collection<? extends E> from) - Adds all elements from another collection.
Removing Elements:
While these methods offer extensive functionality, implementing all of them in a concrete collection class can be
labor-intensive.
The ideal solution might be to provide default implementations in the interface but this feature (of providing default
implementations in interface) was added to JAVA later.
Abstract Implementations
To simplify implementation, Java provides the AbstractCollection class, which implements the Collection interface.
This abstract class offers default implementations for many methods, allowing developers to focus on specific
behaviors for their custom collections.
In this abstract class some implementations remain abstract while others can be extended.
The Iterator interface, located above Collection in the hierarchy, facilitates systematic traversal of collections. Its
primary methods include:
public boolean hasNext() - Checks if there are more elements to iterate over.
public E next() - Returns the next element in the iteration.
public void remove() - Removes the last element accessed by the iterator.
The iterator's remove() method is distinct from the Collection interface's remove() method. While the former removes
the current element accessed via next(), the latter removes a specified object directly from the collection.
Code Example:
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
public class BasicIteratorExample {
public static void main(String[] args) {
Output:
The hasNext() method checks if there are more elements to iterate over.
The next() method retrieves the next element in the collection.
Java introduced the enhanced for loop, which simplifies iteration by implicitly using an iterator. This feature reduces
boilerplate code and improves readability.
Code Example:
import java.util.ArrayList;
import java.util.Collection;
public class EnhancedForLoopExample {
public static void main(String[] args) {
Output:
The enhanced for loop simplifies iteration and implicitly uses an Iterator.
Since having iterator functionality also provides flexibility to write generic functions to operate on collections.
Code Example:
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
public class IteratorRemoveExample {
public static void main(String[] args) {
while (iterator.hasNext()) {
String word = iterator.next();
if (word.startsWith("W") || word.startsWith("w")) {
iterator.remove();
System.out.println("Removed: " + word);
}
}
System.out.println("\nUpdated collection:");
for (String word : words) {
System.out.println(word);
}
}
}
Output:
Original collection:
Hello
World
Welcome
To
Java
Updated collection:
Hello
To
Java
The remove() method of the Iterator interface has specific implications when attempting to remove consecutive
elements from a collection. To achieve this, you must interleave calls to the next() method between successive
remove() calls.
For example, if you need to remove two consecutive elements from a collection, you cannot directly call remove()
twice in succession. The first remove() call removes the last element returned by next(). If you attempt a second
remove() call without invoking next(), it results in an error because there is no "current element" for the iterator to act
upon. The absence of an intermediate next() leaves the iterator in an invalid state.
Code Example:
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
public class ConsecutiveRemoveExample {
public static void main(String[] args) {
try {
while (iterator.hasNext()) {
String word = iterator.next();
if (word.startsWith("W") || word.startsWith("w")) {
iterator.remove(); // First remove call
iterator.remove(); // Second remove call (ERROR)
System.out.println("Removed: " + word);
}
}
} catch (IllegalStateException e) {
System.err.println("Error: " + e.getMessage());
}
}
}
Output:
After calling remove(), the iterator's state requires an intermediate next() call to establish a "current element"
before another remove().
It is important to note that the remove() method in the Iterator interface is distinct from the remove() method in the
Collection interface, differing in both their signatures and behaviors:
1. Iterator.remove()
2. Collection.remove()
The Iterator.remove() method focuses on the element currently being traversed by the iterator, while the
Collection.remove() method is used to directly target a specific object in the collection, independent of iteration.
Concrete Collections
Collections are further organized based on additional properties. These captured by interfaces
The ListIterator interface, which extends Iterator, provides additional functionalities specific to ordered
collections:
The List interface in Java supports both sequential and random access. However, the efficiency of random access
varies depending on the implementation:
Array-based Implementations: Compute the location of an element at index i using arithmetic operations. This
makes random access efficient.
Linked List Implementations: Require traversal of i links to reach the ith element, which makes random access
less efficient.
The List interface includes the following methods for random access:
void add(int index, E element) - Inserts the specified element at the specified position in this list.
E get(int index) - Returns the element at the specified position in this list.
ListIterator<E> listIterator() - Returns a list iterator over the elements in this list (in proper sequence),
starting at the specified position in the list.
void remove(int index) - Removes the element at the specified position in this list.
E set(int index, E element) - Replaces the element at the specified position in this list with the specified
element.
The RandomAccess interface is a marker interface used to indicate that a List supports efficient random access.
Algorithms can adapt their strategy based on whether a list implements RandomAccess.
For instance, swapping two elements in a list can be optimized if the list supports efficient random access:
if (list instanceof RandomAccess) {
// Efficient random access strategy
} else {
// Sequential access strategy
}
Java provides abstract classes to simplify the implementation of the List interface:
1. AbstractList:
Extends AbstractCollection.
Provides default implementations for List methods.
2. AbstractSequentialList:
Extends AbstractList.
Specifically for lists that do not support random access.
Extends AbstractSequentialList.
Characteristics:
ArrayList<E>
Extends AbstractList.
Characteristics:
Practical Considerations
While the List interface guarantees the availability of random access methods like get(int index), their efficiency
depends on the underlying implementation:
ArrayList: Efficient.
LinkedList: Each get() call may require traversal from the beginning, leading to poor performance in loops.
Code Example:
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
public class ListAccessExample {
public static void main(String[] args) {
arrayList.add("Java");
arrayList.add("Python");
arrayList.add("C++");
Output:
Accessing elements by index (ArrayList):
Index 0: Java
Index 1: Python
Index 2: C++
LinkedList requires traversal for each get() call, which is less efficient.
1. In Collection:
2. In ListIterator:
Code Example:
import java.util.ArrayList;
import java.util.List;
import java.util.ListIterator;
public class AddMethodExample {
public static void main(String[] args) {
Output:
The List interface provides versatile mechanisms for managing ordered collections. Understanding the
characteristics and performance implications of specific implementations like LinkedList and ArrayList is crucial for
effective use. Additionally, the ListIterator interface extends functionality for navigating and modifying lists, while
the RandomAccess marker interface informs the efficiency of direct access operations.
The Set Interface
A Set in Java is a collection that ensures no duplicate elements. While the Set interface shares the same method
signatures as the Collection interface, its behavior is more constrained:
add(E element): Adds an element to the set. If the element already exists, the operation has no effect and
returns false.
equals(Object o): Determines equality by comparing contents without considering the order.
The existence of the Set interface alongside Collection, despite their shared methods, is to enforce additional
constraints. By explicitly requiring a Set, programmers can ensure:
No duplicate elements.
Efficient membership checks.
Unlike ordered collections that require iteration to locate an element, sets optimize membership testing by:
The AbstractSet class provides default implementations for many Set methods. Concrete implementations of Set
extend AbstractSet and offer optimized storage and retrieval mechanisms. As similar to the AbstractCollection class
for Collection interface.
1. HashSet
Underlying Structure: An array where values are mapped to positions using a hash function h(v).
Handling Collisions: If two values map to the same position, the collision is resolved using strategies like
probing or chaining.
Membership Testing: Fast. The hash function determines the position, and membership is checked directly.
Order: Unordered. The iterator visits elements in an arbitrary order but guarantees that each element is visited
exactly once.
import java.util.HashSet;
import java.util.Set;
public class SetExample {
public static void main(String[] args) {
// Create a HashSet
Set<String> set = new HashSet<>();
Output:
No Duplicates: Adding "Java" twice results in only one instance in the set.
Order Not Guaranteed: The order of elements in a HashSet is arbitrary.
import java.util.HashSet;
import java.util.Set;
public class HashSetMembership {
public static void main(String[] args) {
// Membership test
System.out.println("Set contains 10: " + set.contains(10)); // True
System.out.println("Set contains 20: " + set.contains(20)); // False
}
}
Output:
2. TreeSet
Underlying Structure: A binary search tree that maintains elements in sorted order.
Membership Testing: Efficient, with a time complexity O(log n) of for n elements.
Order: Sorted. The iterator visits elements in ascending order.
Use Case: When an ordered set is required.
Code Example:
import java.util.TreeSet;
public class TreeSetExample {
public static void main(String[] args) {
// Add elements
treeSet.add(20);
treeSet.add(10);
treeSet.add(30);
// Membership test
System.out.println("TreeSet contains 20: " + treeSet.contains(20));
}
}
Output:
TreeSet contents (sorted): [10, 20, 30]
TreeSet contains 20: true
Sorted Order: Elements are stored in ascending order using a balanced binary search tree.
Efficient Membership Testing: The TreeSet provides O(log n) complexity for lookup operations.
The Set interface in Java provides a powerful abstraction for collections without duplicates. While HashSet is
optimized for fast membership testing and insertion, TreeSet is suited for scenarios requiring sorted order.
Understanding the trade-offs between these implementations is essential for writing efficient and effective code.
| Implementation | Underlying Structure | Order | Membership Test Complexity | Use Case | | :----------------- | :-------------------
------: | :--------------: | :----------------------------: | :------------------------------------------: | -------------------------------------- | | HashSet | Hash Table |
Unordered | True | O(1) | Fast membership testing and insertion. | | TreeSet | Balanced Binary Search Tree | Sorted
(natural) | O(logn) | When sorted order is required. | | LinkedHashSet | Hash Table + LinkedList | Insertion Order | O(1) |
When insertion order needs to be maintained. |
A Queue in Java represents an ordered collection designed for holding elements prior to processing. Elements in a
queue follow a First-In-First-Out (FIFO) order, meaning elements are inserted at the rear and removed from the front.
The Queue interface includes basic operations for adding and removing elements:
boolean add(E element): Inserts the specified element into the queue. If the queue is full, this method throws an
exception.
E remove(): Removes and returns the head of the queue. Throws an exception if the queue is empty.
Gentler Variants
boolean offer(E element): Attempts to insert the specified element. Returns false if the queue is full.
E poll(): Retrieves and removes the head of the queue, returning null if the queue is empty.
The following methods allow inspection of the element at the head without removal:
E element(): Returns the head of the queue but throws an exception if the queue is empty.
E peek(): Returns the head of the queue or null if the queue is empty.
Code Example:
import java.util.LinkedList;
import java.util.Queue;
public class QueueExample {
public static void main(String[] args) {
// Core Methods
System.out.println("Adding elements using add():");
queue.add("A");
queue.add("B");
queue.add("C");
System.out.println("Queue after additions: " + queue);
System.out.println("\nRemoving elements using remove():");
System.out.println("Removed element: " + queue.remove());
System.out.println("Queue after removal: " + queue);
// Gentler Variants
System.out.println("\nAdding elements using offer():");
boolean offerStatus = queue.offer("D");
System.out.println("Offer status: " + offerStatus);
System.out.println("Queue after offer: " + queue);
try {
System.out.println("Head of the queue: " + queue.element());
} catch (Exception e) {
System.out.println("Exception: " + e.getMessage());
}
Output:
add(E element): Adds an element to the rear of the queue; throws an exception if the queue is full.
remove(): Removes and returns the head of the queue; throws an exception if the queue is empty.
offer(E element): Adds an element to the queue and returns false if it fails.
poll(): Removes and returns the head; returns null if the queue is empty.
element(): Returns the head of the queue; throws an exception if the queue is empty.
peek(): Returns the head of the queue or null if empty.
The Deque (Double-Ended Queue) interface extends Queue to allow element insertion and removal from both ends.
1. Insertion:
boolean addFirst(E element): Inserts the element at the front.
boolean addLast(E element): Inserts the element at the rear.
boolean offerFirst(E element): Attempts to insert the element at the front.
boolean offerLast(E element): Attempts to insert the element at the rear.
2. Removal:
E pollFirst(): Removes and returns the front element or null if the deque is empty.
E pollLast(): Removes and returns the rear element or null if the deque is empty.
3. Inspection:
E getFirst(): Retrieves the front element. Throws an exception if the deque is empty.
E getLast(): Retrieves the rear element. Throws an exception if the deque is empty.
E peekFirst(): Retrieves the front element or null if the deque is empty.
E peekLast(): Retrieves the rear element or null if the deque is empty.
The PriorityQueue interface extends Queue and orders elements based on their natural ordering or a custom
comparator. Elements with the highest priority are removed first.
Key Charecterstics:
Insertion: Operates similarly to a standard queue but ensures elements are placed based on priority.
Removal: The remove() method retrieves and removes the element with the highest priority.
Concrete Implementations
1. LinkedList
The LinkedList class implements both Queue and Deque. It provides the following:
Key Characteristics:
2. ArrayDeque
Key Characteristics:
The Queue and Deque interfaces in Java provide versatile tools for managing ordered collections. While Queue ensures
FIFO behavior, Deque allows for flexible insertions and removals from both ends. Implementations like LinkedList and
ArrayDeque cater to different performance needs, enabling developers to choose the most suitable structure for their
applications.
Maps
The Map interface in Java is designed to handle key-value pairs, enabling efficient data storage and retrieval based on
unique keys. Unlike the Collection interface, which deals with grouped data (e.g., arrays, lists, and sets), Map focuses
on key-value structures, analogous to dictionaries in Python.
Keys form a set: Each key is unique, and adding a new value with an existing key overwrites the old value.
put(K key, V value) returns the previous value associated with the key or null if no such value existed.
Code Example:
import java.util.HashMap;
import java.util.Map;
public class CoreMapOperations {
public static void main(String[] args) {
Output:
Score of Alice: 85
Contains key 'Bob'? true
Contains value 90? true
Updating Maps
To address the initialization problem—whether to update an existing entry or create a new one—Java provides:
1. getOrDefault(K key, V defaultValue): Returns the value for a key if it exists; otherwise, returns a default value.
import java.util.*;
Output:
2. putIfAbsent(K key, V value): Adds a key-value pair only if the key is missing.
import java.util.*;
scores.putIfAbsent("Dave", 50);
scores.putIfAbsent("Dave", 100); // Has no effect
System.out.println("Score of Dave: " + scores.get("Dave"));
}
}
Output:
Score of Dave: 50
3. merge(K key, V value, BiFunction<V, V, V> remappingFunction): Combines the current value with a new value
using a specified function.
import java.util.*;
Output:
Using keySet() – Iterates over the keys and retrieves values using them.
Using values() – Iterates directly over the values.
Using entrySet() – Iterates over key-value pairs together.
Using an Iterator – Useful when modifying the map while iterating.
import java.util.*;
// Extracting keys
System.out.println("Keys: " + scores.keySet());
// Extracting values
System.out.println("Values: " + scores.values());
// Extracting entries
System.out.println("Entries: " + scores.entrySet());
}
}
Output:
Java provides multiple implementations of the Map interface, each with distinct features:
1. HashMap
Code Example:
import java.util.HashMap;
import java.util.Map;
Output:
HashMap:
Alice : 85
Charlie : 75
Bob : 90
2. TreeMap
import java.util.Map;
import java.util.TreeMap;
Output:
3. LinkedHashMap
1. External Errors: These include issues like incorrect user input, unavailable resources, or hardware malfunctions. For
example:
2. Coding Mistakes: Errors resulting from flaws in the code, such as:
3. Resource Limitations: Situations where external resources are depleted, such as:
Memory shortages.
Disk space running out.
Signaling Errors
One way to signal an error is to return an invalid value, such as -1 to indicate the end of a file or null to represent the
absence of a valid result. However, this approach has limitations, as it may not always be possible when there is no clearly
defined invalid value.
To handle these situations effectively, Java employs the concept of exceptions, which allow programmers to signal and
manage abnormal conditions in the code. Exception handling provides a structured approach to gracefully manage these
errors, ensuring that the program does not crash unexpectedly.
1. Throwing an Exception:
2. Catching an Exception:
The calling code "catches" the exception and takes corrective actions.
Alternatively, the exception can propagate back up the calling chain.
3. Graceful Interruption:
Instead of crashing, the program terminates gracefully or continues execution after addressing the issue.
try {
} catch (ArithmeticException e) {
} finally {
Output:
Result: 5
Error: Division by zero is not allowed.
Division operation completed.
Code Explanation:
Throwing an Exception:
Catching an Exception:
The try block in main attempts to execute code that might throw an exception.
If an exception is thrown, the catch block catches it and prints a meaningful error message instead of crashing.
Graceful Interruption:
Instead of terminating abruptly, the program catches the exception and provides an error message.
The program continues execution after handling the exception.
Finally Block:
The finally block is optional and always executes, even if no exception is thrown. It is useful for cleanup
operations.
Throwable
|
|-- Error (Unrecoverable JVM-level issues)
|
|-- Exception
|
|-- RuntimeException (Unchecked exceptions)
|
|-- Checked Exceptions
Java organizes errors into a hierarchy under the parent class Throwable. The main categories are:
Error
Errors represent severe issues that are usually beyond the control of the programmer. They indicate problems that typically
arise in the Java Virtual Machine (JVM) itself and are unlikely to be recoverable during runtime.
Key Characteristics:
Errors are unchecked, meaning they are not required to be declared or handled in the program.
Typically used for conditions that the program cannot recover from or correct.
Subclasses of the Error class.
Examples of Errors:
Code Example:
Exception
Exceptions represent conditions that a program should handle gracefully. They are recoverable, and handling them
appropriately allows the program to continue executing.
Key Characteristics:
RuntimeException/Unchecked Exception
Unchecked exceptions occur due to programming mistakes that could have been avoided with proper input validation or
checks. The compiler does not enforce handling or declaring these exceptions.
Key Characteristics:
● Unchecked exceptions extend the RuntimeException class. ● They usually indicate logical or programming errors. ● Do
not need to be declared using the throws keyword.
Code Example:
try {
// Attempt to access an invalid index
System.out.println(numbers[5]);
} catch (ArrayIndexOutOfBoundsException e) {
Output:
Checked Exception
Checked exceptions represent exceptional conditions that the program should anticipate and handle. The Java compiler
enforces that these exceptions must be declared using the throws keyword or handled using a try-catch block.
Key Characteristics:
Code Example:
import java.io.*;
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
br.close();
}
}
Output:
Code Explanation:
Since IOException is a Checked Exception, the compiler forces us to handle or declare it.
Java allows programmers to create their own checked exceptions. This is useful when custom rules or constraints need to
be enforced.
Code Example:
try {
// Test with invalid age
example.validateAge(16);
} catch (InvalidAgeException e) {
// Handle the exception
System.out.println("Caught Exception: " + e.getMessage());
}
try {
// Test with valid age
example.validateAge(20);
} catch (InvalidAgeException e) {
System.out.println("Caught Exception: " + e.getMessage());
}
}
}
Output:
Code Explanation:
This class extends Exception, making it a checked exception (must be handled using try-catch or declared using
throws).
It has a constructor that takes a message and passes it to the parent Exception class.
2. UserDefinedCheckedException Class
Contains the validateAge(int age) method, which checks if the provided age is less than 18.
If the condition is met, it throws an InvalidAgeException with a custom message.
Catching Exceptions
Single Exception
Output:
Code Explanation:
1. Try Block:
The try block contains code that may throw an exception. In this case, the throw statement deliberately throws an
IOException with a custom error message: "Simulated I/O error".
The throw statement is used to create and throw an IOException object, simulating an error scenario.
3. Catch Block:
The catch block is used to handle the exception. It catches the thrown IOException and stores it in the variable e.
Inside the catch block, the getMessage() method of the exception object is used to retrieve and display the error
message.
4. Program Continuation:
After the exception is caught and handled, the program continues its execution with the subsequent statement:
System.out.println("Program execution continues smoothly.");
Multiple Exception
Use multiple catch blocks for different exception types. The blocks are evaluated in sequence.
// Demonstrating multiple catch blocks to handle specific and general exceptions
import java.io.FileNotFoundException;
import java.io.IOException;
} catch (FileNotFoundException e) {
} catch (IOException e) {
Note: Arrange catch blocks from the most specific to the most general. For example, IOException should follow
FileNotFoundException.
Output:
Code Explanation:
1. Try Block:
The try block contains code that throws a FileNotFoundException. This simulates a scenario where a specific I/O
error occurs.
The catch blocks are arranged from the most specific (FileNotFoundException) to the most general (IOException).
This order ensures the appropriate block handles the exception and prevents compilation errors.
4. Program Continuation:
After handling the exception, the program resumes normal execution with the System.out.println("Program
execution continues smoothly."); statement.
Throwing Exceptions
Java allows developers to explicitly throw exceptions using the throw keyword. When a method can throw exceptions, it
must declare them using the throws keyword in the method signature.
Compiler Enforcement: The Java compiler ensures that checked exceptions are either caught or declared in the calling
code.
Code Example:
/**
* This method validates a person's age for voting eligibility.
* It uses the `throws` keyword to declare that it can throw an
* IllegalArgumentException.
*
* @param age The age of the person.
* @throws IllegalArgumentException if the age is less than 18.
*/
public void validateAge(int age) throws IllegalArgumentException {
// Check if the age is less than 18
if (age < 18) {
// Throw an IllegalArgumentException with a custom message
throw new IllegalArgumentException("Age must be 18 or above to vote.");
}
// If no exception occurs, print a success message
System.out.println("Age is valid for voting.");
}
Output:
Code Explanation:
The validateAge method declares that it may throw an IllegalArgumentException using the throws keyword.
Throwing Exceptions:
The throw statement is used inside the method to explicitly throw the exception when the condition (age < 18) is
met.
Catching Exceptions:
The main method uses a try-catch block to call the method and handle any exceptions that are thrown.
Custom Exceptions
Custom exceptions can be created by extending the Exception class. This allows developers to define application-specific
errors.
// Custom exception class to handle negative values
class NegativeValueException extends Exception {
// Constructor to initialize the exception with a custom message
public NegativeValueException(String message) {
super(message); // Pass the message to the parent Exception class
}
}
/**
* Adds a value to the list.
*
* @param value The value to be added.
* @throws NegativeValueException If the value is negative.
*/
public void add(int value) throws NegativeValueException {
// Check if the value is negative
if (value < 0) {
// Throw a custom exception for negative values
throw new NegativeValueException("Negative value: " + value);
}
// Print a success message if the value is valid
System.out.println("Value added: " + value);
}
Output:
Code Explanation:
The NegativeValueException class extends the Exception class, making it a checked exception.
It has a constructor that accepts a custom error message, which is passed to the superclass (Exception).
The add method in the LinearList class declares that it may throw a NegativeValueException.
This informs the calling code that it must handle this exception using a try-catch block.
Inside the add method, if the input value is negative, a NegativeValueException is thrown with an appropriate error
message.
For positive values, the method simply prints a success message.
In the main method, the add method is called inside a try-catch block.
If a NegativeValueException is thrown, the catch block handles it and displays the error message.
A second try-catch block demonstrates how valid values are processed without any exceptions.
The finally block ensures that critical cleanup code runs regardless of whether an exception occurs.
Code Example:
try {
// Allocate the resource
resource = new CustomResource();
System.out.println("Resource initialized successfully.");
} catch (ArithmeticException e) {
} finally {
Output:
CustomResource allocated.
Resource initialized successfully.
Error occurred: / by zero
CustomResource cleaned up.
Cleanup completed in finally block.
Program execution continues.
Code Explanation:
The CustomResource class simulates a resource that needs manual cleanup (e.g., database connection, file handle).
It provides a cleanup method to release resources explicitly.
The finally block ensures that the resource is cleaned up (via the cleanup method) regardless of whether an
exception occurs.
It runs unconditionally, even if no exception is thrown.
5. Use finally:
The finally block is ideal for releasing resources such as file handles, database connections, or network sockets,
ensuring no resource leaks.
Exception Chaining
Java supports chaining exceptions to provide more context about an error. The Throwable class provides methods such as
getCause() and initCause() to work with chained exceptions.
Code Example:
try {
try {
} catch (IllegalArgumentException e) {
} catch (RuntimeException e) {
Output:
Code Explanation:
The inner block simulates an error (e.g., invalid input) by throwing an IllegalArgumentException.
This block catches the IllegalArgumentException and wraps it inside a RuntimeException, using exception chaining to
preserve the original cause.
The outer block catches the RuntimeException thrown from the inner block.
It retrieves the root cause (original exception) using the getCause() method of the Throwable class.
3. Exception Chaining:
Chaining helps propagate the root cause of an error while adding additional context at higher levels.
The RuntimeException message provides context ("Processing failed due to invalid input"), and its cause retains the
original error details ("Invalid input provided").
Methods Used:
Packages
In Java, a package serves as an organizational unit for grouping related classes and interfaces. Packages help prevent
naming conflicts by allowing developers to create unique namespaces. They also facilitate better code organization and
modularity.
Using Packages
* imports all classes in the specified package but does not include sub-packages. For instance:
Benifits of Packages
1. Namespace Management:
2. Code Organization:
Packages group related classes, making code easier to navigate and maintain.
3. Access Control:
Packages allow for modular programming, enabling easy reuse of code components.
Developers can define custom packages by adhering to Java's naming conventions. The convention for package names
follows the reverse of an organization's Internet domain name:
Internet Domain Package Name
onlinedegree.iitm.ac.in in.ac.iitm.onlinedegree
Defining a Package
To include a class in a specific package, add a package declaration at the top of the .java file: Create a file named
Employee.java inside a folder in/ac/iitm/onlinedegree.
// File: in/ac/iitm/onlinedegree/Employee.java
package in.ac.iitm.onlinedegree;
/**
* Represents an Employee with a name.
*/
public class Employee {
private String name; // Employee's name
Using a Package
/**
* Demonstrates the usage of packages in Java.
*/
public class Main {
public static void main(String[] args) {
// Create an Employee object
Employee emp = new Employee("John Doe");
Output:
Code Explanation:
1. Package Declaration:
The package statement at the top of Employee.java specifies that the class belongs to the in.ac.iitm.onlinedegree
package.
2. Import Statement:
The import in.ac.iitm.onlinedegree.Employee; statement in Main.java makes the Employee class accessible.
3. Folder Structure:
By default, classes without a package declaration belong to an anonymous package shared by all classes in the same
directory.
Directory Structure:
root/
├── in/
│ ├── ac/
│ │ ├── iitm/
│ │ │ ├── onlinedegree/
│ │ │ │ ├── Employee.java
├── Main.java
4. Visibility Modifiers:
The Employee class and its getName() method are public, allowing them to be accessed from outside the package.
Visibility Modifiers
Visibility modifiers in Java allow developers to control access to classes, methods, and variables. This ensures
encapsulation, one of the core principles of object-oriented programming (OOP). Proper use of visibility modifiers leads to
better code security, modularity, and maintainability.
Public:
Members marked as public are accessible everywhere — inside the class, outside the class, and even in other
packages.
Used when a member needs to be globally accessible.
Example: Frequently used in APIs or library methods designed for external use.
Private:
Members marked as private are accessible only within the defining class.
Promotes encapsulation by hiding the implementation details.
Used to restrict direct access to sensitive data or internal logic.
Example: Helper methods or internal state variables.
Default (Package-Private):
Protected:
Special Rule: A subclass can make a protected member public in its implementation, thus expanding its visibility.
/**
* Demonstrates different visibility modifiers.
*/
public class VisibilityExample {
public int publicVar = 100; // Accessible everywhere
private int privateVar = 200; // Accessible only within this class
int packagePrivateVar = 300; // Accessible only within the same package
protected int protectedVar = 400; // Accessible within package and in subclasses
/**
* Demonstrates access levels within the same package.
*/
public class SamePackageAccess {
public static void main(String[] args) {
VisibilityExample example = new VisibilityExample();
import in.ac.iitm.onlinedegree.VisibilityExample;
/**
* Demonstrates access levels from a different package.
*/
public class DifferentPackageAccess extends VisibilityExample {
public static void main(String[] args) {
VisibilityExample example = new VisibilityExample();
Output:
Code Explanation
1. Public:
2. Private:
Only accessible within the VisibilityExample class. Demonstrated by calling showPrivate() within a public method.
3. Package-Private:
4. Protected:
Assertions
In Java, when developing functions or methods, it is important to ensure that the input parameters meet certain
expectations or constraints for the function to work correctly. If these constraints are violated, the behavior of the program
can become unpredictable or erroneous.
To handle this, developers often rely on two primary mechanisms: exceptions and assertions.
For public functions that are accessed by external code, it is common to enforce parameter constraints using exceptions.
For example: If a negative value is passed to the function, an IllegalArgumentException is thrown. This approach ensures
that the function's contract is upheld and communicates the error to the calling code.
public class MyFunctionExample {
// Method to calculate the square root of x, assuming x >= 0
public static double myfn(double x) throws IllegalArgumentException {
// Check if x is less than 0 and throw an exception if true
if (x < 0) {
throw new IllegalArgumentException("x < 0: Cannot calculate the square root of a negative number.");
}
// Return the square root of x if valid
return Math.sqrt(x);
}
Output
Code Explanation
Input Validation: The method first checks if x is less than 0. If it is, an IllegalArgumentException is thrown with a
message explaining that negative numbers are not allowed for square root calculation.
Square Root Calculation: If x is valid (i.e., non-negative), the method calculates the square root using Math.sqrt(x)
and returns the result.
2. main Method:
Test Case 1: It calls myfn(16), which is a valid positive number, and prints the Square root of 16: 4.0.
Test Case 2: It calls myfn(0), where the result should be 0.0 (since the square root of 0 is 0).
Test Case 3: It calls myfn(-4), which triggers the exception since the input is negative. The exception is caught, and
the error message is printed.
Assertions provide a lightweight mechanism for validating assumptions during development. Unlike exceptions, assertions
are typically used for internal, private methods where parameter constraints are assumed to be met by the developer's
code.
Code Example
To run the program and ensure assertions are enabled, you must specify the -ea (enable assertions) flag when running the
program. Here’s how you would run the program from the command line:
java -ea AssertionExample
Output
Code Explanation
The method checks if x is non-negative using an assertion: assert x >= 0 : "x must be non-negative";.
If the value of x is less than 0, the condition fails, and an AssertionError is thrown with the optional message "x must
be non-negative".
If the assertion passes (i.e., x >= 0), the square root of x is computed and returned using Math.sqrt(x).
2. main Method:
Test Case 1: Calls myfn(16) which is valid (positive number) and prints the square root (4.0).
Test Case 2: Calls myfn(0), which is also valid (square root of 0 is 0.0).
Test Case 3: Calls myfn(-4), which is invalid (negative number). Since assertions are enabled (with the -ea JVM flag),
an AssertionErroris thrown, and the message "x must be non-negative" is printed.
Features of Assertions
1. Abort on Failure: When an assertion fails, an AssertionError is thrown, aborting the program.
2. Diagnostic Information: The error message and stack trace help identify the source of the failure.
3. Not for Runtime Recovery: Assertions are not meant to be caught or handled during runtime. They indicate
programming errors that need to be fixed during development.
Code Example
Code Explanation
If x is negative, the program terminates with an AssertionError, and the message Invalid input: <value> is displayed.
Assertions in Java can be enabled or disabled at runtime using JVM options, providing flexibility without needing to modify
or recompile the code. This allows developers to control when and where assertions should be active, aiding in debugging
and development while avoiding unnecessary overhead in production environments.
Runtime Configuration
Assertions are enabled or disabled at runtime using JVM options, without requiring code changes or recompilation.
Enable Assertions
To enable assertions in Java, you can use the -ea or -enableassertions option with the JVM.
This enables assertions for the entire application, including all classes.
This enables assertions only for the class com.example.MyClass. Replace com.example.MyClass with the fully qualified name
of any class you want to target.
3. For a Package:
java -ea:com.example.package MyCode
This enables assertions for all classes within the com.example.package package.
Disable Assertions
You can also disable assertions, either globally or for specific parts of the code, by using the -da or -disableassertions
option.
This disables assertions for the class com.example.MyClass, while leaving assertions enabled for other classes.
3. For a package:
java -da:com.example.package MyCode
This disables assertions for all classes within the com.example.package package.
Java allows you to combine enabling and disabling assertions for specific parts of the application, offering more fine-
grained control over which assertions are active.
For example:
java -ea:com.example.package
-da:com.example.package.MyClass MyCode
Assertions are enabled for all classes within the com.example.package package.
Assertions are disabled for the specific class com.example.package.MyClass.
This approach helps in cases where you want to test assertions in most of your code but exclude certain classes or
packages from being checked.
Summary of Options
Option Effect
-ea or -enableassertions Enable assertions globally or for specific classes or packages.
-da or -disableassertions Disable assertions globally or for specific classes or packages.
-ea:package.name Enable assertions for all classes in the specified package.
-ea:package.name.ClassName Enable assertions for a specific class in a package.
-da:package.name Disable assertions for all classes in the specified package.
-da:package.name.ClassName Disable assertions for a specific class in a package.
Use assertions to validate assumptions and invariants during code development and testing.
Assertions should highlight unrecoverable, fatal errors that indicate programming bugs.
Production Code:
Logging
Effective logging is essential for diagnosing issues and maintaining traceability in software systems. While print
statements are a simple way to track program behavior, they lack flexibility, clutter the code, and are difficult to manage in
complex systems. Logging provides a structured and configurable solution for generating diagnostic messages, enabling
developers to monitor, debug, and audit their applications efficiently.
Basics of Logging
The simplest way to log messages in Java is to use the global logger:
import java.util.logging.Level;
import java.util.logging.Logger;
Output
2. Logging Levels:
4. Suppressing Logs:
Setting the level to Level.OFF suppresses all logs, regardless of their severity.
Custom Loggers
In Java, custom loggers allow you to organize and manage logging more effectively, especially in larger projects. Loggers
can be structured hierarchically, similar to package names, providing fine-grained control over logging for specific parts of
your application.
import java.util.logging.Level;
import java.util.logging.Logger;
Output
Code Explanation
Custom loggers are created using Logger.getLogger("loggerName"), where loggerName is a string representing the
logger's name.
The name should follow a hierarchical structure, like package names (e.g., in.ac.iitm).
2. Hierarchy of Loggers:
Loggers are hierarchical, so settings applied to a parent logger (e.g., in.ac.iitm) also affect its child loggers (e.g.,
in.ac.iitm.onlinedegree).
This allows centralized control over logging behavior.
3. Log Levels:
You can set log levels (INFO, WARNING, SEVERE, etc.) independently for parent and child loggers.
If a parent's level is more restrictive (e.g., WARNING), it suppresses lower-level logs from its children.
4. Logging Behavior:
Logging Levels
1. SEVERE
2. WARNING
3. INFO (default)
4. CONFIG
5. FINE
6. FINER
7. FINEST
Logging behavior can also be controlled externally using a configuration file. This allows you to adjust logging levels or
direct output without modifying the code.
Advantages of Logging:
Structured Messages: Logs provide timestamps, function names, and hierarchical organization.
Configurability: Messages can be filtered by importance or category.
External Control: Logging properties can be modified through configuration files.
Handlers: Logs can be processed by external programs, enabling advanced features like filtering or formatting.
Week 7 Home Week 9
Cloning
In Java, creating a faithful copy of an object is not as straightforward as assigning one variable to another.
Normal Assignment
When you assign one object reference to another, both variables point to the same object in memory. Changes
made through one reference affect the object visible through the other reference.
Code Example:
In this example, both e1 and e2 refer to the same object. Updating the name through e2 also changes the name
as seen through e1.
The clone() method, provided by the Object class, creates a bitwise (shallow) copy of the object. To enable
cloning, the class must implement the Cloneable interface, and the clone() method must be overridden as
public.
import java.util.Date;
@Override
public Employee clone() throws CloneNotSupportedException {
return (Employee) super.clone(); // Shallow copy
}
e2.setName("Eknath");
e2.setBirthday(18, 5, 1990); // Changes shared Date object
Output
Shallow Copy
Copies the top-level structure of the object but does not clone the nested objects. Changes to mutable nested
objects in one copy affect the other.
Deep Copy
import java.util.Date;
@Override
public Employee clone() throws CloneNotSupportedException {
Employee cloned = (Employee) super.clone(); // Shallow copy
cloned.birthday = (Date) birthday.clone(); // Deep copy of mutable object
return cloned;
}
e2.setName("Eknath");
e2.setBirthday(18, 5, 1990);
Output
Cloning becomes more complex when inheritance is involved. If a subclass adds additional mutable fields, the
inherited clone() method will not automatically deep-copy these fields. Each subclass must override clone() to
ensure proper behavior.
Code Example
import java.util.Date;
@Override
public Manager clone() throws CloneNotSupportedException {
Manager cloned = (Manager) super.clone();
cloned.promotionDate = (Date) promotionDate.clone(); // Deep copy of promotionDate
return cloned;
}
public String toString() {
return super.toString() + ", promotionDate=" + promotionDate;
}
m2.setName("Eknath");
m2.setBirthday(18, 5, 1990);
m2.promotionDate.setDate(1);
System.out.println(m1);
System.out.println(m2);
}
}
Restrictions on Cloning
1. Interface: A class must implement the Cloneable marker interface to use the clone() method.
2. Visibility: The clone() method in Object is protected. It must be overridden as public for external use.
3. Exception Handling: The clone() method in Object throws CloneNotSupportedException. Subclasses must
either declare or handle this exception.
Type Inference
Java is a strongly typed programming language, which means every variable must be explicitly declared with its
type before use. This enables the compiler to enforce type safety, ensuring that programs are well-typed and free
from a significant category of runtime errors. However, in recent years, Java has incorporated limited support for
type inference to reduce redundancy in type declarations.
Code Example:
In this example, e1 and e2 are explicitly declared as Employee objects. The compiler ensures type safety by
verifying that the assignments and operations involving these variables are consistent with the Employee type.
Type inference allows the compiler to deduce the type of a variable based on the context of its initialization. In
Java, type inference is supported for local variables using the var keyword, introduced in Java 10.
1. Local Variables Only: Type inference is applicable only for local variables within methods or blocks, not for
instance variables or method parameters.
2. Mandatory Initialization: Variables declared with var must be initialized at the time of declaration.
3. Inference from Initialization: The compiler determines the type of the variable based on the expression
used to initialize it.
Code Example:
In this example, the type of each variable is inferred from its initialization. For instance, name is inferred as String,
salary as double, and employee as Employee.
1. Reduced Redundancy: Eliminates the need to repeat type information in declarations, making code more
concise.
For Example:
2. Improved Readability: Simplifies code by reducing clutter, especially in cases involving generics or complex
type hierarchies.
3. Enhanced Productivity: Allows developers to focus on logic rather than writing verbose type annotations.
1. Limited Scope: Applicable only to local variables. Instance variables and method parameters still require
explicit type declarations.
2. Potential Ambiguity: Without explicit types, understanding the inferred type requires examining the
initialization expression.
3. Initialization Requirement: Variables declared with var must be initialized, which can sometimes lead to
verbose or repetitive initialization expressions.
4. Precision: The inferred type is the most specific type possible.
For instance:
var e = new Manager("Ravi", 50000.0);
Here, e is inferred as Manager. If e should have been Employee, an explicit declaration is required:
Employee e = new Manager("Ravi", 50000.0);
Type inference allows the compiler to propagate type information through expressions.
For Example
The inferred type of t is String because it is the result of concatenating s (inferred as String) with another string
constant.
Java performs static type checking at compile-time, ensuring that all type inferences are consistent with the
declared types in the code. For example, consider the following:
Employee e;
Manager m = new Manager("Ravi", 50000.0);
e = m; // Allowed due to subtyping (Manager extends Employee)
var x = e;
x.bonus(); // Compilation error: Employee does not have a bonus() method
Here, the inferred type of x is Employee, so invoking bonus() on x results in a compilation error.
Callbacks Example
A typical use case for higher-order functions is a callback mechanism. Consider a scenario where an object,
MyClass, creates a Timer that runs in parallel. When the timer expires, it must notify MyClass.
Code Example
@Override
public void run() {
try {
Thread.sleep(5000); // Simulate timer
owner.timerDone();
} catch (InterruptedException e) {
System.out.println("Timer interrupted.");
}
}
}
The Comparator interface is a practical example of higher-order functions in Java. It allows customization of the
Arrays.sort method by specifying a comparison function.
Code Example
import java.util.Arrays;
import java.util.Comparator;
System.out.println(Arrays.toString(strings));
}
}
Functional Interfaces
Examples include Comparator and TimerOwner. Functional interfaces enable passing behavior using anonymous
classes or lambda expressions.
Lambda Expressions
Lambda expressions are anonymous functions that can be used wherever a functional interface is required. They
are concise and eliminate the need for verbose anonymous class implementations.
Code Example
import java.util.Arrays;
System.out.println(Arrays.toString(strings));
}
}
In this example, (s1, s2) -> s1.length() - s2.length() is a lambda expression that replaces the anonymous
class implementation.
Lambda expressions can include multiple statements within a block, making them suitable for more complex
logic.
Code Example
Method References
If a lambda expression consists of a single method call, it can be replaced by a method reference. Method
references simplify code further by directly referencing existing methods.
Code Example
import java.util.Arrays;
import java.util.List;
System.out.println(number);
}
}
Streams
Collections in Java provide a powerful way to store and manipulate groups of elements. Traditionally, an iterator
is used to sequentially process these elements. Java’s streams offer an alternative declarative and functional
approach for working with collections.
Streams enable operations such as filtering, mapping, and reducing, often in a more concise and readable
manner.
Using an Iterator:
import java.util.List;
import java.util.Arrays;
// Initialize count to 0
long count = 0;
Using Streams:
import java.util.List;
import java.util.Arrays;
1. Create a Stream
2. Apply Intermediate Operations (transformations)
3. Apply a Terminal Operation (result computation)
Example Workflow:
Creating Streams
1. Collections
2. Arrays
String[] wordArr = {
"banana", "hippopotamus", "apple", "elephant", "encyclopedia"
};
3. Generated Values
Using Stream.generate()
Stream<String> echos = Stream.generate(() -> "Echo");
Stream<Double> randomDs = Stream.generate(Math::random);
Using Stream.iterate()
Stream<Integer> integers = Stream.iterate(0, n -> n + 1);
Transforming Streams
Filtering
Mapping
Flattening
Limiting
Skipping
Conditional Stopping
Reducing Streams
Counting
Finding Maximum/Minimum