본문 바로가기
Book

[모던 자바 인 액션] 3장. 람다 표현식

by Jordy-torvalds 2021. 9. 28.
반응형
public class StreamPerformanceTest {

    private static final intTEST_MAX_RANGE= 30_000_000;
    private static List<Integer>list= new ArrayList<Integer>();

    public static void main(String[] args) {
init("init", () -> IntStream.rangeClosed(1,TEST_MAX_RANGE).boxed().collect(toList()));

test("for-loop", (List<Integer> list) -> {
            int result = 0;
            for (int each : list) {
                result += each;
            }
        });

test("int-stream", () -> {
            IntStream.rangeClosed(1,TEST_MAX_RANGE).sum();
        });

test("int-parallel-stream", () -> {
            IntStream.rangeClosed(1,TEST_MAX_RANGE).parallel().sum();
        });

test("stream", (List<Integer> list) -> {
            list.stream().mapToInt(i -> i.intValue()).sum();
        });

test("parallel-stream-non-unboxing", (List<Integer> list) -> {
            list.parallelStream().mapToInt(i -> i).sum();
        });

test("parallel-stream", (List<Integer> list) -> {
            list.parallelStream().mapToInt(Integer::intValue).sum();
        });

    }

    private static void init(String label, Supplier<List<Integer>> function) {
        long current =currentTimeMillis();
list= function.get();
        long end =currentTimeMillis();
        System.out.println(label + "    " + (end - current));
    }

    private static void test(String label, Consumer<List<Integer>> function) {
        long current =currentTimeMillis();
        function.accept(list);
        long end =currentTimeMillis();
        System.out.println(label + "    " + (end - current));
    }

    private static void test(String label, Runnable function) {
        long current =currentTimeMillis();
        function.run();
        long end =currentTimeMillis();
        System.out.println(label + "    " + (end - current));
    }
}

위 코드는 for-loop와 박싱된 Stream, ParallelStream, 그리고 박싱되지 않은 Stream, parallelStream의 성능 비교를 하는 코드이다.

위 코드를 통해 배울 수 있는 것은 아래와 같다.

메소드 참조를 통한 코드 간결화

일부 코드는 메소드 참조를 사용함으로써 람다를 사용하는 것보다 더 간략하게 표현하는 것이 가능하다.

위 코드에서는 i -> Integer.intValue(i) 라는 mapToInt함수 내부의 로직이 메소드 참조로 간결화 되어 Integer::intValue로 표현되었다.

기본형 클래스의 성능 영향도

그리고 실행해보면 언박싱된 스트림이 박싱된 스트림이나 for-loop보다 빠른 것을 알 수 있는데, 가볍게 생각할 수 있는 박싱/언박싱 유무가 생각 외로 성능에 큰 영향을 줌을 알 수 있다.

IntStream 외에도 IntPredicate, IntConsumer, LongFunction 등 대부분의 기본형 함수형 인터페이스 제공해줌으로 기본형 사용이 가능하면 기본형을 써야할 것이다.

For-loop와 Stream의 성능 비교

for-loop와 박싱된 Stream의 소요시간을 비교해보면 그렇게 큰 차이가 나지 않는 것을 알 수 있는데, 그렇기 때문에 for-loop와 Stream을 두고 고민할 때 parallel을 사용하지 않는 이상 성능이 그 지표가 될 필요는 없으며 Stream은 오히려 lazy processing이 가능하므로 성능과 가독성 모두를 취할 수 있는 선택지가 될 수 있다.

실행 어라운드 패턴

자원처리에 사용하는 순환(반복적인) 패턴은 자원을 열고, 처리한 다음에, 자원을 닫는 순서로 이루어 진다. 서정과 정리 과정을 대부분 비슷하다.

텍스트 파일에서 텍스트를 읽고 자원을 반환하는 코드의 경우에는 아래와 같이 코드가 작성될 수 있는데, 동일 프로젝트 내에서 읽어드리는 파일과 읽어드린 데이터를 처리하는 방법만 변경될 뿐 리더를 열고 닫는 패턴을 비슷할 것이다.

 

위 예시에서는 test란 메소드가 ms를 측정하고 이를 화면에 출력하는 부분은 각 테스트 로직이 모두 동일한 반면 내부에서 실행되는 스트림 로직만 변경되어서 실행 어라운드 패턴을 적용해서 코드를 작성했다.

함수형 인터페이스의 종류

파라미터 타입과 반환 타입에 따라 함수형 인터페이스를 선택할 수 있다. 그 종류는 아래와 같다.

void 호환 규칙

람다의 바디에 일반 표현식이 있으면 void를 반환하는 함수 디스크립터와 호환된다.(물론 파라미터 리스트도 호환되어야 함) 예를 들어 다음 두 행의 예제에서 List의 add메서드는 Consumter 콘텍스트(T → void)가 기대하는 void 대신 boolean을 반환하지만 유효한 코드다.

// list.add는 boolean 반환 값을 가짐
// 그런데, 반환 타입이 void인 Consumer에서 list.add를 써도 컴파일 에러가 발생되지 않음!
Predicate<String> predicate = s -> list.add(s);
Consumer<String> consumer = s -> list.add(s);

지역 변수 사용

람다 표현식은 파라미터로 넘겨받은 변수가 아닌 자유 변수라도 사용할 수 있는데 이를 람다 캡쳐링capturing lambda 이라 한다.

그런데, 람다 캡쳐링에도 제약 조건이 있다. 사용될 지역 변수가 final 키워드를 붙인 상수 이거나 상수처럼 사용되어야 한다는 점이다. 만약 이를 무시하고 아래 코드와 같이 값을 변경하려 시도할 경우 컴파일 에러가 발생한다.

아래 코드는 portNumber = 8443; 을 제거하면 정상적으로 컴파일이 된다.

지역 변수의 제약

왜 위와 같은 제약이 필요한 걸까? 우선 인스턴스 변수는 힙에 저장되는 반면 지역변수는 스택에 저장된다. 람다에서 지역 변수에 바로 접근할 수 있다는 가정하에 람다가 스레드에 서 실행된다면 변수를 할당한 스레드가 사라져서 변수 할당이 해제 되었는데도 람다를 실행하는 스레드에서는 해당 변수에 접근하려 할 수 있다. 따라서 자바 구현에서는 원래 변수에 접근을 허용하는 것이 아니라 자유 지역 변수의 복사본을 제공한다, 따라서 복사본의 값이 바뀌지 않아야 하므로 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생긴 것이다.

그래서 람다가 메소드 바디에 있는 변수가 아닌 클래스의 멤버 변수를 참조하도록 코드를 작성하면 값을 변경할 수 있다.

 

아래와 같이 함수 구현체를 반환 받아 실행하는 것도 가능하다.

Untitled

위 코드의 경우 8443이 콘솔에 출력되는데, Runnable을 반환 받더라도 참조하는 대상이 SubCapturingLambdaExample의 portNumber이기 때문이다.

Predicate 조합

Predicate는 andor 메소드로 조합함으로써 더 복잡한 로직을 만드는 것이 가능하다.
두 메소드 모두 디폴트 메소드로 구현되었으며, IntPredicate와 같은 기본형 함수형 인터페이스는 지원하지 않는다.

또한 타입 파라미터가 다른 경우에는 조합이 불가하다.

class Service {
    public int portNumber;
    public String serviceName;

    public Service(int portNumber, String serviceName) {
        this.portNumber = portNumber;
        this.serviceName = serviceName;
    }
}

class CapturingLambdaExample{
    public static void main(String[] args) {
        Service jordyBlogService = new Service(8443, "죠르디 블로그");

        Predicate<Service> testPortNumber = service -> service.portNumber == 8443;
        Predicate<Service> testServiceName = service -> service.serviceName.equals("죠르디 블로그");
        Predicate<Service> testJordyService = testPortNumber.and(testServiceName);

        System.out.println(testJordyService.test(jordyBlogService));
    }
}

Function 조합

Function도 compose라는 메소드와 andThen이라는 메소드를 사용해 조합하는 것이 가능하다.

compose는 파라미터로 넣어주는 함수를 먼저 실행하지만, andThen은 해당 함수 구현체를 먼저 실행한다.

두 메소드는 디폴트 메소드로 구현되었으며, 기본형 함수형 인터페이스는 지원하지 않는다.
또한 타입 파라미터가 다른 경우에는 조합이 불가하다.

class FunctionComposition{
    public static void main(String[] args) {
        Function<Integer, Integer> plusOne = x -> x + 1;
        Function<Integer, Integer> multiplyThree = x -> x * 3;

        Function<Integer, Integer> firstMultiplyThreeSecondPlusOne 
                        = plusOne.compose(multiplyThree);
        Function<Integer, Integer> firstPlusOneSecondMultiplyThree 
                        = plusOne.andThen(multiplyThree);

        System.out.println(firstMultiplyThreeSecondPlusOne.apply(3)); // 10
        System.out.println(firstPlusOneSecondMultiplyThree.apply(2)); // 9
    }
}
반응형