Java Lambda (4) 기본으로 제공되는 함수형 인터페이스

⏱ 16 분

이전 포스트에서 함수형 인터페이스에 대해서 살펴봤습니다. 이번 포스트에서는 자바에서 기본적으로 제공하는 함수형 인터페이스를 살펴보겠습니다. 기본 API 에서 함수형 인터페이스는 java.util.function 패키지에 정의되어 있습니다.

  • Functions
  • Suppliers
  • Consumers
  • Predicates
  • Operators
  • Legacy Functional Interfaces

함수형 인터페이스 Functional Interface

이전 포스트에서 함수형 인터페이스에 대해 살펴봤습니다. 함수형 인터페이스는 컴파일러가 람다의 타입을 추론할 수 있도록 정보를 제공하는 역할을 합니다. 이 함수형 인터페이스는 단 하나의 추상 메소드(Single Abstract Method, SAM)만을 가질 수 있습니다.[1]

함수형 인터페이스에는 @FunctionalInterface 라는 어노테이션을 붙일 수 있습니다. @FunctionalInterface 는 개발자들에게 해당 인터페이스가 함수형 인터페이스라는 것을 알려주고 컴파일러가 SAM 여부를 체크할 수 있도록 합니다.

1
2
3
4
5
6
7
@FunctionalInterface
interface Calculation {
Integer apply(Integer x, Integer y);
}

Calculation addition = (x, y) -> x + y;
Calculation subtraction = (x, y) -> x - y;

Functions

기본 형태

가장 기본적인 형태로 특정 오브젝트를 받아서 특정 오브젝트를 리턴하는 메소드 시그니쳐입니다. <T> 는 매개변수의 타입이고, <R>은 리턴 타입입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@FunctionalInterface
public interface Function<T, R> {

R apply(T t);

default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}

default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}

static <T> Function<T, T> identity() {
return t -> t;
}
}

간단한 예제를 살펴보겠습니다. value2 를 계산하는 과정에서 스태틱 메소드가 아님에도 String::length 를 사용했는데 이는 참조할 인스턴스가 없기 때문입니다. 자세한 내용은 Java 8 Lambda (3) 메소드 참조 를 참고하세요.

1
2
3
4
5
6
7
8
9
10
// computeIfAbsent 메소드 시그니쳐
// 맵 내에 해당 키가 존재하지 않을 경우 값을 계산해서 저장하고 리턴
default V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {...}

// 시그니처에 맞는 람다가 들어갈 수 있음
Map<String, Integer> nameMap = new HashMap<>();
Integer value = nameMap.computeIfAbsent("John", s -> s.length());

// 여기서 length 는 스태틱 메소드가 아니라 인스턴스 메소드
Integer value2 = nameMap.computeIfAbsent("Tom", String::length);

Function 에는 compose 라는 디폴트 메소드가 있어 메소드를 순서대로 실행시킬 수 있습니다.

1
2
3
4
5
6
// before 메소드 실행 후 결과를 받아서 현재 메소드 실행
// 제네릭 타입은 처음 input 과 마지막 output 의 타입
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}

다음과 같이 사용할 수 있습니다.

1
2
3
4
5
Function<Integer, String> intToString = Objects::toString;
Function<String, String> quote = s -> "'" + s + "'";

Function<Integer, String> quoteIntToString = quote.compose(intToString);
System.out.println(quoteIntToString.apply(5)); // '5'

andThencompose 와 반대 역할을 합니다. 현재 메소드를 실행 후 매개변수로 받은 람다를 실행합니다.

1
2
3
4
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}

다음은 간단한 예제입니다.

1
2
3
4
Function<String, String> upperCase = v -> v.toUpperCase();

String result = upperCase.andThen(s -> s + "abc").apply("a");
System.out.println(result); // Aabc

마지막으로 자신의 값을 그대로 리턴하는 스태틱 메소드 identity 가 있습니다.

1
2
3
static <T> Function<T, T> identity() {
return t -> t;
}

간단한 예제입니다.

1
2
String abc = Function.identity().apply("abc");
System.out.println(abc); // abc

기본형 타입 관련

double, int, long 기본 타입에 대해서는 따로 함수형 인터페이스를 제공합니다. 이를 이용하면 제네릭 타입을 하나 줄일 수 있습니다.

IntFunction, LongFunction, DoubleFunction

매개변수 타입은 함수별로 지정되어 있고, 리턴 타입만 제네릭으로 받는 형태입니다.

1
2
3
4
5
6
@FunctionalInterface
public interface IntFunction<R> {
R apply(int value);
}

IntFunction<Double> intToDouble = n -> n * 1.0;

ToIntFunction, ToLongFunction, ToDoubleFunction

반대로 리턴 타입은 함수별로 지정되어 있고, 매개변수 타입을 제네릭으로 받는 형태입니다.

1
2
3
4
5
6
@FunctionalInterface
public interface ToIntFunction<T> {
int applyAsInt(T value);
}

ToIntFunction<String> strToInt = Integer::parseInt;

간단한 예제를 만들어보면 다음과 같이 될 겁니다.

1
2
3
4
5
6
7
8
9
10
public static int calculateStrSum(List<String> strList, ToIntFunction<String> function) {
int sum = 0;
for (String str : strList) {
sum += function.applyAsInt(str);
}
return sum;
}

int result = calculateStrSum(Arrays.asList("1", "2", "3"), Integer::parseInt);
System.out.println(result); // 6

물론 duble, int, long 외의 타입에 대해서도 만들어 사용할 수 있습니다.

1
2
3
4
@FunctionalInterface
public interface ShortToByteFunction {
byte applyAsByte(short s);
}

매개변수가 두 개인 경우

위에서 살펴 본 람다는 하나의 매개변수를 갖거나 매개변수가 없는 경우입니다. 두 개의 매개변수를 받는 람다는 Bi 키워드가 들어간 람다를 사용합니다.

1
2
3
4
5
6
7
8
9
10
@FunctionalInterface
public interface BiFunction<T, U, R> {

R apply(T t, U u);

default <V> BiFunction<T, U, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t, U u) -> after.apply(apply(t, u));
}
}
  • BiFunction : 두 개의 매개변수를 가지고 리턴 타입은 void
  • ToIntBiFunction : 두 개의 매개변수를 가지고 리턴 타입은 int
  • ToLongBiFunction : 두 개의 매개변수를 가지고 리턴 타입은 long
  • ToDoubleBiFunction : 두 개의 매개변수를 가지고 리턴 타입은 double
1
2
3
4
5
6
7
8
9
// 맵 내에 요소들에 대해 조건에 따라 값을 바꾸는 메소드
default void replaceAll(BiFunction<? super K, ? super V, ? extends V> function) { ... }

Map<String, Integer> salaries = new HashMap<>();
salaries.put("John", 400);
salaries.put("Freddy", 300);
salaries.put("Samuel", 500);

salaries.replaceAll((name, oldValue) -> name.equals("Freddy") ? oldValue : oldValue + 100);

Suppliers

매개변수를 받지 않고 특정 타입의 결과를 리턴하는 함수형 인터페이스입니다.

1
2
3
4
@FunctionalInterface
public interface Supplier<T> {
T get();
}

이를 활용하면 값을 그냥 전하는 것이 아니라, 중간에 로직을 추가해서 전달할 수 있습니다.

1
2
3
4
5
public double squre(double d) {
return Math.pow(d, 2);
}

double squre = squre(3); // 9.0

위 예제는 단순히 제곱을 하는 메소드입니다. Supplier 를 이용해서 단순히 값을 가져오기 전에 실행될 로직을 추가할 수 있습니다. 예제에서는 Guava[2] 를 이용해서 1,000ms 딜레이를 줬습니다.

1
2
3
4
5
6
7
8
9
10
11
public double squareLazy(Supplier<Double> lazyValue) {
return Math.pow(lazyValue.get(), 2);
}

Supplier<Double> lazyValue = () -> {
// Guava 를 이용해 1000ms 딜레이를 줌
Uninterruptibles.sleepUninterruptibly(1000, TimeUnit.MILLISECONDS);
return 3d;
};

Double squre = squareLazy(lazyValue); // 9.0

위에서 살펴본 바와 비슷하게 Supplier 도 기본형 리턴 타입에 따라 제네릭 없이 사용할 수 있습니다.

  • BooleanSupplier
  • DoubleSupplier
  • LongSupplier
  • IntSupplier
1
2
3
4
5
6
7
8
public double squareLazy(DoubleSupplier lazyValue) {
return Math.pow(lazyValue.getAsDouble(), 2);
}

DoubleSupplier lazyValue = () -> {
Uninterruptibles.sleepUninterruptibly(1000, TimeUnit.MILLISECONDS);
return 3d;
};

Consumers

Supplier 와는 반대로 Consumer 는 매개변수를 받고 리턴하지는 않습니다. Supplier 가 매개 변수 없이 특정 타입을 리턴하는 '공급자’이고 Consumer 가 리턴 타입 없이 매개변수를 받아 처리하는 '소비자’로 해석할 수 있으니 쉽게 구분하실 수 있을 것 같습니다.

1
2
3
4
5
6
7
8
9
10
@FunctionalInterface
public interface Consumer<T> {

void accept(T t);

default Consumer<T> andThen(Consumer<? super T> after) {
Objects.requireNonNull(after);
return (T t) -> { accept(t); after.accept(t); };
}
}

forEach 메소드를 이용한 간단한 예제를 살펴보겠습니다.

1
2
3
4
default void forEach(Consumer<? super T> action) { ... }

List<String> names = Arrays.asList("John", "Freddy", "Samuel");
names.forEach(name -> System.out.println("Hello, " + name));

매개변수에 따라 구분해서 사용할 수 있습니다.

  • IntConsumer
  • LongConsumer
  • DoubleConsumer

또한 매개변수가 두 개인 Bi 버전도 있습니다.

1
2
3
4
5
6
7
8
default void forEach(BiConsumer<? super K, ? super V> action) { ... }

Map<String, Integer> ages = new HashMap<>();
ages.put("John", 25);
ages.put("Freddy", 24);
ages.put("Samuel", 30);

ages.forEach((name, age) -> System.out.println(name + " is " + age + " years old."));

BiConsumer 도 타입에 따라 구분해서 사용할 수 있습니다.

  • ObjDoubleConsumer
  • ObjIntConsumer
  • ObjLongConsumer

Predicates

특정 타입의 매개변수를 받아 boolean 값을 리턴하는 함수형 인터페이스입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@FunctionalInterface
public interface Predicate<T> {

boolean test(T t);

default Predicate<T> and(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> test(t) && other.test(t);
}

default Predicate<T> negate() {
return (t) -> !test(t);
}

default Predicate<T> or(Predicate<? super T> other) {
Objects.requireNonNull(other);
return (t) -> test(t) || other.test(t);
}

static <T> Predicate<T> isEqual(Object targetRef) {
return (null == targetRef)
? Objects::isNull
: object -> targetRef.equals(object);
}
}

Stream API 의 filter 메소드와 함께 사용하는 예제입니다.

1
2
3
4
5
6
7
8
9
10
// Stream 클래스의 filter 메소드
Stream<T> filter(Predicate<? super T> predicate);

List<String> names = Arrays.asList("Angela", "Aaron", "Bob", "Claire", "David");

List<String> namesWithA = names.stream()
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());

System.out.println(namesWithA); // [Angela, Aaron]

다른 메소드도 하나씩 살펴볼까요? and, or 메소드는 다른 Predicate 람다와 함께 조합해서 사용이 가능합니다. negate 메소드는 조건을 반전시키고, 마지막으로 스태틱 메소드인 isEqual 을 이용해 매개변수와 동일한지 판단하는 람다를 생성할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
// [Angela]
names.stream().filter(startWithA.and(endWitha)).collect(Collectors.toList());

// [Angela, Aaron, Bob]
names.stream().filter(startWithA.or(startWithB)).collect(Collectors.toList());

// [Bob, Claire, David]
names.stream().filter(startWithA.negate()).collect(Collectors.toList());

// [Claire]
names.stream().filter(Predicate.isEqual("Claire")).collect(Collectors.toList());

다른 함수형 인터페이스와 마찬가지로 매개변수 타입에 따라서 구분해서 사용할 수 있습니다.

  • IntPredicate
  • DoublePredicate
  • LongPredicate

Operators

Operator 는 하나의 매개변수를 받고 동일한 타입을 리턴하는 함수형 인터페이스입니다. Function 인터페이스를 상속하고 있습니다. 여기에는 매개변수의 개수에 따라서 두 가지가 있습니다.

  • UnaryOperator : 매개변수 하나
  • BinaryOperator : 매개변수 둘

UnaryOperator

1
2
3
4
5
6
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
static <T> UnaryOperator<T> identity() {
return t -> t;
}
}

예제에서 replaceAll 은 해당 요소를 바꿔치기하는 것으로 동일한 리턴 타입이 필요합니다. replaceAll 메소드는 리턴 타입이 void 지만 람다를 이용해서 각 요소를 바꿔치기 할 수 있습니다.

1
2
3
4
5
6
default void replaceAll(UnaryOperator<E> operator) { ... }

List<String> names = Arrays.asList("bob", "josh", "megan");
names.replaceAll(String::toUpperCase); // name -> name.toUpperCase()

System.out.println(names); // [BOB, JOSH, MEGAN]

매개변수 타입에 따라 구분해서 사용할 수 있습니다.

  • DoubleUnaryOperator
  • IntUnaryOperator
  • LongUnaryOperator

BinaryOperator

1
2
3
4
5
6
7
8
9
10
11
12
13
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T,T,T> {

public static <T> BinaryOperator<T> minBy(Comparator<? super T> comparator) {
Objects.requireNonNull(comparator);
return (a, b) -> comparator.compare(a, b) <= 0 ? a : b;
}

public static <T> BinaryOperator<T> maxBy(Comparator<? super T> comparator) {
Objects.requireNonNull(comparator);
return (a, b) -> comparator.compare(a, b) >= 0 ? a : b;
}
}

다음 예제는 reduce 메소드를 이용해서 값들의 합을 구하는 예제입니다.

1
2
3
4
5
6
// 초기값을 가지고 모든 요소를 하나의 값으로 만드는 로직을 수행
T reduce(T identity, BinaryOperator<T> accumulator);

List<Integer> values = Arrays.asList(3, 5, 8, 9, 12);
int sum = values.stream()
.reduce(0, (i1, i2) -> i1 + i2);

여기에서 넘겨지는 람다는 수학에서의 결합법칙(associative) 을 만족해야 합니다.

1
2
3
// 결합법칙은 연산 순서가 바뀌어도 동일한 결과가 나오는 것을 의미
// 예를 들면 (a x b) x c = a x (b x c)
op.apply(a, op.apply(b, c)) == op.apply(op.apply(a, b), c)

다른 인터페이스와 마찬가지로 매개변수 타입에 따라 여러 버전이 있습니다.

  • DoubleBinaryOperator
  • IntBinaryOperator
  • LongBinaryOperator

기존 API 속 함수형 인터페이스

기존의 RunnableCallable 인터페이스도 함수형 인터페이스로 변경되었습니다.

1
2
3
4
@FunctionalInterface
public interface Runnable {
public abstract void run();
}

따라서 다음과 같이 람다를 사용해서 심플하게 코드를 작성할 수 있습니다.

1
2
Thread t = new Thread(() -> System.out.println("Hello from another Thread"));
t.start();

요약

이번 포스트에서 살펴 본 함수형 인터페이스들을 정리해보겠습니다. 세부 타입에 따라서 인터페이스가 많지만 제네릭을 이용해서 간단하게 사용하시면 되겠습니다.

  • Function<T, R> : 매개변수 하나를 받아서 특정 타입의 값을 리턴
    • 기본형 타입 매개변수를 받아 특정 타입의 값을 리턴
      • IntFunction<R>
      • LongFunction<R>
      • DoubleFunction<R>
    • 특정 타입 값을 받아 기본형 타입을 리턴
      • ToIntFunction<T>
      • ToLongFunction<T>
      • ToDoubleFunction<T>
    • BiFunction<T, U, R> : 매개변수가 두 개이고 특정 타입의 값을 리턴
      • 매개변수가 두 개이고 기본형 타입을 리턴
      • ToIntBiFunction<T, U>
      • ToLongBiFunction<T, U>
      • ToDoubleBiFunction<T, U>
  • Supplier<T> : 매개변수 없이 특정 타입의 값을 리턴
    • 매개변수 없이 기본형 리턴 타입
      • BooleanSupplier
      • DoubleSupplier
      • LongSupplier
      • IntSupplier
  • Cousumer<T> : 매개변수를 받기만 하고 리턴하지는 않음
    • 매개변수 기본 타입
      • IntConsumer<T>
      • LongConsumer<T>
      • DoubleConsumer<T>
    • BiConsumer<T, U> : 매개변수가 두 개인 경우
    • 매개변수 중 하나의 타입이 기본 타입인 경우
      • ObjDoubleConsumer<T>
      • ObjIntConsumer<T>
      • ObjLongConsumer<T>
  • Predicate<T> : 매개변수를 받아 boolean 값을 리턴
    • 매개변수 기본 타입
      • intPredicate
      • DoublePredicate
      • LongPredicate
  • UnaryOperator<T> : 하나의 매개변수를 받아 동일한 타입의 값을 리턴
  • BinaryOperator<T> : 동일한 타입의 매개변수를 두 개 받아 동일한 타입의 값을 리턴
    • 기본형 타입
      • DoubleBinaryOperator
      • IntBinaryOperator
      • LongBinaryOperator
  • 기존 API 속 함수형 인터페이스
    • Runnable
    • Callable

참고


  1. 1.디폴트 메소드와 스태틱 메소드는 추상 메소드가 아니기 때문에 여러 개 있어도 상관없습니다.
  2. 2.Guava : Google Core Libraries for Java