프로그래밍/자바

자바8의 함수형 인터페이스(Functional Interfaces in Java 8)

말랑공룡 2021. 8. 12. 16:23

이 포스팅은 https://www.baeldung.com/java-8-functional-interfaces의 번역 포스팅입니다.


1. 소개

이 튜토리얼은 자바8의 다양한 함수형 인터페이스를 가이드합니다.

 

2. 자바8의 람다

 

자바8은 람다표현식을 내놓으면서 코드의 엄청난 향상을 가져왔습니다.

람다는 우리가 1급 시민으로써 다룰수 있는 익명 함수입니다. 예를 들어, 우리는 람다를 전달하거나 어느 다른 메소드로부터 리턴 받을 수 있습니다.

자바8 이전에는, 캡슐화가 필요한 하나하나의 기능이 생길 때마다 클래스를 만들곤 했습니다. 이런 방식은 불필요한 문법식 코드를 내포하고 있었죠.

 “Lambda Expressions and Functional Interfaces: Tips and Best Practices” 포스팅에서는 함수형 인터페이스와 람다를 이용한 응용을 더 자세히 다루고 있습니다. 

 

3. 함수형 인터페이스(Functional Interfaces)

 

모든 함수형 인터페이스에는 유용한 @FunctionalInterface 어노테이션이 붙어있기를 권장합니다.

이것은 명확하게 인터페이스의 목적을 알리고 있고 어노테이션이 붙은 인터페이스가 조건을 충족하지 못하면 컴파일러가 에러를 발생시키게 할 수 있습니다.

SAM(Single Abstract Method)이 있는 모든 인터페이스는 함수형 인터페이스입니다. 그리고 구현은 람다 표현식으로 되어있겠죠.

자바8의 기본(default) 메소드는 abstract가 아니므로 SAM에 포함시키지 않습니다.

그래도 함수형 인터페이스는 많은 default 메소드를 가지고 있습니다. 우리는 Function's 문서를 통해 이것을 확인할 수 있습니다.

 

4. Functions

 

가장 간단하고 일반적인 람다는 하나의 값을 받아 다른 곳에 리턴하는 함수형 인터페이스입니다.

이것은 Function 인터페이스가 대표적인데, 매개변수(argument)와 리턴해주는 값을 파라미터로 지정합니다.

public interface Function<T, R> { … }

표준 라이브러리에서 Function 타입을 사용하는 예제 중 하나는 Map.computeIfAbsent 메소드입니다.

이 메소드는 키(key)로 맵에서 값을 리턴하지만 만약 키가 맵에 존재하지 않는 경우의 값도 계산합니다.

그 때, 값을 계산하기 위해 Function 구현을 사용합니다:

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

이 경우, 키에 함수를 적용하여 값을 계산하고, 맵에 넣고 메소드 호출에서도 반환할 것입니다.

전달된 값과 반환된 값 유형과 일치하는 메서드 참조로 람다를 대체할 수 있습니다.

메소드를 호출하는 객체는 실제로 메소드의 암시적 첫 번째 인수입니다. 이를 통해 함수 인터페이스에 대한 인스턴스 메서드 길이(length) 참조를 캐스팅할 수 있습니다.

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

또한 Function 인터페이스에는 여러 함수를 하나로 결합하여 순차적으로 실행할 수 있는 compose 메소드가 있습니다.

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

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

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

quoteIntToString 함수는 quote 함수와 intToString 함수의 결과를 적용한 결합 함수가 됩니다.

 

5. Primitive Function Specializations

 

원시 형식은 일반 형식 인수가 될 수 없으므로 가장 많이 사용되는 원시 유형에 대한 함수 인터페이스 버전 double, int, long 및 인수 및 반환 형식에는 다음과 같은 조합이 있습니다:

  • IntFunction, LongFunction, DoubleFunction: 인수 -> 지정된 유형이고 반환 유형이 매개 변수화됩니다.
  • ToIntFunction, ToLongFunction, ToDoubleFunction:  반환 유형이 지정된 형식이고 인수가 매개 변수화됩니다.
  • DoubleToIntFunction, DoubleToLongFunction, LongToLongFunction, LongToDoubleFunction: 인수와 반환 유형 모두 이름으로 지정된 원시 유형으로 정의합니다.

예를 들어, short를 받아 byte로 리턴하는 함수형 인터페이스는 없지만 직접 만들어 봅시다.

@FunctionalInterface
public interface ShortToByteFunction {

    byte applyAsByte(short s);

}

이렇게 되면 ShortToByteFunction을 정의해서 short의 배열을 byte의 배열로 변환하는 메소드를 만들 수 있게 됩니다.

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;
}

2를 곱하는 변형도 만들 수 있겠죠.

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

 

6.Two-Arity Function Specializations

2개의 인자를 받는 람다를 정의하기 위해서는 "Bi" 키워드를 갖는 추가적인 인터페이스를 사용해야 합니다.: BiFunction, ToDoubleBiFunction, ToIntBiFunction, and ToLongBiFunction.

BiFunction은 인수와 반환 형식을 모두 생성하며 ToDoubleBiFunction 등은 원시 값을 반환할 수 있도록 합니다.

표준 API에서 이 인터페이스를 사용하는 일반적인 예 중 하나는 맵의 모든 값을 일부 계산된 값으로 바꿀 수 있는 Map.replaceAll 메소드에 있습니다.

BiFunction을 통해 키(key)와 이전 값을 새로운 값으로 계산해서 리턴하는 과정을 구현해보겠습니다.

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

 

7.Suppliers

 

Supplier 함수형 인터페이스는 인수를 사용하지 않는 또 다른 Function specialization입니다. 일반적으로 값의 lazy한 발생에 사용합니다. 예를 들어, 두 값을 제곱하는 함수를 정의하겠습니다. 이 함수는 인수로 그 자체의 값을 받는게 아니라 값의 Supplier를 받습니다.:

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

따라서 Supplier 구현을 사용하여 이 기능의 호출에 대한 인수를 필요한 때에 맞춰 생성할 수 있습니다. 이것은 인수를 생성하는 데 상당한 시간이 걸리는 경우에 유용할 수 있습니다.  Guava'ssleepUninterruptibly 메소드로 시뮬레이션해 보겠습니다.

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

Double valueSquared = squareLazy(lazyValue);

Supplier의 또 다른 활용 사례는 시퀀스 생성을 위한 로직을 정의하는 것입니다.

이를 입증하기 위해 static Stream.generate 메서드를 사용하여 Fibonacci 숫자의 Stream을 생성하겠습니다.:

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가 일반적으로 일종의 외부 상태를 필요로 합니다. 이 경우 상태는 마지막 두 개의 피보나치 시퀀스 번호로 구성됩니다.

람다 내부에서 사용되는 모든 외부 변수가 효과적으로 최종 변수여야 하므로 이 상태를 구현하기 위해 두 개의 변수 대신 배열을 사용합니다.

Supplier 함수형 인터페이스의 다른 specialization에BooleanSupplier, DoubleSupplier, LongSupplierIntSupplier가 있으며, 반환 유형은 해당 원시 타입입니다.

 

8. Consumers

 

Supplier와 반대로, Consumer는 생성된 인수를 받아들이고 아무것도 반환하지 않습니다. 

예를 들어, 콘솔에서 인사말을 프린트하여 이름 목록에 있는 모든 사람을 인사하도록 하겠습니다. List.for.Each 메서드에 전달된 람다는 Consumer  함수형 인터페이스를 구현합니다.:

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

또한 Double Consumer, IntConsumerLong Consumer와 같이 원시적인 가치를 인수로 받는 특수화된 버전의 Consumer도 있습니다. 더 흥미로운 것은 BiConsumer 인터페이스입니다. 사용 사례 중 하나는 다음과 같이 맵의 항목을 반복하는 것입니다.

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, ObjIntConsumerObjLongConsumer로 구성되며, 두 인수 중 하나는 제네릭 타입이고 다른 하나는 원시 유형입니다.

 

9. Predicates

 

수학 논리에서 predicate는 값을 수신하고 부울(boolean) 값을 반환합니다.

Predicate 함수형 인터페이스는 제네릭 값을 받아 부울을 반환하는 Function specialization입니다. Predicate 람다의 일반적인 사용 사례는 값 집합을 필터링하는 것입니다.:

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

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

위의 코드에서는 Stream API를 사용하여 목록을 필터링하고 문자 "A"로 시작하는 이름만 유지합니다.

Predicate 구현은 필터링 로직을 캡슐화합니다.
앞의 모든 예와 마찬가지로 이 함수의 원시 타입의 값을 받는 IntPredicate, DoublePredicateLongPredicate 버전이 있습니다.

 

10. Operators

 

연산자(Operator) 인터페이스는 동일한 값 유형을 수신하고 반환하는 함수의 특수한 경우입니다.

UnaryOperator 인터페이스는 단일 인수를 수신합니다. Collections API의 사용 사례 중 하나는 목록의 모든 값을 동일한 유형의 일부 계산된 값으로 바꾸는 것입니다.:

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

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

List.replaceAll 함수는 제자리에 있는 값을 대체할 때 void를 반환합니다.

목적에 부합하기 위해서는, 리스트의 값을 변환하는 데 사용되는 람다가 받는 결과와 동일한 결과 유형을 반환해야 합니다.

이것이 UnaryOperator가 여기서 유용한 이유입니다.
물론 name -> name.to UpperCase() 대신 메소드 참조를 사용해도 됩니다.

names.replaceAll(String::toUpperCase);

BinaryOperator의 가장 흥미로운 사용 사례 중 하나는 축소(reduction) 연산입니다.

모든 값의 합으로 정수 집합을 집계한다고 가정합니다.

Stream API를 사용하면 collector를 사용하여 이 작업을 수행할 수 있지만, 더 일반적인 방법은 reduce를 사용하는 것입니다.:

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

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

reduce 메소드 처음 축적 값과 BinaryOperator 함수를 수신합니다.

이 함수의 인수는 동일한 유형의 값 쌍이며, 함수 자체에도 동일한 유형의 단일 값에 결합하기 위한 논리가 포함되어 있습니다.

전달된 함수는 연관성이 있어야 하며, 이는 값 집계 순서는 중요하지 않습니다 즉, 다음 조건이 충족되어야 합니다.:

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

BinaryOperator 함수의 연관 특성을 통해 축소 프로세스를 쉽게 병렬화할 수 있습니다.

물론 DoubleUnary 연산자, IntUnary 연산자, LongUnary 연산자, DoubleBinary 연산자, IntBinary 연산자, LongBinary 연산자 등 원시 타입 값과 함께 사용할 수 있는 UnaryOperator BinaryOperator specializations도 있습니다.

 

11. Legacy Functional Interfaces

 

Java 8에서 모든 함수형 인터페이스가 나오지는 않았습니다.

이전 버전의 Java의 많은 인터페이스는 FunctionalInterface의 제약 조건을 준수하고 그것들을 람다로 사용할 수 있습니다.

대표적인 예로는 동시성 API에 사용되는 RunnableCallable 인터페이스가 있습니다.

Java 8에서 이러한 인터페이스는 @FunctionalInterface 으로도 표시합니다.

이를 통해 동시성 코드를 크게 간소화할 수 있습니다.

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