Trap of deleting elements in Java List

Posted by dunn_cann on Tue, 15 Feb 2022 11:55:21 +0100

When a List deletes an element, the first reaction is often a for loop, which calls List based on the if condition Remove (I) or List Remove (object) to remove the elements on the collection.

public static void main(String[] args) {
    List<String> list = new ArrayList(2) {
        {
            add("a");
            add("b");
        }
    };
    for (int i = 0; i < list.size(); i++) {
        if ("a".equals(list.get(i))) {
            list.remove(i);
        }
    }
    list.forEach(System.out::println);
}
Console:
    b

Process finished with exit code 0

But in fact, there is a logical trap. This trap is actually not complicated at all, just as a reminder of itself. I'll modify the above code a little.

public static void main(String[] args) {
    List<String> list = new ArrayList(2) {
        {
            add("a");
            add("b");
            add("a");
            add("a");
        }
    };
    for (int i = 0; i < list.size(); i++) {
        if ("a".equals(list.get(i))) {
            list.remove(i);
        }
    }
    list.forEach(System.out::println);
}
Console:
    b
    a

Process finished with exit code 0

Houli crab clearly specifies to delete string a, but why only delete two of them? This is the trap. Everything comes from one method, list Size() method of length. We fall into this trap because we default that the length of this cycle is unchanged, but with the occurrence of remove():

// Source code of ArrayList
public E remove(int index) {
    rangeCheck(index);

    modCount++;
    E oldValue = elementData(index);

    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work

    return oldValue;
}

The simplest understanding of this source code is that after the -- size operator is executed, the length of the array will be updated immediately. The next time you call size(), the boundary will be reduced. We can clearly infer the problem:

 // Initial: [a, B, a, a] list size = 4;  The cycle condition is I < size; i++;
 // list[0] = "a" is judged by [i = 0 < size] for the first time; Qualified, so the array becomes [b, a, a] list size = 3;
 // The second time [i = 1 < size] judges that list[1] = "b" does not meet the conditions, and the array has not changed.
 // The third time [i = 2 < size] judges that list[0] = "a"; Qualified, so the array becomes [b, a] list size = 2;
 // The fourth time [i = 3 > size] does not meet the conditions of the cycle, so break; Out of loop, so the array is fixed in [b, a] list size = 2;

Therefore, the result is not our initial expectation, but because we ignore the problems caused by the change of size with the array. However, this does not mean that this method is wrong, but only that it is an unsafe and defective method, because this method can fulfill the same responsibility in the face of some List sets, such as ensuring that the qualified elements only appear once, This method is also easy.

Well, after talking about the trap, how can we ensure safety?

A. In a very simple way, reverse the thinking in the original method. Since our sequential array will have unsafe problems, i change it to reverse the order, because the biggest difference from the positive order is that i already know that the end of i is 0, so i can ensure the completion of the element cycle.

public static void main(String[] args) {
    List<String> list = new ArrayList(2) {
        {
            add("a");
            add("b");
            add("a");
            add("a");
        }
    };
    for (int i = list.size() - 1; i >= 0; i--) {
        if ("a".equals(list.get(i))) {
            list.remove(i);
        }
    }
    list.forEach(System.out::println);
}
Console:
    b

Process finished with exit code 0

B. This is done using iterators

public static void main(String[] args) {
    List<String> list = new ArrayList(2) {
        {
            add("a");
            add("b");
            add("a");
            add("a");
        }
    };
    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {
        if ("a".equals(iterator.next())) {
            iterator.remove();
        }
    }
    list.forEach(System.out::println);
}
Console:
    b

Process finished with exit code 0

But why can iterators do this? Let's read the list Iterator () knows:

public Iterator<E> iterator() {
    return new Itr();
}
/**
 * Copy part of the source code
 * An optimized version of AbstractList.Itr
 */
private class Itr implements Iterator<E> {
    int cursor;       // index of next element to return
    int lastRet = -1; // index of last element returned; -1 if no such
    int expectedModCount = modCount;

We can simply see and draw a conclusion that an iterator is an object. In essence, it is just an instance of an object that operates on a list, and it is no longer our direct operation on a list. Then look at iterator hasNext();

public boolean hasNext() {
    return cursor != size;
}

Just simply judge whether cursor is equal to itself. The size of this list is the same, and you can't know too much information. Then look at iterator next()

public E next() {
    checkForComodification();
    int i = cursor;
    if (i >= size)
        throw new NoSuchElementException();
    Object[] elementData = ArrayList.this.elementData;
    if (i >= elementData.length)
        throw new ConcurrentModificationException();
    cursor = i + 1;
    return (E) elementData[lastRet = i];
}

The simple reading is to get the array itself, because int is 0 by default, so the created iterator cursor is 0 to start the calculation, and after the operation is + 1, it is assigned to the lastRet variable to locate the cursor of the last operation. But we still need to finish watching the protagonist iterator remove();

public void remove() {
    if (lastRet < 0)
        throw new IllegalStateException();
    checkForComodification();

    try {
        ArrayList.this.remove(lastRet);
        cursor = lastRet;
        lastRet = -1;
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

Simple understanding, because cursor = lastRet; Therefore, we can know that after deleting the element, the iterator does not move forward, and the cursor still stops at the last operation. Therefore, we know that when remove() occurs, the cursor just stays at the position of the last operation. When the new array is filled, next() gets the position of the last operation, but the elements inside change. Therefore, it is safe for the iterator to complete the deletion of elements.

C. After Java 8, the concept of flow | removeIf() is used to complete the operation of collection.

// Method 1 uses the filter() method to filter elements. filter() only keeps those that meet the conditions, so our condition becomes! "a".equals(s).
public static void main(String[] args) {
    List<String> list = new ArrayList(2) {
        {
            add("a");
            add("b");
            add("a");
            add("a");
        }
    };
    list = list.stream().filter(s -> !"a".equals(s)).collect(Collectors.toList());
    list.forEach(System.out::println);
}
Console:
    b

Process finished with exit code 0
==============================================================================================================
 // Method 2 uses removeIf()
 public static void main(String[] args) {
    List<String> list = new ArrayList(2) {
        {
            add("a");
            add("b");
            add("a");
            add("a");
        }
    };
    list.removeIf(s -> "a".equals(s));
    list.forEach(System.out::println);
}
Console:
    b

Process finished with exit code 0

The concept of Stream is very easy to use after Java 8. I understand that the Stream is just like its name. It is just a data Stream. When it is used up, it will become what you want me to become, or complete the iteration, or complete the addition, deletion and modification of collection elements, or merge other data streams.
It doesn't mean how efficient the performance operation is. You can achieve the same effect by using the underlying array or set operation. The performance may be better than data flow, but what I mentioned here is the concept of flow, which actually wants to realize the set operation into pipelined code, Let's see more clearly and concisely the "change history" of this collection, which is a more concise and cleaner programming habit for me. For me, the code itself is free, as vast as the endless wilderness. What can lead me not to get lost in it is programming ideas, not rote code.

removeIf() is a method added in Java 8. Variables are actually put into anonymous methods through lamda expressions. This method is an anonymous method carrying String s, and the method body is "a" equals(s); Translate into code

public boolean method (String s) {
    return "a".equals(s);
}

In fact, it is very simple to know that the underlying method still uses iterators to complete the operation, but it may involve another programming method: functional programming. Java8 Lamda expression is actually to simplify the writing of anonymous functions. It looks very tall (I think so when I see it for the first time), but of course it can be used simply, but why does it appear? I understand that the Java team also wants to improve Java's support for functional programming?! Because the Lamda expression does make anonymous functions more concise and easy to use, it can also reduce the creation of some objects.
I also learned about functional programming in 18 years (just came out to work). Although Java8 has completed its support for this, we newcomers hardly know the idea of functional programming. I told them that I just think new things make me feel very tall, but the actual Java code is still object-oriented programming, At that time, I didn't realize that functional programming had such a great impact on me.
In fact, I can't fully explain what functional programming is now. I understand that functional programming is that functions are the first citizens, that is, functions are the most important. Functions are actually like variables. They can operate like variables. Each function is pure. This almost affects my thinking up to now. I agree that each function is pure, I usually want to pass a parameter to the function, and the function will only respond to my same answer. It should not be affected by external factors, so that the results of the parameters I pass are different.
Of course, the idea of object-oriented programming makes the code easier to read, but the risk of code complexity and repetition also increases. This is a problem that many newcomers cannot realize. Although the simple use of functional programming in Java is unreasonable and inappropriate, after all, object-oriented programming has been so long, so I prefer to use the two kinds of mixed programming, When I write code, I expect it to be more like functional programming. Each function and interface are pure. What kind of calculation and responsibility the function needs to complete need to be thought out in advance and then coded. However, if the method of realizing the function needs to use face-to-object, I still don't hesitate to use it.
As I mentioned earlier, the code itself is free and as vast as the endless wilderness. What affects you is often not the dazzling technologies or products on the market, but the programming ideas. Of course, the following words have deviated from the theme. When I still want to share my current feelings, because I don't know what the future will be like. The company won't care what ideas and technologies you use. Knowledge will never be finished, and I don't want me to only learn some programming ideas, family, society, universe, and a lot of problems worth thinking about.

The above are my immature opinions. I hope to remind myself. It's played by hand, and it won't change if there's an error (\ dog)

Topics: Java list