/ jvm

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

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

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

sum(1,2);

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

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

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

동작 파라미터화를 사용하는 이유는 요구사항의 변화에 (유연하게) 대응할 수 있기 때문입니다~~(정말?)~~. 동작 파라미터를 코드에서 사용하기 위해선 인터페이스를 선언하고 특정 동작을 수행하는 코드를 구현하면 됩니다. 문장으로 쓰고 보니 대단히 쉽지만 막상 하려고 덤벼들면 잘 안되는게 우리의 인생사이니까 예를 들어보겠습니다.

Predicate 인터페이스를 예뢰 들어보겠습니다.


interface GundamPredicate{
    public boolean test(MobileSuite mobileSuite);
}

static class FirstGundamPredicate implements GundamPredicate {
    @Override
    public boolean test(MobileSuite mobileSuite) {
        return "RX-78-2".equals(gundam.getModelNumber());
    }
}

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

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


// 가독성에 좋지 않음. 왜 안 좋은지 말하기 힘들지만 일단 안 좋은거라고 받아들이자.
List<Gundam> firstGundams = filterGundams(inventory, new FirstGundamPredicate() {
    public boolean test(MobileSuite mobileSuite) {
        return "RX-78".equals(gundam.getModelNumber());
    }
});

람다(Lambda)

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

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


// 익명함수
Comparator<MobileSuite> byYear = new Comparator<MobileSuite>() {
    public int compare(MobileSuite m1, MobileSuite m2) {
        return m1.getYear().compareTo(m2.getYear());
    }
};

// 람다 표현식
Comparator<MobileSuite> byYear = (MobileSuite m1, MobileSuite m2) -> m1.getYear().compareTo(m2.getYear());

람다 표현식에서 (MobileSuite m1, MobileSuite m2)람다 파라미터(아규먼트 혹은 인수가 아닙니다)라고 하고, ->화살표(arrow) 연산자라 하며, m1.getYear().compareTo(m2.getYear())람다 바디라 합니다.

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!" // 람다 파라미터가 없음

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

함수형 인터페이스(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에서 람다 표현식의 비중이 큰 이유는 람다식을 사용해서 기존의 번잡한 코드를 간결하게 변경할 수 있기 때문입니다.

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

자신을 감싼 영역에 있는 외부 변수에 접근할 수 있는 함수를 일반적으로 클로저 혹은 클로저 함수라고 하며, 해당 클로저가 접근하는 함수 밖의 변수를 자유 변수(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); // 참조 가능

대표적인 함수형 인터페이스

  • Predicate

    • test라는 추상 메서드를 정의하며, test는 <T>의 객체를 인수로 받아 boolean을 반환합니다.
  • Consumer

    • accept라는 추상 메서드를 정의하며, <T> 형식의 객체를 인수로 받아서 어떤 동작을 수행하고 싶을 때 Consumer 인터페이스를 사용할 수 있습니다.
  • Function

    • apply라는 추상 메서드를 정의하며, <T>를 인수로 받아서 제네릭 형식 <R> 객체를 반환하는 apply 라는 추상 메서드를 정의합니다. 입력을 출력으로 매핑하는 람다를 정의할 때 Function 인터페이스를 활용할 수 있습니다.

====

Ref[1] : 자바 8 인 액션
Ref[2] : Functional Programming in Java 8
Ref[3] : 모던 자바 (자바8) 못다한 이야기, 케빈 TV
Ref[4] : State of the Lambda
Ref[5] : State of the Lambda: Libraries Edition
Ref[6] : About the Lambda FAQ