본문 바로가기

Reading/Effective Java

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

//컬렉션 순회하기
for(Iterator<Integer> i = numberList.iterator();i.hasNext();){
    Integer number = i.next();
    ...
}
//배열 순회하기
for(int i = 0; i < numberArrays.length; i++{
    Integer number = numberArrays[i];
    ...
}

 

단순히 순회를 목적으로 한다면 필요한 것은 원소일 뿐인데 위와 같은 for문을 사용해야할까? 이 for문은 다음과 같은 단점을 가진다.

  • 쓰이는 요소 종류가 늘어나면 헷갈려서 오류가 생길 가능성 증가한다.
  • 컬렉션 예시 코드는 반복자 i가 세 번 쓰이고, 배열 예시 코드는 인덱스 i가 네 번 쓰이는데 이는 잘못된 변수 사용 가능성이 커진다.
  • 변수를 잘못 사용할 경우, 컴파일 타임에 에러로 잡히지 않는다.
  • 컬렉션이냐 배열이냐에 따라 코드 형태가 상당히 달라진다.

for-each 문( 향상된 for 문; enhanced for statement )


반복자와 인덱스 변수를 사용하지 않은 향상된 for 문은 코드가 깔끔해지고 오류가 날 일도 없다.

 

for (Integer number : numbers) {
    ...
}

 

콜론(:)은 안(in)과 같은 의미이고, numbers 안의 원소 number라고 읽을 수 있다. 하나의 관용구로 컬렉션과 배열을 모두 처리해 어떤 컨테이너를 다루든 신경쓰지 않아도 되고 속도도 그대로다. for-each 문이 만들어내는 코드는 사람이 최적화 한것과 사실상 같다.

 

그러면 컴파일 타임에 for-each문은 어떤식으로 최적화 할지 알아보자.

 

컴파일 전 배열과 리스트 각각 for-each 문 형성

 

컴파일 후 변환된 class

 

컴파일 전 for-each 문으로 통일했는데, 컴파일러는 List의 경우에는 iterator로, 배열의 경우에는 가장 보편적인 length를 통해 인덱스를 접근하는 방식으로 변형시켜주었다. 즉, for-each 문은 사용자가 사용하기 편하게 제공하면서 내부적으로는 최적화를 시켜주는 것이다.

 

여기서 이상한 점이 있다. for-each 문 내부를 전부 비웠긴 하지만, list의 for-each 문이 최적화된 내용은 var4 라는 변수를 증감식에서 next를 호출했다. 그러면 제일 처음 for 문에서는 var4 라는 변수를 사용할 수 없는 것 아닌가? 라고 생각했다.

그래서 다음과 같이 두 경우로 나눠서 어떤 식으로 컴파일 되는지를 확인했다.

 

// for-each 내부에 println 메소드 넣기 - java
List<Integer> numberList = List.of(1, 2, 3);
for (Integer number : numberList) {
    System.out.println("number = " + number);
}

// for-each 내부에 println 메소드 넣기 - class
List<Integer> numberList = List.of(1, 2, 3);
Iterator var2 = numberList.iterator();
while(var2.hasNext()) {
    Integer number = (Integer)var2.next();
    System.out.println("number = " + number);
}

// for-each 내부에 새로운 변수를 선언하고, 거기에 현재 원소를 넣기 - java
List<Integer> numberList = List.of(1, 2, 3);
for (Integer number : numberList) {
   int a = number+2;
}

// for-each 내부에 새로운 변수를 선언하고, 거기에 현재 원소를 넣기 - class
List<Integer> numberList = List.of(1, 2, 3);
Integer number;
int var4;
for(Iterator var2 = numberList.iterator(); var2.hasNext(); var4 = number + 2) {
    number = (Integer)var2.next();
}

 

매번 동일한 형식의 정적인 for 문을 형성하는게 아닌 가장 최적의 방법으로 컴파일 되고 필요하다면 while 문도 사용했다.

즉, 사람이 만들어낸 코드보다 더 안전하게 최적화 해준다는 것을 알 수 있다.

 

컬렉션 중첩 순회


이 경우 for-each 문의 이점이 더 커진다. for 문의 반복문을 중첩할 때 저지르는 실수를 예방할 수도 있다.

다음과 같이 모양을 나타내는 Suit 열거타입과 숫자를 나타내는 Rank를 통해 카드를 만든다고 가정해보자.

 

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

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

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

 

이 때 일반적인 iterator로 for 문을 구성하면 버그가 발생할 수 있다.

 

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

 

 

13개의 숫자(Rank) * 4개의 모양(Suit)  = 52개의 카드로 덱을 설정해야 하지만 Suit의 next 메소드가 내부 Rank의 반복자에서 같이 호출된다. 이렇게 되면 (CLUB, ACE), (DIAMOND, DEUCE), (HEART, THREE), (SPADE, FOUR) 까지 호출하고, Rank가 FIVE를 호출할 때 Suit는 순회가 끝났는데 next() 메소드를 호출하면서 NoSuchElementException을 던진다.

 

만약 숫자도 4개까지 있었으면, 4*4 = 16개의 카드로 덱을 생성했던 것을 기대했던 것과 달리 네 번의 반복만 하고 예외도 던지지 않고 종료하게 된다. 주사위를 두 번 굴렸을 때 나오는 경우의 수 6*6을 기대했다면, 위 코드처럼 코딩할 경우 (1,1)...(6.6)이 되며 예외도 던지지 않고 종료된다는 의미다. [주사위 예시 코드]

 

하지만 for-each 문을 사용한다면 이 문제는 굉장히 단순해지고 간결해진다.

 

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

 

for-each 문을 사용할 수 없는 상황


파괴적인 필터링(destructive filtering)

컬렉션을 순회하며 선택된 원소를 제거해야 한다면 반복자의 remove 메소드를 호출해야한다. 자바 8부터는 Collection의 removeIf 메소드를 사용해 컬렉션을 명시적으로 순회하는 일을 피할 수 있다.

[테스트 코드 확인]

 

변형(transforming)

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

[테스트 코드 확인]

 

병렬 반복(parallel iteration)

여러 컬렉션을 병렬로 순회해야 하면, 각각의 반복자와 인덱스 변수를 사용해 엄격하고 명시적으로 제어해야한다.

[테스트 코드 확인] - 위 카드의 iterator 실수 예시와 같은 사례

 

3가지 상황에 속한다면 일반적인 for문을 사용하되, 설명한 예외 사례들을 주의하며 사용하자.

 

 

for-each문의 사용 조건


컬렉션과 배열은 물론 Iterable 인터페이스를 구현한 객체라면 무엇이든 순회할 수 있다.

 

// Iterable 인터페이스는 이 객체의 원소들을 순회하는 반복자를 반환해주고
// splicator와 forEach 메소드가 default로 제공된다.
public interface Iterable<T> {

    Iterator<T> iterator();

    default void forEach(Consumer<? super T> action) {
        Objects.requireNonNull(action);
        for (T t : this) {
            action.accept(t);
        }
    }

    default Spliterator<T> spliterator() {
        return Spliterators.spliteratorUnknownSize(iterator(), 0);
    }
}

 

따라서 원소들의 묶음을 표현하는 타입을 작성해야하면 Collection 인터페이스를 구현하진 않더라도 Iterable을 구현해두면 원소들의 순회에 사용할 수 있어서 좋아진다. for-each 문은 명료하고, 유연하고 버그를 예방해주며 성능저하도 없으니 가능한 모든 곳에서 사용하자.

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.