Post

Java List에서 특정 값의 모든 항목 제거

1. while 루프 사용

단일 요소를 제거하는 방법을 알고 있으므로 루프에서 반복적으로 수행하는 것은 간단해 보인다.

1
2
3
4
5
void removeAll(List<Integer> list, int element) {
    while (list.contains(element)) {
        list.remove(element);
    }
}

그러나 예상대로 작동하지 않는다.

1
2
3
4
5
6
7
// given
List<Integer> list = list(1, 2, 3);
int valueToRemove = 1;

// when
assertThatThrownBy(() -> removeAll(list, valueToRemove))
  .isInstanceOf(IndexOutOfBoundsException.class);

문제는 세 번째 줄에 있다. List.remove(int)를 호출한다. List.remove(int)는 인수를 제거하려는 값이 아니라 인덱스로 취급한다.

위의 테스트에서는 항상 list.remove(1)를 호출하지만 제거하려는 요소의 인덱스는 0이다. List.remove()를 호출하면 제거된 요소 뒤의 모든 요소가 더 작은 인덱스로 이동한다.

이 시나리오에서는 첫 번째 요소를 제외한 모든 요소를 삭제한다는 의미이다.

첫 번째만 남으면 인덱스 1이 불법이 된다. 따라서 우리는 예외를 얻는다.

컴파일러가 일치하는 오버로드된 메서드를 찾으려고 시도할 때 가장 먼저 수행하는 작업이 넓어지기 때문에 기본 byte, short, char 또는 int 인수를 사용하여 List.remove()를 호출하는 경우에만 이 문제에 직면한다.

값을 Integer로 전달하여 수정할 수 있다.

1
2
3
4
5
void removeAll(List<Integer> list, Integer element) {
    while (list.contains(element)) {
        list.remove(element);
    }
}

이제 코드가 예상대로 작동한다.

1
2
3
4
5
6
7
8
9
// given
List<Integer> list = list(1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

List.contains()List.remove() 둘 다 요소의 첫 번째 항목을 찾아야 하므로 이 코드는 불필요한 요소 순회를 유발한다.

첫 번째 발생의 인덱스를 저장하면 더 잘할 수 있다.

1
2
3
4
5
6
void removeAll(List<Integer> list, Integer element) {
    int index;
    while ((index = list.indexOf(element)) >= 0) {
        list.remove(index);
    }
}

작동하는지 확인할 수 있다.

1
2
3
4
5
6
7
8
9
// given
List<Integer> list = list(1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

이러한 솔루션은 짧고 깨끗한 코드를 생성하지만, 여전히 성능이 좋지 않다. 진행 상황을 추적하지 않기 때문에 List.remove()는 제공된 값의 첫 번째 발생을 찾아 삭제해야 합니다.

또한 ArrayList를 사용할 때 요소 이동으로 인해 많은 참조 복사가 발생할 수 있으며 백업 배열을 여러 번 재할당하는 경우도 있다.

2. List가 변경될 때까지 제거

List.remove(E element)에는 아직 언급하지 않은 기능이 있다. boolean 값을 반환한다. 작업으로 인해 List가 변경된 경우 true이므로 요소를 포함한다.

List.remove(int index)는 제공된 인덱스가 유효한 경우 List가 항상 인덱스를 제거하기 때문에 void를 반환한다. 그렇지 않으면 IndexOutOfBoundsException이 발생한다.

이를 통해 목록이 변경될 때까지 제거를 수행할 수 있다.

1
2
3
void removeAll(List<Integer> list, int element) {
    while (list.remove(element));
}

예상대로 작동한다.

1
2
3
4
5
6
7
8
9
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

짧음에도 불구하고 이 구현은 이전 섹션에서 설명한 것과 동일한 문제를 겪고 있다.

3. for 루프 사용

for 루프를 사용하여 요소를 순회하여 진행 상황을 추적하고 일치하는 경우 현재 요소를 제거할 수 있다.

1
2
3
4
5
6
7
void removeAll(List<Integer> list, int element) {
    for (int i = 0; i < list.size(); i++) {
        if (Objects.equals(element, list.get(i))) {
            list.remove(i);
        }
    }
}

예상대로 작동한다.

1
2
3
4
5
6
7
8
9
// given
List<Integer> list = list(1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

그러나 다른 입력으로 시도하면 잘못된 출력이 제공된다.

1
2
3
4
5
6
7
8
9
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(1, 2, 3));

코드가 어떻게 작동하는지 단계별로 분석해본다.

  • i = 0
    • element와 list.get(i)는 3행에서 모두 1 이므로 Java는 if 문의 본문에 들어간다.
    • 인덱스 0 에서 요소를 제거한다.
    • 이제 List에는 1, 2 및 3이 포함된다.
  • i = 1
    • list.get(i)는 List에서 요소를 제거할 때 모든 진행 요소를 더 작은 인덱스로 이동하기 때문에 2를 반환한다.

따라서 제거하려는 두 개의 인접한 값이 있을 때 이 문제에 직면한다. 이를 해결하기 위해서는 루프 변수를 유지해야 한다.

요소를 제거할 때 감소

1
2
3
4
5
6
7
8
void removeAll(List<Integer> list, int element) {
    for (int i = 0; i < list.size(); i++) {
        if (Objects.equals(element, list.get(i))) {
            list.remove(i);
            i--;
        }
    }
}

요소를 제거하지 않을 때만 늘린다.

1
2
3
4
5
6
7
8
9
void removeAll(List<Integer> list, int element) {
    for (int i = 0; i < list.size();) {
        if (Objects.equals(element, list.get(i))) {
            list.remove(i);
        } else {
            i++;
        }
    }
}

후자의 경우 2행에서 i++문을 제거했다.

두 솔루션 모두 예상대로 작동한다.

1
2
3
4
5
6
7
8
9
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

이 구현은 옳은 것 같다. 그러나 여전히 심각한 성능 문제가 있다.

  • ArrayList에서 요소를 제거하고 그 뒤의 모든 항목을 이동한다.

  • LinkedList에서 인덱스로 요소에 액세스한다는 것은 인덱스를 찾을 때까지 요소를 하나씩 순회하는 것을 의미한다.

4. for-each 루프 사용

Java 5부터 for-each 루프를 사용하여 List를 반복 할 수 있다. 이를 사용하여 요소를 제거해 본다.

1
2
3
4
5
6
7
void removeAll(List<Integer> list, int element) {
    for (Integer number : list) {
        if (Objects.equals(number, element)) {
            list.remove(number);
        }
    }
}

루프 변수의 유형으로 Integer를 사용한다. 따라서 NullPointerException을 얻지 못할 것이다.

또한 이렇게 하면 인덱스가 아니라 제거하려는 값을 예상하는 List.remove(E element)를 호출한다.

보기에는 깨끗해 보이지만 안타깝게도 작동하지 않는다.

1
2
3
4
5
6
7
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
assertThatThrownBy(() -> removeWithForEachLoop(list, valueToRemove))
  .isInstanceOf(ConcurrentModificationException.class);

for-each 루프는 Iterator를 사용하여 요소를 순회한다. 그러나 List를 수정하면 Iterator가 일관성 없는 상태가 된다. 따라서 ConcurrentModificationException이 발생한다.

교훈은 for-each 루프 에서 해당 요소에 액세스하는 동안 List를 수정해서는 안 된다는 것이다.

5. Iterator 사용

Iterator를 직접 사용하여 List를 순회하고 수정할 수 있다.

1
2
3
4
5
6
7
8
void removeAll(List<Integer> list, int element) {
    for (Iterator<Integer> i = list.iterator(); i.hasNext();) {
        Integer number = i.next();
        if (Objects.equals(number, element)) {
            i.remove();
        }
    }
}

이렇게 하면 Iterator가 List의 상태를 추적할 수 있다 (수정하기 때문). 결과적으로 위의 코드는 예상대로 작동한다.

1
2
3
4
5
6
7
8
9
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

모든 List 클래스는 자체 Iterator 구현을 제공할 수 있으므로 가장 효율적인 방법으로 요소 순회 및 제거를 구현한다고 가정할 수 있다.

그러나 ArrayList를 사용한다는 것은 여전히 많은 요소 이동 (및 배열 재할당)을 의미한다. 또한 위의 코드는 대부분의 개발자에게 익숙한 표준 for 루프와 다르기 때문에 읽기가 약간 더 어렵다.

6. Collecting

여기까지는 필요하지 않은 항목을 제거하여 원래 List 개체를 수정했다. 오히려 새 목록을 만들고 유지하려는 항목을 수집할 수 있다.

1
2
3
4
5
6
7
8
9
List<Integer> removeAll(List<Integer> list, int element) {
    List<Integer> remainingElements = new ArrayList<>();
    for (Integer number : list) {
        if (!Objects.equals(number, element)) {
            remainingElements.add(number);
        }
    }
    return remainingElements;
}

새로운 List 객체에 결과를 제공하기 때문에 메서드에서 반환해야 한다. 따라서 이 방법을 다른 방식으로 사용해야 한다.

1
2
3
4
5
6
7
8
9
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
List<Integer> result = removeAll(list, valueToRemove);

// then
assertThat(result).isEqualTo(list(2, 3));

현재 반복하고 있는 목록을 수정하지 않기 때문에 이제 for-each 루프를 사용할 수 있다.

제거가 없기 때문에 요소를 이동할 필요가 없다. 따라서 이 구현은 ArrayList를 사용할 때 잘 수행된다.

이 구현은 몇 가지 방식에서 이전 구현과 다르게 동작한다.

  • 원래 목록을 수정하지 않고 새 목록을 반환한다.

  • 메서드는 반환된 List의 구현이 무엇인지 결정하며 원래와 다를 수 있다.

또한 구현을 수정하여 이전 동작을 얻을 수 있다. 원래 목록을 지우고 수집된 요소를 추가한다.

1
2
3
4
5
6
7
8
9
10
11
void removeAll(List<Integer> list, int element) {
    List<Integer> remainingElements = new ArrayList<>();
    for (Integer number : list) {
        if (!Objects.equals(number, element)) {
            remainingElements.add(number);
        }
    }

    list.clear();
    list.addAll(remainingElements);
}

이전과 동일한 방식으로 작동한다.

1
2
3
4
5
6
7
8
9
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

List를 지속적으로 수정하지 않기 때문에 위치별로 요소에 액세스하거나 요소를 이동할 필요가 없다. 또한 가능한 array 재할당은 List.clear()List.addAll()을 호출할 때 두 가지뿐이다.

7. Stream API 사용

Java 8은 람다 식과 스트림 API를 도입했다. 이러한 강력한 기능을 통해 매우 깔끔한 코드로 문제를 해결할 수 있다.

1
2
3
4
5
List<Integer> removeAll(List<Integer> list, int element) {
    return list.stream()
      .filter(e -> !Objects.equals(e, element))
      .collect(Collectors.toList());
}

이 솔루션은 나머지 요소를 수집할 때와 동일한 방식으로 작동한다.

결과적으로 동일한 특성을 가지며 이를 사용하여 결과를 반환해야 한다.

1
2
3
4
5
6
7
8
9
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
List<Integer> result = removeAll(list, valueToRemove);

// then
assertThat(result).isEqualTo(list(2, 3));

원래 ‘collecting’ 구현과 동일한 접근 방식을 사용하여 다른 솔루션처럼 작동하도록 변환할 수 있다.

8. removeIf 사용

람다 및 기능적 인터페이스와 함께 Java 8은 일부 API 확장도 도입했다. 예를 들어 List.removeIf() 메서드는 지난 섹션에서 본 것을 구현한다.

요소를 유지하려고 할 때 true를 반환해야 했던 이전 예제와 달리 요소를 제거하려고 할 때 true를 반환해야 하는 Predicate를 기대한다.

1
2
3
void removeAll(List<Integer> list, int element) {
    list.removeIf(n -> Objects.equals(n, element));
}

위의 다른 솔루션처럼 작동한다.

1
2
3
4
5
6
7
8
9
// given
List<Integer> list = list(1, 1, 2, 3);
int valueToRemove = 1;

// when
removeAll(list, valueToRemove);

// then
assertThat(list).isEqualTo(list(2, 3));

List 자체가 이 메서드를 구현한다는 사실 때문에 최상의 성능을 제공한다고 가정할 수 있다. 무엇보다 이 솔루션은 가장 깨끗한 코드를 제공한다.

[출처 및 참고]

This post is licensed under CC BY 4.0 by the author.