Java 8 람다 표현식 자세히 살펴보기

2010년도에 ‘Project Lambda’ 라는 프로젝트로 진행되어 Java 8 에 공식 릴리즈가 되었다. 기존의 Java 언어에 어떻게 함수형 프로그래밍을 녹여내었는지 좀 더 자세히 정리하고자 한다.

함수형 프로그래밍 간략하게 알아보기

자바의 람다식을 소개하기 전에 함수형 프로그래밍에 대해서 간략하게라도 알아볼 필요가 있다. (람다 대수에 근간을 두는 함수형 프로그래밍은 패러다임이고, 람다 표현식은 이를 나타낸다고 볼 수 있기 때문!)

함수형 프로그래밍은 함수의 입력만을 의존하여 출력을 만드는 구조로 외부에 상태를 변경하는 것을 지양하는 패러다임으로 부작용(Side-effect) 발생을 최소화 하는 방법론이라 할 수 있다. 함수형 프로그래밍에서는 다음의 조건을 만족시켜야 하는데 먼저 이것부터 정리해보자.

  • 순수한 함수 (Pure Function)

부작용 (Side-effect)가 없는 함수로 함수의 실행이 외부의 상태를 변경시키지 않는 함수를 의미한다. 순수한 함수는 멀티 쓰레드 환경에서도 안전하고, 병렬처리 및 계산이 가능하다. 오직 입력에 의해서만 출력이 정해지고, 환경이나 상태에 영향을 받아서는 안된다는 의미이다.

  • 익명 함수 (Annonymous Function)

이름이 없는 함수를 정의할 수 있어야 한다. 이러한 익명 함수는 대부분의 프로그래밍 언어에서 ‘람다식’으로 표현하고 있으며, 이론적인 근거는 람다 대수에 있다.

  • 고계 함수 (Higher-order Function)

함수를 다루는 상위의 함수로 함수형 언어에서는 함수도 하나의 값으로 취급하고, 함수의 인자로 함수를 전달할 수 있는 특성이 있다. 이러한 함수를 일급 객체 (a.k.a 일급 함수) 로 간주한다.

그렇다면, 자바에서는 함수형 프로그래밍을 어떻게 언어 차원에서 지원할 수 있었는지 간략하게 알아보자.

자바에는 함수의 개념이 없다. (자바의 메소드는 일급 함수가 아니므로, 다른 메소드로 전달할 수 없다. 자바에는 모든 것이 객체다. 메소드는 객체의 행위를 정의하고 객체의 상태를 변경한다.) 이런 이유로 기존의 자바 언어 체계에서는 함수형 언어를 언어 차원에서 지원하지는 못하였다. (함수형 프로그래밍의 조건을 만족하도록 구현한다면 기존에도 가능하다고 할 수 있겠다.)

떄문에, Java8 에서 함수형 인터페이스(단 하나의 메소드만이 선언된 인터페이스)라는 개념을 도입하게 되었고, 함수형 인터페이스의 경우, 람다식으로 표현이 가능할 수 있게 제공하였다.

Java8에서 함수형 인터페이스라는 개념과 람다식 표현을 통해 입력에 의해서만 출력이 결정되도록 ‘순수한 함수’를 표현할 수 있게 되었고, 람다식으로 표현함으로써 ‘익명 함수’를 정의할 수 있게 되었고, 함수형 인터페이스의 메소드에서 또다른 함수형 인터페이스를 인자로 받을 수 있도록 하여 ‘고계 함수’를 정의할 수 있게 되었다. 즉, 함수형 프로그래밍 언어의 조건을 만족시킬 수 있게 되었다고 할 수 있다.

 1public interface Functional1 {
 2  boolean accept();
 3}
 4
 5public interface Functional2 {
 6  boolean accept();
 7  default boolean reject() { return !accept(); }
 8}
 9
10@FunctionalInterface
11public interface Functional3 {
12  boolean accept();
13}
14
15public interface NotFunctional {
16  boolean accept();
17  boolean reject();
18}

함수형 인터페이스가 무엇인지 예제를 통해 간략히 알아보면, Functional1, 2, 3는 모두 함수형 인터페이스를 만족한다. 특이한 점이 Functional3 인터페이스의 경우, @FunctionalInterface 어노테이션을 붙여주었는데, 이는 컴파일러에게 명시적으로 함수형 인터페이스임을 알려주는 역할을 하고, 해당 인터페이스가 함수형 인터페이스 명세를 어기면 컴파일러 에러를 발생시켜 준다.

람다 표현식 자세히 알아보기

자바에서 기본적인 람다식 구조는 아래와 같다.

1(int a, int b) -> {return a + b} // 매개변수 -> 함수 로직 (+@ 리턴)

정리해보자면 아래와 같다.

  • 단순한 람다 구문의 경우, 람다 구분에 중괄호가 없을 수도 있다.

  • return 이 없을 수도 있다.

  • 매개변수에는 타입을 명시하지 않아도 된다. (타입 추론)

  • 람다식 문법을 컴파일러가 익명 클래스로 변환한다. 즉, 함수형 인터페이스를 컴파일러가 구현하도록 위임하는 형태라 볼 수 있다

 1() -> {}                     // No parameters; result is void
 2() -> 42                     // No parameters, expression body
 3() -> null                   // No parameters, expression body
 4() -> { return 42; }         // No parameters, block body with return
 5() -> { System.gc(); }       // No parameters, void block body
 6() -> {
 7  if (true) { return 12; }
 8  else { return 11; }
 9}                          // Complex block body with returns
10(int x) -> x+1             // Single declared-type parameter
11(int x) -> { return x+1; } // Single declared-type parameter
12(x) -> x+1                 // Single inferred-type parameter
13x -> x+1                   // Parens optional for single inferred-type case
14(String s) -> s.length()   // Single declared-type parameter
15(Thread t) -> { t.start(); } // Single declared-type parameter
16s -> s.length()              // Single inferred-type parameter
17t -> { t.start(); }          // Single inferred-type parameter
18(int x, int y) -> x+y      // Multiple declared-type parameters
19(x,y) -> x+y               // Multiple inferred-type parameters
20(final int x) -> x+1       // Modified declared-type parameter
21(x, final y) -> x+y        // Illegal: can't modify inferred-type parameters
22(x, int y) -> x+y          // Illegal: can't mix inferred and declared types

람다 표현식 활용

위에서 자바의 함수형 프로그래밍과 람다 표현식에 대해서 자세하게 살펴보았고, 이번에는 람다의 구체적인 명세에 대해서 정리해보고자 한다. 람다식을 활용함에 있어 어떤 부분이 문법적인 제약이 있는지, 어떤 방법으로 활용될 수 있는지에 대해서 정리해보는 부분이라고 이해하면 좋다.

파라미터에 행위 전달 (Parameterized Behaviors)

메소드에 사용할 데이터 혹은 변수와 행위를 같이 전달하게 하여 메소드의 행위 부분도 분리할 수 있을 것이다. 이를 통해 얻을 수 있는 장점은 아래 정도로 정리할 수 있을 것이다.

  • 런타임에 행위를 전달 받아서 제어 흐름 수행 (cf. 전략 패턴)
  • 메소드 단위의 추상화가 가능
  • 함수형 언어의 고차 함수 (Higher-Order Function)
 1public class Collections {
 2  ...
 3  public static <T> T max(Collection<? extends T> coll, Comparator<? super T> comp) {
 4    ...
 5  }
 6  ...
 7}
 8
 9public class Fruit {
10  public String name;
11}
12
13// AS-IS
14Collections.max(fruits, new Comparator<Fruit> {
15  @Override
16  public int compare(Fruit o1, Fruit o2) {
17      return o1.name.compareTo(o2.name);
18  }
19});
20
21// TO-BE
22Collections.max(fruits, (o1, o2) -> o1.name.compareTo(o2.name) ;

스프링 프레임워크에서 익명 클래스를 이용한 행위 파라미터를 적극적으로 활용해 ‘템플릿 콜백 패턴’ 디자인패턴으로 이미 유용하게 사용되던 기법이었고, 람다식으로 간결하게 사용할 수 있게 된 것이다.

불변 변수 사용 (Immutable Free Variables)

자바에서 익명 클래스 + 자유 변수 포획으로 클로저를 가능하게 하였는데, 포획된 변수에는 명시적으로 final 지시자를 사용하도록 강제하였다. 람다식에서는 포획된 변수에 final 을 명시하지 않아도 되도록 변경되었지만 기존과 동일하게 포획된 변수는 변경할 수 없고, 변경하는 경우 컴파일 에러가 발생한다.

1int counter = 0 // Free Variable
2
3new Thread(() -> System.out.println(counter)) // OK
4new Thread(() -> System.out.println(counter++)) // Compile Error (Free variable is immutable!)

상태 없는 객체 (Stateless Object)

클래스의 메소드(행위)에서 멤버 변수(상태)를 자유롭게 제어할 수 있다. 즉, 객체가 메소드를 호출하면 입력(Input)+상태(Properties)로부터 출력(Output)이 결정되기 때문에 Side-Effect가 발생할 수 있다. 함수 단위의 배타적 수행이 보장되지 않기 때문에 병렬 처리와 멀티 스레드 환경에서 여러 단점에 노출될 가능성이 있다.

반면에 람다식으로 표현하게 되면, 오로지 입력(Input)과 출력(Output)에 종속되어 있기 때문에 Side-Effect 가 발생하지 않는 것을 최대한 보장할 수 있게 된 것이다. 후술할 스트림 API 에서는 함수형 인터페이스를 최대한 활용해 병렬(Parallel) 처리를 어떻게 효과적으로 할 수 있는지 알아볼 예정이다.

Optional + Lambda 조합

java.util.Optional 이라는 클래스는 값이 있거나 없는 경우를 표현하기 위한 클래스로 map, filter, flatMap 등의 고차 함수를 가지고 있다. Optional의 고차 함수를 조합하여 간결하게 표현이 가능하며, 언제 발생할지 모르는 NullPointerException 의 두려움에 방어 로직으로부터 벗어날 수 있지 않을까 한다.

  • ‘If (obj != Null)’ Null 체크로부터 해방
 1// AS-IS
 2Member member = memberRepository.findById(1L);
 3Coord coord = null;
 4if (member != null) {
 5  if (member.getAddress() != null) {
 6    String zipCode = member.getAddress().getZipCode();
 7    if (zipCode != null) {
 8      coord = coordRepository.findByZipCode(zipCode)
 9    }
10  }
11}
12
13// TO-BE
14Optional<Member> member = memberRepository.findById(1L);
15Coord coord = member.map(Member::getAddress)
16    .map(address -> address.getZipCode())
17    .map(zipCode -> coordRepository.findByZipCode(zipCode))
18    .orElse(null)
  • 비어 있는 객체 생성
1Optional<Member> member = Optional.empty();
  • Null 허용하지 않는 객체 생성
1Optional<Member> member = Optional.of(memberRepository.findById(1L));
2member.get() // NullPointerException !!!
  • 값이 존재할 때, 특정 메소드 호출
1// AS-IS
2Member member = memberRepository.findById(1L);
3if (member != null) {
4  System.out.println(member);
5}
6
7// TO-BE
8Optional<Member> member = Optional.ofNullable(memberRepository.findById(1L));
9member.ifPresent(System.out::println);
  • 값 존재할 경우와 아닌 경우를 삼항 연산자로 표현하지 않아도 됨
1// AS-IS
2Member member = memberRepository.findById(1L);
3System.out.println(member != null ? member : new Member("Unknown"));
4
5// TO-BE
6Optional<Member> member = Optional.ofNullable(memberRepository.findById(1L));
7member.orElse(new Member("Unknown")).ifPresent(System.out::println);
  • 특정 조건을 만족하는 경우에만 특정 행위를 하고 싶을 경우
 1// AS-IS
 2Member member = memberRepository.findById(1L);
 3if (member != null && member.getRating() != null && member.getRating() >= 4.0) {
 4  System.out.println(member);
 5}
 6
 7// TO-BE
 8Optional<Member> member = Optional.ofNullable(memberRepository.findById(1L));
 9member.filter(m -> m.getRating() >= 4.0)
10    .ifPresent(m -> System.out::println)

참고 링크

comments powered by Disqus