Java 8 - 람다 표현식과 함수형 인터페이스

13 June 2017

2014년 3월에 발표된 Java 8은 프로그램을 더 효과적이고 간결하게 구현할 수 있는 새로운 개념과 기능을 제공한다. 조금 많이 늦었지만 Java 8의 변경들에 대해 정리해보고자 한다.

주요 변경 및 키워드

  • 스트림 API
    • @병렬성. 가변 공유 상태가 없는 병렬 실행 가능. 멀티쓰레딩 코드를 직접 구현하지 않고도 효율적인 병렬성을 얻을 수 있음.
  • 함수형 프로그래밍
  • 람다 표현식
  • 동작 파라미터화 (메서드의 인수로 코드를 전달할 수 있음)
  • 인터페이스에 static 메서드 및 default 메서드 추가
    • 기존의 코드를 건드리지 않고도 인터페이스 설계를 확장 가능
  • default 메서드를 이용한 Collections API 확장 (예: List.sort)
  • Optional 클래스 추가 -> NullPointerException을 피할 수 있도록 도와줌
  • java.time 패키지에 새로운 날짜/시간관련 API 추가 (LocalDate 등)
  • Concurrency API 확장 (ForkJoinPool, CompletableFuture 등)
  • Java 9 : 대규모 컴포넌트를 정의하고 사용하는 기능 추가. 이를 이용해 모듈을 사용하거나 리액티브 프로그래밍 툴킷을 임포트해 시스템을 구성할 수 있음. - 관련 링크
  • Java 10 : 지역 변수 타입 추론 (var 사용 가능) - 관련 링크
  • Java 11 : 람다 표현식 파라미터에 var 사용 가능

동작 파라미터화 (Behavior Parameterization)

  • 메서드를 호출할 때 인수로 코드 블록을 전달함으로써 코드 블록의 내용에 따라 메서드의 동작이 달라지게 되는 것.
  • 전달하는 인수만 교체하면 되므로 변화하는 요구사항에 유연하게 대응할 수 있음.
  • 파라미터는 클래스의 인스턴스일수도 있고, 익명 클래스 혹은 람다 표현식일 수도 있음.
  • 일종의 Strategy 패턴 (전략을 캡슐화하는 인터페이스를 정의해놓고, 런타임시에 어떤 전략 구현체를 사용할지 선택하는 기법).
// Predicate 인터페이스를 구현하는 새로운 클래스를 정의
public class ColorPredicate implements Predicate {
    @Override
    public boolean test(Item item) {
        return "green".equals(item.getColor());
    }
}

// 클래스를 인스턴스화하여 파라미터로 전달
List<Item> result = filterItem(list, new ColorPredicate());

메서드 호출 시 익명클래스를 이용하여 클래스 선언과 인스턴스 생성을 동시에 수행한 뒤 곧바로 파라미터로 전달할 수도 있다. (즉석에서 필요한 구현을 만들어서 사용) 코드가 좀 더 간결해진다.

// 익명클래스 사용
List<Item> result = filterItem(list, new ColorPredicate() {
    @Override
    public boolean test(Item item) {
        return "green".equals(item.getColor());
    }
});

Java 8에 추가된 람다 표현식을 이용하면 위의 익명클래스 코드를 단 한 줄로 변경할 수 있다.

List<Item> result =
    filterItem(list, (Item item) -> "green".equals(item.getColor()));

람다 표현식

(파라미터 리스트) -> { statement; }
(int a, int b) -> { return a + b; }

(파라미터 리스트) -> expression
(int a, int b) -> a + b

  • 람다식이란 = 일종의 익명 함수
  • 람다는 메서드와 유사하지만 특정 클래스에 종속되지 않으므로 메서드가 아닌 함수라고 부른다.
  • 람다 표현식을 메서드 인수로 전달하거나(동작 파라미터화) 변수에 할당할 수 있음.

다양한 형태의 람다 표현식

// Apple형 파라미터를 받아서 boolean형을 반환
(Apple a) -> a.getWeight() > 150

// 람다 바디 부분을 중괄호로 묶을 경우 명시적으로 return 키워드가 필요함
(Apple a) -> { return a.getWeight() > 150; }

// 파라미터의 타입은 생략이 가능함 (파라미터 형식 추론)
(a) -> a.getWeight() > 150

// 파라미터가 한 개일 경우 괄호도 생략이 가능함
a -> a.getWeight() > 150

// int형 파라미터 2개를 받고, 리턴값 없음(void)
(num1, num2) -> { System.out.println(num1 + num2); }

// 파라미터 없고 Apple형 반환
() -> new Apple();

함수형 인터페이스

  • 함수형 인터페이스란 -> 추상메서드가 정확히 한 개인 인터페이스.
    • 디폴트 메서드는 있어도 상관 없음
  • @FunctionalInterface 어노테이션을 사용
  • 람다 표현식은 함수형 인터페이스를 구현한 콘크리트 클래스의 인스턴스임.
  • 함수형 인터페이스 타입을 인수로 받는 메서드에게 람다 표현식을 파라미터로 넘길 수 있음.
// 람다 표현식.
// System.out.println() 메서드는 void형을 반환하므로, 이 람다 표현식 또한 void를 반환한다.
Runnable r1 = () -> System.out.println("Hello World 1");

// 익명 클래스
Runnable r2 = new Runnable() {
    public void run() {
        System.out.println("Hello World 2");
    }
}

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

process(r1);	// Hello World 1 출력
process(r2);	// Hello World 2 출력
process(() -> System.out.println("Hello World 3"));

함수 디스크립터

  • 함수형 인터페이스의 추상메서드 시그니처는 곧 람다 표현식의 시그니처이다. 이 시그니처를 함수 디스크립터라고 한다.
    • () -> void
    • (item, item) -> int
    • 기타 등등

함수형 인터페이스 종류

Java 8의 java.util.function는 다양한 메서드 시그니처에 대응한 함수를 일일이 별도 인터페이스로 정의하고 있다. 람다식을 이용하여 필요한 형태에 맞는 인터페이스의 구현체를 즉석에서 정의하여 사용하는 것이다. 물론 원하는 형태의 인터페이스가 없다면 함수형 인터페이스를 직접 만들어서 사용해도 된다. 다시 말해 파라미터로 함수 자체를 넘기는 것이 아니라 객체(함수를 랩핑한 클래스)를 넘기는 것이고, 자바에서 함수는 여전히 일급 시민(first class citizen)이 아니다.

Predicate : T -> boolean

  • 한 개의 인수를 받아서 boolean 값을 반환. 인수에 대해 참/거짓을 확인할 때 사용한다.
  • 두 개의 인수를 받는 BiPredicate (T, U) -> boolean 도 있음.
@FunctionalInterface
public interface Predicate<T> {
	boolean test(T t);
}

Predicate<String> p = (String s) -> !s.isEmpty();

Consumer : T -> void

  • 한 개의 인수를 받아 ‘소비’하고 반환값은 없음(void).
  • 두 개의 인수를 받는 BiConsumer (T, U) -> void 도 있음.
@FunctionalInterface
public interface Consumer<T> {
	void accept(T t);
}

Consumer<Integer> c = (Integer i) -> System.out.println(i);

Supplier : () -> T

  • 인수를 받지 않고(void), 한 개의 객체를 반환함.
@FunctionalInterface
public interface Supplier<T> {
	T get();
}

Supplier<String> f = () -> "hello";

Function : T -> R

  • 한 개의 인수를 받아서 한 개의 객체를 반환함. 인수에 따른 출력값을 매핑할 때 사용.
  • 두 개의 인수를 받는 BiFunction (T, U) -> R 도 있음.
@FunctionalInterface
public interface Function<T, R> {
	R apply(T t);
}

Function<String, Integer> f = (String s) -> s.length();

Operator

  • UnaryOperator : Function의 서브 인터페이스. 인수 한 개를 받아서, 동일한 타입의 객체를 반환하는 단항 연산자. T -> T
  • BinaryOperator : BiFunction의 서브 인터페이스. 같은 타입의 인수 두 개를 받아서, 동일한 타입의 객체를 반환하는 이항 연산자. (T, T) -> T

기본형(Primitive Type) 특화 인터페이스

  • 자바의 모든 타입은 참조형(Reference Type - Integer, Object, List 등) 아니면 기본형(Primitive Type - int, double, long 등) 인데, Generic 파라미터에는 참조형만 사용할 수 있다.
  • 기본형을 참조형으로 자동으로 변환하는 것을 박싱, 그 반대를 언박싱이라고 하며 자바에서는 오토박싱을 지원한다.
  • 단, 박싱한 값은 기본형을 랩핑한 것이며 힙 메모리에 저장되므로 메모리를 더 소비하며, 기본형을 가져올 때에도 메모리를 탐색하는 과정이 필요하다.
  • Java 8에서는 기본형 int, double, long을 입출력으로 사용하는 상황에서 오토박싱을 피할 수 있도록 특별한 형태의 함수형 인터페이스를 제공하여 대신 사용할 수 있게 한다.

타입 검사 및 추론

  • 자바 컴파일러는 람다식이 사용된 컨텍스트를 이용하여 람다의 타입을 추론할 수 있다. 람다가 전달될 메서드의 파라미터나, 람다가 할당되는 변수의 타입에서 기대되는 람다식의 타입을 Target Type이라고 한다.
    • 람다의 바디에 일반 표현식이 올 경우 리턴값이 있는 표현식일지라도 void를 반환하는 함수 디스크립터와 호환된다. 예를들어 List의 add 메서드는 boolean을 반환하지만, Consumer<String> c = s -> list.add(s); 또한 유효하다.
  • 이를 통해 람다식에 해당되는 함수형 인터페이스의 함수 디스크립터를 알 수 있으므로 람다 문법의 파라미터 리스트에서 타입을 생략할 수 있다.
// 명시적으로 형식 포함
Comparator<Apple> c1 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

// 형식 생략 - 형식 추론
Comparator<Apple> c2 = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());

람다 캡처링 (Capturing Lambda)

  • 람다 표현식 내에서 람다 표현식 외부에 정의된 변수를 사용하는 것을 람다가 객체를 캡처한다고 함.

  • 일종의 Closure 이지만 람다식 = Closure는 아님.)

    클로저는 어휘적(lexical) 클로저 또는 함수(function) 클로저를 간단하게 부르는 말이다. 단순하게 말하면 자신을 감싼 영역에 있는 외부 변수에 접근하는 함수다. 클로저에서 접근하는 함수 밖의 변수를 자유 변수(free variable)라 한다. 이 정의에 따르면 람다 표현식으로 정의한 익명 함수 가운데 일부는 클로저고 일부는 클로저가 아니다. 변수의 범위와 연관해 클로저를 정의하기도 하지만 단순히 함수를 감싼 객체를 모두 클로저라고 표현하기도 한다. Groovy에서는 자유 변수를 참조하는지 여부와 상관없이 익명 함수를 클로저라 한다. 그 외에도 함수를 객체로 감싸는 패턴은 Function Object, Functor, Functionoid 등 다양하게 불린다. - NAVER D2

  • 명시적으로 final로 선언된 변수이거나, final 키워드를 붙이지 않았지만 사실상 final로 사용되고 있는 변수여야 함(effectively final).
  • 즉 람다에서는 한 번만 할당되는 지역변수만 캡처할 수 있으며, final 이므로 값을 바꿀 수는 없고 참조만 가능.

  • 아래의 코드에서 지역변수 num1은 final로 선언되어있지는 않지만 범위 내에서 한 번만 할당되므로 실질적으로 final임. 따라서 람다식 내에서 사용 가능한 free variable임.
int num1 = 10;
Runnable r = () -> System.out.println(num1);
  • num2 변수에는 값이 두 번 할당되므로, 실질적인 final이 아니라 람다식 내에서 사용이 불가능하다.
int num2 = 20;
Runnable r = () -> System.out.println(num2);	// 에러 발생
num2 = 30;
  • 자바에서 지역변수는 힙이 아닌 스택에 위치하므로 지역변수를 정의한 쓰레드가 사라질 경우 람다를 실행하는 쓰레드에서는 지역변수에 접근할 수 없다. 따라서 람다에서 지역 변수를 사용하는 경우 변수 자체에 접근을 허용하는 것이 아니라 변수의 복사본을 제공하고, 복사본의 값이 바뀌지 않아야 하므로 final 이어야 한다는 제약이 생긴 것이다.

메서드 레퍼런스 & 생성자 레퍼런스

메서드 레퍼런스

  • 특정 메서드만을 호출하는 람다 표현식의 축약형 문법.
  • 메서드 레서런스의 세 가지 유형
    1. static 메서드 레퍼런스
      • (num) -> Integer.parseInt(num)Integer::parseInt 으로
    2. 인스턴스 메서드 레퍼런스
      • (String s) -> s.length()String::length 으로
      • (s1, s2) -> s1.concat(s2)String::concat 으로
    3. 기존 객체의 인스턴스 메서드 레퍼런스
      • () -> obj.getValue()obj::getValue 으로
        • 이 경우 obj 인스턴스에 대한 평가는 컴파일시에 일어남.
      • (String s) -> this.transfer(s)this::transfer으로
  • 메서드 레퍼런스의 왼쪽 부분(:: 의 앞) 타겟 레퍼런스 라고 함.

생성자 레퍼런스

  • 클래스명::new

  • 인수가 없는 생성자의 경우

// Supplier<T> 인터페이스는 파라미터를 받지 않고 T 타입 인스턴스를 반환한다
Supplier<Apple> s1 = () -> new Apple();

// 생성자 레퍼런스 이용. Apple 클래스의 인수가 없는 생성자가 호출됨.
Supplier<Apple> s1 = Apple::new;
  • 인수가 한 개인 생성자의 경우
// Function<T, R> 인터페이스는 T 타입을 파라미터로 받고 R 타입을 반환한다
Function<Integer, Apple> f1 = (int weight) -> new Apple(weight);

// 생성자 레퍼런스 이용. Apple 클래스에서 int형 인수를 받는 생성자가 호출됨.
Function<Integer, Apple> f2 = Apple::new;
  • 인수가 두 개인 생성자의 경우
// BiFunction<T, U, R> 인터페이스는 2개의 파라미터(T 타입, U 타입)를 받고 R 타입을 반환한다
BiFunction<String, Integer, Apple> b1 = new Apple("green", 10);

// 생성자 레퍼런스 이용. Apple 클래스에서 String, int 인수를 받는 생성자가 호출됨.
BiFunction<String, Integer, Apple> b1 = Apple::new;
  • 인수가 세 개 이상인 생성자 레퍼런스를 사용하려면 이에 맞게 인터페이스를 직접 구현하여 사용하면 됨.

함수형 인터페이스의 유용한 메소드

  • 몇몇 함수형 인터페이스들은 다양한 유틸리티 메서드를 디폴트 메서드로 포함하고 있다.
  • 이들을 이용하여 여러 개의 람다 표현식을 조합해서 복잡한 람다 표현식을 만들 수 있음.

Comparator

  • Comparator 인터페이스는 Comparable 키를 추출하는 함수를 인수로 받는 Comparator.comparing static 메서드를 포함한다.
    • static Comparator<T> comparing(Function keyExtractor)
  • 추출한 키를 비교할 때 사용할 Comparator를 두 번째 인수로 넘겨줄 수도 있다.
    • static Comparator<T> comparing(Function keyExtractor, Comparator keyComparator))
  • 이를 이용하여 Apple 객체의 weight를 비교하는 Comparator를 다음과 같이 정의하여 정렬에 사용할 수 있다.
Comparator<Apple> byWeight = Comparator.comparing(Apple::getWeight);
inventory.sort(byWeight);

// 내림차순으로 정렬하려면 reversed 메소드를 이용한다.
inventory.sort(Comparator.comparing(Apple::getW- eight)
				.reversed());
  • thenComparing 메서드를 이용해서 첫 번째 키 값이 동일할 경우 사용할 두 번째 비교 조건을 지정할 수 있다.
inventory.sort(Comparator.comparing(Apple::getWeight)
				.reversed()
				.thenComparing(Apple::getCountry));

Predicate

  • Predicate 인터페이스는 더욱 복잡한 Predicate를 만들 수 있도록 negate, and, or 메서드를 제공한다.
// 색이 Red인지 테스트하는 Predicate
Predicate<Apple> p = (a) -> a.getColor().equals("Red");

// 색이 Red가 아닌지 테스트하는 Predicate
Predicate<Apple> p = (a) -> a.getColor().equals("Red").negate();

// 색이 Red이면서 무게가 100 이상인지 테스트하는 Predicate
Predicate<Apple> p = (a) -> 
	a.getColor().equals("Red").and((a) -> a.getWeight() > 100);

// 색이 Red 이거나 무게가 100 이상인지 테스트하는 Predicate
Predicate<Apple> p = (a) ->
	a.getColor().equals("Red").or((a) -> a.getWeight() > 100);

Function

  • Function 인터페이스는 함수형 메서드간의 결과를 순차적으로 수행할수 있도록 도와주는 andThen, compose 메소드를 제공한다.
  • andThen 메서드는 주어진 함수를 적용한 결과를 다른 함수의 입력으로 전달하는 함수를 반환한다.
Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.andThen(g);
int result = h.apply(1);	// g(f(1)) = 4 반환
  • compose 메서드는 인수로 넘긴 함수를 실행한 다음에 그 결과를 외부 함수의 인수로 제공한다.
Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.compose(g);
int result = h.apply(1);	// f(g(1)) = 3 반환
  • Function.identity() static 메서드는 입력받은 인수를 그대로 반환해준다.

References

  • Java 8 in Action - 한빛미디어
  • NAVER D2
파일 내용 랜덤으로 섞기 - Shuffle the contents of a file Java 8 - Stream (스트림)