KEMBAR78
Java Quiz 2 | PDF | Method (Computer Programming) | Class (Computer Programming)
0% found this document useful (0 votes)
4 views70 pages

Java Quiz 2

It's the quiz 2 (week 5 - week 8) theory of IITM BS degrees Java course.

Uploaded by

veredem846
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
4 views70 pages

Java Quiz 2

It's the quiz 2 (week 5 - week 8) theory of IITM BS degrees Java course.

Uploaded by

veredem846
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 70

Home Week-4 Week-6

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.

Every object knows what it needs to do.

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.

1 public static void copyArray(Object[] source, Object[] 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 }

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.

Polymorphic Data Structures

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:

1 public class LinkedList {


2 private Node head;
3
4 private class Node {
5 Object data;
6 Node next;
7
8 public Node(Object data) {
9 this.data = data;
10 this.next = null;
11 }
12 }
13
14 public void add(Object data) {
15 if (head == null) {
16 head = new Node(data);
17 } else {
18 Node current = head;
19 while (current.next != null) {
20 current = current.next;
21 }
22 current.next = new Node(data);
23 }
24 }
25
26 public Object get(int index) {
27 Node current = head;
28 for (int i = 0; i < index; i++) {
29 if (current == null) {
30 return null;
31 }
32 current = current.next;
33 }
34 return current.data;
35 }
36
37 public boolean remove(int index) {
38 //...
39 }
40 }

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:

Type information is lost, we need to typecast each element


Data can be homogenous

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:

1 public static int countOccurrences(int[] array, int target) {


2 int count = 0;
3 for (int i = 0; i < array.length; i++) {
4 if (array[i] == target) {
5 count++;
6 }
7 }
8 return count;
9 }

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:

1 String[] stringArray = { "apple", "orange", "banana", "apple" };


2 System.out.println(countOccurrences(stringArray, "apple"));
3
4 Double[] doubleArray = { 3.14, 2.71, 3.14, 1.41 };
5 System.out.println(countOccurrences(doubleArray, 3.14));

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.

Polymorphic Data Structures using Generics

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.

1 public class LinkedList<T> {


2 private Node<T> head;
3
4 private static class Node<T> {
5 private T data;
6 private Node<T> next;
7
8 public Node(T data) {
9 this.data = data;
10 }
11 }
12
13 public void add(T data) {
14 Node<T> newNode = new Node<>(data);
15 if (head == null) {
16 head = newNode;
17 } else {
18 Node<T> currentNode = head;
19 while (currentNode.next != null) {
20 currentNode = currentNode.next;
21 }
22 currentNode.next = newNode;
23 }
24 }
25
26 public void printList() {
27 Node<T> currentNode = head;
28 while (currentNode != null) {
29 System.out.print(currentNode.data + " ");
30 currentNode = currentNode.next;
31 }
32 System.out.println();
33 }
34 }

We can make a LinkedList for only storing Integers as follows:

1 public static void main(String[] args) {


2 LinkedList<Integer> myList = new LinkedList<>();
3 myList.add(1);
4 myList.add(2);
5 myList.add(3);
6 myList.printList(); // Output: 1 2 3
7 }

Hiding a Type Variable

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.

1 public <T> void add(T data) {


2 Node<T> newNode = new Node<>(data);
3 if (head == null) {
4 head = newNode;
5 } else {
6 Node<T> currentNode = head;
7 while (currentNode.next != null) {
8 currentNode = currentNode.next;
9 }
10 currentNode.next = newNode;
11 }
12 }

Extending Subtypes

If S is compatible with T , S[] is compatible with T[] .


1 class Fruit{
2 //...
3 }
4 class Apple extends Fruit{
5 //...
6 }
7 class Mango extends Fruit{
8 //...
9 }
10 public class Main{
11 public static void main(String[] args){
12 Fruit[] fruitArr = new Apple[5];
13 //This is valid
14 }
15 }

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:

1 fruitArr[0] = new Fruit(); //Error

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.

1 public static void printList(LinkedList<Fruit> l){


2 Fruit current;
3 Iterator it = l.getIterator();
4 while(it.has_next()){
5 current = it.get_next();
6 System.out.println(current);
7 }
8 }

But as we have seen earlier we can define type variables in order to solve this issue

1 public static <T> void printList(LinkedList<T> l){


2 //...
3 }

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 :

1 public static void printNumbers(LinkedList<? extends Number> list) {


2 for (Number num : list) {
3 System.out.println(num);
4 }
5 }

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 :

1 public static void addStrings(LinkedList<? super String> list) {


2 list.add("hello");
3 list.add("world");
4 }

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.

1 LinkedList<?> l = new LinkedList<String>();


2 l.add(new Object()) //compiler error

Compiler cannot guarantee type matches

Use of Bounded Wildcards

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:

1 Fruit apple = new Apple();


2 //...
3 if (apple instanceof Apple){
4 //...
5 }

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.

Creating Class object

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();

Creating class information for a particular class using it's name

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.

1 Object o = c1.newInstance(); // creates a new instance of class which obj belongs to


2 Object newFruit = c2.newInstance(); // creates a new instance of Fruit class

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.

1 Class params[] = constructorArr[0].getParameterTypes();


2 //Code to get the list of parameters that the first constructor takes

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.

1 LinkedList<T> --> LinkedList<Object>


2 LinkedList<T extends Shape> --> LinkedList<Shape>

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 }

Incorrect Function Overloading

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:

1 public void printList(LinkedList<String> l){


2 //...
3 }
4 public void printList(LinkedList<Integer> l){
5 //...
6 }

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

The Benefits of Indirection - Separating Interface from Implementation


Abstract Data Types (ADTs) separate the public interface from private implementation, promoting reusability. For
example, a generic Queue typically includes methods like:

add(element): Adds an element to the queue.


remove(): Removes an element.
size(): Returns the queue size

Common implementations include:

Circular Array: Fixed-size array where elements wrap around when reaching the end.
Linked List: Dynamic structure where each node links to the next.

Trade-offs Between Implementations

Circular Array:

Efficient due to fixed storage.


Limited by initial size.

Linked List:

Flexible and grows dynamically.


Higher memory overhead.

Multiple Implementations

Developers may offer separate implementations to suit various needs:


CircularArrayQueue<Date> dateQueue = new CircularArrayQueue<>();
LinkedListQueue<String> stringQueue = new LinkedListQueue<>();

However, changing dateQueue to a flexible type requires updating all references in the code, which can be error-prone.

Using Interfaces for Flexibility

Using an interface abstracts implementation details and improves flexibility:


public interface Queue<E> {
void add(E element);
E remove();
int size();
}
public class CircularArrayQueue<E> implements Queue<E> {
public void add(E element) { /* Implementation */ }
public E remove() { /* Implementation */ }
public int size() { /* Implementation */ }
}
public class LinkedListQueue<E> implements Queue<E> {
public void add(E element) { /* Implementation */ }
public E remove() { /* Implementation */ }
public int size() { /* Implementation */ }
}

Declare variables using the interface, deferring implementation decisions to instantiation:


Queue<Date> dateQueue = new CircularArrayQueue<>();
Queue<String> stringQueue = new LinkedListQueue<>();

Here, Switching implementations require only updating the instantiation as compared to previous implementation.

Real-Life Analogy

An organization providing office cars to staff can:

Concrete: Assign specific cars to individuals (rigid and inflexible).


Indirection: Maintain a car pool or contract taxis, offering flexibility regardless of specific vehicle issues.

Collections in Programming Languages


Many programming languages provide built-in data structures for managing grouped data, such as arrays, lists,
dictionaries, and sets. In Python, these structures are well-integrated into the language. Similarly, Java offers a variety
of data structures, which have evolved significantly over time.

Early Data Structures in Java

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.

The Collection interface

To address these issues, Java introduced the Collection Framework, a unified architecture for working with data
structures. This framework provides:

Abstraction: Common behaviors of grouped data are abstracted through interfaces.


Standardization: A consistent and predictable API for working with collections.
Extensibility: The ability to add custom implementations with minimal effort.

Key Interfaces in the Collection Framework

The framework revolves around several core interfaces:


1. Collection: The root interface for all non-key-value data structures.

2. List: Represents an ordered collection.

Examples: ArrayList, LinkedList

3. Set: Represents a collection with unique elements.

Examples: HashSet, LinkedHashSet

4. Queue: Represents a collection designed for holding elements prior to processing.

Examples: PriorityQueue

Each interface defines specific behaviors and is implemented by concrete classes.

Methods in Collection interface

The Collection interface provides a variety of methods to perform common operations:

Adding Elements:

public boolean add(E e) - Inserts an element into the collection.

Iteration:

Iterator<E> iterator() - Returns an iterator to traverse the collection.

Size and Emptiness:

int size() - Returns the number of elements in the collection.


boolean isEmpty() - Checks if the collection is empty.

Containment Checks:

boolean contains(Object obj) - Checks if the collection contains a specified element.


boolean containsAll(Collection<?> c) - Checks if the collection contains all elements of another
collection.

Equality and Additions:

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:

boolean remove(Object obj) - Removes a specified element.


boolean removeAll(Collection<?> c) - Removes all elements contained in another collection.

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.

The other way is to provide concrete implementations through an abstract class.

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

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) {

// Create a collection of strings


Collection<String> words = new ArrayList<>();

// Add elements to the collection


words.add("Hello");
words.add("World");
words.add("Welcome");
words.add("To");
words.add("Java");

// Create an Iterator to traverse the collection


Iterator<String> iterator = words.iterator();

System.out.println("Iterating over the collection:");


// Use the Iterator to traverse and print each element
while (iterator.hasNext()) {
String word = iterator.next();
System.out.println(word); // Print each element
}
}
}

Output:

Iterating over the collection:


Hello
World
Welcome
To
Java

The hasNext() method checks if there are more elements to iterate over.
The next() method retrieves the next element in the collection.

Enhanced for Loop (For-Each Loop)

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) {

// Create a collection of strings


Collection<String> words = new ArrayList<>();

// Add elements to the collection


words.add("Hello");
words.add("World");
words.add("Welcome");
words.add("To");
words.add("Java");

System.out.println("Using enhanced for loop:");


// Use enhanced for loop to iterate over the collection
for (String word : words) {
System.out.println(word); // Print each element
}
}
}

Output:

Using enhanced for loop:


Hello
World
Welcome
To
Java

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.

Removing elements from Iterator

The remove() method of the Iterator interface has specific behavior:

It removes the last element accessed by next().


Consecutive calls to remove() require an intermediate call to next(). Failing to do so results in an
IllegalStateException, as there is no "current element" to remove after the first remove() call.

Code Example:

import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
public class IteratorRemoveExample {
public static void main(String[] args) {

// Create a collection of strings


Collection<String> words = new ArrayList<>();
// Add elements to the collection
words.add("Hello");
words.add("World");
words.add("Welcome");
words.add("To");
words.add("Java");

// Create an Iterator to traverse the collection


Iterator<String> iterator = words.iterator();
System.out.println("Original collection:");
for (String word : words) {
System.out.println(word);
}
System.out.println("\nRemoving elements starting with 'W':"); // Use the Iterator to remove elements

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

Removing elements starting with 'W':


Removed: World
Removed: Welcome

Updated collection:
Hello
To
Java

The remove() method removes the last element returned by next().


Calling remove() directly without invoking next() leads to an IllegalStateException.

Implications of Using Iterator.remove()

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) {

// Create a collection of strings


Collection<String> words = new ArrayList<>();

// Add elements to the collection


words.add("Hello");
words.add("World");
words.add("Welcome");

// Create an Iterator to traverse the collection


Iterator<String> iterator = words.iterator();
System.out.println("Attempting consecutive remove calls:");

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:

Attempting consecutive remove calls:


Error: remove() can only be called once per call to next()

After calling remove(), the iterator's state requires an intermediate next() call to establish a "current element"
before another remove().

Distinction Between remove() in Iterator and Collection

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()

Signature: void remove()


Behavior: Removes the last element returned by the next() method. It operates on the current position of the
iterator within the collection.

2. Collection.remove()

Signature: boolean remove(Object obj)


Behavior: Removes a specified object from the collection. If the object exists within the collection, it is removed,
and the method returns true; otherwise, it returns false.

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

Interface List - For ordered collections


Interface Set - For collection without duplicates.
Interface Queue - For ordered collections with constraints on addition and deletion.

The List Interface - Ordered Collections in Java

Accessing Elements in Ordered Collections

An ordered collection can be accessed in two primary ways:

1. Through an Iterator: Traverses the collection sequentially.


2. By Position (Random Access): Directly retrieves or manipulates an element at a specified index.

Additional Functions for Random Access

The ListIterator interface, which extends Iterator, provides additional functionalities specific to ordered
collections:

void add(E element): Inserts an element before the current index.


void previous(): Moves to the previous element in the sequence.
boolean hasPrevious(): Checks if there are elements before the current position.

The List Interface and Random Access

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 Tagging Interface RandomAccess

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
}

Abstract Implementations of the List Interface

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.

Concrete Implementations of Lists


LinkedList<E>

Extends AbstractSequentialList.

Internal Structure: A doubly linked list.

Characteristics:

Efficient for inserting and removing elements at arbitrary positions.


Inefficient for random access due to traversal requirements.

ArrayList<E>

Extends AbstractList.

Internal Structure: A dynamically resizable array.

Characteristics:

Supports efficient random access.


Handles dynamic resizing by allocating larger arrays as needed.

Practical Considerations

Using Random Access Methods

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: Supports efficient random access


List<String> arrayList = new ArrayList<>();

arrayList.add("Java");
arrayList.add("Python");
arrayList.add("C++");

// LinkedList: Inefficient random access


List<String> linkedList = new LinkedList<>(arrayList);
System.out.println("Accessing elements by index (ArrayList):");

for (int i = 0; i < arrayList.size(); i++) {


System.out.println("Index " + i + ": " + arrayList.get(i));
}

System.out.println("\nAccessing elements by index (LinkedList):");

for (int i = 0; i < linkedList.size(); i++) {


System.out.println("Index " + i + ": " + linkedList.get(i)); // Traverses for each call
}
}
}

Output:
Accessing elements by index (ArrayList):
Index 0: Java
Index 1: Python
Index 2: C++

Accessing elements by index (LinkedList):


Index 0: Java
Index 1: Python
Index 2: C++

ArrayList provides efficient random access using arithmetic operations.

LinkedList requires traversal for each get() call, which is less efficient.

Differentiating add() Methods

The add() method behaves differently depending on the context:

1. In Collection:

Appends an element to the end of the collection.


Returns a boolean indicating success.

2. In ListIterator:

Inserts an element before the current position.


Returns void as insertion always succeeds.

Code Example:

import java.util.ArrayList;
import java.util.List;
import java.util.ListIterator;
public class AddMethodExample {
public static void main(String[] args) {

// Using the List's add method


List<String> list = new ArrayList<>();
list.add("End"); // Appends to the end

System.out.println("List after appending: " + list);

// Using the ListIterator's add method


ListIterator<String> iterator = list.listIterator();
iterator.add("Insert"); // Inserts before the current position
System.out.println("List after inserting: " + list);
}
}

Output:

List after appending: [End]


List after inserting: [Insert, End]

list.add(E element) appends elements to the end.


iterator.add(E element) inserts elements before the current iterator position.

Summary - The List Interface

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.

Purpose of the Set Interface

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.

Efficient Membership Tests in Sets

Unlike ordered collections that require iteration to locate an element, sets optimize membership testing by:

1. Hashing: Mapping values to positions using a hash function.


2. Balanced Search Trees: Structuring values in a hierarchical manner for efficient lookup.

AbstractSet and Concrete Implementations

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.

Concrete Implementations of Sets

1. HashSet

The HashSet class uses a hash table for storage.

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.

Code Example: Adding Elements and Ensuring Uniqueness

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<>();

// Add elements to the set


set.add("Java");
set.add("Python");
set.add("C++");
set.add("Java"); // Duplicate element

// Print the set


System.out.println("Set contents: " + set);
}
}

Output:

Set contents: [Java, Python, C++]

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.

Code Example: Membership Testing with HashSet

import java.util.HashSet;
import java.util.Set;
public class HashSetMembership {
public static void main(String[] args) {

Set<Integer> set = new HashSet<>();


set.add(5);
set.add(10);
set.add(15);

// Membership test
System.out.println("Set contains 10: " + set.contains(10)); // True
System.out.println("Set contains 20: " + set.contains(20)); // False
}
}

Output:

Set contains 10: true


Set contains 20: false

Efficient Membership Testing: Hashing allows constant-time complexity for contains.

2. TreeSet

The TreeSet class uses a balanced search tree to store elements.

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) {

TreeSet<Integer> treeSet = new TreeSet<>();

// Add elements
treeSet.add(20);
treeSet.add(10);
treeSet.add(30);

// Print elements in sorted order


System.out.println("TreeSet contents (sorted): " + treeSet);

// 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.

Summary - The Set Interface

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. |

The Queue Interface

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.

Core Methods in Queue Interface

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

To handle errors more gracefully, the Queue interface provides alternatives:

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.

Inspecting the Head

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) {

// Create a Queue using LinkedList


Queue<String> queue = new LinkedList<>();

// 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);

System.out.println("\nPolling elements using poll():");


String polledElement = queue.poll();
System.out.println("Polled element: " + polledElement);
System.out.println("Queue after poll: " + queue);

// Inspecting the Head


System.out.println("\nInspecting the head using element():");

try {
System.out.println("Head of the queue: " + queue.element());
} catch (Exception e) {
System.out.println("Exception: " + e.getMessage());
}

System.out.println("\nInspecting the head using peek():");


String head = queue.peek();
System.out.println("Head of the queue (peek): " + (head != null ? head : "Queue is empty"));
}
}

Output:

Adding elements using add():


Queue after additions: [A, B, C]

Removing elements using remove():


Removed element: A
Queue after removal: [B, C]

Adding elements using offer():


Offer status: true
Queue after offer: [B, C, D]

Polling elements using poll():


Polled element: B
Queue after poll: [C, D]

Inspecting the head using element():


Head of the queue: C

Inspecting the head using peek():


Head of the queue (peek): C

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 Interface

The Deque (Double-Ended Queue) interface extends Queue to allow element insertion and removal from both ends.

Core Methods in Deque Interface

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

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:

Underlying Structure: Doubly linked list.

Key Characteristics:

Efficient insertion and removal at both ends.


Can function as a queue or deque.

2. ArrayDeque

The ArrayDeque class provides a circular array implementation of Deque.

Key Characteristics:

Supports efficient resizing.


Preferred over LinkedList for stack and queue operations due to lower overhead.

Summary - The Queue Interface

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.

A Map has two type parameters:

K: The type for keys.


V: The type for values.

Key operations include:

get(K key): Retrieves the value associated with a key.


put(K key, V value): Updates or adds a key-value pair.
containsKey(Object key) and containsValue(Object value): Check for the presence of specific keys or values.

Key Charecterstics of Maps

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) {

Map<String, Integer> scores = new HashMap<>();

// Adding key-value pairs using put()


scores.put("Alice", 85);
scores.put("Bob", 90);

// Retrieving values using get()


System.out.println("Score of Alice: " + scores.get("Alice"));

// Checking for keys and values


System.out.println("Contains key 'Bob'? " + scores.containsKey("Bob"));
System.out.println("Contains value 90? " + scores.containsValue(90));
}
}

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.*;

public class MapGetOrDefault {


public static void main(String[] args) {

Map<String, Integer> scores = new HashMap<>();

int defaultScore = scores.getOrDefault("Charlie", 0);


System.out.println("Default score for Charlie: " + defaultScore);

scores.put("Charlie", defaultScore + 10);


System.out.println("Updated score for Charlie: " + scores.get("Charlie"));
}
}

Output:

Default score for Charlie: 0


Updated score for Charlie: 10

2. putIfAbsent(K key, V value): Adds a key-value pair only if the key is missing.
import java.util.*;

public class MapPutIfAbsent {


public static void main(String[] args) {

Map<String, Integer> scores = new HashMap<>();

// Adding a key-value pair only if the key is absent

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.*;

public class MapMerge {


public static void main(String[] args) {

Map<String, Integer> scores = new HashMap<>();


scores.put("Eve", 20);

// Merging values using a remapping function


scores.merge("Eve", 10, Integer::sum);
System.out.println("Updated score for Eve: " + scores.get("Eve"));
}
}

Output:

Updated score for Eve: 30

Extracting Keys and Values

Maps provide methods to extract subsets of their data:

keySet(): Returns a Set of all keys.


values(): Returns a Collection of all values.
entrySet(): Returns a Set of key-value pairs as Map.Entry<K, V> objects.

Iterating Over a Map

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.*;

public class MapExtractingData {


public static void main(String[] args) {

Map<String, Integer> scores = Map.of("Alice", 85, "Bob", 90);

// 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:

Keys: [Alice, Bob]


Values: [85, 90]
Entries: [Alice=85, Bob=90]

Concrete Implementations of Maps

Java provides multiple implementations of the Map interface, each with distinct features:

1. HashMap

Uses a hash table for storage.


Provides fast access but does not guarantee order.

Code Example:

import java.util.HashMap;
import java.util.Map;

public class HashMapExample {

public static void main(String[] args) {

Map<String, Integer> hashMap = new HashMap<>();

// Adding key-value pairs


hashMap.put("Alice", 85);
hashMap.put("Bob", 90);
hashMap.put("Charlie", 75);

// Printing the HashMap


System.out.println("HashMap:");

for (Map.Entry<String, Integer> entry : hashMap.entrySet()) {


System.out.println(entry.getKey() + " : " + entry.getValue());
}
}
}

Output:

HashMap:
Alice : 85
Charlie : 75
Bob : 90

2. TreeMap

Uses a balanced search tree.


Keys are sorted in natural or custom order.
Code Example:

import java.util.Map;
import java.util.TreeMap;

public class TreeMapExample {

public static void main(String[] args) {

Map<String, Integer> treeMap = new TreeMap<>();

// Adding key-value pairs


treeMap.put("Alice", 85);
treeMap.put("Charlie", 75);
treeMap.put("Bob", 90);

// Printing the TreeMap (Keys are sorted in ascending order)


System.out.println("TreeMap (Keys sorted):");

for (Map.Entry<String, Integer> entry : treeMap.entrySet()) {


System.out.println(entry.getKey() + " : " + entry.getValue());
}
}
}

Output:

TreeMap (Keys sorted):


Alice : 85
Bob : 90
Charlie : 75

3. LinkedHashMap

Maintains insertion order or access order.


Ideal for implementing least recently used (LRU) caching.
import java.util.LinkedHashMap;
import java.util.Map;

public class LinkedHashMapExample {

public static void main(String[] args) {

Map<String, Integer> linkedHashMap = new LinkedHashMap<>();

// Adding key-value pairs


linkedHashMap.put("Alice", 85);
linkedHashMap.put("Bob", 90);

// Printing the LinkedHashMap (Preserves insertion order)


System.out.println("LinkedHashMap (Insertion Order):");

for (Map.Entry<String, Integer> entry : linkedHashMap.entrySet()) {


System.out.println(entry.getKey() + " : " + entry.getValue());
}
}
}

LinkedHashMap (Insertion Order):


Alice : 85
Bob : 90
Charlie : 75
Week 6 Home Week 8

Errors And Exceptions


Errors and exceptions are an inevitable part of programming. While we aim for correctness, errors can occur due to various
reasons, such as invalid user input, resource limitations, or coding mistakes.

Types of Errors in Java

1. External Errors: These include issues like incorrect user input, unavailable resources, or hardware malfunctions. For
example:

Typing an incorrect file name.


Trying to print to a printer that is out of paper.
Writing to a disk that is full.

2. Coding Mistakes: Errors resulting from flaws in the code, such as:

Accessing an array out of bounds.


Using an invalid key in a hash table.
Performing illegal arithmetic operations, such as division by zero.

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.

This limitation brings to the topic of Exception handling.

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.

Exception Handelling in Java


Java provides a structured mechanism to handle errors gracefully using exceptions. This ensures that errors do not lead to
program crash but allow recovery or meaningful failure notifications.

1. Throwing an Exception:

Code that detects an error generates/throws an exception object.


The exception contains details about the type and nature of the error.

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.

Example: Throwing and Catching Exceptions

public class ExceptionExample {

// Method to demonstrate throwing an exception


public static int divide(int a, int b) throws ArithmeticException {

// Check for division by zero


if (b == 0) {

// Throw an ArithmeticException with a meaningful message


throw new ArithmeticException("Division by zero is not allowed.");
}

// Perform division if no exception occurs


return a / b;
}

public static void main(String[] args) {

try {

// Attempt to divide numbers, expecting a successful result


System.out.println("Result: " + divide(10, 2));

// Attempt to divide by zero, which will throw an exception


System.out.println("Result: " + divide(10, 0));

} catch (ArithmeticException e) {

// Catch the exception and handle it gracefully


System.out.println("Error: " + e.getMessage());

} finally {

// Optional: The `finally` block executes regardless of exceptions


System.out.println("Division operation completed.");
}
}
}

Output:

Result: 5
Error: Division by zero is not allowed.
Division operation completed.

Code Explanation:

Throwing an Exception:

The divide method checks if the denominator b is zero.


If b == 0, it throws an ArithmeticException with a custom message.
This demonstrates how Java detects and raises errors.

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.

Java's Classification of Erros

Error Hierarcy in Java

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:

1. OutOfMemoryError: Thrown when the JVM runs out of memory.


2. StackOverflowError: Occurs when the call stack exceeds its limit, usually due to infinite recursion.
3. VirtualMachineError: Represents serious errors like JVM crashes or internal issues.

Code Example:

public class ErrorExample {


public static void main(String[] args) {
// Example of a StackOverflowError
causeStackOverflow();
}

public static void causeStackOverflow() {


// Recursive call without termination
causeStackOverflow();
}
}

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:

Exceptions are further divided into checked and unchecked exceptions.


Subclasses of the Exception class.

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.

Examples of Unchecked Exceptions:

1. ArithmeticException: Thrown during mathematical errors (e.g., division by zero).


2. NullPointerException: Occurs when trying to use an object reference that is null.
3. ArrayIndexOutOfBoundsException: Accessing an array element with an invalid index.

Code Example:

public class UncheckedExceptionExample {

public static void main(String[] args) {

int[] numbers = {1, 2, 3};

try {
// Attempt to access an invalid index
System.out.println(numbers[5]);

} catch (ArrayIndexOutOfBoundsException e) {

// Handle the unchecked exception


System.out.println("Error: " + e.getMessage());
}
}
}

Output:

Error: Index 5 out of bounds for length 3

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:

Checked exceptions extend the Exception class (excluding RuntimeException).


Designed for situations where recovery is expected or required.
Often result from external issues like file handling, database access, or user input.

Examples of Checked Exceptions:

1. IOException: Issues with input/output operations, such as file handling.


2. SQLException: Errors related to database access.
3. FileNotFoundException: Raised when a file cannot be located.

Code Example:

import java.io.*;

public class CheckedExceptionExample {


public static void main(String[] args) {
try {
readFile("example.txt");
} catch (IOException e) {
System.out.println("An error occurred: " + e.getMessage());
}
}
// Method that throws a checked exception
public static void readFile(String fileName) throws IOException {
FileReader file = new FileReader(fileName);
BufferedReader br = new BufferedReader(file);

String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}

br.close();
}
}

Output:

An error occurred: example.txt (No such file or directory)

Code Explanation:

FileReader and BufferedReader are used to read a file.


FileReader may throw an IOException if the file doesn't exist or can't be accessed.
The readFile method declares throws IOException, meaning the caller must handle it.
In main(), we use a try-catch block to handle the IOException.

Since IOException is a Checked Exception, the compiler forces us to handle or declare it.

User-Defined Checked Exception:

Java allows programmers to create their own checked exceptions. This is useful when custom rules or constraints need to
be enforced.

Note: This is also an example of Custom Exception.

Code Example:

// Custom exception class extending Exception (checked exception)


class InvalidAgeException extends Exception {
public InvalidAgeException(String message) {
super(message); // Pass the message to the parent Exception class
}
}

public class UserDefinedCheckedException {


// Method that throws a user-defined checked exception
public void validateAge(int age) throws InvalidAgeException {
if (age < 18) {
// Throw custom checked exception if age is less than 18
throw new InvalidAgeException("Age must be 18 or older.");
}
System.out.println("Age validation passed. You are eligible.");
}

public static void main(String[] args) {


UserDefinedCheckedException example = new UserDefinedCheckedException();

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:

Caught Exception: Age must be 18 or older.


Age validation passed. You are eligible.

Code Explanation:

1. Custom Exception (InvalidAgeException)

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.

3. Main Method (main)

Creates an instance of UserDefinedCheckedException.


Calls validateAge(16), which throws an exception because 16 is less than 18. This exception is caught and handled.
Calls validateAge(20), which passes the check and prints a success message.

Aspect Checked Exceptions Unchecked Exceptions


Declaration Must be declared using throws. No need to declare.
Compiler
Enforced by the compiler. Not enforced by the compiler.
Enforcement
Use Case Anticipated conditions; recoverable. Programming errors; logical flaws.
ArithmeticException,
Examples IOException, SQLException.
NullPointerException.
Extends Exception class (not
Inheritance Extends RuntimeException class.
RuntimeException).

Basics of Exception Handelling


Exception handling in Java revolves around three main constructs:

1. try Block: Encapsulates code that may generate an exception.


2. catch Block: Handles the exception if it occurs.
3. finally Block: Executes cleanup code regardless of whether an exception was thrown.
try {
// Code that might throw an exception
}
catch (ExceptionType1 e) {
// Handle specific exception
}
catch (ExceptionType2 e) {
// Handle another type of exception
}
finally {
// Cleanup code
}

Catching Exceptions

Single Exception

Handle a specific type of exception.


// Demonstrating a single checked exception using IOException
import java.io.IOException;
public class SingleExceptionExample {
public static void main(String[] args) {
// Use a try-catch block to handle the IOException
try {
// Simulate an I/O operation that throws an exception
throw new IOException("Simulated I/O error");
} catch (IOException e) {
// Handle the exception and display the error message
System.out.println("Caught an IOException: " + e.getMessage());
}
// Program continues execution after handling the exception
System.out.println("Program execution continues smoothly.");
}
}

Output:

Caught an IOException: Simulated I/O error


Program execution continues smoothly.

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".

2. Throw Statement (throw new IOException(...)):

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;

public class MultipleExceptionsExample {


public static void main(String[] args) {

// Use a try-catch block to handle multiple exception types


try {

// Simulate a file-related error by throwing FileNotFoundException


throw new FileNotFoundException("File not found error");

} catch (FileNotFoundException e) {

// Catch and handle the more specific exception (FileNotFoundException)


System.out.println("Caught FileNotFoundException: " + e.getMessage());

} catch (IOException e) {

// Catch and handle the more general exception (IOException)


System.out.println("Caught IOException: " + e.getMessage());
}

// Program continues execution after handling the exceptions


System.out.println("Program execution continues smoothly.");
}
}

Note: Arrange catch blocks from the most specific to the most general. For example, IOException should follow
FileNotFoundException.

Output:

Caught FileNotFoundException: File not found error


Program execution continues smoothly.

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.

2. Multiple Catch Blocks:

First Catch Block:

The catch (FileNotFoundException e) block catches the more specific exception.


This block is evaluated first since it matches the thrown exception type.

Second Catch Block:

The catch (IOException e) block catches more general I/O-related exceptions.


If the FileNotFoundException block were absent, this block would handle the exception because
FileNotFoundException is a subclass of IOException.

3. Order of Catch Blocks:

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:

// Class to demonstrate exceptions with the `throws` keyword


public class ThrowsKeywordExample {

/**
* 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.");
}

public static void main(String[] args) {


// Create an instance of the class
ThrowsKeywordExample example = new ThrowsKeywordExample();

// Test cases with different ages


int[] testAges = {16, 20, 15, 25};

for (int age : testAges) {


try {
// Validate the age using the method
example.validateAge(age);
} catch (IllegalArgumentException e) {
// Handle the exception and print the error message
System.out.println("Caught an exception: " + e.getMessage());
}
}
}
}

Output:

Caught an exception: Age must be 18 or above to vote.


Age is valid for voting.
Caught an exception: Age must be 18 or above to vote.
Age is valid for voting.

Code Explanation:

Declaring Exceptions with throws:

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
}
}

// A class representing a simple linear list


public class LinearList {

/**
* 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);
}

public static void main(String[] args) {


// Create an instance of the LinearList class
LinearList list = new LinearList();

// Use a try-catch block to handle the custom exception


try {
// Attempt to add a negative value
list.add(-10);
} catch (NegativeValueException e) {
// Handle the custom exception and display the error message
System.out.println("Caught Exception: " + e.getMessage());
}

// Attempt to add a valid positive value


try {
list.add(15);
} catch (NegativeValueException e) {
System.out.println("Caught Exception: " + e.getMessage());
}

// Program continues execution smoothly after handling exceptions


System.out.println("Program execution continues.");
}
}

Output:

Caught Exception: Negative value: -10


Value added: 15
Program execution continues.

Code Explanation:

1. Custom Exception Class (NegativeValueException):

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).

2. Method Declaration with throws:

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.

3. Throwing the Custom Exception:

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.

4. Handling the Custom Exception:

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.

Using finally for Cleanup

The finally block ensures that critical cleanup code runs regardless of whether an exception occurs.
Code Example:

// Demonstrating the use of finally for resource cleanup

public class FinallyExample {


public static void main(String[] args) {

// Simulate a resource by using a custom resource object


CustomResource resource = null;

try {
// Allocate the resource
resource = new CustomResource();
System.out.println("Resource initialized successfully.");

// Simulate an operation that throws an exception


int result = 10 / 0; // This will cause an ArithmeticException
System.out.println("Operation result: " + result);

} catch (ArithmeticException e) {

// Handle the specific exception


System.out.println("Error occurred: " + e.getMessage());

} finally {

// Ensure the resource is cleaned up, regardless of an exception


if (resource != null) {
resource.cleanup();
}
System.out.println("Cleanup completed in finally block.");
}

// Program continues after exception handling


System.out.println("Program execution continues.");
}
}

// A custom resource class to simulate resource management


class CustomResource {
// Constructor to initialize the resource
public CustomResource() {
System.out.println("CustomResource allocated.");
}

// Method to release the resource


public void cleanup() {
System.out.println("CustomResource cleaned up.");
}
}

Output:

CustomResource allocated.
Resource initialized successfully.
Error occurred: / by zero
CustomResource cleaned up.
Cleanup completed in finally block.
Program execution continues.

Code Explanation:

1. Custom Resource Simulation:

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.

2. The try Block:

The try block initializes the resource and performs operations.


Here, an arithmetic operation (10 / 0) is intentionally included to simulate an exception.

3. The catch Block:


The catch block handles the ArithmeticException caused by the division by zero.
It ensures that the error is logged, and the program does not crash.

4. The finally Block:

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:

// Demonstrating exception chaining in Java


public class ExceptionChainingExample {
public static void main(String[] args) {

try {

// Outer try-catch block to catch and handle RuntimeException

try {

// Inner try-catch block to simulate and handle an underlying exception


throw new IllegalArgumentException("Invalid input provided");

} catch (IllegalArgumentException e) {

// Wrap the original exception (cause) into a higher-level exception


throw new RuntimeException("Processing failed due to invalid input", e);
}

} catch (RuntimeException e) {

// Handle the chained RuntimeException and display its message


System.out.println("Caught Exception: " + e.getMessage());

// Retrieve and display the cause of the RuntimeException


Throwable cause = e.getCause();
if (cause != null) {
System.out.println("Caused by: " + cause.getMessage());
}
}

// Program execution continues after handling the exception


System.out.println("Program execution continues.");
}
}

Output:

Caught Exception: Processing failed due to invalid input


Caused by: Invalid input provided
Program execution continues.

Code Explanation:

1. Inner try-catch Block:

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.

2. Outer try-catch Block:

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:

getMessage(): Fetches the message of the exception.


getCause(): Retrieves the cause of the current exception.

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

To use classes from a package, the import statement is employed:


import java.math.BigDecimal; // Imports the BigDecimal class
import java.math.*; // Imports all classes in the java.math package

* imports all classes in the specified package but does not include sub-packages. For instance:

import java.math.* imports all classes in java.math, but not java.math.geometry.


Writing import java.* is not allowed.

Benifits of Packages

1. Namespace Management:

Prevents naming conflicts by creating unique namespaces.


For example, two developers can have a class named Employee in different packages without conflict.

2. Code Organization:

Packages group related classes, making code easier to navigate and maintain.

3. Access Control:

Packages define access levels for classes and members.


Visibility modifiers (public, private, protected, default) control which parts of the code are accessible from outside
the package.

4. Reusability and Modularity:

Packages allow for modular programming, enabling easy reuse of code components.

Creating and Naming Packages

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

// Constructor to initialize the employee's name


public Employee(String name) {
this.name = name;
}

// Getter method for the employee's name


public String getName() {
return name;
}
}

Using a Package

Create a file Main.java in the root directory or another package.


// File: Main.java
// Import the Employee class from the specified package
import in.ac.iitm.onlinedegree.Employee;

/**
* 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");

// Access the public method of the Employee class


System.out.println("Employee Name: " + emp.getName());
}
}

Output:

Employee Name: John Doe

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.

Types of Visibility Modifies:

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):

If no modifier is specified, the member or class has package-private visibility.


Accessible only within the same package.
Useful for grouping related classes and providing access only to those in the same module.

Protected:

Members marked as protected are accessible:

Within the same package.


In subclasses, even if they are in a different package.

Special Rule: A subclass can make a protected member public in its implementation, thus expanding its visibility.

Use Case: Useful in inheritance when designing extensible classes.

Code Example: Visibility Modifiers:

1. Create the VisibilityExample Class


// File: in/ac/iitm/onlinedegree/VisibilityExample.java
package in.ac.iitm.onlinedegree;

/**
* 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

// Public method: accessible everywhere


public void showPublic() {
System.out.println("Public Variable: " + publicVar);
}

// Private method: accessible only within this class


private void showPrivate() {
System.out.println("Private Variable: " + privateVar);
}

// Package-private method: accessible only within the same package


void showPackagePrivate() {
System.out.println("Package-Private Variable: " + packagePrivateVar);
}

// Protected method: accessible within package and subclasses


protected void showProtected() {
System.out.println("Protected Variable: " + protectedVar);
}

public void demonstratePrivateAccess() {


// Private members are accessible within the same class
showPrivate();
}
}

2. Create a Class in the Same Package


// File: in/ac/iitm/onlinedegree/SamePackageAccess.java
package in.ac.iitm.onlinedegree;

/**
* Demonstrates access levels within the same package.
*/
public class SamePackageAccess {
public static void main(String[] args) {
VisibilityExample example = new VisibilityExample();

// Accessing public member


example.showPublic();

// Accessing package-private member


example.showPackagePrivate();

// Accessing protected member


example.showProtected();

// Accessing private member (will not compile if uncommented)


// example.showPrivate(); // ERROR: Private members are not accessible
}
}

3. Create a Class in a Different Package


// File: DifferentPackageAccess.java
package different;

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();

// Accessing public member


example.showPublic();
// Accessing package-private member (will not compile if uncommented)
// example.showPackagePrivate(); // ERROR: Package-private members are not accessible outside the package

// Accessing protected member through inheritance


DifferentPackageAccess inherited = new DifferentPackageAccess();
inherited.showProtected();

// Accessing private member (will not compile if uncommented)


// example.showPrivate(); // ERROR: Private members are not accessible
}
}

Output:

For SamePackageAccess Class


Public Variable: 100
Package-Private Variable: 300
Protected Variable: 400

For DifferentPackageAccess Class


Public Variable: 100
Protected Variable: 400

Code Explanation

1. Public:

Accessible in both SamePackageAccess and DifferentPackageAccess.

2. Private:

Only accessible within the VisibilityExample class. Demonstrated by calling showPrivate() within a public method.

3. Package-Private:

Accessible only in SamePackageAccess because it belongs to the same package as VisibilityExample.

4. Protected:

Accessible in SamePackageAccess (same package).


Accessible in DifferentPackageAccess only through inheritance.

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.

1. Using Exceptions: Validates constraints and throws an exception when violated.


2. Using Assertions: Validates assumptions during development and testing but does not trigger during production
runtime.

Validating Constraints with Exceptions

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);
}

public static void main(String[] args) {


// Test cases to demonstrate the behavior of myfn

// Test case 1: Valid input (positive number)


try {
double result1 = myfn(16); // Expected output: 4.0
System.out.println("Square root of 16: " + result1);
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}

// Test case 2: Valid input (0)


try {
double result2 = myfn(0); // Expected output: 0.0
System.out.println("Square root of 0: " + result2);
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}

// Test case 3: Invalid input (negative number)


try {
double result3 = myfn(-4); // Should throw an exception
System.out.println("Square root of -4: " + result3);
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage()); // Expected error message
}
}
}

Output

Square root of 16: 4.0


Square root of 0: 0.0
Error: x < 0: Cannot calculate the square root of a negative number.

Code Explanation

1. Method myfn(double x):

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.

Using Assertions for Internal Checks

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

public class AssertionExample {


// Method to calculate the square root of x, assuming x >= 0 (checked with an assertion)
public static double myfn(double x) {
// Use assertion to ensure that x is non-negative
assert x >= 0 : "x must be non-negative"; // Throws AssertionError if x < 0
return Math.sqrt(x);
}

public static void main(String[] args) {


// Enabling assertions requires the JVM to be run with the -ea flag.
// Example: java -ea AssertionExample

// Test case 1: Valid input (positive number)


try {
double result1 = myfn(16); // Expected output: 4.0
System.out.println("Square root of 16: " + result1);
} catch (AssertionError e) {
System.out.println("Assertion failed: " + e.getMessage());
}

// Test case 2: Valid input (0)


try {
double result2 = myfn(0); // Expected output: 0.0
System.out.println("Square root of 0: " + result2);
} catch (AssertionError e) {
System.out.println("Assertion failed: " + e.getMessage());
}

// Test case 3: Invalid input (negative number)


try {
double result3 = myfn(-4); // Should throw AssertionError
System.out.println("Square root of -4: " + result3);
} catch (AssertionError e) {
System.out.println("Assertion failed: " + e.getMessage()); // Expected error message
}
}
}

Running the Program

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

Square root of 16: 4.0


Square root of 0: 0.0
Assertion failed: x must be non-negative

Code Explanation

1. Method myfn(double x):

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

public static double myfn(double x) {


assert x >= 0 : "Invalid input: " + x;
return Math.sqrt(x);
}

Code Explanation

If x is negative, the program terminates with an AssertionError, and the message Invalid input: <value> is displayed.

Enabling and Disabling Assertions in Java

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.

1. Globally (for all classes in the program):


java -ea MyCode

This enables assertions for the entire application, including all classes.

2. For a Specific class:


java -ea:com.example.MyClass MyCode

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.

1. Globally (for all classes):


java -da MyCode

This disables assertions throughout the entire application.

2. For a specific class:


java -da:com.example.MyClass MyCode

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.

Combining Options: Selective Enabling and Disabling

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.

When to Use Assertions

Development and Debugging:

Use assertions to validate assumptions and invariants during code development and testing.
Assertions should highlight unrecoverable, fatal errors that indicate programming bugs.

Production Code:

Assertions are disabled by default in production to avoid runtime overhead.

Assertions vs. Exceptions

Feature Assertions Exceptions


Purpose Validate assumptions during testing Handle runtime errors gracefully
When Used Development and debugging Production
Runtime Behavior Disabled in production Always enabled
Handling Do not catch Should be caught and handled

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.

The Need for Logging


Print statements have significant drawbacks:

They must be manually added or removed.


They produce unstructured output.
Turning them on or off requires modifying the code.

Logging addresses these issues by:

Organizing messages hierarchically by importance.


Providing metadata (e.g., timestamps, context).
Enabling external control via configuration files.

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;

public class LoggingExample {


public static void main(String[] args) {
// Obtain the global logger
Logger globalLogger = Logger.getGlobal();

// Example 1: Default logging behavior


System.out.println("Example 1: Default Logging Behavior");
globalLogger.info("Default: Application started.");
globalLogger.warning("Default: A potential issue detected.");
globalLogger.severe("Default: Critical error occurred!");

// Example 2: Suppress logging by setting level to OFF


System.out.println("\nExample 2: Logging Suppressed");
globalLogger.setLevel(Level.OFF); // Suppress all logs
globalLogger.info("This log will not be shown.");
globalLogger.warning("This log will not be shown either.");
globalLogger.severe("Even severe logs are suppressed.");

// Example 3: Customizing the log level


System.out.println("\nExample 3: Custom Log Level");
globalLogger.setLevel(Level.WARNING); // Log only WARNING and above
globalLogger.info("This INFO log is suppressed.");
globalLogger.warning("This WARNING log is displayed.");
globalLogger.severe("This SEVERE log is displayed.");
}
}

Output

Example 1: Default logging Behaviour


Example 1: Default Logging Behavior
Jan 8, 2025, 10:30:15 PM LoggingExample main
INFO: Default: Application started.
Jan 8, 2025, 10:30:15 PM LoggingExample main
WARNING: Default: A potential issue detected.
Jan 8, 2025, 10:30:15 PM LoggingExample main
SEVERE: Default: Critical error occurred!

Example 2: Logging Suppressed


Example 2: Logging Suppressed
(No logs are displayed.)

Example 3: Custom Log Level


Example 3: Custom Log Level
Jan 8, 2025, 10:30:15 PM LoggingExample main
WARNING: This WARNING log is displayed.
Jan 8, 2025, 10:30:15 PM LoggingExample main
SEVERE: This SEVERE log is displayed.
Code Explanation

1. Obtaining the Global Logger:

The global logger is accessed using Logger.getGlobal().


It is a pre-configured logger that can be used for basic logging without additional setup.

2. Logging Levels:

Logging levels define the severity of a message:

INFO: General informational messages.


WARNING: Indications of potential problems.
SEVERE: Critical issues that need immediate attention.
OFF: Suppresses all logging.

3. Customizing the Logging Level:

Use globalLogger.setLevel(Level.<LEVEL>) to set the minimum level of logs to display.


Logs with severity below the set level are ignored.

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;

public class CustomLoggerExample {


// Creating a custom logger
private static final Logger parentLogger = Logger.getLogger("in.ac.iitm");
private static final Logger childLogger = Logger.getLogger("in.ac.iitm.onlinedegree");

public static void main(String[] args) {


System.out.println("Custom Logger Example:");

// Example 1: Default logging behavior of custom loggers


System.out.println("\nExample 1: Default Logging Behavior");
parentLogger.info("Parent logger: This is an informational message.");
childLogger.info("Child logger: This is an informational message.");

// Example 2: Setting log levels for the parent logger


System.out.println("\nExample 2: Setting Parent Logger Level");
parentLogger.setLevel(Level.WARNING); // Logs only WARNING and SEVERE for parent and its children
parentLogger.info("Parent logger: This INFO message is suppressed.");
parentLogger.warning("Parent logger: This WARNING message is displayed.");
childLogger.info("Child logger: This INFO message is suppressed by the parent.");
childLogger.severe("Child logger: This SEVERE message is displayed.");

// Example 3: Setting log levels specifically for the child logger


System.out.println("\nExample 3: Setting Child Logger Level");
childLogger.setLevel(Level.INFO); // Logs INFO, WARNING, and SEVERE for the child logger
parentLogger.warning("Parent logger: This WARNING message is displayed.");
childLogger.info("Child logger: This INFO message is now displayed.");
childLogger.warning("Child logger: This WARNING message is displayed.");
}
}

Output

Example 1: Default Logging Behavior


Example 1: Default Logging Behavior
Jan 8, 2025, 10:30:15 PM in.ac.iitm
INFO: Parent logger: This is an informational message.
Jan 8, 2025, 10:30:15 PM in.ac.iitm.onlinedegree
INFO: Child logger: This is an informational message.

Example 2: Setting Parent Logger Level


Example 2: Setting Parent Logger Level
Jan 8, 2025, 10:30:15 PM in.ac.iitm
WARNING: Parent logger: This WARNING message is displayed.
Jan 8, 2025, 10:30:15 PM in.ac.iitm.onlinedegree
SEVERE: Child logger: This SEVERE message is displayed.

Example 3: Setting Child Logger Level


Example 3: Setting Child Logger Level
Jan 8, 2025, 10:30:15 PM in.ac.iitm
WARNING: Parent logger: This WARNING message is displayed.
Jan 8, 2025, 10:30:15 PM in.ac.iitm.onlinedegree
INFO: Child logger: This INFO message is now displayed.
Jan 8, 2025, 10:30:15 PM in.ac.iitm.onlinedegree
WARNING: Child logger: This WARNING message is displayed.

Code Explanation

1. Creating Custom Loggers:

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:

By default, loggers inherit the log level from their parent.


Customizing log levels for specific loggers allows targeted control over which messages are displayed.

Logging Levels

Java provides seven logging levels to categorize messages:

1. SEVERE
2. WARNING
3. INFO (default)
4. CONFIG
5. FINE
6. FINER
7. FINEST

You can control the level of logging:


logger.setLevel(Level.FINE);

Turn on all levels: logger.setLevel(Level.ALL);


Turn off logging: logger.setLevel(Level.OFF);
Advanced Configurations and Advantages of Logging

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:

public class Employee {


private String name;
private double salary;

public Employee(String name, double salary) {


this.name = name;
this.salary = salary;
}

public void setName(String name) {


this.name = name;
}

public String toString() {


return "Employee{name='" + name + "', salary=" + salary + "}";
}

public static void main(String[] args) {


Employee e1 = new Employee("Dhruv", 21500.0);
Employee e2 = e1; // e2 refers to the same object as e1
e2.setName("Eknath");
System.out.println(e1); // Output: Employee{name='Eknath', salary=21500.0}
}
}

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

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;

public class Employee implements Cloneable {


private String name;
private double salary;
private Date birthday;
public Employee(String name, double salary, Date birthday) {
this.name = name;
this.salary = salary;
this.birthday = birthday;
}

public void setName(String name) {


this.name = name;
}

public void setBirthday(int day, int month, int year) {


this.birthday.setDate(day);
this.birthday.setMonth(month);
this.birthday.setYear(year);
}

@Override
public Employee clone() throws CloneNotSupportedException {
return (Employee) super.clone(); // Shallow copy
}

public String toString() {


return "Employee{name='" + name + "', salary=" + salary + ", birthday=" + birthday + "}";
}

public static void main(String[] args) throws CloneNotSupportedException {


Date birthday = new Date(97, 3, 16); // April 16, 1997
Employee e1 = new Employee("Dhruv", 21500.0, birthday);
Employee e2 = e1.clone(); // Shallow copy

e2.setName("Eknath");
e2.setBirthday(18, 5, 1990); // Changes shared Date object

// Birthday and name changes affect e1 due to shallow copy


System.out.println(e1);
System.out.println(e2);
}
}

Output

Employee{name='Dhruv', salary=21500.0, birthday=Fri May 18 00:00:00 IST 1990}


Employee{name='Eknath', salary=21500.0, birthday=Fri May 18 00:00:00 IST 1990}

Shallow Copy vs. Deep Copy

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

Recursively clones all nested objects, creating a fully independent copy.

Code Example of Deep Copy

import java.util.Date;

public class Employee implements Cloneable {


private String name;
private double salary;
private Date birthday;

public Employee(String name, double salary, Date birthday) {


this.name = name;
this.salary = salary;
this.birthday = birthday;
}

public void setName(String name) {


this.name = name;
}

public void setBirthday(int day, int month, int year) {


this.birthday.setDate(day);
this.birthday.setMonth(month);
this.birthday.setYear(year);
}

@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;
}

public String toString() {


return "Employee{name='" + name + "', salary=" + salary + ", birthday=" + birthday + "}";
}

public static void main(String[] args) throws CloneNotSupportedException {


Date birthday = new Date(97, 3, 16); // April 16, 1997
Employee e1 = new Employee("Dhruv", 21500.0, birthday);
Employee e2 = e1.clone(); // Deep copy

e2.setName("Eknath");
e2.setBirthday(18, 5, 1990);

System.out.println(e1); // e1 remains unchanged


System.out.println(e2);
}
}

Output

Employee{name='Dhruv', salary=21500.0, birthday=Wed Apr 16 00:00:00 IST 1997}


Employee{name='Eknath', salary=21500.0, birthday=Fri May 18 00:00:00 IST 1990}

Cloning and Inheritance

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;

public class Manager extends Employee {


private Date promotionDate;

public Manager(String name, double salary, Date birthday, Date promotionDate) {


super(name, salary, birthday);
this.promotionDate = promotionDate;
}

@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;
}

public static void main(String[] args) throws CloneNotSupportedException {


Date birthday = new Date(97, 3, 16);
Date promotionDate = new Date(121, 5, 18);

Manager m1 = new Manager("Dhruv", 40000.0, birthday, promotionDate);


Manager m2 = m1.clone();

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.

Type Declarations in Java

Type declarations explicitly specify the type of a variable when it is defined.

Code Example:

public class Employee {


private String name;
private double salary;

public Employee(String name, double salary) {


this.name = name;
this.salary = salary;
}

public void setName(String name) {


this.name = name;
}

public String toString() {


return "Employee{name='" + name + "', salary=" + salary + "}";
}

public static void main(String[] args) {


Employee e1 = new Employee("Dhruv", 21500.0);
Employee e2 = e1;
e2.setName("Eknath");

System.out.println(e1); // Output: Employee{name='Eknath', salary=21500.0}


}
}

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.

Key Features of Type Inference in Java

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:

public class TypeInferenceExample {


public static void main(String[] args) {
var name = "Dhruv"; // Inferred as String
var salary = 21500.0; // Inferred as double

System.out.println("Name: " + name);


System.out.println("Salary: " + salary);

var employee = new Employee("Eknath", 30000.0); // Inferred as Employee


System.out.println(employee);
}
}

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.

Benifits of Type Inference

1. Reduced Redundancy: Eliminates the need to repeat type information in declarations, making code more
concise.

For Example:

Manager m = new Manager("Ravi", 50000.0);

can be simplified to:


var m = new Manager("Ravi", 50000.0);

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.

Limitations of Type Inference

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);

Propagation of Inferred Types

Type inference allows the compiler to propagate type information through expressions.

For Example

var s = "Hello, "; // Inferred as String


var t = s + "world!"; // Propagated as String
System.out.println(t);

The inferred type of t is String because it is the result of concatenating s (inferred as String) with another string
constant.

Static Type Checking

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.

Higher Order Function


A higher-order function is a function that takes another function as an argument. While this concept is common
in many programming paradigms, its integration into Java—a strongly object-oriented language—is achieved
through interfaces and functional programming constructs like lambda expressions and method references.

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.

In object-oriented programming, this is achieved using an interface:

Code Example

public interface TimerOwner {


void timerDone();
}

public class MyClass implements TimerOwner {


@Override
public void timerDone() {
System.out.println("Timer has expired.");
}

public static void main(String[] args) {


MyClass myClass = new MyClass();
Timer timer = new Timer(myClass);
timer.start();
}
}

public class Timer implements Runnable {


private final TimerOwner owner;

public Timer(TimerOwner owner) {


this.owner = owner;
}

public void start() {


new Thread(this).start();
}

@Override
public void run() {
try {
Thread.sleep(5000); // Simulate timer
owner.timerDone();
} catch (InterruptedException e) {
System.out.println("Timer interrupted.");
}
}
}

Customizing Behavior with Comparator

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;

public class StringLengthSorter {


public static void main(String[] args) {
String[] strings = {"apple", "banana", "cherry"};

Arrays.sort(strings, new Comparator<String>() {


@Override
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
});

System.out.println(Arrays.toString(strings));
}
}

Functional Interfaces

Functional interfaces are interfaces with a single abstract method.

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;

public class LambdaExample {


public static void main(String[] args) {
String[] strings = {"apple", "banana", "cherry"};

Arrays.sort(strings, (s1, s2) -> s1.length() - s2.length());

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.

Complex Lambda Expressions

Lambda expressions can include multiple statements within a block, making them suitable for more complex
logic.

Code Example

Arrays.sort(strings, (s1, s2) -> {


if (s1.length() < s2.length())
return -1;
else if (s1.length() > s2.length())
return 1;
else
return 0;
});

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;

public class MethodReferenceExample {


public static void main(String[] args) {
List<String> strings = Arrays.asList("apple", "banana", "cherry");

strings.forEach(System.out::println); // Method reference


}
}

Method Reference Syntax

1. Static Method: ClassName::methodName


2. Instance Method of Specific Object: object::methodName
3. Instance Method of Arbitrary Object of a Class: ClassName::methodName
4. Constructor: ClassName::new

Example with Constructor Reference


import java.util.function.Function;

public class ConstructorReferenceExample {


public static void main(String[] args) {
Function<String, Integer> stringToInteger = Integer::new;
Integer number = stringToInteger.apply("123");

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.

Example: Counting Long Words

Using an Iterator:
import java.util.List;
import java.util.Arrays;

public class Main {


public static void main(String[] args) {
// Example list of words
List<String> words = Arrays.asList(
"banana", "hippopotamus", "apple", "elephant", "encyclopedia"
);

// Initialize count to 0
long count = 0;

// Iterate over each word in the list


for (String word : words) {
// Check if the word length is greater than 10
if (word.length() > 10) {
count++;
}
}

// Output the count of words with length greater than 10


System.out.println("Number of words with length greater than 10: " + count);
}
}

Using Streams:
import java.util.List;
import java.util.Arrays;

public class Main {


public static void main(String[] args) {
List<String> words = Arrays.asList(
"banana", "hippopotamus", "apple", "elephant", "encyclopedia"
);

// Using streams to count words with length greater than 10


long count = words.stream()
.filter(w -> w.length() > 10)
.count();
System.out.println("Number of words with length greater than 10: " + count);
}
}

Why Use Streams?

1. Declarative Style: Focus on what to compute rather than how to compute.


2. Parallel Processing: Operations like filter() and count() can be parallelized.
3. Lazy Evaluation: Streams process elements only when needed, optimizing performance.
4. Support for Infinite Streams: Streams can generate values dynamically, even for infinite sequences.

Example: Parellel Streams

long count = words.parallelStream()


.filter(w -> w.length() > 10)
.count();

Working with Streams

Streams allow you to:

1. Create a Stream
2. Apply Intermediate Operations (transformations)
3. Apply a Terminal Operation (result computation)

Streams are non-destructive; they do not modify the underlying collection.

Example Workflow:

long count = words.stream()


.filter(w -> w.length() > 10) // Intermediate operation
.count(); // Terminal operation

Creating Streams

Streams can be Created from:

1. Collections

List<String> wordList = Arrays.asList(


"banana", "hippopotamus", "apple", "elephant", "encyclopedia"
);

Stream<String> wordStream = wordList.stream();

2. Arrays

String[] wordArr = {
"banana", "hippopotamus", "apple", "elephant", "encyclopedia"
};

Stream<String> wordStream = Stream.of(wordArr);

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);

With a termination condition:


Stream<Integer> limitedIntegers = Stream.iterate(0, n -> n < 100, n -> n + 1);

Transforming Streams

Filtering

Filters elements based on a predicate:


Stream<String> longWords = wordList.stream()
.filter(w -> w.length() > 10);

Mapping

Applies a function to each element:


Stream<String> startLongWords = wordList.stream()
.filter(w -> w.length() > 10)
.map(s -> s.substring(0, 1));

Flattening

Combines nested lists into a single stream:


Stream<Character> letters = wordList.stream()
.flatMap(s -> s.chars().mapToObj(c -> (char) c));

Managing Stream Size

Limiting

Restricts the stream to a fixed number of elements:


Stream<Double> randomDs = Stream.generate(Math::random)
.limit(100);

Skipping

Skips the first n elements:


Stream<Double> randomds = Stream.generate(Math::random).skip(10);

Conditional Stopping

Take While: Stops when a condition becomes false:


Stream<Double> filtered = Stream.generate(Math::random)
.takeWhile(n -> n >= 0.5);

Drop While: Starts when a condition becomes false:


Stream<Double> filtered = Stream.generate(Math::random)
.dropWhile(n -> n <= 0.05);

Reducing Streams

Counting

Counts the number of elements:


long count = Stream.generate(Math::random)
.limit(100)
.filter(n -> n > 0.1)
.count();

Finding Maximum/Minimum

Finds the largest/smallest element based on a comparator:


import java.util.Optional;
import java.util.stream.Stream;

public class MaxRandomExample {


public static void main(String[] args) {
Optional<Double> maxRand = Stream.generate(Math::random)
.limit(100)
.max(Double::compareTo);

// Print the maximum random number if present


maxRand.ifPresentOrElse(
max -> System.out.println("Maximum random number: " + max),
() -> System.out.println("No maximum value found")
);
}
}

Finding the First Element

Retrieves the first element:


import java.util.Optional;
import java.util.stream.Stream;

public class FirstRandomExample {


public static void main(String[] args) {
Optional<Double> firstRand = Stream.generate(Math::random)
.limit(100)
.filter(n -> n > 0.999)
.findFirst();

// Print the first matching random number if present


firstRand.ifPresentOrElse(
num -> System.out.println("First random number > 0.999: " + num),
() -> System.out.println("No number greater than 0.999 found")
);
}
}

You might also like