Cloning, Type Inference, Higher Order Functions, and Streams
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() 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}
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.
Manager m = new Manager("Ravi", 50000.0);var m = new Manager("Ravi", 50000.0);
var must be initialized, which can sometimes
lead to verbose or repetitive initialization expressions.
var e = new Manager("Ravi", 50000.0);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.");
}
}
}
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 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::methodName
object::methodName
ClassName::methodName
ClassName::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: Parallel 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);
Stream<String> longWords = wordList.stream()
.filter(w -> w.length() > 10);
Stream<String> startLongWords = wordList.stream()
.filter(w -> w.length() > 10)
.map(s -> s.substring(0, 1));
Stream<Character> letters = wordList.stream()
.flatMap(s -> s.chars().mapToObj(c -> (char) c));
Stream<Double> randomDs = Stream.generate(Math::random)
.limit(100);
n elements:
Stream<Double> randomds = Stream.generate(Math::random).skip(10);
Stream<Double> filtered = Stream.generate(Math::random)
.takeWhile(n -> n >= 0.5);
Stream<Double> filtered = Stream.generate(Math::random)
.dropWhile(n -> n <= 0.05);
long count = Stream.generate(Math::random)
.limit(100)
.filter(n -> n > 0.1)
.count();
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")
);
}
}
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")
);
}
}