/ jvm

뒤늦게 배워보자, Java 8 Part. 1

  • Updated - 2018.07.16

이 문서는 자바 8 인 액션 - 람다, 스트림, 함수형 프로그래밍으로 새로워진 자바 마스터하기Functional Programming in Java 8 - 자바 8 람다의 힘을 참고하였습니다. 개인적으로 자바 8 인 액션을 추천드리며, 해당 기사에서 사용한 모든 예제는 자바 8 인 액션을 발췌, 수정하였습니다.

람다 표현식(Lambda Expressions)

2010년도에 'Project Lambda'라는 프로젝트로 진행된 결과물인 람다 표현식(lambda expressions)이 JDK 8에 포함되었습니다. JDK 8에서 함수형 인터페이스(functional interface)와 람다 표현식을 사용하여 입력에 의해서만(!) 출력이 결정되도록 하여 '순수한 함수'(pure function)를 표현할 수 있게 되었고, 람다 표현식을 사용해서 '익명 함수'(annonymous function)를 정의할 수 있게 되었으며, 함수형 인터페이스의 메소드에서 또 다른 함수형 인터페이스를 인자로 받을 수 있도록 하여 '고차 함수'(higher-order function)를 정의할 수 있게 되었습니다.

이 중에서 오늘 알아볼 람다 표현식은 알론조 처치(Alonzo Church)가 1930년대에 제안한 (λ-calculus, lambda-calculus)에서 유래했습니다. 람다 표현식을 프로그래밍 언어의 개념으로는 단순하게 정의하자면 (단순한) 익명 함수 생성 문법이라 할 수 있습니다.

동작 파라미터화(혹은 행위 매개변수화, behavior parameterization)

동작 파라미터화(behavior parameterization)란 어떤 형태로 실행될지 결정되지 않은 코드 블록(code block)을 의미합니다. 동작 파라미터화에서 사용되는 코드 블록의 실행은 (당연하게도) 미뤄질 수 있습니다(laziness). 동작 파라미터화라는 단어나 행위 매개변수화에 대한 번역이 (굉장히) 어색하고 영어/한글/한자가 모두 포함된 전형적인 보그형 단어라 생각하지만 몇몇 책에서 해당 용어를 이런 형태로 번역하고 있어서 차용해서 사용하기로 했습니다. 단어가 아스트랄(astral)하지만 단어를 풀어보면 '행위' 혹은 '동작'을 매개변수 형태로 변경(化)한다는 뜻 입니다.

int sum(x,y) {
    return x+y
}

sum(1,2);

위의 예제에서 x,y를 흔히 파라미터(parameter) 혹은 매개변수라고 하고 기능적으론 외부에서 임의의 값을 받아들이는 용도로 사용됩니다. 이와 비슷하지만 전혀 다른 용도로 사용되는 용어가 아규먼트(argument) 혹은 인수(引數)라는 단어 입니다. 위의 예에선 1, 2를 지칭합니다. 매개변수에 실제로 전달되는 값을 지칭합니다. 따라서 동작 파라미터화를 한국어로 풀어보면 '임의의 동작을 할 것으로 예상되는 코드나 코드 블록을 전달 받을 수 있도록 만들어 놓은 것'쯤으로 해석할 수 있습니다.

더 쉽고 아름다운 한국어로 번역하면 '매개변수로 코드를 전달받을 수 있도록 한 것'이라 할 수 있습니다. 이 이상의 설명은 자신이 없기 때문에 Java 8: Behavior parameterization을 참고하셔서 공부해보시길 추천해드립니다.

동작 파라미터화를 왜 사용하는가?

동작 파라미터화를 사용하는 이유는 요구사항의 변화에 (유연하게) 대응할 수 있기 때문입니다~~(정말?)~~. 동작 파라미터를 코드에서 사용하기 위해선 인터페이스를 선언하고 특정 동작을 수행하는 코드를 구현하면 됩니다.

어떤 코드 블록에 기초해서 boolean 값을 반환하는 방법이 있다면, 속성값을 유연하게 전달할 수 있지 않을까요? 우리는 이와 같은 동작은 프레디케이트(Predicate)라고 하며, JDK 8에선 선택 조건을 결정하는 인터페이스라고 합니다.

문장으로 쓰고 보니 대단히 쉽지만 막상 하려고 덤벼들면 잘 안되는게 우리의 인생사이니까 예를 들어보겠습니다.


interface ApplePredicate {
    public boolean test(Apple apple);
}

static class AppleHeavyWeightPredicate implements ApplePredicate {
    @Override
    public boolean test(Apple apple) {
        return apple.getWeight() > 150;
    }
}

이런 간단한 것을 확인하기 위해서 인터페이스를 구현하는게 올바른 일인가를 뒤로하고, 위의 예제는 Apple 타입의 매개변수 Weight가 150보다 큰 속성을 가지고 있는지 판별하는 코드 입니다. 하지만 test()메서드가 다양한 동작을 받아서 해당 동작에 관한 결과를 내부적으로 구성해야 할 경우가 있다고 가정하겠습니다~~(왜냐는 질문은 사절합니다. 그래야 하기 떄문입니다. 설명을 위해서!)~~.

그럴경우 동작을 전달하기 위해서 우리가 흔히 사용하는 가장 보편적인 방법은 익명 클래스를 사용하는 겁니다. 아래의 예를 통해서 알 수 있지만, 그러나 익명 클래스를 사용하게 되면 코드가 장황(verbosity)하게 길어지는 특징이 있습니다~~(코드가 긴게 뭐가!! 어때서!!)~~.


// 가독성에 좋지 않음. 왜 안 좋은지 말하기 힘들지만 일단 안 좋은거라고 받아들이자.
public static List<Apple> filterApplePredicate(List<Apple> inventory, ApplePredicate applePredicate) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if (applePredicate.test(apple)) {
            result.add(apple);
        }
    }
    return result;
}

// 익명 클래스를 사용
List<Apple> filterAnonymousPredicate = filterApplePredicate(inventory, new ApplePredicate() {
    @Override
    public boolean test(Apple apple) {
        return "red".equals(apple.getColor());
    }
});

람다(Lambda) 혹은 익명함수

익명 클래스로 다양한 동작을 구현할 수 있지만, 만족할 만큼 코드가 깔끔하지는 않습니다~~(충분히 깔끔하다고 느낀다면 그건 깔끔한걸 못 봐서 그렇다고 하지만 깔끔하다는건 어쩌면 개인의 취향차이 아닌가?). 깔끔하지 않은 코드는 동작 파리미터를 실전에 적용하는 것을 가로막습니다(내 생각엔 동작 파라미터화란 이름 자체가 가로 막는 듯..). JDK 8에서는 가독성을 높이면서 유연성을 확보하기 위해서 익명 클래스처럼 이름이 없는 함수이자 메서드 인수(매개변수가 아닙니다)로 전달할 수 있는 람다 표현식을 사용 할 수 있습니다(드디어! 람다 출현!)~~.

람다 표현식의 대표적인 특징은 일반적인 메서드와 달리 이름이 없으며, 특정 클래스에 종속되지 않기 때문에 '메서드'가 아니라 함수라고 부릅니다. 또한 람다 표현식은 메서드 인수(매개변수가 아닙니다)로 전달하거나 변수로 전달할 수 있으며, 익명 클래스처럼 습관적인 코드를 구현할 필요가 없습니다. 익명 클래스의 가장 큰 단점은 불필요한 코드가 엄청 많다는 것이고, 람다의 장점은 쓸데없는 코드가 거의 없다는 점입니다~~(하지만 이러한 장점 덕분에 난간함 상황에 봉착하게 될 예정이니 긴장을 늦추지 마세요)~~.


List<Apple> filterLambdaPredicate = filterApplePredicate(inventory, apple) -> "red".equals(apple.getColor()));
System.out.println(filterLambdaPredicate);

람다 표현식에서 (inventory, (Apple apple))람다 파라미터(아규먼트 혹은 인수가 아닙니다)라고 하고, ->화살표(arrow) 연산자라 하며 람다 파라미터 리스트와 바디를 구분, "red".equals(apple.getColor())람다 바디라 하며 람다의 반환값에 해당하는 표현식 입니다.

JDK 8에서는 5가지 형태의 람다 표현식을 지원합니다. 대부분의 경우 람다 파라미터, 화살표 연산자, 람다 바디를 모두 표현하고 있지만 몇몇 람다 표현식의 경우 람다 파라미터가 없는 경우가 있으며, 람다 바디의 경우 코드 블록으로 작성할 수 있습니다. 특히 대부분의 람다 바디에서 특정 값을 반환(return)하지 않고 있다는 점도 특이할 사항입니다.


(String s) -> s.length() // return이 함축되어 있음
(String s) -> s.length() > 10
(String s1, String s2) -> { // return이 없음(void)
    System.out.println("s1 + s2 length");
    System.out.println(s1.length() + s2.length());
}
(String s1, String s2) -> s1.length().compareTo(s2.length());
() -> "hello!" // 람다 파라미터가 없음

람다의 대표적인 특징은 1) 보통의 메서드와 달리 이름이 없으므로 익명으로 표현됩니다. 2) 람다는 메서드처럼 특정 클래스에 종속되지 않으므로 함수라 부릅니다. 3) 람다 표현식을 메서드 인수로 전달하거나 변수로 저장할 수 있습니다. 4) 익명 클래스와 같이 자질구레한 코드를 구현할 필요가 없습니다.


() -> new Apple(10)
(List<String> list) -> list.isEmpty()
(Apple a) -> { System.out.println(a.getWeight()); }
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight())

함수 인터페이스(Functional Interface) 사용법

제네릭(Generic)을 사용하면 추상화 시킬 수 있습니다. 일단 ApplePredicate 인터페이스를 변경하면 아래와 같이 변경 가능합니다. Predicate<T>와 같은 형태의 인터페이스를 함수 인터페이스(Functional Interface)라고 하며, JDK 8에서 기본적으로 지원하는 함수 인터페이스(Functional Interface)를 활용하면 됩니다.


public interface Predicate<T> {
    public boolean test(T t);
}

// import java.util.function.Predicate;

추상화된 인터페이스를 사용하면 아래와 같이 코드를 변경 할 수 있습니다.


public static <T> List<T> filter(List<T> list, Predicate<T> predicate) {
    List<T> result = new ArrayList<>();
    for (T e : list) {
        if (predicate.test(e)) {
            result.add(e);
        }
    }
    return result;
}

List<Apple> filterApple = filter(inventory, (Apple apple) -> "red".equals(apple.getColor()));
System.out.println(filterApple);

함수형 인터페이스(Functional Interface)는 '하나의 추상 메서드'만 가지고 있는 인터페이스를 지칭하는 용어 입니다. 이번 JDK 8에 도입되었습니다. 함수 인터페이스의 가장 중요한 특징은 람다 표현식을 인수로 전달할 수 있습니다.

대표적인 함수형 인터페이스는 Comparator, Runnable등이 있습니다. 함수형 인터페이스라는 이름을 붙여주기전에도 자바에선 함수형 인터페이스의 구성과 비슷한 형태의 인터페이스를 많이 사용했습니다. 대표적으로 Runnable 인터페이스는 내부에 run() 메서드 하나만 담고 있었습니다. 즉, 하나의 추상 메서드를 가지는 인터페이스를 '함수형 인터페이스'로 정의했다는 점을 잊지 않아야 합니다. 함수 인터페이스의 가장 큰 특징과 함수형 인터페이스를 정의하는 가장 중요한 요소는 '하나의 추상 메서드'만 가진다는 점 입니다.

추상 메서드 시그너처(signature)는 람다 표현식의 시그너처를 가리킬 수 있습니다. 람다 표현식의 시그너처를 서술하는 메서드를 함수 디스크립터(function descriptor)라고 부릅니다. 따라서 추상 메서드의 시그너처와 함수 디스크립터가 같다면, 함수형 인터페이스를 활용 할 수 있습니다.

아래 예제를 보시죠.


public static void process(Runnable r) {
    r.run();
}

Runnable r1 = new Runnable() {
    public void run() {
        System.out.println("Hello World");
    }
};

Runnable r2 = () -> System.out.println("Hello World");

process(r1);
process(r2);
process(() -> System.out.println("Hello World"))

r1은 익명 클래스 를 사용한 방식입니다. 반면 r2는 람다식을 Runnable 변수에 저장합니다. 마지막으로 process(() -> System.out.println("Hello World"))의 경우가 람다 표현식과 함수 인터페이스를 혼합해서 사용하는 가장 대표적인 방법이라 할 수 있습니다. 람다 표현식를 인수(네! 인수로 전달하였습니다!)로 전달하니 작동합니다. JDK 8에서 람다 표현식의 비중이 큰 이유는 람다식을 사용해서 기존의 번잡한 코드를 간결하게 변경할 수 있기 때문입니다.

알아두면 좋을 대표적인 함수형 인터페이스를 정리하면 아래와 같습니다.

함수형 인터페이스 파라미터타입 반환 타입 추상 메서드 이름 설명 다른 메소드
Runnable 없음 void run 인자나 반환 값 없이 액션을 수행한다. 없음
Supplier 없음 T get T 타입 값을 공급한다. 없음
Consumer T void accept T 타입 값을 소비한다. andThen
BiConsumer<T, U> T, U void accept T와 U타입 값을 소비한다. andThen
Function<T, R> T R apply T 타입 인자를 받는 함수다. compose
andThen
identity
BiFunction<T, U, R> T, U R apply T와 U타입 인자를 받는 함수다. andThen
UnaryOperator T T apply T 타입에 적용하는 단항 연산자다. compose
andThen
identity
BinaryOperator T, T T apply T 타입에 적용하는 이항 연산자다. andThen
maxBy
minBy
Predicate T boolean test Boolean 값을 반환하는 함수다. and
or
negate
isEqual
BiPredicate<T, U> T, U boolean test 두 가지 인자를 받고 boolean 값을 반환하는 함수다. and
or
negate

형식 추론

람다 표현식이 사용된 컨텍스트를 이용해서 람다 표현식과 관련된 함수형 인터페이스를 추론합니다. 즉, 대상 형식을 이용해서 함수 디스크립터를 알 수 있으므로 컴파일러는 람다의 시그니처도 추론할 수 있습니다. 결과적으로 말해서 컴파일러는 람다 표현식의 파라미터 형식에 접근할 수 있으므로 람다 문법에서 이를 생략할 수 있다.


Comparator<Apple> c1 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
Comparator<Apple> c2 = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());

람다 표현식, 클로저 그리고 지역 변수 사용

자신을 감싼 영역에 있는 외부 변수에 접근할 수 있는 함수를 일반적으로 클로저 혹은 클로저 함수라고 하며, 해당 클로저가 접근하는 함수 밖의 변수를 자유 변수(free variable)라 합니다. 이 정의를 기반으로 람다 표현식으로 정의한 익명 함수를 판단하면 일부는 클로저이고 일부는 클로저가 아닙니다. 아래 예제에선 eff가 자유 변수이고, filter()가 대표적인 클로저입니다. 이 경우 final이 아님에도 불구하고 클로저 안에서 재할당 할 수 없습니다. 이런 연유로 JDK 8에 도입된 람다 표현식에 대한 약간의 불만 혹은 단점을 지적하는 목소리도 있습니다(물론 그 이전부터 람다 표현식에 대해선 여러가지 말이 많았습니다만...).


public List<String> findFirstGundamByEFF(String eff) {  
    List<String> effList = effRepository.findAll();
    return effList.stream()
        .filter(mobileSuite -> {
            if (eff == null) {
                eff = ""; // compile error
            }
            return eff.equals(mobileSuite.getProducer());
        })
    ...
}

람다는 인스턴스 변수와 정적 변수를 자유롭게 캡처(자신의 바디에서 참조할 수 있음)할 수 있습니다. 하지만 그러려면 지역 변수는 명시적으로 final로 선언되어 있어야 하거나, 실질적으로 final로 선언된 변수와 똑같이 사용되어야 합니다. 즉 람다 표현식은 한 번만 할당할 수 있는 지역 변수를 캡처할 수 있습니다. 이러한 이유는 자유 변수의 복사본을 제공하기 때문에 복사본의 값이 바뀌지 않아야 하므로 지역 변수에는 한 번만 값을 할당해야 한다는 제약이 생긴 것으로 파악할 수 있습니다.


int portNumber = 137;
Runnable r = () -> System.out.println(portNumber); // 참조 가능

메서드 레퍼런스

함수형 인터페이스와 람다를 사용하여 기존의 메서드 정의를 재활용해서 람다처럼 전달할 수 있는 방법이 JDK 8에서 제공됩니다. 때로는 람다 표현식보다 메서드 레퍼런스를 사용하는 것이 더 가독성이 좋을 수 있습니다.


inventory.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()) );
inventory.sort(comparing(Apple::getWeight)); // 메서드 레퍼런스를 사용

메서드 레퍼런스는 기존의 메서드를 재활용 할 수 있으며, 가독성을 높일 수 있습니다. 아래는 몇가지 간단한 예제 입니다.


(Apple a) -> a.getWeight() <==> Apple::getWeight
() -> Thread.currentThread().dumpStack() <==> Thread.currentThread()::dumpStack
(str, i) -> str.substring(i) <==> String::substring
(String s) -> System.out.println(s) <==> System.out::println

메서드 레퍼런스는 세 가지 유형으로 구분할 수 있습니다. 1) 정적 메서드 레퍼런스는 IntegerparseInt 메서드는 Integer::parseInt로 표현할 수 있고 2) 다양한 형식의 인스턴스 메서드 레퍼런스는 String::length로 표현합니다. 3) 기존 객체의 인스턴스 메서드 레퍼런스의 경우 expensiveTransaction 지역 변수의 경우 expensiveTransaction::getValue라고 표현할 수 있습니다.


(args) -> ClassName.staticMethod(args) <==> ClassName::staticMethod
(arg0, rest) -> arg0.instanceMethod(rest) <==> ClassName::instanceMethod
(args) -> expr.instanceMethod(args) <==> expr::instanceMethod

아래 예에서 확인할 수 있듯이 메서드 레퍼런스가 주어진 함수형 인터페이스와 호환하는지 확인해야 합니다. 즉, 메서드 레퍼런스는 컨텍스트의 형식과 일치해야 한다.


List<String> str = Arrays.asList("a","b","A","B");
str.sort(String::compareToIgnoreCase); //

ClassName::new 처럼 클래스명과 new 키워드를 이용해서 기존 생성자의 레퍼런스를 만들 수 있습니다. 아래 예제를 보시면 SupplierFunction 함수형 인터페이스를 활용하고 있음을 확인 할 수 있습니다.


Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get();

Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(110);

Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
Apple a2 = c2.apply(110);

참고