Backend/Java

[Effective Java : Item 58] 전통적인 for 문보다는 for-each 문을 사용하라

김세진 2024. 3. 18. 17:47
반응형

 

 

 

 

for 문

 

// for 문으로 컬렉션 순회
for (Iterator<Element> i = c.iterator(); i.hasNext(); ) {
    Element e = i.next();
    ... // 부수적인 동작
}

// for 문으로 배열 순회
for (int i = 0; i < a.length; i++) {
    ... // 부수적인 동작
}

 

위 코드는 for 문으로 컬렉션과 배열을 순회하는 코드이다. 이러한 순회 방식은 while 보다는 낫지만, 몇 가지 단점이 존재한다.

 

단점

1. 가독성

반복자와 인덱스 변수는 가독성을 해친다. 또한 원소만 필요한 경우 원소 반환 목적 외에는 의미가 없는 코드이다.

2. 휴먼 에러

반복자와 인덱스가 많은 빈도로 등장하여 변수를 잘못 사용할 가능성이 높아진다. 또한 컴파일러가 잘못 사용된 변수에 대한 오류를 잡아주리라는 보장이 없다.

3. 작성 형태

컬렉션이냐 배열이냐에 따라 코드 형태가 상당히 달라지게 된다. 이는 추후 코드가 변경될 가능성이 있을 때, 유연성을 해칠 수 있다.

 

 

 

컬렉션을 중첩해 순회하는 경우의 예제를 살펴보도록 하자.

 

enum Suit { CLUB, DIAMOND, HEART, SPADE }
enum Rank { ACE, DEUCE, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, TEN, JACK, QUEEN, KING }

static Collection<Suit> suits = Arrays.asList(Suit.values());
static Collection<Rank> ranks = Arrays.asList(Rank.values());

static class Card {
    private final Suit suit;
    private final Rank rank;

    Card(Suit suit, Rank rank) {
        this.suit = suit;
        this.rank = rank;
    }
}

public static void main(String[] args) {
    List<Card> deck = new ArrayList<>();
    for (Iterator<Suit> i = suits.iterator(); i.hasNext(); ) {
        for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); ) {
            deck.add(new Card(i.next(), j.next()));
        }
    }
}

 

위 코드의 main은 카드의 무늬(suit)와 숫자(rank)를 순회하며 카드의 모든 조합을 출력하는 코드이다. 사실은 버그가 존재하는 코드로, 숙련된 프로그래머도 쉽게 알아차리기 힘들 것이라고 저자는 설명한다. 위 main 코드를 수행하면 아래와 같이 NoSuchElementException이 발생한다.

 

 

마지막 줄의 i.next() 가 Rank 를 순회할 동안 한 번만 호출되어야 하지만, 안쪽의 Rank 를 순회하는 코드에서 반복 호출되기 때문에 위 예외가 발생한 것이다. 

 

정상 작동하기 위해선 바깥 반복문의 원소를 저장할 변수를 하나 추가해야 한다.

 

for (Iterator<Suit> i = suits.iterator(); i.hasNext(); ) {
    Suit suit = i.next();
    for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); ) {
        deck.add(new Card(suit, j.next()));
    }
}

 

이제 예외가 발생하지는 않지만, 여전히 가독성이 떨어져 보기에 좋지는 않다.

 

심지어 바깥 컬렉션의 크기가 안쪽 컬렉션 크기의 배수라면 위 반복문은 예외를 던지지도 않는다. 아래는 주사위 두 개를 던질 때 나올 수 있는 모든 조합(순열)을 찾는 예제이다.

 

enum Face { ONE, TWO, THREE, FOUR, FIVE, SIX }

public static void main(String[] args) {
    Collection<Face> faces = EnumSet.allOf(Face.class);

    for (Iterator<Face> i = faces.iterator(); i.hasNext(); ) {
        for (Iterator<Face> j = faces.iterator(); j.hasNext(); ) {
            System.out.println(i.next() + " " + j.next());
        }
    }
}

 

위 코드는 예외가 발생하지는 않지만, 아래와 같이 당초 의도했던 36개의 조합이 아닌 "ONE ONE", "TWO TWO", ... , "SIX SIX" 까지 6개의 조합만을 출력한다.

 

 

이 문제를 해결하려면 마찬가지로 바깥 반복문의 원소를 저장할 변수를 하나 추가해야 한다.

 

 


for-each 문 (enhanced for statement)

 

위에서 언급된 문제들은 for 문 대신 for-each 문을 사용하면 모두 해결된다. 

 

장점

1. 반복자와 인덱스 변수를 사용하지 않아 가독성이 좋아지고 오류가 날 일이 없어진다.

2. 하나의 관용구로 컬렉션 및 배열을 모두 처리할 수 있어서 어떤 컨테이너를 다루든지 신경쓰지 않아도 된다.

3. 심지어 반복 대상과 상관없이 for 문과 비교하여 성능이 동일하다.

 

for (Element e : elements) {
    ... // 부수적인 동작
}

 

위는 for-each 문 예시 코드이다. 컬렉션과 배열의 구분 없이 같은 관용구를 사용하며, "elements 안의 각 원소 e에 대해" 라 읽을 수 있다. 반복자와 인덱스를 제거하여 코드가 명료해진 것을 볼 수 있다. 

 

위에서 언급했던 Card 예제 코드에 for-each 문을 적용하면 다음과 같이 바꿀 수 있다.

 

for (Suit suit : suits) {
    for (Rank rank : ranks) {
        deck.add(new Card(suit, rank));
    }
}

 

전통적인 for 문을 사용할 때와 비교해서 코드가 상당히 깔끔하고 명료해진 것을 볼 수 있다.

 

객체에 적용

for-each 문은 컬렉션과 배열 뿐 아니라 Iterable 인터페이스를 구현한 객체라면 무엇이든 순회가 가능하다. 원소의 묶음을 표현하는 타입을 작성해야 한다면, Collection 인터페이스는 구현하지 않더라도 Iterable 인터페이스를 구현하는 것은 고려해보는 것이 좋겠다.

 

IDE 단축키

한편, IntelliJ 같은 IDE를 사용하면 컬렉션 및 배열에 대한 for-each 문을 더욱 쉽게 작성할 수 있다.

 

 

위처럼 for-each 로 순회하고 싶은 컬렉션 혹은 배열의 뒤에 for을 입력하고 엔터를 누르면 아래와 같이 올바른 원소 타입을 가져와 자동으로 for-each 문을 작성해준다.

 

 

 


for-each 를 사용할 수 없는 경우

 

for-each 문을 사용할 수 없는 상황 세 가지가 존재한다.

 

파괴적 필터링(destructive filtering)

컬렉션을 순회하는 도중 컬렉션에 직접 remove 메서드를 사용할 경우 ConcurrentModificationException 이 발생한다.

 

List<Integer> numbers = new ArrayList<>();
numbers.add(1);
numbers.add(2);
numbers.add(3);

// for-each 문 사용 불가
for (Integer num : numbers) {
    if (num % 2 == 1) {
        numbers.remove(num); // ConcurrentModificationException 발생
    }
}

 

따라서 컬렉션을 순회하며 선택된 원소를 제거해야 한다면 반복자의 remove 메서드를 호출해야 한다. 

 

// 반복자를 통해 제거
Iterator<Integer> iterator = numbers.iterator();
while (iterator.hasNext()) {
    Integer num = iterator.next();
    if (num % 2 == 1) {
        iterator.remove();
    }
}

 

자바 8부터는 Collection의 removeIf() 메서드를 통해 컬렉션을 명시적으로 순회하지 않고 원소를 제거할 수 있다.

 

// 명시적으로 순회하지 않고 원소 제거
numbers.removeIf(num -> num % 2 == 1);

 

변형(transforming)

리스트나 배열을 순회하며 그 원소의 값 일부 혹은 전체를 교체해야 한다면 리스트의 반복자나 배열의 인덱스를 사용해야 한다.

List<String> strings = new ArrayList<>();
strings.add("Hello");
strings.add("World");

// for-each 문 사용 불가
for (String str : strings) {
    str = str.toUpperCase(); // 리스트의 값이 변경되지 않음
}

// 반복자나 인덱스를 사용
for (int i = 0; i < strings.size(); i++) {
    strings.set(i, strings.get(i).toUpperCase()); // 리스트의 값을 대문자로 변경
}

 

 

병렬 반복(parallel iteration)

for-each는 한 리스트의 요소에 대한 인덱스를 다른 리스트에서 사용할 수 없다. 따라서 여러 컬렉션을 병렬로 순회해야 하는 경우, 각각의 반복자와 인덱스 변수를 사용해 엄격하고 명시적으로 제어해야 한다.

 

 

 

위의 세 가지 상황 중 하나에 속할 때는 어쩔 수 없이 일반적인 for 문을 사용하되, 언급된 문제들을 경계하며 사용해야 한다.

 

 


정리

 

1. 전통적 for 문과 비교했을 때 for-each 문은 명료하고, 유연하며, 버그를 예방한다.

2. for 문과 비교해 성능 저하도 발생하지 않는다.

3. 가능하다면, 최대한 for 문 대신 for-each 문을 사용하자.

 

 

 

 

 

 

반응형