Chapter SIX
Arrays, Generics, and Collections


Exam Objectives

Create Java arrays, List, Set, Map, and Deque collections, and add, remove, update, retrieve and sort their elements.
Use generics, including wildcards.

Chapter Content


Arrays

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.

Creating and Initializing Arrays

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:

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.

Anonymous Arrays

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.

Using an Array

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.

Multidimensional Arrays

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.

The java.util.Arrays Class

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

Sorting

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.

Searching

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.

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

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

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.

Generics

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.

Understanding Type Erasure

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.

Creating Generic Classes

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.

Naming Conventions for Generics

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:

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

Writing Generic Methods and Constructors

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.

Returning Generic Types

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.

Overloading a Generic Method

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:

  1. The first method takes a single generic parameter T and prints it.
  2. The second method takes two generic parameters of the same type T and prints them.
  3. The third method takes two generic parameters of different types 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:

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.

Implementing Generic Interfaces

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:

Creating Generic Records

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

Bounding Generic Types

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

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

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

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:

The Collections Framework

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:

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.

Here’s a diagram that shows the hierarchy of these collections:

                   ┌─────────────┐
                   │ Iterable<E> │
                   └──────┬──────┘
                          │
                   ┌──────┴──────┐
                   │Collection<E>│
                   └──────┬──────┘
                          │
        ┌─────────────────┼─────────────────┐
        │                 │                 │
 ┌──────┴──────┐   ┌──────┴──────┐   ┌──────┴──────┐
 │   List<E>   │   │   Set<E>    │   │  Deque<E>   │
 └──────┬──────┘   └──────┬──────┘   └──────┬──────┘
        │                 │                 │
 ┌──────┴───────┐  ┌──────┴──────┐   ┌──────┴──────┐
 │ ArrayList<E> │  │ HashSet<E>  │   │ArrayDeque<E>│
 │ LinkedList<E>│  │ TreeSet<E>  │   │LinkedList<E>│
 └──────────────┘  └─────────────┘   └─────────────┘

 ┌─────────────┐
 │   Map<K,V>  │
 └─────┬───────┘
       │
 ┌─────┴───────┐
 │ HashMap<K,V>│
 │ TreeMap<K,V>│
 └─────────────┘

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.

In the next sections, we’ll review in more detail each interface.

The List Interface

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

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

Working with List Methods

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

The Set Interface

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

When choosing a Set implementation, consider the following:

Creating a 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"));

Working with Set Methods

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

The Deque Interface

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

When choosing a Deque implementation, consider the following:

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

Working with Deque Methods

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

These methods are useful when you want to avoid exceptions and handle special cases more gracefully.

The Map Interface

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

When choosing a Map implementation, consider the following:

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

Working with Map Methods

The Map interface provides several methods for working with its key-value pairs:

Overriding 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 Data

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.

The Comparable Interface

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

The Comparator Interface

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

There are also default methods in the Comparator interface that allow you to combine and modify comparators:

In this example, the nameAndAgeComparator first compares Person objects by their name, and if the names are equal, it compares them by their age.

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

In terms of their implementation:

In terms of their flexibility:

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 Comparators interchangeably to sort the people list based on different criteria.

In summary, when choosing between Comparable and Comparator, consider the following:

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.

Summary of Collection Types

Here are a few tables to help you quickly reference key information about the Java Collections Framework:

Table 1: Collections Interfaces and Implementations

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

Table 2: Core Collections Interfaces Functionality

Interface Ordering Duplicates Null Values
List Ordered Allowed Allowed
Set Unordered Not Allowed Allowed
Deque Ordered Allowed Not Allowed

Table 2.1: Map Interface Functionality

Interface Ordering Duplicate Keys Null Keys Null Values
Map Unordered Not Allowed Allowed Allowed

Table 3: Common Methods for Collections

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

Table 4: List-Specific Methods

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

Table 5: Set-Specific Methods

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

Table 6: Deque-Specific Methods

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

Table 7: Map-Specific Methods

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

Key Points

Practice Questions

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?

Report an issue with the book

Contact me