1. Overview

In this lesson, we’ll explore how to work with multiple elements at once in a list. Instead of handling items one by one, we’ll learn how to perform operations that affect groups of elements efficiently and concisely. These bulk operations make list manipulation more expressive and often more performant, especially when dealing with larger collections.

The relevant module we need to import when we’re starting with this lesson is bulk-operations-on-lists-start.

If we want to have a look at the fully implemented lesson as a reference, feel free to import: bulk-operations-on-lists-end.

2. Why Use Bulk Operations?

Suppose we want to add ten tasks to a to-do list, or remove several completed items from the main list of tasks. Doing this task by task (e.g., calling add() ten times) is repetitive and can clutter your code. Bulk operations allow you to perform these actions on an entire collection of elements in a single, expressive method call.

2.1. Performance and Convenience

When working with multiple elements, methods like addAll() can be more efficient than calling add() repeatedly in a loop. This is because the underlying implementations usually optimize the process.

For example, an ArrayList might resize its internal array only once if needed, rather than potentially multiple times in a loop. More importantly, these operations significantly enhance code clarity. A single addAll() is much easier to read and understand than a for loop explicitly adding each new task.

2.2. Simplifying Common Tasks

Bulk operations are highly efficient for common data manipulation scenarios:

  • Merging Lists: Combining two lists into one
  • List Difference: Removing from one list all elements that exist in another.
  • Data Initialization: Populating a list with a predefined set of items
  • Cleanup: Removing all items that meet certain criteria

By leveraging bulk operations, you write less boilerplate code, reduce the chance of off-by-one errors in loops, and clarify your intent.

Let’s now dive into a few practical examples. For this lesson, we’ve included some JavaCollectionsTests test cases in the start module. These use a one-by-one manual looping approach first, so that we can clearly see how bulk operations improve both readability and efficiency.

3. Adding Multiple Elements with addAll()

A common task is adding multiple items to a list. The addAll() method lets us do this easily, either by appending elements at the end or by inserting them at a specific position.

3.1. Adding at the End

If we need to add several new items to the end of an existing list, one way is to iterate through the collection of new items and add them one by one:

@Test
public void whenAddingAllElementsAtEnd_thenListContainsThem() {
    List<String> tasks = new ArrayList<>();
    tasks.add("Task A");

    List<String> newTasks = List.of("Task B", "Task C", "Task D");

    for (int i = 0; i < newTasks.size(); i++) {
        tasks.add(newTasks.get(i));
    }

    // assertions
}

This approach works, but it requires writing explicit loop logic for a common operation.

The addAll() method provides a more direct way to append all elements from another collection to the end of the current list. All we need to do is change our for loop to a simple line:

List<String> newTasks = List.of("Task B", "Task C", "Task D");
tasks.addAll(newTasks);

3.2. Adding at a Specific Index

Sometimes, we must insert a group of elements at a particular position within the list, not just at the end. Manually inserting multiple elements at a specific index is more complex. We’d need to use the add(index, element) method repeatedly, carefully updating the insertion index for each new element to maintain the desired order of the inserted block:

@Test
public void whenAddingAllElementsAtIndex_thenListContainsThem() {
    List<String> tasks = new ArrayList<>(List.of("Task A", "Task E"));
    List<String> itemsToInsert = List.of("Task B", "Task C", "Task D");
    int insertionIndex = 1;

    for (int i = 0; i < itemsToInsert.size(); i++) {
        tasks.add(insertionIndex + i, itemsToInsert.get(i));
    }

    // assertions
}

This requires careful index management. The List interface offers an overloaded version of addAll() that accepts an index. Let’s use this method to insert items into the middle of a list:

List<String> tasks = new ArrayList<>(List.of("Task A", "Task E"));
List<String> itemsToInsert = List.of("Task B", "Task C", "Task D");
tasks.addAll(1, itemsToInsert);

We inserted the new elements in the middle of our list. Existing items at or after that position get shifted to make room. This is much cleaner than manual index manipulation.

4. Removing Groups of Elements

Removing multiple items at once is as simple as adding them. We can either remove specific groups of items or clear the entire list.

4.1. Removing Multiple Elements

Let’s imagine we want to exhaustively remove all the elements of one list from another. A solution for that would look like this:

@Test
public void whenExhaustivelyRemoving_thenListNotContainingAny() {
    List<String> tasks = new ArrayList<>(List.of("Task A", "Task B", "Task C", "Task B"));
    List<String> toRemove = List.of("Task B", "Task D");

    for (int i = 0; i < toRemove.size(); i++) {
        String elementToRemove = toRemove.get(i);
        while(tasks.contains(elementToRemove)) {
            tasks.remove(elementToRemove);
        }
    }
    
    // assertions
}

Alternatively, the removeAll() method is a much simpler and more expressive choice. All we need to do is change our for loop with a simple method call:

List<String> tasks = new ArrayList<>(List.of("Task A", "Task B", "Task C", "Task B"));
List<String> toRemove = List.of("Task B", "Task D");

tasks.removeAll(toRemove);

4.2. Removing Elements Conditionally

Sometimes, we need to remove elements based on a certain condition rather than by matching elements in another collection.

For this, we would need to loop through the whole list and remove the matching elements. However, doing this with a forward loop causes problems: removing an item shifts the remaining elements left, potentially skipping items or causing incorrect results. To avoid this, we need to iterate in reverse order, ensuring that the shifting doesn’t interfere with the remaining items we still need to check:

@Test
public void whenRemovingElementsConditionally_thenListNotContainingAny() {
    List<String> tasks = new ArrayList<>(List.of("Task Important", "Task Minor", "Task Urgent", "Task Normal"));

    for (int i = tasks.size() - 1; i >= 0; i--) {
        String task = tasks.get(i);
        if (task.contains("Minor") || task.contains("Normal")) {
            tasks.remove(i);
        }
    }

    // assertions
}

To improve this, we can use removeIf() passing in a lambda expression as a Predicate argument to perform the filtering:

List<String> tasks = new ArrayList<>(List.of("Task Important", "Task Minor", "Task Urgent", "Task Normal"));
tasks.removeIf(task -> task.contains("Minor") || task.contains("Normal"));

4.3. Clearing the Entire List

To clear a list entirely, we can usually just reassign the variable (i.e., tasks = new ArrayList<>()). This approach is expressive and performant. However, there are situations where reassignment isn’t possible; for example, if the variable is declared final or if there are external references to the original list.

In those cases, we need to clear the list in place by removing all elements one by one:

@Test
public void whenClearingAList_thenListIsEmpty() {
    List<String> tasks = new ArrayList<>(List.of("Task A", "Task B", "Task C"));
    while (!tasks.isEmpty()) {
        tasks.remove(0);
    }

    // assertions
}

The isEmpty() method is part of the Collection interface and returns true if the list contains no elements. It’s a convenient and readable way to check whether a list (or any collection) is empty, compared to checking tasks.size() == 0.

The clear() method is the most straightforward and efficient solution for removing all elements. It modifies the list in place:

List<String> tasks = new ArrayList<>(List.of("Task A", "Task B"));
tasks.clear();

5. Replacing Elements With replaceAll()

Sometimes, we need to transform every element in the list, rather than adding or removing elements. Without replaceAll(), we would typically iterate through the list using an index (e.g., a traditional for loop) and use the set(index, newElement) method to replace each element:

@Test
public void whenReplacingAllElements_thenAllElementsTransformed() {
    List<String> tasks = new ArrayList<>(List.of("Task A", "Task B", "Task C"));

    for (int i = 0; i < tasks.size(); i++) {
        String originalTask = tasks.get(i);
        tasks.set(i, originalTask + " - Done");
    }

    // assertions
}

This works, but the Collection framework provides the replaceAll() method to perform this in a less verbose manner. By passing a lambda expression as a UnaryOperator instance to replaceAll(), we can adjust every element in place without creating a new list or managing indices manually:

List<String> tasks = new ArrayList<>(List.of("Task A", "Task B", "Task C"));
tasks.replaceAll(task -> task + " - Done");

Let’s run the entire test class to verify that all tests still pass and produce the expected results: