Java/모던 자바 인 액션

[Java] 모던 자바 인 액션(3) - 함수형 인터페이스와 람다 표현식

ready-go 2023. 2. 26. 03:39

함수형 인터페이스

지난 글의 마지막에 사용자 정의 인터페이스를 대체할 수 있는 함수형 인터페이스를 언급했다. 이번 글에서는 함수형 인터페이스에 대해 자세히 알아보도록 하자.

 

먼저, 함수형 인터페이스라는 이름에 주목해보자. 인터페이스는 인터페이스인데, '함수형'이라니?

 

함수란 무엇인가? 고등학교 수학에서 자주 나오는 문제다. 정의역이 주어지고, 정의역에 속하는 x값은 모두 하나의 y값에 대응되어야 한다. y값이 0개도, 2개 이상도 안된다. (물론, y값은 공역의 원소여야한다.)

 

당연히 Java에서 x값은 파라미터에 해당하고, y값은 리턴값에 해당한다. 이것은 인터페이스로 정의한다면, 인터페이스 내에 구현이 없는 메소드 시그니처를 정의해주면 될 것이다. 근데 만약 메소드 시그니처가 2개라면 어떻게 될까? 파라미터 개수가 다르거나, 타입이 다르거나, 리턴 타입이 다를 것이다. 이 말은 정의역과 공역이 하나로 정해지지 않는다는 것이다. 따라서 함수형 인터페이스는 딱 하나의 메소드 시그니처만을 가져야 한다.

 

예를 들면 아래와 같다.

public interface Condition {
    boolean check(Menu menu);
}

재사용성을 높이기 위해 지난 글의 예시를 가져왔다. 이것이 함수형 인터페이스라는 것을 명시적으로 나타내려면 아래와 같이 어노테이션을 추가해 줄 수도 있다. 필수는 아니다.

@FunctionalInterface
public interface Condition {
    boolean check(Menu menu);
}

정의역은 Menu 타입이고, 공역은 boolean 값으로 고정되어있다. 이 말은 리턴 타입이 정수형이면 비슷한 인터페이스를 또 정의해야 한다는 것이고, 파라미터 타입이 바뀌어도 마찬가지다. 당연히 귀찮게 반복되는 코드 작성을 피하고 싶다. 타입을 마음대로 바꾸고 싶으면 파라미터로 넣으면 된다. Java의 제네릭을 이용하자.

@FunctionalInterface
public interface Condition<T, R> {
    R check(T t);
}

고맙게도, Java 8에서는 메소드 이름만 다른 인터페이스를 제공해준다.

@FunctionalInterface
public interface Function<T, R> {
	R apply(T t);
    /* ... 생략 ... */
}

 

람다 표현식의 활용

이제 Java의 기본 기능을 사용해서 Condition 인터페이스의 check 메소드와 같은 기능을 하는 함수형 인터페이스를 만들기 위해서는 Function<Menu, Boolean> 타입으로 만들어주면 된다. 아래와 같이 익명 클래스로 만들어 줄 수 있다.

Function<Menu, Boolean> coffeeCondition = new Function<>() {
	@Override
	public Boolean apply(Menu menu) {
		return menu.type == MenuType.COFFEE;
	}
};

필요한 로직은 딱 한줄 뿐인데 불필요한 코드가 너무 많다. 람다 표현식을 사용하면 불필요한 부분을 제거할 수 있다.

Function<Menu, Boolean> coffeeCondition = menu -> menu.type == MenuType.COFFEE;

이번 예제는 간단해서 한 줄이지만, 만약 더 복잡한 로직이 필요해서 여러줄인 경우는 어떻게 사용할까? 일반적인 메소드 구현처럼 중괄호로 묶어주면 된다. 리턴 타입이 void가 아니라면 명시적으로 return을 적어줘야 한다.

Function<Menu, Boolean> cheapCoffeeCondition = menu -> {
	if (menu.price > 5000) {
		return false;
	}
	return menu.type == MenuType.COFFEE;
};

또, 파라미터의 타입을 명시적으로 적어주고 싶다면 일반 메소드처럼 소괄호로 묶어주면 된다.

Function<Menu, Boolean> cheapCoffeeCondition = (Menu menu) -> {
	if (menu.price > 5000) {
		return false;
	}
	return menu.type == MenuType.COFFEE;
};

 

다양한 함수형 인터페이스

위 예시에서는 Function을 사용했지만, 사실 리턴 타입으로 Boolean을 사용하는 특수한 경우에는 Predicate이라는 함수형 인터페이스가 있다. 리턴 타입이 정해졌으므로 타입 파라미터는 1개이다.

Predicate<Menu> coffeeCondition = new Predicate<Menu>() {
	@Override
	public boolean test(Menu menu) {
		return menu.type == MenuType.COFFEE;
	}
};

Predicate도 람다 표현식으로 나타낼 수 있다.

Predicate<Menu> coffeeCondition = menu -> menu.type == MenuType.COFFEE;

Java 8에서는 Predicate 이외에도 많은 함수형 인터페이스가 존재한다. 리턴 타입이 void인 경우는 Consumer를 사용할 수 있다.

Consumer<String> printConsumer = new Consumer<>() {
	@Override
	public void accept(String s) {
		System.out.println(s);
	}
};

Consumer도 당연히 람다 표현식으로 나타낼 수 있다.

Consumer<String> printConsumer = s -> System.out.println(s);

파라미터가 없고, 리턴 값이 있는 경우는 Supplier가 있다.

Supplier<String> helloSupplier = new Supplier<>() {
	@Override
	public String get() {
		return "Hello World!";
	}
};

Supplier의 람다 표현식은 아래와 같다.

Supplier<String> helloSupplier = () -> "Hello World!";

좀 더 일반적인 상황으로, 파라미터가 2개인 경우는 BiFunction이 있다.

BiFunction<String, Integer, String> repeatBiFunction = new BiFunction<>() {
	@Override
	public String apply(String s, Integer integer) {
		StringBuilder sb = new StringBuilder();
		for (int i = 0; i < integer; i++) {
			sb.append(s);
		}
		return sb.toString();
	}
};

역시나 람다 표현식으로 나타낼 수 있다.

BiFunction<String, Integer, String> repeatBiFunction = (s, integer) -> {
	StringBuilder sb = new StringBuilder();
	for (int i = 0; i < integer; i++) {
		sb.append(s);
	}
	return sb.toString();
};

이 외에도 primitive type에 특화된 IntConsumer, DoubleSupplier, LongPredicate 등이 존재한다. Consumer, Supplier, Predicate, Function을 알고 있으니, 이름만 봐도 어떤 역할인지 감이 올 것이다.

 

람다 표현식의 정체

위에서 여러 가지 함수형 인터페이스를 구현하면서 람다 표현식으로 만드는 작업을 반복했다. 이쯤 되면 람다 표현식의 정체를 눈치챘을 것이다. 람다 표현식은 함수형 인터페이스를 구현한 인스턴스다. 따라서 우리는 변수에도 할당할 수 있고, 메소드 파라미터로 넘겨줄 수도 있는 것이다.

 

책에는 없는 내용이지만 내부 동작을 알아보자.

 

바이트코드를 살펴보면 람다 표현식 부분에 invokedynamic이라는 opcode가 있다. Java 8에서는 이 invokedynamic을 이용해 람다 표현식을 구현한다. 사실 invokedynamic은 JVM에서 실행되는 동적타입 언어를 위한 것인데, 람다 표현식의 컨텍스트를 분석하여 적절한 함수형 인터페이스를 찾는다. 이 정보는 LambdaMetafactory라는 클래스로 전달되고, 메소드를 실행할 수 있는 MethodHandle 객체를 담고있는 CallSite를 만들어 반환한다. 이 MethodHandle은 메소드 참조에서도 사용되는 것 같다.

 

[참고 자료](https://blog.hexabrain.net/400)

 

람다 표현식의 유연성

람다 표현식은 invokedynamic을 이용해 동적타입 언어들처럼 파라미터 타입과 리턴 타입을 결정한다. 예를 들어, 지난 글에서 사용했던 List에서 특정한 조건을 만족하는 것들만 필터링하는 메소드를 생각해보자.

public static List<Menu> filter(List<Menu> menu, Predicate<Menu> condition) {
	List<Menu> result = new ArrayList<>();
	for (Menu m : menu) {
 		if (condition.test(m)) {
			result.add(m);
		}
	}
	return result;
}

이 메소드는 아래와 같이 사용할 수 있다.

List<Menu> coffee = filter(menu, m -> m.type == MenuType.COFFEE);

람다 표현식에는 명시적으로 파라미터 타입을 선언하지 않았다. 그러나 filter 메소드의 시그니처를 참고하면 Menu 타입이라는 것을 알 수 있고, 람다 표현식이 Predicate라는 것 또한 알 수 있다.

 

사실 위의 예시에서 이미 보여주었던 것이다. 우리는 Function과 Predicate를 이용해 정확히 동일한 람다 표현식을 변수로 받을 수 있었다.

Function<Menu, Boolean> coffeeCondition1 = menu -> menu.type == MenuType.COFFEE;
Predicate<Menu> coffeeCondition2 = menu -> menu.type == MenuType.COFFEE;

 

함수형 인터페이스의 조합

Java 8의 함수형 인터페이스는 여러 개의 함수형 인터페이스를 조합할 수 있도록 도와주는 디폴트 메소드들을 제공한다. 예를 들어, Function은 두 함수를 순서대로 실행해주는 andThen과 함수의 합성을 지원하는 compose가 있다.

Function<Integer, Integer> h1 = f.andThen(g);
Function<Integer, Integer> h2 = f.compose(g);

위의 h1, h2 함수는 비슷하지만 조금 다르다. h1은 f를 먼저 실행한 결과를 g에 전달한다. 반대로, h2는 g의 결과를 f에 전달하여 실행한다.

비슷한 예시로, Predicate 인터페이스도 and와 or, negate라는 디폴트 메소드를 제공한다.

Predicate<Integer> and = p1.and(p2);
Predicate<Integer> or = p1.or(p2);
Predicate<Integer> negate = p1.negate();

이름만 봐도 알겠지만 and는 두 조건을 모두 만족해야 true, or는 둘 중 하나만 만족하면 true, negate는 true는 false로, false는 true로 바꿔주는 메소드이다.

 

사실 함수형 인터페이스를 조합할 수 있다는 것은 별로 놀랍지 않을지도 모른다. 그러나 개인적으로 이 책의 주제인 Java 8의 철학을 정말 잘 보여주는 예시라고 생각한다. 다음 글에서 살펴볼 Stream API처럼 파이프라인을 구성할 수 있다는 점과 Java 8에서 추가된 인터페이스의 디폴트 메소드를 활용한 것이기 때문이다.

 

3줄 요약

  1. 함수형 인터페이스는 구현해야할 메소드가 하나인 인터페이스
  2. 람다 표현식을 사용하면 함수형 인터페이스 구현체를 만들 수 있다
  3. 람다 표현식의 타입은 컴파일 타임에 자동으로 해석된다