
In Java, creating a faithful copy of an object is not as straightforward as assigning one variable to another.
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.
clone() MethodThe 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}
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.
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 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);
}
}
Cloneable marker interface to use the clone() method.clone() method in Object is protected. It must be overridden as public for external use.clone() method in Object throws
CloneNotSupportedException. Subclasses must either declare or handle this exception.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 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.
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.
var must be initialized at the time of declaration.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.
For Example:
Manager m = new Manager("Ravi", 50000.0);
can be simplified to:
var m = new Manager("Ravi", 50000.0);
var must be initialized, which can sometimes lead to verbose or repetitive initialization expressions.For instance:
var e = new Manager("Ravi", 50000.0);
Here, e is inferred as Manager. If e should have been Employee, an explicit declaration is required:
Employee e = new Manager("Ravi", 50000.0);
Type inference allows the compiler to propagate type information through expressions.
For Example
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.
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.
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.
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.");
}
}
}
ComparatorThe 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 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 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.
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;
});
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
ClassName::methodNameobject::methodNameClassName::methodNameClassName::newExample 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);
}
}
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
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);
}
}
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);
}
}
filter() and count() can be parallelized.Example: Parellel Streams
long count = words.parallelStream()
.filter(w -> w.length() > 10)
.count();
Streams allow you to:
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
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
Stream.generate()Stream<String> echos = Stream.generate(() -> "Echo");
Stream<Double> randomDs = Stream.generate(Math::random);
Stream.iterate()Stream<Integer> integers = Stream.iterate(0, n -> n + 1);
Stream<Integer> limitedIntegers = Stream.iterate(0, n -> n < 100, n -> n + 1);
Filters elements based on a predicate:
Stream<String> longWords = wordList.stream()
.filter(w -> w.length() > 10);
Applies a function to each element:
Stream<String> startLongWords = wordList.stream()
.filter(w -> w.length() > 10)
.map(s -> s.substring(0, 1));
Combines nested lists into a single stream:
Stream<Character> letters = wordList.stream()
.flatMap(s -> s.chars().mapToObj(c -> (char) c));
Restricts the stream to a fixed number of elements:
Stream<Double> randomDs = Stream.generate(Math::random)
.limit(100);
Skips the first n elements:
Stream<Double> randomds = Stream.generate(Math::random).skip(10);
Conditional Stopping
Stream<Double> filtered = Stream.generate(Math::random)
.takeWhile(n -> n >= 0.5);
Stream<Double> filtered = Stream.generate(Math::random)
.dropWhile(n -> n <= 0.05);
Counts the number of elements:
long count = Stream.generate(Math::random)
.limit(100)
.filter(n -> n > 0.1)
.count();
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")
);
}
}
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")
);
}
}