본문 바로가기

Programing/JVM(Java, Kotlin)

[Java] forEach와 for each 의 차이점은?

카페에 올라온 질문이다.

import java.util.ArrayList;
import java.util.List;

public class LoopTest {
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();

        for (int i=0; i<10; i++) {
            list.add(""+i);
        }

        // 1
        list.forEach(str-> {
            System.out.println(str);
        });

        // 2
        for(String str : list) {
            System.out.println(str);
        }
    }
}

1번과 2번의 차이점은 무엇인가요?

코드리뷰

일단 차이점을 알아보기 전에 코드를 리뷰해보자.

Review 1. ArrayList 객체 생성

일단 ArrayList를 List 인터페이스로 받은 것은 잘한 것 같다.
왜 그런지는 이펙티브자바 3/E "아이템 64. 객체는 인터페이스를 사용해 참조하라"를 읽어보자.

 

다만 자바 7부터라면 다이아몬드 연산자(diamond operator)가 추가되었는데 아래와 같이 쓸 수 있다.

이미 자바 8의 기능(feature)인 Consumer가 코드에 보이므로 언급을 했다.

List<String> list = new ArrayList<>();

만약 자바 10이상이라면 JEP 286(Local Variable Type Inference)이 추가 되었으니 아래와 같이 더욱 타이핑을 줄일 수 있다.

var list = = new ArrayList<String>();

다만 왼쪽의 var는 List 인터페이스가 아닌 우변(LHS)의 ArrayList로 추론이 된다.

Review 2. 숫자의 문자 변환(캐스팅; casting)

아래와 같이 0부터 10까지 숫자를 리스트에 넣기 위해 for 반복문을 이용하고 있다.

그런데 i의 타입은 int 인데 List의 원소는 String이다. 그래서 정수형을 문자형으로 변환을 해서 add를 하고 있다.

list.add("" + i);

"" + i 문장을 보면서 이 문장을 작성한 사람은 JavaScript를 해본 사람이라는 느낌이 들었다.

자바스크립트에서는 문자형(string)과 숫자형(number)을 + 연산으로 수행하면 결과가 string이 된다.

연산의 수행의 세부 내용은 ECMAScript 언어사양의 12.8.3The Addition Operator 를 참고한다.

 

하지만 자바의 경우는 어떻게 동작할까?

자바 버전이나 컴파일러에 따라 다르지만 나의 "11.0.2" 2019-01-15 LTS 컴파일러의 경우 아래와 같이 결과를 만들었다.

LINENUMBER 11 L4
ALOAD 1
NEW java/lang/StringBuilder
DUP
INVOKESPECIAL java/lang/StringBuilder.<init> ()V
LDC ""
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
ILOAD 2
INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
INVOKEINTERFACE java/util/List.add (Ljava/lang/Object;)Z (itf)
POP

다시 자바코드로 옮기면 아래와 같다.

for (int i = 0; i < 10; i++) {
    // list.add("" + i); 는 아래와 같이 수행된다.
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.append("");
    stringBuilder.append(i);
    list.add(stringBuilder.toString());
}

결국 형변환을 위해 루프의 개수만큼 StringBuilder 객체가 생기고 append 연산을 두 번해서 문자열을 만들고 있다.

개선 1안: List<Integer>

테스트를 위한 코드이기 때문에 애초에 List<String>대신에 List<Integer>로 했으면 어떨까하는 생각이 든다.

public class LoopTest {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();

        for (int i = 0; i < 10; i++) {
            list.add(i);
        }

        // 1
        list.forEach(System.out::println);

        // 2
        for (Integer str : list) {
            System.out.println(str);
        }
    }
}

이렇게 되면 list.add(i) 부분은 아래와 같이 컴파일이 되는데

LINENUMBER 11 L4
ALOAD 1
ILOAD 2
INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
INVOKEINTERFACE java/util/List.add (Ljava/lang/Object;)Z (itf)
POP

동일한 자바코드로 옮기면 아래와 같다.

for (int i = 0; i < 10; i++) {
    list.add(Integer.valueOf(i));
}

Primitive 타입인 int 가 Boxed 타입인 Integer로 오토박싱이 되는 정도의 코드가 추가되는 셈이다.

개선 2안: List<String> 으로 유지한다면?

int 를 String으로 바꾸는 것으로 볼 수 있을 것이다.

크게 두 가지가 있다.

1) String.valueOf 을 사용

for (int i = 0; i < 10; i++) {
    list.add(String.valueOf(i));
}

2) Integer.toString 을 사용

for (int i = 0; i < 10; i++) {
    list.add(Integer.toString(i));
}

다만 둘 중에 어떤 것을 쓸지 선택을 해야 하는데,

String.valueOf(int i) 의 경우는 내부적으로 Integer.toString(int i)를 호출한다.

package java.lang;

public final class String
	implements java.io.Serializable, Comparable<String>, CharSequence {
    
	// ...
    public static String valueOf(int i) {
        return Integer.toString(i);
    }
    // ..
    
public final class Integer extends Number implements Comparable<Integer> {
	// ...
    public static String toString(int i) {
        int size = stringSize(i);
        if (COMPACT_STRINGS) {
            byte[] buf = new byte[size];
            getChars(i, size, buf);
            return new String(buf, LATIN1);
        } else {
            byte[] buf = new byte[size * 2];
            StringUTF16.getChars(i, size, buf);
            return new String(buf, UTF16);
        }
    }
    // ..

차이점

이제 원래 질문인 차이점을 살펴보자.

2번

먼저 자바8 이전부터 있던 2번을 먼저 보자.

이런 형태는 자바 규격에서는 향상된 for 문(The enhanced for statement)이라고 정의하고 있다.

for (String str : list) {
    System.out.println(str);
}

이것은 아래 코드처럼 동작한다.

Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    System.out.println(iterator.next());
}

1번

1번의 forEach 정의는 일단 Iterable이라는 인터페이스에 정의되어 있다.

참고로 ArrayList부터의 클래스 계층도를 살펴보면 아래와 같다.

제일 위에 Iterable 이 보인다.

이 문단 제일 앞에 'forEach의 정의'라는 표현이 있었는데 혹시 어색함을 느꼈어야 한다.

어색함을 느끼지 못했다면
1) 이미 자바8에 대해 잘 알고 있던지 아니면 2) 자바8 이전의 인터페이스의 특성을 모르고 있던 중 둘 중 하나일 것이다.

이 언급을 하는 이유는 forEach가 Iterable에 아래와 같이 정의 되어 있기 때문이다.

public interface Iterable<T> {
    Iterator<T> iterator();

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

최상위 인터페이스 답게 이 인터페이스를 구현(implements)하기 위해서는,
Iterator을 돌려주는 iterator() 메서드만 정의하면 된다. (첫줄)

 

자바 8부터 default method 라는 이름과 함께 인터페이스에 메서드 바디를 가질 수 있게 되었다.

그 이전에서는 인터페이스에 메서드를 만드는 것이 아예 불가능했다.

 

forEach의 구현을 보면 아까 2번에서 설명했던 것과 거의 유사하다.

 

// A
for (String str : list) {
    System.out.println(str);
}
// vs
// B
for (T t : this) {
    action.accept(t);
}

다만 좀 다른 것은 B의 경우는 반복문 바디가 인터페이스 내부에 정의되어 있다는 점이다.

따라서 루프를 직접 타이핑할 필요가 없고 다만 action이라는 수행해야 할 동작을 파라미터로 전달하면 되는 것이다.

 

혹시 자바스크립트나 C언어를 해보았다면 이런 비슷한 개념을 이미 접해보았을 것 같다.

전자에서는 함수를 파라미터로 넘기는 일이 빈번하게 일어난다. C언에서는 함수 포인터라는 것을 통해 유사하게 할 수 있다.

여기에서 예제와 비슷하게 자바스크립트로 만들어보면 아래와 같다.

class Test {
    constructor(arr) {
        this.arr = arr;
    }
    forEach(fn) {
        for (let elem of this.arr) {
            fn(elem);
        }
    }
}

const test = new Test([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
test.forEach(console.log);

 

다만 Java는 JavaScript와 달리 함수가 1급 시민이 아니다.

그래서 Consumer라는 인터페이스를 전달받아서 내부적으로는 accept라는 메서드를 실행하도록 구조가 되어 있다.

참고로 Consumer 인터페이스는 함수형 인터페이스(Functional Interface)의 한 종류로 아래와 같이 선언되어 있다.

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
    // ...

결국 위에서 아래와 같은 문장은

list.forEach(str-> {
    System.out.println(str);
});

아래와 유사하다고 볼 수 있다.

 

list.forEach(new PrintWithLinefeed());

//..
class PrintWithLinefeed implements Consumer<String> {
    @Override
    public void accept(String str) {
        System.out.println(str);
    }
}

아래는 익명클래스를 이용한 인터페이스의 구현이다.

list.forEach(new Consumer<String>() {
    @Override
    public void accept(String str) {
        System.out.println(str);
    }
});

바로 위의 코드는 아래 처럼 람다로 바꿀 수 있다.

list.forEach(str -> System.out.println(str));

자, 원래 질문자의 코드가 되었다.

사실 자바8에는 한 단계가 더 있다. 메서드 레퍼런스라는 것을 이용하면 람다 표현이 아래와 같이 바꿀 수 있다.

list.forEach(System.out::println);