Java 8 부터 도입된 함수형 인터페이스(Functional Interface)에 대해서 알아보려고 합니다. Java 8 이 배포된 지 한참의 시간이 지났음에도 불구하고, 여전히 이에 대한 이해가 충분하지 않았습니다. 또한 일상적인 부분에서 잘 사용하지 않으면 활용하지 못하고 끝내 오래된 방식으로 개발을 하게 되므로 이 기회에 한번 정리해 보고자 합니다.

정리가 잘 된 내용들이 많이 있지만, 좋은 예시가 있어 참고합니다.

여기서 모든 내용을 다루지는 않지만 기본적인 사항을 이해하는 데 도움이 될 것이라고 생각하고 있으며, 저 역시 공부한 내용을 정리하는 목적으로서 남겨둡니다.

Java 8 에서의 Lambda

Java 8 부터 변화된 것 중 가장 큰 변화는 아마도 람다식(Lambda)일 겁니다. 저는 Java 1.5 정도부터 개발을 해왔는데, 이 lambda를 처음 봤을 때 매우 감동적(?)이었습니다. 람다는 인풋, 아웃풋이 명확하게 구분되는 경우, 그것을 특별한 명시적인 구문 없이도 함수를 호출하고 그 결과를 받게 해 줍니다. Java 8 에서의 람다식이 있기 전에는 일반적으로 모든 케이스에 대한 클래스, 그리고 그 하위에 함수를 생성하고 각각의 경우에 대해서 함수를 구현해야만 했습니다. 이러한 방식은 분명 소모적인 코드를 증가시키고 가독성을 떨어뜨리는 것이 사실입니다.

람다식의 확대 적용을 통해 얻게 된 것 중 하나가 바로 Functional Interface입니다. 여기서는 이에 초점을 두고 이야기하지만, lambda식 자체에 대한 것들도 나중에 다뤄볼 생각입니다.

Functional Interface

Functional Interface 를 생성하는 데에는 @FunctionalInterface 라는 annotation을 붙이도록 하는 것이 권장 가이드입니다. 이렇게 함으로써, 이 인터페이스가 어떤 목적을 갖는지를 명확히 하고, 컴파일러에게 이 만들어진 functional interface가 어떤 조건에서 문제를 일으킬 수 있는지 여부를 알려줍니다.

SAM(Single Abstract Method)를 가진 모든 인터페이스는 functional interface 이고, 그것을 구현한 것은 람다식으로 정의됩니다.

Functions

가장 간단하고 일반적인 람다식은 하나의 값을 받고 또 다른 값을 리턴하는 functional interface 입니다. 이러한 것은 Function 인터페이스로 구현합니다. 이것은 하나의 타입으로 된 파라미터와 리턴 값으로 정의됩니다.

public interface Function<T, R> { ... }

이러한 Function의 예시는 Map.computeIfAbsent 라는 함수에서 볼 수 있습니다. 이 함수는 특정 키에 대한 값을 리턴하지만, 만약 키가 없다면 값을 계산해서 리턴합니다. 값을 계산하기 위해서 Function 을 사용하게 됩니다.

Map<String, Integer> nameMap = new HashMap<>();
Integer value = nameMap.computeIfAbsent("John", s -> s.length());

이 예시에서 키에 대한 값을 가져오기 위해서 function을 넣어줍니다. 그 map 에서 method 호출로 이 과정이 이루어집니다. 물론 이 과정에 lambda 식 대신에 함수를 넣어줄 수도 있습니다. 물론 그 함수의 input과 return 은 이 조건에 맞아야 합니다. 다음과 같이 바꿔서 사용할 수 있습니다.

Integer value = nameMap.computeIfAbsent("John", String::length);

이 Function 에서 가지고 있는 함수 중 compose 는 여러 개의 function을 연결고리로 이어주는 역할을 합니다.

Function<Integer, String> intToString = Object::toString;
Function<String, String> quote = s -> "'" + s + "'";

Function<Integer, String> quoteIntToString = quote.compose(intToString);

assertEquals("'5'", quoteIntToString.apply(5));

여기서 말하는 quiteIntToString 은 intToString 함수를 실행한 뒤, quote 라는 함수를 실행하게 해 줍니다.

Primitive Function Specializations

primitive type은 generic type이 될 수 없기 때문에, 가장 많이 사용되는 primitive types 들에 대해서 몇 가지 종류의 Function 클래스가 존재합니다.

  • IntFunction, LongFunction, DoubleFunction: 인자가 특정지어진 함수이고, 리턴 타입이 파라미터로 정의됩니다.

  • ToIntFunction, ToLongFunction, ToDoubleFunction: 리턴 타입이 특정지어진 함수이고, 인자가 파라미터로 정의됩니다.

  • DoubleToIntFunction, DoubleToLongFunction, IntToDoubleFunction, IntToLongFunction, LongToIntFunction, LongToDoubleFunction: 인자와 리턴 타입 모두 primitive type으로 정의된 것입니다. 각각의 이름에 따릅니다.

하나의 예로서, 여기에는 short를 받아서 byte를 리턴하는 경우는 없습니다. 하지만 직접 아래 과정을 통해서 생성해볼 수 있습니다.

@FunctionalInterface
public interface ShortToByteFunction {

    byte applyAsByte(short s);

}

이제 short 배열을 받아서 변환하는 함수를 직접 만들 수 있습니다. 이 내용은 ShortToByteFunction에 정의됩니다.

public byte[] transformArray(short[] array, ShortToByteFunction function) {
    byte[] transformedArray = new byte[array.length];
    for (int i = 0; i < array.length; i++) {
        transformedArray[i] = function.applyAsByte(array[i]);
    }
    return transformedArray;
}

다음 예시는 short배열을 받아서 2를 곱한 수에 해당하는 byte를 리턴하는 코드입니다.

short[] array = {(short) 1, (short) 2, (short) 3};
byte[] transformedArray = transformArray(array, s -> (byte) (s * 2));

byte[] expectedArray = {(byte) 2, (byte) 4, (byte) 6};
assertArrayEquals(expectedArray, transformedArray);

두 개의 인자를 가진 특수한 함수

람다식에 두 개의 인자를 정의하기 위해서, BI라는 키워드가 있는 함수를 사용해야 합니다. BiFunction, ToDoubleBiFunction, ToIntBiFunction, ToLongBiFunction 이 그 예입니다.

BiFunction은 두 개의 인자를 받고 정의된 타입으로 리턴합니다. 반면에 ToDoubleBiFunction 을 비롯한 나머지 함수들은 primitive 타입의 값을 리턴합니다.

가장 쉬운 예로 Map.replaceAll 함수를 예로 들 수 있습니다. 이 함수는 모든 값을 계산된 값으로 치환하는 데 사용됩니다.

예로서, 키와 값을 받아서 새로운 값을 계산하여 리턴하는 함수를 살펴봅니다.

Map<String, Integer> salaries = new HashMap<>();
salaries.put("John", 40000);
salaries.put("Freddy", 30000);
salaries.put("Samuel", 50000);

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

Suppliers

함수형 인터페이스 중 Supplier 는 어떠한 인자도 받지 않습니다. 어떠한 값의 lazy generation 을 위해 사용합니다. 예를 들어 제곱하는 함수를 정의할 수 있습니다. 이것은 어떠한 값도 인자로 받지 않고 값을 제공합니다.

public double squareLazy(Supplier<Double> lazyValue) {
    return Math.pow(lazyValue.get(), 2);
}

이 함수는 어떠한 시간 이후에 작업을 진행하고자 할 때 유용하게 사용할 수 있습니다. 이는 Guava의 sleepUninterruptibly 라는 함수를 사용합니다.

Supplier<Double> lazyValue = () -> {
    Uninterruptibles.sleepUninterruptibly(1000, TimeUnit.MILLISECONDS);
    return 9d;
};

Double valueSquared = squareLazy(lazyValue);

또 다른 예는 sequence 를 생성할 때 입니다. 이를 구현하기 위해서 static한 Stream.generate 함수를 사용하여 피보나치(Fibonacci) 수열을 생성할 수 있습니다.

int[] fibs = {0, 1};
Stream<Integer> fibonacci = Stream.generate(() -> {
    int result = fibs[1];
    int fib3 = fibs[0] + fibs[1];
    fibs[0] = fibs[1];
    fibs[1] = fib3;
    return result;
});

이 예에서 Stream.generate 함수에 전달하는 함수는 Supplier 인터페이스 구현체입니다. 이것은 일종의 generator 처럼 활용할 수 있습니다. 이 Supplier 는 보통은 외부의 상태에 대한 정렬을 필요로 할 때가 있습니다. 이러한 경우에 이 상태(state)는 최근의 두 피보나치 수열 값이 됩니다.

이 상태에 대한 값을 구현하기 위해서 여러개의 변수를 사용하는 것이 아니라 배열(array)을 이용합니다. 왜냐하면 모든 외부 변수들이 람다식에서 사용되기 위해서는 final 이어야 하기 때문입니다.

이 밖에도 BooleanSupplier, DoubleSupplier, LongSupplier 그리고 IntSupplier가 있습니다. 이들은 모두 primitive 타입을 리턴합니다.

Consumers

Supplier 와는 반대로, Consumer는 인자를 받아서 아무것도 리턴하지 않습니다. 별도의 처리를 위해서 이런 방식을 활용합니다.

예를 들어, 이름 배열에서 이름을 찾아 인사하는 것을 Console 에 출력해 보도록 합니다. 람다식은 List.forEach 함수에 전달되는데, 이것이 일종의 Consumer입니다.

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

DoubleConsumer, IntConsumer 그리고 LongConsumer 처럼 특이한 형태의 Consumer 들도 있습니다. 이들은 모두 primitive 값을 인자로 받습니다. 더 특이한 것은 BiConsumer 입니다. 다음과 같이 Map에서 entry 를 iterating 하는 경우를 예로 들 수 있습니다.

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"));

또 다른 예로 ObjDoubleConsumer, ObjIntConsumer, 그리고 ObjLongConsumer 가 있습니다. 이들은 두 개의 인자(하나는 generified, 다른 하나는 primitive)를 받습니다.

Predicates

수학적인 로직의 처리로서, predicate 는 하나의 값을 인자로 받고 boolean 값을 리턴합니다. Preicate 는 Function의 특이한 형태로서 generified 값을 받아 boolean 을 리턴합니다. 가장 쉬운 예로 람다 식을 이용한 collection에서의 filter 를 예로 들 수 있습니다.

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

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

이 예에서 Stream 을 통해서 list의 원소들 중, ‘A’로 시작하는 것들만을 필터하는 것을 볼 수 있습니다.

Primitive 값을 인자로 받는 또 다른 예로서 IntPredicate, DoublePredicate 그리고 LongPredicate 가 있습니다.

Operators

Operator 는 Function의 하나로서, 인자와 리턴 값이 모두 동일한 형태입니다. UnaryOperator 는 하나의 인자를 받습니다. Collection API 중 리스트의 모든 값을 받아서 같은 타입으로 리턴하는 경우를 볼 수 있습니다.

List<String> names = Arrays.asList("bob", "josh", "megan");

names.replaceAll(name -> name.toUpperCase());

List.replaceAll 함수는 void 를 리턴하고 그 안에 값들만을 변경합니다. 목적에 맞게 람다식은 특정 값을 받아서 같은 타입을 리턴합니다. 이것이 바로 UnaryOperator 가 유용한 것입니다.

물론 name -> name.toUpperCase() 부분을 다음과 같이 간소화할 수 있습니다.

names.replaceAll(String::toUpperCase);

BinaryOperator 의 예로서 reduction operation 을 들 수 있습니다. 만약 integer list 에서 값을 뽑아 모든 값을 합치는 경우를 예로 들어 봅니다. Stream API 를 이용하여 이를 구현할 수도있겠지만, reduce 함수를 이용하는 것이 더 좋습니다.

List<Integer> values = Arrays.asList(3, 5, 8, 9, 12);

int sum = values.stream()
  .reduce(0, (i1, i2) -> i1 + i2);

reduce 함수는 최초 값과 BinaryOperator를 인자로 받습니다. 여기서의 인자는 같은 타입의 쌍 입니다. 이 두값을 하나의 값으로 합치게 됩니다. 이 인자로 들어가는 함수는 반드시 associative이어야 합니다. 이 말은 앞선 값의 연산은 영향이 없다는 것입니다. 즉 다음 내용을 보면 됩니다.

op.apply(a, op.apply(b, c)) == op.apply(op.apply(a, b), c)

BinaryOperator 는 reduction 과정을 parallelize 해 줍니다.

물론 또 다른 예로 UnaryOperatorBinaryOperator 가 있습니다. 이것들은 primitive 타입을 사용할 때 이용할 수 있습니다. 또한 DoubleUnaryOperator, IntUnaryOperator, LongUnaryOperator, DoubleBinaryOperator, IntBinaryOperator, 그리고 LongBinaryOperator 가 있습니다.

Legacy Functional Interfaces

앞선 Java 버전에 대해서 많은 부분들이 Functional Interface 로 제공됩니다. 그리고 그것들은 람다식으로 사용할 수 있습니다. 예로서, RunnableCallable이 그 예입니다. Java 8 에서 이러한 인터페이스는 @FunctionalInterface 로 선언되어 있습니다. 이 결과로 꽤나 단순화된 코드 작성이 가능합니다.

Thread thread = new Thread(() -> System.out.println("Hello From Another Thread"));
thread.start();

참고자료 및 출처


Leave a comment