Create arrays, List, Set, Map and Deque collections, and add, remove, update, retrieve and sort their elements.
List
Interface
Set
Interface
Deque
Interface
Map
Interface
An array is an object that holds a fixed number of values of a single type in contiguous memory locations. These values, or elements, can be of a primitive type or a reference type.
Here’s a diagram to help you visualize one and two dimension arrays:
One-dimensional Array:
┌─────┬─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ int[] numbers = new int[5];
└─────┴─────┴─────┴─────┴─────┘
▲
└── Index
Two-dimensional Array:
┌─────┬─────┬─────┐
│ 0,0 │ 0,1 │ 0,2 │
├─────┼─────┼─────┤
│ 1,0 │ 1,1 │ 1,2 │ int[][] matrix = new int[2][3];
└─────┴─────┴─────┘
▲ ▲
│ └── Column Index
└──────── Row Index
Let’s start by reviewing how you create and initialize an array.
To create an array, you have to declare a variable of the desired array type and then use the new
keyword to create the array object and assign it to the variable:
// Creates an array of integers
int[] myArray;
myArray = new int[5];
You can also combine the declaration and the creation of the array in one statement:
int[] myArray = new int[5];
The number inside the brackets specifies the number of elements the array will hold, in other words, the size of the array. This size must be decided when the array is created and cannot be changed later.
This is an important limitation to keep in mind, you cannot resize an array after it has been created. If you need a data structure that can dynamically grow or shrink, you should consider using one of the collection classes like ArrayList
instead.
When an array is created, its elements are automatically initialized with default values:
0
for numeric typesfalse
for booleannull
for reference types.However, you can also explicitly initialize an array during creation:
int[] myArray = new int[] {10, 20, 30, 40, 50};
This creates an array of 5 integers and initializes them with the specified values. The size of the array is determined by the number of values provided.
If you don’t need to specify the values at the time of declaration, you can leave some or all elements uninitialized:
int[] myArray = new int[5];
myArray[0] = 10;
myArray[1] = 20;
This creates an array of 5 integers, initializes the first two, and leaves the rest with their default value of 0
.
It’s important to note that all elements of an array must be of the same type. You cannot mix different data types in a single array.
An anonymous array is an array that is declared and initialized in a single statement without assigning it to a variable:
new int[] {10, 20, 30, 40, 50}
Anonymous arrays are often used when passing an array as an argument to a method:
myMethod(new int[] {10, 20, 30, 40, 50});
They provide a convenient way to create and pass an array inline, without the need for a separate variable declaration.
However, anonymous arrays are not limited to method arguments. They can be used anywhere an array is expected, such as in an assignment:
int[] myArray = new int[] {10, 20, 30, 40, 50};
In this case, the anonymous array is created and immediately assigned to the myArray
variable.
To access an element of an array, you use the array name followed by the index of the element in square brackets:
int[] myArray = new int[] {10, 20, 30, 40, 50};
System.out.println(myArray[0]); // Outputs 10
System.out.println(myArray[2]); // Outputs 30
Array indices start at 0, so the first element is at index 0, the second at index 1, and so on.
You can also use a variable for the index:
int index = 2;
System.out.println(myArray[index]); // Outputs 30
Trying to access an element outside the bounds of the array will result in an ArrayIndexOutOfBoundsException
.
To find out the number of elements in an array, you can use the length attribute:
System.out.println(myArray.length); // Outputs 5
Note that this is an attribute, not a method, so you don’t use parentheses.
Trying to change the size of an array after it has been created, either by assigning a new array to the variable or by using the length attribute, will result in a compile-time error.
While you can’t resize an array, you can copy the contents of one array to another:
int[] sourceArray = new int[] {10, 20, 30, 40, 50};
int[] destArray = new int[5];
System.arraycopy(sourceArray, 0, destArray, 0, 5);
This copies the elements from sourceArray
to destArray
. The arguments specify the source array, the starting position in the source array, the destination array, the starting position in the destination array, and the number of elements to copy.
However, this is not the same as assigning one array to another:
int[] sourceArray = new int[] {10, 20, 30, 40, 50};
int[] destArray = sourceArray;
This does not create a copy of the array. Instead, it makes destArray
reference the same array object as sourceArray
. Changes made through either variable will be reflected in the other, as they both point to the same array in memory.
Java also supports multidimensional arrays, which can be thought of as arrays of arrays.
The most common type of multidimensional array is the two-dimensional array, often used to represent matrices or tables of data. But Java places no limit on the number of dimensions an array can have.
To declare a multidimensional array, you specify each additional dimension with another set of square brackets. For example, here’s how you would declare a two-dimensional array of integers:
int[][] matrix;
This declares a variable matrix
that is an array of integer arrays.
You can then create the array with the new
keyword:
matrix = new int[3][4];
This creates a two-dimensional array with 3 rows and 4 columns. Essentially, it’s an array that contains 3 arrays, each of which contains 4 integers.
Just as with one-dimensional arrays, you can combine the declaration and creation:
int[][] matrix = new int[3][4];
You can also initialize the array upon creation:
int[][] matrix = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
This creates the same 3x4 array as before, but also initializes it with the specified values.
Accessing elements in a multidimensional array is similar to a one-dimensional array, but now you need to specify an index for each dimension:
int[][] matrix = new int[3][4];
matrix[0][0] = 1;
matrix[1][2] = 7;
System.out.println(matrix[1][2]); // Outputs 7
Here, matrix[0][0]
refers to the element in the first row and first column, matrix[1][2]
refers to the element in the second row and third column, and so on.
You can also use nested loops to iterate over a multidimensional array:
int[][] matrix = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
for(int i = 0; i < matrix.length; i++) {
for(int j = 0; j < matrix[i].length; j++) {
System.out.print(matrix[i][j] + " ");
}
System.out.println();
}
This will be the output:
1 2 3 4
5 6 7 8
9 10 11 12
The outer loop iterates over the rows, and the inner loop iterates over the columns in each row.
Note that in a multidimensional array, the length attribute gives the number of arrays in the first dimension. To get the length of the arrays in the second dimension, you need to specify an index for the first dimension, like matrix[i].length
.
Also note that while all the arrays in the second dimension have the same length in this example, this is not a requirement. You can have a ragged array where each array in the second dimension has a different length:
int[][] ragged = {
{1, 2, 3, 4},
{5, 6},
{7, 8, 9}
};
This flexibility can be useful in certain situations, but it’s more common to work with rectangular arrays where all the second-dimension arrays have the same length.
java.util.Arrays
ClassThe java.util.Arrays
class contains various static methods for manipulating arrays. It provides methods for sorting, searching, comparing, and filling array elements. Let’s look at some of the most commonly used methods.
The sort()
method sorts the elements of an array into ascending order. It has several overloads for different types of arrays:
int[] numbers = {4, 2, 7, 1, 3};
Arrays.sort(numbers);
System.out.println(Arrays.toString(numbers)); // [1, 2, 3, 4, 7]
This sorts the numbers
array in place, modifying the original array.
You can also sort a portion of an array by specifying the start (inclusive) and end (exclusive) indices:
int[] numbers = {4, 2, 7, 1, 3};
Arrays.sort(numbers, 1, 4);
System.out.println(Arrays.toString(numbers)); // [4, 1, 2, 7, 3]
This sorts only the elements from index 1 to 3, leaving the elements at indices 0 and 4 untouched.
For arrays of objects, the objects must implement the Comparable
interface for sort()
to work. Alternatively, you can provide a Comparator
object to define the sorting order:
String[] strings = {"banana", "apple", "cherry"};
Arrays.sort(strings, Comparator.comparingInt(String::length));
System.out.println(Arrays.toString(strings)); // [apple, banana, cherry]
This sorts the strings
array by the length of each string, using a Comparator
created by the comparingInt()
method.
The binarySearch()
method searches for a specific element in a sorted array using the binary search algorithm. If the element is found, it returns its index. If not, it returns a negative value.
int[] numbers = {1, 2, 3, 4, 7};
System.out.println(Arrays.binarySearch(numbers, 3)); // 2
System.out.println(Arrays.binarySearch(numbers, 5)); // -5
In the first search, the element 3 is found at index 2. In the second search, the element 5 is not found, so the method returns -5. The negative value is calculated as -(insertion point) - 1
, where the insertion point is the index at which the element would be inserted to maintain the sorted order.
Note that for binarySearch()
to work correctly, the array must be sorted. If the array is not sorted, the results are undefined.
compare()
The compare()
method compares two arrays lexicographically (by dictionary order). It returns a negative value if the first array is less than the second, a positive value if the first array is greater than the second, and zero if they are equal.
int[] arr1 = {1, 2, 3};
int[] arr2 = {1, 2, 3};
int[] arr3 = {1, 2, 4};
System.out.println(Arrays.compare(arr1, arr2)); // 0
System.out.println(Arrays.compare(arr1, arr3)); // -1
System.out.println(Arrays.compare(arr3, arr1)); // 1
In comparing arr1
and arr2
, the method returns 0 because the arrays are equal. In comparing arr1
and arr3
, it returns -1 because arr1
is lexicographically less than arr3
(because 3 < 4). Similarly, in comparing arr3
and arr1
, it returns 1.
fill()
The fill()
method in the Arrays
class is used to fill an array or a portion of it with a specific value. It’s a convenient way to set all elements to the same value.
The fill()
method has several overloads:
fill(array, value)
: Fills the entire array with the specified value.fill(array, fromIndex, toIndex, value)
: Fills a portion of the array, from the fromIndex
(inclusive) to the toIndex
(exclusive), with the specified value.Here’s an example of using fill()
to fill an entire array:
int[] numbers = new int[5];
Arrays.fill(numbers, 10);
System.out.println(Arrays.toString(numbers)); // [10, 10, 10, 10, 10]
This creates an array of 5 integers and fills it entirely with the value 10.
You can also fill just a portion of an array:
int[] numbers = {1, 2, 3, 4, 5};
Arrays.fill(numbers, 1, 4, 10);
System.out.println(Arrays.toString(numbers)); // [1, 10, 10, 10, 5]
This fills the elements from index 1 to 3 (remember, the toIndex
is exclusive) with the value 10, leaving the elements at indices 0 and 4 unchanged.
The fill()
method has overloads for all primitive types and for object references. When used with object references, each element will point to the same object:
String[] strings = new String[3];
Arrays.fill(strings, "Hello");
System.out.println(Arrays.toString(strings)); // [Hello, Hello, Hello]
This fills the strings
array with references to the same "Hello"
string.
It’s important to understand that the Arrays.fill()
method in Java does not create new objects for each element. Instead, it sets each element to reference the same object. If you modify the object through one of these references, all elements in the array will reflect that change. However, this behavior also depends on whether the objects are mutable or immutable.
Here’s an example with immutable objects (strings):
String[] strings = new String[3];
Arrays.fill(strings, new String("Hello"));
strings[0] = "Hi";
System.out.println(Arrays.toString(strings)); // [Hi, Hello, Hello]
Arrays.fill(strings, new String("Hello"));
sets each element in the array to reference a new String
object with the value "Hello"
. Thus, strings[0]
, strings[1]
, and strings[2]
all reference the same String
object initially. When you update strings[0] = "Hi";
, you change the reference at strings[0]
to point to a new String
object with the value "Hi"
. Since String
objects are immutable in Java, this does not affect strings[1]
and strings[2]
. The output will be [Hi, Hello, Hello]
, as modifying one element does not affect the others.
However, when using Arrays.fill()
with mutable objects, each element will reference the same object. If you modify one instance, all elements in the array will reflect that change:
class Point {
int x, y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return "(" + x + ", " + y + ")";
}
}
public class Main {
public static void main(String[] args) {
Point[] points = new Point[3];
Arrays.fill(points, new Point(0, 0));
// Modifying one element
points[0].x = 1;
points[0].y = 1;
System.out.println(Arrays.toString(points)); // Output: [(1, 1), (1, 1), (1, 1)]
}
}
Here, points[0]
, points[1]
, and points[2]
all reference the same Point
object. Changing the x
and y
values of points[0]
affects all three because they all reference the same Point
instance.
There are many other useful methods in the Arrays
class, such as equals()
or copyOf()
, etc. It’s worth exploring the documentation to see what’s available.
If you’ve been programming in Java for a while, you’ve probably come across generics at some point. But what exactly are they, and why are they useful?
In simple terms, generics allow you to write code that can work with different types, without losing the benefits of type safety. They provide a way to parameterize types, so that you can create classes, interfaces, and methods that can operate on objects of various types while still maintaining compile-time type checking.
Now, you might be thinking, “Aren’t generics just a fancy way to avoid using Object
everywhere?” It’s true that before generics were introduced in Java 5, developers often used the Object
type to write code that could handle different types. However, this approach has several drawbacks. It requires a lot of explicit casting, which can lead to runtime errors if the wrong type is used. It also doesn’t provide any compile-time type safety. Generics, on the other hand, allow you to specify the types you want to work with, providing better type safety and reducing the need for casting.
Type erasure is a process where the compiler removes all the generic type information at compile time, replacing it with their bounds or with the Object
type if no bounds are specified. This means that at runtime, a generic type like List<String>
is essentially treated as a plain List
, without any specific type information.
You might wonder, “If type erasure removes type information, does it mean generics don’t provide any type safety at all?” While it’s true that the generic type information is not available at runtime due to type erasure, generics still provide significant type safety benefits at compile time. The compiler uses the generic type information to perform type checks and catch potential type-related errors early on. It ensures that you don’t accidentally add an object of the wrong type to a generic collection or return the wrong type from a generic method.
However, type erasure does impose some limitations. For instance, you can’t use the instanceof
operator directly with generic types. If you try something like:
if (obj instanceof List<String>) {
// ...
}
You’ll get a compile error. This is because the generic type information is erased at runtime, so the instanceof
operator can only check against the raw type (List
in this case), not the specific parameterized type.
Another limitation is that you can’t create arrays of parameterized types. So, you can’t do something like:
List<String>[] array = new List<String>[10];
Again, this is due to type erasure. The compiler doesn’t have enough information at runtime to create an array of the specific parameterized type.
You might wonder why Java uses type erasure in the first place. One main reason is to maintain backward compatibility with older versions of Java that didn’t have generics. By erasing the generic type information at compile time, generic code can still be used with non-generic legacy code without causing runtime issues.
So, while type erasure can sometimes feel like a limitation, it’s a deliberate design choice in Java. It strikes a balance between providing type safety at compile time and maintaining compatibility with earlier versions of the language.
Now that you have a solid understanding of type erasure, let’s explore how to create your own generic classes.
Creating a generic class is quite straightforward. You simply define the class with one or more type parameters in angle brackets after the class name. These type parameters act as placeholders for the actual types that will be used when the class is instantiated.
For example, let’s say you want to create a simple generic class called Pair
, which holds two values of potentially different types:
public class Pair<T, U> {
private T first;
private U second;
public Pair(T first, U second) {
this.first = first;
this.second = second;
}
public T getFirst() {
return first;
}
public U getSecond() {
return second;
}
public void setFirst(T first) {
this.first = first;
}
public void setSecond(U second) {
this.second = second;
}
public static void main(String[] args) {
Pair<String, Integer> pair = new Pair<>("Hello", 42);
// Demonstrate compile-time type safety
String firstElement = pair.getFirst(); // No casting required
Integer secondElement = pair.getSecond();
System.out.println("First: " + firstElement);
System.out.println("Second: " + secondElement);
// Compiler will catch type mismatch errors
// pair.setFirst(100); // Uncommenting this line will cause a compile-time error
}
}
In this example, T
and U
are the type parameters. They can be replaced with any valid type when creating an instance of the Pair
class. For instance, you could create a Pair<String, Integer>
to hold a pair of a String
and an Integer
.
Using Object
to design a flexible class is not enough. While using Object
would allow you to store any type of object in your class, it lacks type safety. With generics, you can specify the exact types you want to work with, and the compiler will ensure that only objects of those types are used with your class. This catches potential type-related errors at compile time rather than runtime.
Besides, using generics does not have a significant impact on performance. Remember, the Java compiler performs type erasure, so the generic type information is removed at compile time, and the generated bytecode is essentially the same as if you had used raw types. In most cases, the performance difference is negligible.
When creating generic classes or methods, it’s important to follow the established naming conventions for type parameters. While not strictly required by the compiler, adhering to these conventions makes your code more readable and maintainable.
The most common type parameter names are single uppercase letters, such as:
E
for an elementK
for a map keyV
for a map valueT
for a general typeS
, U
, V
, etc. for additional typesWhile you could technically use longer names for type parameters, it’s generally discouraged. The single-letter names are a widely accepted convention and make the code more concise and easier to read. It’s a good idea to stick with the conventional names unless you have a compelling reason to do otherwise.
For example, in the case of maps, the convention is to use K
for keys and V
for values, but the compiler won’t enforce this. However, following the convention makes your code more consistent and easier for other developers to understand.
These naming conventions provide a consistent vocabulary that developers can rely on when reading and writing generic code. By following these conventions, you make your code more idiomatic and easier to maintain.
Now that you’re familiar with generic classes and type parameters, let’s explore another powerful feature of generics: writing generic methods.
Generic methods allow you to write reusable code that can work with different types, providing flexibility and type safety. By defining type parameters at the method level, you can create methods that can accept and return values of varying types.
Here’s an example of a generic method:
public static <T> void printArray(T[] array) {
for (T element : array) {
System.out.println(element);
}
}
In this example, the printArray
method is defined with a type parameter T
. The method takes an array of type T
and prints each element of the array. The type parameter T
is declared before the return type of the method, enclosed in angle brackets <>
.
You can invoke this generic method with arrays of different types:
String[] strings = { "Hello", "World", "Java" } ;
printArray(strings);
Integer[] integers = { 1, 2, 3, 4, 5 };
printArray(integers);
The printArray
method can be called with an array of strings or an array of integers, demonstrating its flexibility to work with different types.
Here’s another example of a generic method that returns a value:
public static <T> T getFirst(T[] array) {
if (array != null && array.length > 0) {
return array[0];
}
return null;
}
In this example, the getFirst
method is defined with a type parameter T
. It takes an array of type T
and returns the first element of the array, also of type T
. If the array is null
or empty, it returns null
.
You can invoke this method and assign the result to a variable of the appropriate type:
String[] strings = { "Hello", "World", "Java" };
String firstString = getFirst(strings);
Integer[] integers = { 1, 2, 3, 4, 5 };
Integer firstInteger = getFirst(integers);
When invoking a generic method, you have the option to explicitly specify the type arguments or let the compiler infer them based on the context.
Here’s an example of how to explicitly specify the argument type:
String[] strings = { "Hello", "World", "Java" };
String firstString = GenericMethodExample.<String>getFirst(strings);
In this example, we explicitly specify the type argument <String>
when invoking the getFirst
method. This tells the compiler that the type parameter T
should be bound to the String
type.
And here’s an example of type inference:
Integer[] integers = { 1, 2, 3, 4, 5 };
Integer firstInteger = GenericMethodExample.getFirst(integers);
In this case, we omit the explicit type argument and let the compiler infer the type based on the method argument. The compiler infers that the type parameter T
should be bound to the Integer
type.
Generic parameters work similarly with constructors:
public class GenericBox<T> {
private T content;
public GenericBox(T content) {
this.content = content;
}
public T getContent() {
return content;
}
}
In this example, the GenericBox
class has a constructor that accepts a generic argument of type T
. To create an instance of GenericBox
with a specific type, you can pass the generic argument when calling the constructor:
GenericBox<String> stringBox = new GenericBox<>("Hello");
GenericBox<Integer> integerBox = new GenericBox<>(42);
By specifying <String>
or <Integer>
when creating the GenericBox
instances, you explicitly define the type of the content
stored in each box.
You can also pass generic arguments to a static factory method, for example:
public class GenericFactory {
public static <T> List<T> createList(T... elements) {
return new ArrayList<>(Arrays.asList(elements));
}
}
Here, the createList
method is a static factory method that creates a new ArrayList
based on the provided elements. To pass generic arguments when calling this method, you can use the following syntax:
List<String> stringList = GenericFactory.<String>createList("Apple", "Banana", "Orange");
List<Integer> integerList = GenericFactory.<Integer>createList(1, 2, 3, 4, 5);
By explicitly specifying <String>
or <Integer>
before the method name, you indicate the desired type parameter for the created list.
It’s important to note that in many cases, the Java compiler can infer the generic type arguments based on the context, such as the types of the method arguments or the variable assignment. In such cases, you can omit the explicit generic argument and let the compiler infer it automatically:
List<String> stringList = GenericFactory.createList("Apple", "Banana", "Orange");
However, there may be situations where explicitly passing generic arguments is necessary, such as when the compiler cannot infer the type or when you want to enforce a specific type.
In addition to creating generic classes and methods that accept generic type parameters, you can also return generic types from methods. This allows you to write more flexible and reusable code by enabling methods to return values whose types are determined by the type parameters.
Consider this example:
public class GenericReturn {
public static <T> T identity(T value) {
return value;
}
}
The identity
method takes a value of type T
and simply returns it. The method uses the type parameter T
to specify both the input parameter type and the return type. This is a simple example of returning the same type as the input.
However, you can also return a different type based on the input, for example:
public class GenericReturn {
public static <T, R> R process(T input, Function<T, R> processor) {
return processor.apply(input);
}
}
In this example, the process
method takes an input of type T
and a Function
that converts T
to R
. The method applies the processor
function to the input and returns the result of type R
. This demonstrates how you can return a different type based on the input and a provided function.
Or, you can return a generic collection:
public class GenericReturn {
public static <T> List<T> toList(T... elements) {
return Arrays.asList(elements);
}
}
In this example, the toList
method takes a varargs parameter of type T
and returns a List
of type T
. This method converts the input elements into a generic list, showcasing how you can return a generic collection.
And here’s an example of returning a generic type based on multiple type parameters:
public class GenericReturn {
public static <K, V> Map<K, V> singletonMap(K key, V value) {
return Collections.singletonMap(key, value);
}
}
The singletonMap
method takes a key of type K
and a value of type V
and returns a Map
with the key-value pair. This method demonstrates how you can return a generic type that depends on multiple type parameters.
These examples showcase the flexibility and power of returning generic types.
When designing methods with generic return types, consider the following:
By leveraging generic return types, you can create methods that adapt to different input types and return types, making your code more reusable and applicable to various scenarios.
Method overloading is a fundamental feature in Java that allows multiple methods with the same name but different parameter types in the same class. This principle extends to generic methods as well. You can overload a generic method by providing different type parameters or by using different parameter types.
Consider the following example:
public class GenericMethodOverloading {
public static <T> void print(T item) {
System.out.println("Printing single item: " + item);
}
public static <T> void print(T item1, T item2) {
System.out.println("Printing two items: " + item1 + ", " + item2);
}
public static <T, U> void print(T item1, U item2) {
System.out.println("Printing two items of different types: " + item1 + ", " + item2);
}
}
In this example, we have three overloaded versions of the generic print
method:
T
and prints it.T
and prints them.T
and U
and prints them.When calling these methods, the compiler will determine which version to invoke based on the number and types of arguments provided.
GenericMethodOverloading.print("Hello");
GenericMethodOverloading.print(10, 20);
GenericMethodOverloading.print("Hello", 42);
In the above code snippet:
print
, with T
inferred as String
.print
with the same type, with T
inferred as Integer
.print
with different types, with T
inferred as String
and U
inferred as Integer
.Overloading generic methods provides flexibility and allows you to define multiple variations of a method that can handle different types or combinations of types.
However, it’s important to be cautious when overloading generic methods. The compiler’s type inference mechanism may not always be able to determine the intended version of the method to call, especially if the overloaded methods have similar type parameters. In such cases, you may need to explicitly specify the type arguments to disambiguate the method call.
GenericMethodOverloading.<String>print("Hello");
In this example, we explicitly specify the type argument <String>
to ensure that the single-parameter version of print
is called.
Just as you can define generic classes, you can also define generic interfaces in Java. Generic interfaces provide a way to specify a contract that classes can implement, allowing for greater flexibility and reusability.
Let’s review an example to understand how to implement generic interfaces:
public interface Processor<T> {
void process(T data);
}
public class StringProcessor implements Processor<String> {
@Override
public void process(String data) {
System.out.println("Processing string: " + data);
}
}
public class IntegerProcessor implements Processor<Integer> {
@Override
public void process(Integer data) {
System.out.println("Processing integer: " + data);
}
}
In this example, we have a generic interface Processor<T>
. The interface declares a single method process
that takes an argument of type T
. The purpose of this interface is to define a contract for processing data of type T
.
We then have two classes, StringProcessor
and IntegerProcessor
, that implement the Processor
interface with different type parameters.
The StringProcessor
class implements Processor<String>
, indicating that it will provide an implementation of the process
method that handles String
data. Inside the process
method, we simply print a message along with the provided string data.
Similarly, the IntegerProcessor
class implements Processor<Integer>
, specifying that it will process Integer
data. The process
method in this class prints a message along with the provided integer data.
By implementing the generic Processor
interface, both classes adhere to the contract of processing data, but they can handle different types (String
and Integer
in this case).
Here’s how you can use the StringProcessor
and IntegerProcessor
classes:
Processor<String> stringProcessor = new StringProcessor();
stringProcessor.process("Hello, World!");
Processor<Integer> integerProcessor = new IntegerProcessor();
integerProcessor.process(42);
In this example, we create instances of StringProcessor
and IntegerProcessor
and assign them to variables of type Processor<String>
and Processor<Integer>
, respectively. We then invoke the process
method on each processor, passing the appropriate data type.
The output of this code snippet is:
Processing string: Hello, World!
Processing integer: 42
Implementing generic interfaces enables code reuse, polymorphism, and the ability to create more general-purpose classes and algorithms.
However, you need to keep in mind the following:
Records provide a concise way to define immutable data classes. They can also be generic, allowing you to create flexible and reusable data structures.
Here’s an example of creating a generic record:
public record Pair<T, U>(T first, U second) {
public Pair {
if (first == null || second == null) {
throw new IllegalArgumentException("Both elements must be non-null");
}
}
}
In this example, we define a generic record called Pair
. It has two type parameters, T
and U
, representing the types of the first and second elements of the pair.
The Pair
record has two components: first
of type T
and second
of type U
. These components are automatically translated into private final
fields and public
accessor methods.
We also include a compact constructor in the record definition. The compact constructor allows us to add validation or additional logic during the creation of a Pair
instance. In this case, we check if either first
or second
is null
, and if so, we throw an IllegalArgumentException
to enforce that both elements must be non-null.
Creating and using instances of the generic Pair
record is straightforward:
Pair<String, Integer> pair1 = new Pair<>("Hello", 42);
System.out.println(pair1.first() + ", " + pair1.second());
Pair<Double, Boolean> pair2 = new Pair<>(3.14, true);
System.out.println(pair2.first() + ", " + pair2.second());
In this example, we create two instances of the Pair
record with different type arguments. pair1
is a Pair<String, Integer>
, representing a pair of a string and an integer. We create it by passing the values “Hello” and 42 to the constructor.
Similarly, pair2
is a Pair<Double, Boolean>
, representing a pair of a double and a boolean. We create it by passing the values 3.14
and true
to the constructor.
We can access the components of the Pair
instances using the automatically generated accessor methods first()
and second()
.
The output of this code snippet would be:
Hello, 42
3.14, true
When working with generics, there may be situations where you want to restrict the types that can be used as type arguments. This is where bounding generic types comes into play. Java provides three ways to bound generic types: unbounded wildcards, wildcards with upper bounds, and wildcards with lower bounds.
Unbounded wildcards, represented by the ?
symbol, provide the most flexibility when working with generic types. They allow any type to be used as the type argument, making them useful in situations where you don’t have any specific type constraints.
Consider this example:
public static void printList(List<?> list) {
for (Object item : list) {
System.out.println(item);
}
}
Here, we have a generic method printList
that accepts a List<?>
as a parameter. The unbounded wildcard ?
means that the method can accept a list of any type. Inside the method, we iterate over the list and print each item.
Here’s an example of calling the printList
method:
List<String> stringList = Arrays.asList("Hello", "World");
printList(stringList);
List<Integer> integerList = Arrays.asList(1, 2, 3);
printList(integerList);
In the above code, we create a List<String>
and a List<Integer>
, and pass them to the printList
method. The method can handle lists of any type due to the unbounded wildcard.
One thing to note is that when using an unbounded wildcard, you can only read from the collection and treat the elements as objects of the Object
class. You cannot add elements to the collection because the compiler doesn’t know the specific type of the elements.
Unbounded wildcards are useful when you want to write generic code that can work with any type, without imposing any specific type constraints.
Upper-bounded wildcards, represented by ? extends type
, restrict the types that can be used as type arguments to subtypes of the specified type. They provide a way to write more specific generic code while still allowing flexibility.
Consider this example:
public static double sumNumbers(List<? extends Number> numbers) {
double sum = 0;
for (Number number : numbers) {
sum += number.doubleValue();
}
return sum;
}
Here, we have a generic method sumNumbers
that accepts a List<? extends Number>
as a parameter. The upper-bounded wildcard ? extends Number
means that the method can accept a list of any type that is a subtype of Number
, such as Integer
, Double
, or Long
.
Here’s an example of calling the sumNumbers
method:
List<Integer> integerList = Arrays.asList(1, 2, 3);
double integerSum = sumNumbers(integerList);
System.out.println("Sum of integers: " + integerSum);
List<Double> doubleList = Arrays.asList(1.5, 2.7, 3.2);
double doubleSum = sumNumbers(doubleList);
System.out.println("Sum of doubles: " + doubleSum);
In the above code, we create a List<Integer>
and a List<Double>
, and pass them to the sumNumbers
method. The method can handle lists of any subtype of Number
due to the upper-bounded wildcard.
By using an upper-bounded wildcard, we can safely invoke methods defined in the Number
class, such as doubleValue()
, on the elements of the list. This allows us to perform specific operations on the elements while maintaining type safety.
However, similar to unbounded wildcards, you cannot add elements to a collection with an upper-bounded wildcard because the compiler doesn’t know the specific subtype of the elements.
Upper-bounded wildcards are useful when you want to write generic code that operates on a specific type hierarchy, allowing flexibility within that hierarchy.
Lower-bounded wildcards, represented by ? super type
, restrict the types that can be used as type arguments to supertypes of the specified type. They provide a way to write generic code that can work with a specific type and its supertypes.
Consider this example:
public static void addNumbers(List<? super Integer> numbers) {
numbers.add(10);
numbers.add(20);
numbers.add(30);
}
Here, we have a generic method addNumbers
that accepts a List<? super Integer>
as a parameter. The lower-bounded wildcard ? super Integer
means that the method can accept a list of any type that is a supertype of Integer
, such as Number
or Object
.
Here’s an example of calling the addNumbers
method:
List<Integer> integerList = new ArrayList<>();
addNumbers(integerList);
System.out.println("Integer list: " + integerList);
List<Number> numberList = new ArrayList<>();
addNumbers(numberList);
System.out.println("Number list: " + numberList);
In the above code, we create an empty List<Integer>
and an empty List<Number>
, and pass them to the addNumbers
method. The method can add Integer
objects to both lists because Integer
is a subtype of Number
and Object
.
Unlike unbounded and upper-bounded wildcards, with lower-bounded wildcards, you can safely add elements of the specified type (Integer
in this case) to the collection. This is because the compiler knows that the collection can hold elements of the specified type or its supertypes.
However, when reading elements from a collection with a lower-bounded wildcard, you can only treat them as objects of the specified type or its supertypes. You cannot assume any more specific type information.
Lower-bounded wildcards are useful when you want to write generic code that can accept a specific type and its supertypes, allowing you to add elements of that type to the collection.
Each type of wildcard serves a specific purpose and provides different capabilities when working with generic types. Remember:
?
) when you don’t have any specific type constraints and want to allow any type.? extends type
) when you want to restrict the types to subtypes of a specific type and perform operations specific to that type.? super type
) when you want to restrict the types to supertypes of a specific type and add elements of that type to the collection.One of the most useful parts of the Java standard library is the Collections Framework, which provides a set of reusable components for managing groups of objects. The framework includes several main interfaces that extend from the java.util.Collection
interface (which in turn, extends from java.lang.Iterable
) to define the different types of collections:
The List
interface represents an ordered collection that allows duplicate elements. Its main implementations are ArrayList
, which is backed by a resizable array, and LinkedList
, which uses a doubly-linked list.
The Set
interface defines a collection that doesn’t allow duplicate elements. The HashSet
class provides a hash table implementation, while TreeSet
uses a red-black tree to store its elements, keeping them in ascending order.
The Deque
interface, which stands for double-ended queue, represents a collection that allows insertion and removal at both ends. Main implementations include ArrayDeque
and LinkedList
, with ArrayDeque
often providing better performance for most operations.
The Map
interface maps unique keys to values. The HashMap
class uses a hash table, providing constant-time performance for basic operations, while TreeMap
uses a red-black tree and orders its elements by the key’s natural ordering or a provided Comparator
.
However, it’s worth noting that while the Map
interface is part of the Java Collections Framework, it is not a descendant of the Collection
interface.
Java 21 introduced three new interfaces to represent collections with a defined encounter order:
SequencedCollection
: A collection with a well-defined encounter order, providing uniform APIs for accessing the first and last elements, and processing elements in forward and reverse order.
SequencedSet
: A set with a defined encounter order, extending both Set
and SequencedCollection
.
SequencedMap
: A map with a defined encounter order for its entries, keys, and values.
These new interfaces provide a more consistent way to work with ordered collections across different implementations.
Here’s a diagram that shows the hierarchy of these collections, including the new sequenced interfaces:
┌───────────────┐ ┌───────────┐
│ Collection │ │ Map │
└───────┬───────┘ └─────┬─────┘
│ │
┌──────────────────┼──────────────┐ │
│ │ │ │
┌───┴──────┐ ┌──────┴──────┐ ┌────┴────┐ ┌─────┴─────┐
│ Set │ │ Sequenced │ │ Queue │ │ Sequenced │
│ │ │ Collection │ │ │ │ Map │
└─┬─────┬──┘ └──────┬──────┘ └────┬────┘ └─────┬─────┘
│ │ ┌──────────┼───────┐ │ │
│ │ │ │ │ │ │
│ ┌───┴───┴─┐ ┌────┴───┐ │ ┌───┴────┐ ┌────┴────┐
│ │Sequenced│ │ List │ └──│ Deque │ │ Sorted │
│ │ Set │ └────────┘ └────────┘ │ Map │
│ └───┬─────┘ └─────────┘
│ │
│ ┌───┴────┐
└─│ Sorted │
│ Set │
└────────┘
When declaring a collection, we can take advantage of the Diamond Operator (<>
) to specify the type:
List<Integer> numbers = new ArrayList<>();
Map<String, Person> people = new HashMap<>();
The compiler will infer the type arguments for the constructor based on the variable declaration.
There are several common operations we can perform on a collection. To add a single element, we use the add
method:
List<String> words = new ArrayList<>();
words.add("hello");
words.add("world");
To add all the elements of another collection, use addAll
:
List<String> moreWords = Arrays.asList("goodbye", "cruel", "world");
words.addAll(moreWords);
We remove elements with the remove
method, specifying either the object to remove or its index for ordered collections:
words.remove("hello");
words.remove(1); // removes element at index 1
The size
method returns the number of elements currently in the collection:
int count = words.size();
To remove all elements from a collection, call the clear
method:
words.clear();
The contains
method checks if a collection contains a specified element, returning true
if found or false
otherwise:
boolean found = words.contains("hello");
The removeIf
method allows removing all elements that satisfy a given predicate:
words.removeIf(word -> word.length() < 5);
The above code snippet removes all strings with fewer than 5 characters from the words
list.
The forEach
method (which actually comes from the java.lang.Iterable
interface) performs a given action on each element of the collection:
words.forEach(word -> System.out.println(word));
This prints each word from the list to the console.
The equals
method checks if another object is equal to the collection. For two collections to be considered equal, they must contain the same elements in the same order (for ordered collections) or the same elements in any order (for unordered collections):
List<String> list1 = Arrays.asList("a", "b", "c");
List<String> list2 = Arrays.asList("a", "b", "c");
List<String> list3 = Arrays.asList("c", "b", "a");
System.out.println(list1.equals(list2)); // true
System.out.println(list1.equals(list3)); // false
Set<String> set1 = new HashSet<>(Arrays.asList("a", "b", "c"));
Set<String> set2 = new HashSet<>(Arrays.asList("c", "b", "a"));
System.out.println(set1.equals(set2)); // true
It’s important to note that for two collections to be equal, the elements they contain must also implement the equals
method correctly.
With the introduction of sequenced collections in Java 21, we now have consistent methods for working with the first and last elements of collections with a defined encounter order:
SequencedCollection<String> seq = new ArrayList<>(List.of("first", "second", "third"));
String first = seq.getFirst(); // "first"
String last = seq.getLast(); // "third"
seq.addFirst("new first");
seq.addLast("new last");
SequencedCollection<String> reversed =
seq.reversed(); // [new last, third, second, first, new first]
These methods are available on List
, Deque
, LinkedHashSet
, and other collections that implement the new sequenced interfaces.
In the next sections, we’ll review in more detail each of these interfaces.
List
InterfaceAs mentioned before, the List
interface represents an ordered collection that allows duplicate elements. The two main implementations of List
are ArrayList
and LinkedList
. While both classes implement the same interface, they have different performance characteristics.
An ArrayList
is backed by a dynamic array, which provides amortized constant-time performance for the basic operations (add at the end, get, and set), assuming the index is known. However, inserting or removing elements from the middle of an ArrayList
can be slow, as it requires shifting all the subsequent elements, resulting in O(n)
complexity.
On the other hand, a LinkedList
stores its elements in a doubly-linked list. This provides constant-time performance for insertion and deletion operations at both ends of the list. However, accessing elements by index requires traversing the list from the beginning or the end, which takes linear time. Insertion or deletion in the middle of the list also takes linear time.
Therefore, if your application mainly needs to access elements by index, an ArrayList
is generally the better choice. If it frequently inserts or deletes elements from the middle of the list, a LinkedList
may be a better choice.
List
The most common approach to create a List
instance is to use a constructor:
List<String> fruits = new ArrayList<>();
List<String> vegetables = new LinkedList<>();
You can also create a List
from an array using the Arrays.asList
method:
String[] fruitArray = {"apple", "banana", "orange"};
List<String> fruits = Arrays.asList(fruitArray);
Note that the List
returned by Arrays.asList
is backed by the original array, so any changes made to the array will be reflected in the List
, and vice versa. Additionally, this List
has a fixed size, so you cannot add or remove elements.
You can also use the factory methods List.of
and List.copyOf
to create unmodifiable lists:
List<String> fruits = List.of("apple", "banana", "orange");
List<String> vegetables = List.copyOf(new ArrayList<>(Arrays.asList("carrot", "broccoli", "potato")));
The List.of
method takes a varargs parameter, allowing you to specify the elements individually, while List.copyOf
creates a new unmodifiable List
from an existing collection. These unmodifiable lists will throw UnsupportedOperationException
if you attempt to modify them.
List
MethodsThe List
interface provides several methods for working with its elements. The add
method inserts an element at a specified position or appends it to the end of the List
:
List<String> fruits = new ArrayList<>();
fruits.add("apple");
fruits.add(0, "banana");
The get
and set
methods allow you to access and modify elements by their indices:
String fruit = fruits.get(0);
fruits.set(1, "orange");
To remove an element, use the remove
method, specifying either the object to remove or its index:
fruits.remove("banana");
fruits.remove(0);
The replaceAll
method applies a given function to each element of the List
, replacing each element with the result of the function:
fruits.replaceAll(String::toUpperCase);
To sort the elements of a List
, use the sort
method:
fruits.sort(Comparator.naturalOrder());
The sort
method uses the natural ordering of the elements, or you can provide a custom Comparator
.
To convert a List
to an array, use the toArray
method:
String[] fruitArray = fruits.toArray(new String[0]);
The toArray
method takes an array parameter, which serves as the return type and can also be used to size the resulting array if it’s large enough. If the provided array is smaller than the List
, a new array of the same runtime type will be created with the size of the List
.
Set
InterfaceThe Set
interface defines a collection that does not allow duplicate elements. The main implementations of Set
are HashSet
, LinkedHashSet
, and TreeSet
. Each of these classes have different characteristics and use cases:
A HashSet
stores its elements in a hash table, providing constant-time performance for basic operations (add, remove, contains, and size) assuming the hash function disperses the elements properly among the buckets. However, a HashSet
does not maintain any order of its elements.
A LinkedHashSet
is an ordered version of HashSet
that maintains a doubly-linked list running through all of its entries. This allows LinkedHashSet
to preserve the insertion order of the elements. The LinkedHashSet
has slightly higher memory consumption and slightly slower performance for basic operations than a HashSet
.
A TreeSet
stores its elements in a red-black tree, keeping them in ascending order according to their natural ordering or a provided Comparator
. This provides guaranteed log(n) time cost for basic operations, but it is generally slower than a HashSet
.
When choosing a Set
implementation, consider the following:
HashSet
.LinkedHashSet
.TreeSet
.Set
You can create a Set
using a constructor, just like with lists:
Set<String> fruits = new HashSet<>();
Set<String> vegetables = new LinkedHashSet<>();
Set<String> nuts = new TreeSet<>();
Alternatively, you can also use the factory methods Set.of
and Set.copyOf
to create unmodifiable sets:
Set<String> fruits = Set.of("apple", "banana", "orange");
Set<String> vegetables = Set.copyOf(List.of("carrot", "broccoli", "potato"));
Set
MethodsThe Set
interface provides several methods for working with its elements. The add
method inserts an element into the Set
if it’s not already present:
Set<String> fruits = new HashSet<>();
fruits.add("apple");
fruits.add("banana");
fruits.add("apple"); // This will not be added, as "apple" is already in the Set
To check if an element is present in the Set
, use the contains
method:
boolean containsApple = fruits.contains("apple"); // true
To remove an element from the Set
, use the remove
method:
fruits.remove("banana");
The size
method returns the number of elements in the Set
:
int numberOfFruits = fruits.size();
To iterate over the elements of a Set
, you can use a for-each
loop or the forEach
method:
for (String fruit : fruits) {
System.out.println(fruit);
}
fruits.forEach(System.out::println);
Deque
InterfaceThe Deque
interface, which stands for double-ended queue, represents a collection that allows insertion and removal at both the head and the tail of the deque. The main implementations of Deque
are ArrayDeque
and LinkedList
:
An ArrayDeque
is a resizable array implementation of the Deque
interface. It provides constant-time performance for insertion and deletion operations at both ends of the deque, making it more efficient than a LinkedList
for most use cases. An ArrayDeque
does not have a fixed capacity and will automatically grow as needed.
A LinkedList
is a doubly-linked list implementation that also implements the Deque
interface. It provides constant-time performance for insertion and deletion operations at both ends of the list. LinkedList
is suitable when you need a Deque
implementation that can also function as a List
.
When choosing a Deque
implementation, consider the following:
ArrayDeque
for better performance.Deque
implementation that can also function as a List
, use a LinkedList
.Deque
You can create a Deque
using a constructor, similar to other collection types:
Deque<String> fruits = new ArrayDeque<>();
Deque<String> vegetables = new LinkedList<>();
You can also specify an initial capacity for an ArrayDeque
:
Deque<String> fruits = new ArrayDeque<>(20);
This creates an ArrayDeque
with an initial capacity of 20 elements. If the number of elements exceeds the initial capacity, the ArrayDeque
will automatically grow as needed.
For a LinkedList
, you can create an empty deque or initialize it with another collection:
Deque<String> fruits = new LinkedList<>();
List<String> fruitList = Arrays.asList("apple", "banana", "orange");
Deque<String> fruitDeque = new LinkedList<>(fruitList);
Deque
MethodsThe Deque interface provides several methods for working with elements at both ends of the deque. The addFirst
and addLast
methods insert elements at the head and the tail of the deque, respectively:
Deque<String> fruits = new ArrayDeque<>();
fruits.addFirst("apple");
fruits.addLast("banana");
The getFirst
and getLast
methods retrieve, but do not remove, the elements at the head and tail of the deque. If the deque is empty, they throw a NoSuchElementException
:
String firstFruit = fruits.getFirst();
String lastFruit = fruits.getLast();
To remove and return the elements at the head and tail of the deque, use the removeFirst
and removeLast
methods. If the deque is empty, they throw a NoSuchElementException
:
String removedFirstFruit = fruits.removeFirst();
String removedLastFruit = fruits.removeLast();
The Deque
interface also provides methods for using the deque as a stack. The push
method inserts an element at the head of the deque, the pop
method removes and returns the element at the head, and the peek
method retrieves, but does not remove, the element at the head:
Deque<String> stack = new ArrayDeque<>();
stack.push("apple");
stack.push("banana");
String topElement = stack.peek(); // banana
String poppedElement = stack.pop(); // banana
These methods are equivalent to using addFirst
, removeFirst
, and getFirst
, respectively, but provide a more intuitive naming convention when using the deque as a stack.
Additionally, the Deque
interface provides the offerFirst
, offerLast
, peekFirst
, peekLast
, pollFirst
, and pollLast
methods, which are similar to their counterparts without the offer
, peek
, or poll
prefix but behave differently when the deque is empty:
offerFirst
and offerLast
: Insert elements at the head and tail of the deque, respectively. They return a boolean
value indicating whether the insertion was successful.
boolean addedFirst = fruits.offerFirst("apple");
boolean addedLast = fruits.offerLast("banana");
peekFirst
and peekLast
: Retrieve, but do not remove, the elements at the head and tail of the deque. They return null
if the deque is empty.
String firstFruit = fruits.peekFirst(); // banana
String lastFruit = fruits.peekLast(); // apple
pollFirst
and pollLast
: Remove and return the elements at the head and tail of the deque. They return null
if the deque is empty.
String removedFirstFruit = fruits.pollFirst(); // banana
String removedLastFruit = fruits.pollLast(); // apple
These methods are useful when you want to avoid exceptions and handle special cases more gracefully.
Map
InterfaceThe Map
interface represents a collection that maps unique keys to values. It is not a subtype of the Collection
interface, but it is still considered part of the Java Collections Framework. The main implementations of Map
are HashMap
, LinkedHashMap
, and TreeMap
.
A HashMap
is an implementation of the Map
interface that stores key-value pairs in a hash table. It provides constant-time performance for basic operations (put, get, remove) assuming the hash function disperses the elements properly among the buckets. HashMap
does not guarantee any order of the elements.
A LinkedHashMap
is an implementation of the Map
interface that maintains a doubly-linked list running through all of its entries. This allows it to preserve the insertion order of the key-value pairs. LinkedHashMap
provides nearly identical performance to HashMap
for basic operations.
A TreeMap
is an implementation of the Map
interface that stores its entries in a red-black tree, sorted according to the natural ordering of its keys or by a provided Comparator
. This provides guaranteed log(n)
time cost for basic operations, but it is generally slower than HashMap
.
When choosing a Map
implementation, consider the following:
HashMap
.LinkedHashMap
.TreeMap
.Map
You can create a Map
using a constructor, similar to other collection types:
Map<String, Integer> fruitCounts = new HashMap<>();
Map<String, Integer> vegetableCounts = new LinkedHashMap<>();
Map<String, Integer> nutCounts = new TreeMap<>();
You can also create a Map
with an initial capacity and load factor (for HashMap
and LinkedHashMap
):
Map<String, Integer> fruitCounts = new HashMap<>(20, 0.8f);
This creates a HashMap
with an initial capacity of 20 and a load factor of 0.8. The load factor determines when the HashMap
should be resized to maintain performance.
Map
MethodsThe Map
interface provides several methods for working with its key-value pairs:
clear
: Removes all entries from the map.
fruitCounts.clear();
containsKey
: Returns true
if the map contains the specified key.
boolean containsApple = fruitCounts.containsKey("apple");
containsValue
: Returns true
if the map contains the specified value.
boolean containsCount = fruitCounts.containsValue(5);
entrySet
: Returns a Set
view of the entries in the map.
Set<Map.Entry<String, Integer>> entries = fruitCounts.entrySet();
forEach
: Performs the given action for each entry in the map.
fruitCounts.forEach((fruit, count) -> System.out.println(fruit + ": " + count));
get
: Returns the value associated with the specified key, or null
if the key is not found.
Integer appleCount = fruitCounts.get("apple");
getOrDefault
: Returns the value associated with the specified key, or the given default value if the key is not found.
Integer appleCount = fruitCounts.getOrDefault("apple", 0);
isEmpty
: Returns true
if the map contains no entries.
boolean empty = fruitCounts.isEmpty();
keySet
: Returns a Set
view of the keys in the map.
Set<String> fruits = fruitCounts.keySet();
merge
: If the specified key is not already associated with a value or is associated with null
, associates it with the given non-null value. Otherwise, replaces the associated value with the results of the given remapping function.
fruitCounts.merge("apple", 1, Integer::sum);
put
: Associates the specified value with the specified key in the map.
fruitCounts.put("apple", 5);
putIfAbsent
: If the specified key is not already associated with a value (or is mapped to null
), associates it with the given value and returns null
; otherwise, returns the current value.
fruitCounts.putIfAbsent("apple", 5);
remove
: Removes the entry for the specified key from the map if present.
fruitCounts.remove("apple");
replace
: Replaces the entry for the specified key only if it is currently mapped to some value.
fruitCounts.replace("apple", 6);
replaceAll
: Replaces each entry’s value with the result of invoking the given function on that entry until all entries have been processed or the function throws an exception.
fruitCounts.replaceAll((fruit, count) -> count * 2);
size
: Returns the number of entries in the map.
int numberOfFruits = fruitCounts.size();
values
: Returns a Collection
view of the values contained in the map.
Collection<Integer> counts = fruitCounts.values();
hashCode()
When using a HashMap
or LinkedHashMap
, it’s essential to ensure that the keys’ hashCode
method is properly overridden. The hashCode
method should return the same hash code for objects considered equal according to the equals
method. This is necessary for the map to function correctly and efficiently.
Here’s an example of a custom class with properly overridden hashCode
and equals
methods:
class Person {
private String name;
private int age;
// Constructor, getters, and setters
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && Objects.equals(name, person.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
In this example, the hashCode
method of the Person
class is implemented by passing the name
and age
fields to the Objects.hash
method. This ensures that the generated hash code is based on the values of these fields.
The Objects.hash
method is a static utility method provided by the java.util.Objects
class that generates a hash code for a sequence of input values. Here’s the general syntax of the Objects.hash
method:
public static int hash(Object... values)
The method accepts any number of arguments of type Object
, which means you can pass values of different types. It calculates the hash code for each input value using their respective hashCode
methods and then combines them to produce a single hash code. It also handles null
values correctly, so you don’t need to include null
checks in your hashCode
implementation.
It’s important to note that when you override the hashCode
method using Objects.hash
, you should also override the equals
method to ensure that objects that are considered equal have the same hash code. This is necessary for the proper functioning of hash-based collections like HashMap
and HashSet
.
Sorting is an operation that allows you to arrange elements in a specific order. In Java, you can sort data using the Comparable
interface or the Comparator
interface. The Comparable
interface defines the natural ordering of elements, while the Comparator
interface allows you to define custom ordering.
Comparable
InterfaceTo create a class that can be sorted using its natural ordering, you need to implement the Comparable
interface. It defines a single method, compareTo
, which compares the current object with another object of the same type.
Here’s an example of a Person
class that implements Comparable
:
class Person implements Comparable<Person> {
private String name;
private int age;
// Constructor, getters, and setters
@Override
public int compareTo(Person other) {
// Compare by age first, then by name if ages are equal
int ageComparison = Integer.compare(this.age, other.age);
if (ageComparison != 0) {
return ageComparison;
}
return this.name.compareTo(other.name);
}
}
In this example, the compareTo
method first compares two Person
objects based on their age first. If the ages are equal, it compares their names lexicographically. The compareTo
method returns a negative value, zero, or a positive value if the current object is less than, equal to, or greater than the other object, respectively.
When implementing the compareTo
method, it’s important to handle null
values correctly to avoid a NullPointerException
. You can do this by adding a null
check at the beginning of the method:
@Override
public int compareTo(Person other) {
if (other == null) {
return 1; // Consider non-null values to be greater than null values
}
// Rest of the comparison logic
}
In this example, if the other
object is null
, the method returns 1, indicating that the current object is greater than the null
value. You can adjust this behavior based on your specific requirements.
Also, it’s important to ensure that the behavior of the compareTo
method is consistent with the equals
method. If two objects are considered equal according to the equals
method, their compareTo
method should return zero.
Here’s an example of an equals
method that is consistent with the compareTo
method:
class Person implements Comparable<Person> {
private String name;
private int age;
// Constructor, getters, and setters
@Override
public int compareTo(Person other) {
// Compare by age first, then by name if ages are equal
int ageComparison = Integer.compare(this.age, other.age);
if (ageComparison != 0) {
return ageComparison;
}
return this.name.compareTo(other.name);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && Objects.equals(name, person.name);
}
}
In this example, the equals
method considers two Person objects equal if they have the same age and name. This is consistent with the compareTo
method, which compares age first and then name.
Comparator
InterfaceWhile the Comparable
interface defines the natural ordering of elements, the Comparator
interface allows you to define custom ordering. A Comparator
is a separate class that contains the comparison logic.
Here’s an example of a Comparator
that compares Person
objects by their name length:
class NameLengthComparator implements Comparator<Person> {
@Override
public int compare(Person p1, Person p2) {
return Integer.compare(p1.getName().length(), p2.getName().length());
}
}
In this example, the compare
method of the NameLengthComparator
compares two Person
objects based on the length of their names. It returns a negative value, zero, or a positive value if the length of the first person’s name is less than, equal to, or greater than the length of the second person’s name, respectively.
There are several helper methods in the Comparator
interface that make it easier to build comparators:
comparing
: Creates a comparator based on a function that extracts a Comparable
key from a type.
Comparator<Person> nameComparator = Comparator.comparing(Person::getName);
comparingDouble
, comparingInt
, comparingLong
: Create comparators based on functions that extract double
, int
, or long
keys from a type.
Comparator<Person> ageComparator = Comparator.comparingInt(Person::getAge);
naturalOrder
, reverseOrder
: Create comparators based on the natural ordering or the reverse of the natural ordering of a type.
Comparator<String> naturalStringComparator = Comparator.naturalOrder();
Comparator<String> reverseStringComparator = Comparator.reverseOrder();
There are also default methods in the Comparator
interface that allow you to combine and modify comparators:
reversed
: Reverses the order of a comparator.
Comparator<Person> reversedNameComparator = nameComparator.reversed();
thenComparing
, thenComparingDouble
, thenComparingInt
, thenComparingLong
: Allow chaining comparators.
Comparator<Person> nameAndAgeComparator = nameComparator.thenComparingInt(Person::getAge);
In this example, the nameAndAgeComparator
first compares Person objects by their name, and if the names are equal, it compares them by their age.
Comparable
and Comparator
Both Comparable
and Comparator
are used for sorting elements in Java, but they have some key differences.
In terms of their purpose:
Comparable
is used to define the natural ordering of elements within a class. It is suitable when the class has an inherent ordering that is appropriate for most use cases.Comparator
is used to define custom ordering for elements of a class. It allows for multiple ways to compare elements and is useful when the natural ordering is not appropriate or when you need to sort elements based on different criteria.In terms of their implementation:
Comparable
is an interface that is implemented by the class itself. The class must define the compareTo
method, which compares the current object with another object of the same type and returns a negative value, zero, or a positive value if the current object is less than, equal to, or greater than the other object, respectively.Comparator
is an interface that is implemented as a separate class. The class that implements Comparator
must define the compare
method, which compares two objects of a specific type and returns a negative value, zero, or a positive value if the first object is less than, equal to, or greater than the second object, respectively.In terms of their flexibility:
Comparable
provides a single way to compare elements of a class. Once the compareTo
method is defined, it becomes the natural ordering for that class. If you need to change the ordering, you have to modify the class itself.Comparator
allows for multiple ways to compare elements of a class. You can define multiple Comparator
classes, each with its own compare
method, to provide different ordering criteria. This is particularly useful when you need to sort elements based on different attributes or when you want to have alternative sorting options.Here’s an example that demonstrates the flexibility of Comparator
:
class Person {
private String name;
private int age;
// Constructor, getters, and setters
}
class NameComparator implements Comparator<Person> {
@Override
public int compare(Person p1, Person p2) {
return p1.getName().compareTo(p2.getName());
}
}
class AgeComparator implements Comparator<Person> {
@Override
public int compare(Person p1, Person p2) {
return Integer.compare(p1.getAge(), p2.getAge());
}
}
// Usage
List<Person> people = new ArrayList<>();
// Add elements to the list
// Sort using NameComparator
Collections.sort(people, new NameComparator());
// Sort using AgeComparator
Collections.sort(people, new AgeComparator());
In this example, we define two Comparator
classes: NameComparator
and AgeComparator
. NameComparator
compares Person
objects based on their names, while AgeComparator
compares them based on their ages. We can use these Comparator
s interchangeably to sort the people
list based on different criteria.
In summary, when choosing between Comparable
and Comparator
, consider the following:
Comparable
.Comparator
.Comparable
and Comparator
together. If a Comparator
is provided to a sorting method, it takes precedence over the natural ordering defined by Comparable
.Collections.sort
and Collections.binarySearch
The Collections
class provides utility methods for working with collections, including methods for sorting and searching.
The Collections.sort
method sorts a List
using its natural ordering (defined by the Comparable
interface) or a provided Comparator
:
List<Person> people = new ArrayList<>();
// Add elements to the list
// Sort using natural ordering (Comparable)
Collections.sort(people);
// Sort using a custom Comparator
Collections.sort(people, new NameLengthComparator());
In this example, the first Collections.sort
call sorts the people
list using the natural ordering defined by the compareTo
method of the Person
class. The second call sorts the list using the custom NameLengthComparator
.
The Collections.binarySearch
method searches for an element in a sorted List
using the binary search algorithm. The List
must be sorted in ascending order according to the natural ordering (Comparable
) or the provided Comparator
:
List<Person> people = new ArrayList<>();
// Add elements to the list and sort it
Person searchKey = new Person("John", 30);
int index = Collections.binarySearch(people, searchKey);
if (index >= 0) {
System.out.println("Found at index: " + index);
} else {
System.out.println("Not found");
}
In this example, the Collections.binarySearch
method searches for the searchKey
object in the sorted people
list. If the element is found, it returns its index; otherwise, it returns a negative value.
If the List
is not sorted or is sorted according to a different order than the one used in the binary search, the results are undefined.
It’s important to note that when using Collections.sort
or Collections.binarySearch
with a custom Comparator
, the Comparator
should be consistent with equals
to ensure proper behavior. If two elements are equal according to the Comparator
, they should also be equal according to the equals
method.
Here are a few tables to help you quickly reference key information about the Java Collections Framework:
Interface | Description | Main Implementations | Characteristics |
---|---|---|---|
List |
Ordered collection that allows duplicate elements | ArrayList , LinkedList |
ArrayList backed by resizable array, LinkedList uses doubly-linked list |
Set |
Collection that doesn’t allow duplicate elements | HashSet , LinkedHashSet , TreeSet |
HashSet uses hash table, LinkedHashSet maintains insertion order, TreeSet uses red-black tree for sorting |
Deque |
Double-ended queue, allows insertion and removal at both ends | ArrayDeque , LinkedList |
ArrayDeque resizable array, LinkedList doubly-linked list |
Map |
Maps unique keys to values | HashMap , LinkedHashMap , TreeMap |
HashMap hash table, LinkedHashMap maintains insertion order, TreeMap red-black tree for sorted keys |
Interface | Ordering | Duplicates | Null Values |
---|---|---|---|
List |
Ordered | Allowed | Allowed |
Set |
Unordered | Not Allowed | Allowed |
Deque |
Ordered | Allowed | Not Allowed |
Interface | Ordering | Duplicate Keys | Null Keys | Null Values |
---|---|---|---|---|
Map |
Unordered | Not Allowed | Allowed | Allowed |
Interface | Method | Description |
---|---|---|
Collection |
add(E e) |
Adds an element to the collection |
Collection |
addAll(Collection<? extends E> c) |
Adds all elements from another collection |
Collection |
remove(Object o) |
Removes a specified element |
Collection |
size() |
Returns the number of elements |
Collection |
clear() |
Removes all elements |
Collection |
contains(Object o) |
Checks if the collection contains a specified element |
Collection |
removeIf(Predicate<? super E> filter) |
Removes all elements that satisfy a predicate |
Collection |
forEach(Consumer<? super E> action) |
Performs an action for each element |
Collection |
equals(Object o) |
Checks if another object is equal to the collection |
Method | Description |
---|---|
add(int index, E element) |
Inserts an element at a specified position |
get(int index) |
Returns the element at a specified position |
set(int index, E element) |
Replaces the element at a specified position |
remove(int index) |
Removes the element at a specified position |
replaceAll(UnaryOperator<E> operator) |
Replaces each element with the result of a function |
sort(Comparator<? super E> c) |
Sorts the list using a comparator |
toArray(T[] a) |
Converts the list to an array |
Method | Description |
---|---|
add(E e) |
Adds an element to the set if not already present |
contains(Object o) |
Checks if the set contains a specified element |
remove(Object o) |
Removes a specified element |
size() |
Returns the number of elements |
forEach(Consumer<? super E> action) |
Performs an action for each element |
Method | Description |
---|---|
addFirst(E e) |
Inserts an element at the head of the deque |
addLast(E e) |
Inserts an element at the tail of the deque |
getFirst() |
Retrieves, but does not remove, the head of the deque |
getLast() |
Retrieves, but does not remove, the tail of the deque |
removeFirst() |
Removes and returns the head of the deque |
removeLast() |
Removes and returns the tail of the deque |
push(E e) |
Inserts an element at the head of the deque |
pop() |
Removes and returns the element at the head of the deque |
Method | Description |
---|---|
clear() |
Removes all entries from the map |
containsKey(Object key) |
Checks if the map contains a specified key |
containsValue(Object value) |
Checks if the map contains a specified value |
entrySet() |
Returns a set view of the map’s entries |
forEach(BiConsumer<? super K,? super V> action) |
Performs an action for each entry |
get(Object key) |
Returns the value associated with a specified key |
getOrDefault(Object key, V defaultValue) |
Returns the value for a key, or a default value if the key is not found |
isEmpty() |
Checks if the map contains no entries |
keySet() |
Returns a set view of the keys in the map |
merge(K key, V value, BiFunction<? super V,? super V,? extends V> remappingFunction) |
Merges the value with an existing value for the key |
put(K key, V value) |
Associates a value with a key |
putIfAbsent(K key, V value) |
Associates a value with a key if not already associated |
remove(Object key) |
Removes the entry for a key |
replace(K key, V value) |
Replaces the entry for a key |
replaceAll(BiFunction<? super K,? super V,? extends V> function) |
Replaces each value with the result of a function |
size() |
Returns the number of entries |
values() |
Returns a collection view of the values in the map |
An array is an object that holds a fixed number of values of a single type in contiguous memory locations.
To create an array, you declare a variable of the desired array type and use the new
keyword to create the array object.
Array elements are automatically initialized with default values (0 for numeric types, false
for boolean, and null
for reference types).
Array indices start at 0. Accessing an element outside the bounds of the array will result in an ArrayIndexOutOfBoundsException
.
The length
attribute gives the number of elements in an array. It is an attribute, not a method, so parentheses are not used.
Multidimensional arrays are arrays of arrays. The most common type is the two-dimensional array, often used to represent matrices or tables of data.
Anonymous arrays are declared and initialized in a single statement without assigning them to a variable. They are often used when passing an array as an argument to a method.
The java.util.Arrays
class contains various static methods for manipulating arrays, including methods for sorting, searching, comparing, and filling array elements.
The Arrays.sort()
method sorts the elements of an array into ascending order. It has overloads for different types of arrays and can sort a portion of an array.
The Arrays.binarySearch()
method searches for a specific element in a sorted array using the binary search algorithm. The array must be sorted for the method to work correctly.
The Arrays.compare()
method compares two arrays lexicographically (by dictionary order).
The Arrays.fill()
method fills an array or a portion of it with a specific value. It sets each element to reference the same object for object arrays.
Generics are a mechanism in Java that allow you to write code that can work with different types while maintaining type safety.
Type erasure is the process where the compiler removes all generic type information at compile time, replacing it with their bounds or the Object
type.
Generic classes are defined with one or more type parameters in angle brackets after the class name. These type parameters act as placeholders for the actual types used when the class is instantiated.
Generic methods allow you to write reusable code that can work with different types. Type parameters are defined before the method’s return type.
When invoking a generic method, you can explicitly specify the type arguments or let the compiler infer them based on the context.
Generic constructors and static factory methods can also accept and return generic types.
When designing methods with generic return types, use descriptive type parameter names, ensure compatibility with intended usage, and consider the impact on code complexity.
Generic interfaces provide a way to specify a contract that classes can implement, allowing for greater flexibility and reusability.
Generic records provide a concise way to define immutable data classes that can work with different types.
Wildcard types (?
, ? extends T
, ? super T
) allow you to specify unknown types, restrict type parameters to subtypes of a type, or restrict type parameters to supertypes of a type, respectively.
The Java Collections Framework provides a set of reusable components for managing groups of objects, including the List
, Set
, Deque
, and Map
interfaces.
The Comparable
interface defines the natural ordering of elements within a class, while the Comparator
interface allows you to define custom ordering for elements of a class.
The Collections.sort()
method sorts a List
using its natural ordering or a provided Comparator
, while Collections.binarySearch()
searches for an element in a sorted List
using the binary search algorithm.
1. What is the output of the following program?
public class MultiDimArray {
public static void main(String[] args) {
int[][] arr = new int[2][3];
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j < arr[i].length; j++) {
arr[i][j] = i + j;
}
}
for (int i = 0; i < arr.length; i++) {
for (int j = 0; j < arr[i].length; j++) {
System.out.print(arr[i][j] + " ");
}
System.out.println();
}
}
}
A)
0 0 0
0 0 0
B)
0 1 2
0 1 2
C)
0 0 0
1 1 1
D)
0 1 2
1 2 3
2. Which of the following generic method definitions correctly declares a method that returns the first element of a given array?
A)
public static T getFirstElement(T[] array) {
return array[0];
}
B)
public static <T> T getFirstElement(T[] array) {
return array[0];
}
C)
public static <T> getFirstElement(T[] array) {
return array[0];
}
D)
public static <T> T[] getFirstElement(T[] array) {
return array[0];
}
3. What is the result of compiling and running the following code?
import java.util.*;
public class WildcardTest {
public static void printList(List<? extends Number> list) {
for (Number n : list) {
System.out.print(n + " ");
}
System.out.println();
}
public static void main(String[] args) {
List<Integer> ints = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(1.1, 2.2, 3.3);
List<String> strings = Arrays.asList("one", "two", "three");
printList(ints);
printList(doubles);
printList(strings);
}
}
A) The code compiles and prints:
1 2 3
1.1 2.2 3.3
one two three
B) The code compiles and prints:
1 2 3
1.1 2.2 3.3
C) The code does not compile due to an error in the printList
method.
D) The code does not compile due to an error in the main
method.
E) The code compiles but throws a runtime exception when executed.
4. What is the output of the following program?
import java.util.*;
public class ListExample {
public static void main(String[] args) {
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
list.add(2, "E");
System.out.println(list);
}
}
A) [A, B, E, C, D]
B) [A, E, B, C, D]
C) [A, B, C, E, D]
D) [A, B, C, D, E]
E) [A, C, B, E, D]
5. Which of the following statements about the Set
interface are true? (Choose all that apply.)
A) A Set
allows duplicate elements.
B) Elements in a Set
are maintained in the order they were inserted.
C) The Set
interface includes methods for adding, removing, and checking the presence of elements.
D) The Set
interface is implemented by classes like HashSet
, LinkedHashSet
, and TreeSet
.
E) A Set
guarantees constant-time performance for the basic operations (add, remove, contains).
6. What will be the output of the following program?
import java.util.*;
public class DequeExample {
public static void main(String[] args) {
Deque<String> deque = new ArrayDeque<>();
deque.addFirst("A");
deque.addLast("B");
deque.addFirst("C");
deque.addLast("D");
System.out.println(deque);
}
}
A) [A, B, C, D]
B) [C, B, A, D]
C) [C, A, B, D]
D) [D, B, A, C]
E) [A, C, B, D]
7. What will be the output of the following program?
import java.util.*;
public class MapExample {
public static void main(String[] args) {
Map<Integer, String> map = new HashMap<>();
map.put(1, "A");
map.put(2, "B");
map.put(3, "C");
map.put(2, "D");
System.out.println(map);
}
}
A) {1=A, 2=B, 3=C, 2=D}
B) {1=A, 2=B, 3=C}
C) {1=A, 2=D, 3=C, 2=D}
D) {1=A, 2=D, 3=C}
E) {1=A, 3=C, 2=B}
8. What is the result of running the following program?
import java.util.*;
public class ComparableExample {
public static void main(String[] args) {
List<Person> people = new ArrayList<>();
people.add(new Person("Alice", 30));
people.add(new Person("Bob", 25));
people.add(new Person("Charlie", 35));
Collections.sort(people);
for (Person p : people) {
System.out.println(p.getName() + " " + p.getAge());
}
}
}
class Person implements Comparable<Person> {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
@Override
public int compareTo(Person other) {
return Integer.compare(this.age, other.age);
}
}
A)
Alice 30
Bob 25
Charlie 35
B)
Charlie 35
Alice 30
Bob 25
C)
Bob 25
Alice 30
Charlie 35
D)
Bob 25
Charlie 35
Alice 30
E)
Alice 30
Charlie 35
Bob 25
9. What will be the output of the following program when using the provided Comparator
?
import java.util.*;
public class ComparatorExample {
public static void main(String[] args) {
List<Person> people = new ArrayList<>();
people.add(new Person("Alice", 30));
people.add(new Person("Bob", 25));
people.add(new Person("Charlie", 35));
Collections.sort(people, new AgeComparator());
for (Person p : people) {
System.out.println(p.getName() + " " + p.getAge());
}
}
}
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
class AgeComparator implements Comparator<Person> {
@Override
public int compare(Person p1, Person p2) {
return Integer.compare(p1.getAge(), p2.getAge());
}
}
A)
Bob 25
Alice 30
Charlie 35
B)
Charlie 35
Alice 30
Bob 25
C)
Alice 30
Bob 25
Charlie 35
D)
Bob 25
Charlie 35
Alice 30
E)
Alice 30
Charlie 35
Bob 25
Do you like what you read? Would you consider?
Do you have a problem or something to say?