함수형 인터페이스
#Java/adv3
함수형 인터페이스와 제네릭1
함수형 인터페이스에서 제네릭이 필요한 이유
방법1: 함수형 인터페이스 따로 선언
@FunctionalInterface
interface StringFunction {
String apply(String s);
}
@FunctionalInterface
interface NumberFunction {
Integer apply(Integer s);
}
StringFunction upperCase = s -> s.toUpperCase();
NumberFunction square = n -> n * n;
- 두 인터페이스의
apply
메서드는 하나의 인자를 입력받고 결과를 반환한다. - 타입이 다르기 때문에 두 개의 인터페이스를 따로 만들어서 람다를 할당해야 한다.
- 타입이 다를때마다 함수형 인터페이스를 계속 만들어야 할까?
방법2: Object로 합치기
ObjectFunction upperCase = s -> ((String)s).toUpperCase();
String result1 = (String) upperCase.apply("hello");
ObjectFunction square = n -> (Integer) n * (Integer) n;
Integer result2 = (Integer) square.apply(3);
@FunctionalInterface
interface ObjectFunction {
Object apply(Object s);
}
- 함수형 인터페이스 2개를 1개로 합쳤지만,
Object
를 사용하기 때문에 복잡하고 안전하지 않은 캐스팅 과정이 필요하다. - 코드의 중복을 제거하고 재사용성을 늘렸지만,
Object
를 사용하므로 다운 캐스팅을 해야 하고, 결과적으로 타입 안전성 문제가 발생한다.
정리
- 방법1: 코드 재사용X, 타입 안정성O
- 방법2: 코드 재사용O, 타입 안정성X
- 이거 어디서 많이 본 내용…? 제네릭의 도입
함수형 인터페이스와 제네릭2
함수형 인터페이스에 제네릭을 도입해서 코드 재사용과 타입 안정성 두 마리 토끼를 모두 챙겨보자.
@FunctionalInterface
interface GenericFunction<T, R> {
R apply(T s);
}
GenericFunction<String, String> upperCase = s -> s.toUpperCase();
GenericFunction<Integer, Integer> square = n -> n * n;
GenericFunction<Integer, Integer> square = x -> x * x;
GenericFunction<Integer, Boolean> isEven = num -> num % 2 == 0;
GenericFunction
은 매개변수가 1개이고, 반환값이 있는 모든 람다에 사용할 수 있다.- 제네릭을 도입함으로써 코드 재사용성을 높임과 동시에 타입 안정성까지 챙겼다.
- 코드의 중복을 줄이고 유지 보수성을 높일 수 있다.
- 동일한 구조의 함수형 인터페이스를 다양한 타입에 재사용할 수 있다.
T
는 입력 타입을,R
은 반환 타입을 나타내며, 실제 사용할 때 구체적인 타입을 지정하면 된다.
람다와 타겟 타입
직접 만든 GenericFunction
의 문제점
- 문제 1. 모든 개발자들이 비슷한 함수형 인터페이스를 개발해야 한다.
- 비슷한
GenericFunction
같은 함수형 인터페이스들이 수없이 만들어진다.
- 비슷한
- 문제 2. 개발자가 만든 함수형 인터페이스와 개발자가 만든 함수형 인터페이스는 서로 호환되지 않는다.
FunctionA functionA = i -> "value = " + i;
FunctionB functionB = i -> "value = " + i;
- 컴파일 에러:
FunctionB targetB = functionA; // 컴파일 에러!
- 람다 자체는 동일한 모양이지만, 자바 타입 시스템상 전혀 다른 인터페이스이므로 서로 호환되지 않는다. 이는 람다가 타켓 타입에 의해 타입이 결정되기 때문이다. 자세히 알아보자.
- 람다와 타켓 타입
- 람다는 그 자체만으로는 구체적인 타입이 정해져 있지 않고, 타겟 타입(target type)이라고 불리는 맥락(대입되는 참조형)에 의해 타입이 결정된다.
FunctionA functionA = i -> "value = " + i;
- 이 코드에서
i -> "value = " + i
라는 람다는FunctionA
라는 타겟 타입을 만나서 비로소FunctionA
타입으로 결정된다.
- 이 코드에서
FunctionB functionB = i -> "value = " + i;
- 동일한 람다라도 이런 코드가 있었다면, 똑같은 람다가 이번에는
FunctionB
타입으로 타겟팅되어 유효하게 컴파일된다.
- 동일한 람다라도 이런 코드가 있었다면, 똑같은 람다가 이번에는
- 즉, 람다는 대입되는 함수형 인터페이스(타겟 타입)에 의해 비로소 타입이 결정된다.
- 자바 컴파일러는 다른 타입으로 간주하기 때문에 상호 대입이 불가능하다.
- 대입되는 함수형 인터페이스(타겟 타입)에 의해 비로소 타입이 결정된다.
- /자바가 기본으로 제공하는 함수형 인터페이스
- 자바는 이런 문제들을 해결하기 위해 필요한 함수형 인터페이스 대부분을 기본으로 제공한다.
- 이제 비슷한 함수형 인터페이스를 불필요하게 만들 필요도 없고, 호환성 문제도 해결할 수 있다.
package java.util.function; @FunctionalInterface public interface Function<T, R> { R apply(T t); // ... }
Function<String, String> upperCase = s -> s.toUpperCase();
Function<Integer, Integer> square = n -> n * n;
Function<Integer, String> functionA = i -> "value = " + i;
- 대입 가능:
Function<Integer, String> functionB = functionA;
- 따라서 자바가 기본으로 제공하는 함수형 인터페이스를 사용하자.
기본 함수형 인터페이스
자바가 제공하는 대표적인 함수형 인터페이스
Function
: 입력O, 반환OConsumer
: 입력O, 반환XSupplier
: 입력X, 반환ORunnable
: 입력X, 반환X
Function: 입력O, 반환O
package java.util.function;
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
// ...
}
Function<String, Integer> function2 = s -> s.length();
System.out.println("function2 = " + function2.apply("hello"));
- 하나의 매개변수를 받고, 결과를 반환하는 함수형 인터페이스이다.
- 입력값(
T
)을 받아서 다른 타입의 출력값(R
)을 반환하는 연산을 표현할 때 사용한다. 물론 같은 타입의 출력 값도 가능하다. - 어떠한 값 t에 함수를 apply(적용)해서 결과를 얻는다.
Consumer: 입력O, 반환X
package java.util.function;
@FunctionalInterface
public interface Consumer<T> {
void accept(T t);
}
Consumer<String> consumer2 = (s) -> System.out.println(s);
consumer2.accept("hello consumer");
- 입력 값(
T
)만 받고, 결과를 반환하지 않는(void
) 연산을 수행하는 함수형 인터페이스이다. - 입력값(
T
)을 받아서 처리하지만 결과를 반환하지 않는 연산을 표현할 때 사용한다. - 입력 받은 데이터를 기반으로 내부적으로 처리만 하는 경우에 유용하다.
- 예) 컬렉션에 값 추가, 콘솔 출력, 로그 작성, DB 저장 등
Consumer: 소비자
라는 의미에 맞게, 데이터를 accept 하여 consume(소비)만 하고 아무것도 반환하지 않는다.
Supplier: 입력X, 반환O
package java.util.function;
@FunctionalInterface
public interface Supplier<T> {
T get();
}
Supplier<Integer> supplier2 = () -> new Random().nextInt(10);
System.out.println("supplier2.get() = " + supplier2.get());
- 입력을 받지 않고(
()
) 어떤 데이터를 공급(supply)해주는 함수형 인터페이스이다. - 객체나 값 생성, 지연 초기화 등에 주로 사용된다.
Supplier
는 "공급자"라는 의미로, 요청할 때마다 값을 공급해주는 역할을 한다.get()
을 통해Supplier
로 부터 값을 Get 한다.
Runnable: 입력X, 반환X
package java.lang;
@FunctionalInterface
public interface Runnable {
void run();
}
Runnable runnable2 = () -> System.out.println("Hello Runnable");
- 입력값도 없고 반환값도 없는 함수형 인터페이스이다. 자바에서는 원래부터 스레드 실행을 위한 인터페이스로 쓰였지만, 자바 8 이후에는 람다식으로도 많이 표현된다. 자바8로 업데이트 되면서
@FunctionalInterface
애노테이션도 붙었다. java.lang
패키지에 있다. 자바의 경우 원래부터 있던 인터페이스는 하위 호환을 위해 그대로 유지한다.- 주로 멀티스레딩에서 스레드의 작업을 정의할 때 사용한다.
- 입력값도 없고, 반환값도 없는 함수형 인터페이스가 필요할 때 사용한다.
특화 함수형 인터페이스
- 의도를 명확하게 만든 조금 특별한 함수형 인터페이스다.
Predicate
: 입력O, 반환 boolean- 조건 검사, 필터링 용도
Operator
(UnaryOperator
,BinaryOperator
): 입력O, 반환O- 동일한 타입의 연산 수행, 입력과 같은 타입을 반환하는 연산 용도
- 기본 함수형 인터페이스로도 대체할 수 있지만, 의도를 명확하게 드러내기 위해 특화 함수형 인터페이스가 따로 존재한다.
Predicate
package java.util.function;
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
}
Predicate<Integer> predicate2 = value -> value % 2 == 0;
System.out.println("predicate2.test(10) = " + predicate2.test(10));
- 입력 값(
T
)을 받아서true
또는false
로 구분(판단)하는 함수형 인터페이스이다. - 조건 검사, 필터링 등의 용도로 많이 사용된다(뒤에서 설명할 스트림 API에서 필터 조건을 지정할 때 자주 등장한다).
Predicate: 술어
. 조건을 만족하는지test
하여 참/거짓을 판단한다.Function<T, Boolean>
으로 대체할 수 있지만,Predicate<T>
는 "입력 값을 받아 true/false 로 결과를 판단한다"라는 의도를 명시적으로 드러내기 위해 정의된 함수형 인터페이스이다.Function<T, Boolean>
을 사용하면 계산을 하여 Boolean을 반환한다고 보일 수 있지만,Predicate<T>
를 사용하면 "이 함수는 조건을 검사하거나 필터링 용도로 쓰인다"라는 의도가 더 분명해진다.- 여러 사람과 협업하는 프로젝트에서, "조건을 판단하는 함수"는
Predicate<T>
라는 패턴을 사용함으로써 의미 전달이 명확해진다.
boolean
판단 로직이 들어가는 부분에서Predicate<T>
를 사용하면 코드 가독성과 유지보수성이 향상된다.- 이름도 명시적이고, 제네릭에
<Boolean>
을 적지 않아도 된다.
- 이름도 명시적이고, 제네릭에
Operator
Operator
는UnaryOperator
,BinaryOperator
2가지 종류가 제공된다.- 수학에서 사용되는 Operator 개념에서 왔다.
- 수학에서 Operator(연산자)는 보통 같은 타입의 값들을 받아서 동일한 타입의 결과를 반환한다.
- 덧셈 연산자(+):
숫자 + 숫자
→숫자
- 곱셈 연산자(*):
숫자 * 숫자
→숫자
- 논리 연산자(AND):
boolean AND boolean
→boolean
- 자바에서는 수학처럼 숫자의 연산에만 사용된다기 보다는 입력과 반환이 동일한 타입의 연산에 사용할 수 있다.
UnaryOperator(단항 연산)
package java.util.function;
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
T apply(T t);
// 실제 코드가 있지는 않음
}
- 단항 연산은 하나의 피연산자(operand)에 대해 연산을 수행하는 것을 말한다.
- 예) 숫자의 부호 연산(
-x
), 논리 부정 연산(!x
) 등
- 예) 숫자의 부호 연산(
- 입력(피연산자)과 결과(연산 결과)가 동일한 타입인 연산을 수행할 때 사용한다.
- 예) 숫자 5를 입력하고 그 수를 제곱한 결과를 반환한다.
- 예)
String
을 입력받아 다시String
을 반환하면서, 내부적으로 문자열을 대문자로 바꾼다든지, 앞뒤에 추가 문자열을 붙이는 작업을 할 수 있다.
Function<T, T>
를 상속받는데, 입력과 반환을 모두 같은T
로 고정한다. 따라서UnaryOperator
는 입력과 반환 타입이 반드시 같아야 한다.
BinaryOperator(이항 연산)
package java.util.function;
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T,T,T> {
T apply(T t1, T t2);
// 실제 코드가 있지는 않음
}
- 이항 연산은 두 개의 피연산자(operand)에 대해 연산을 수행하는 것을 말한다.
- 예: 두 수의 덧셈(
x + y
), 곱셈(x * y
) 등
- 예: 두 수의 덧셈(
- 같은 타입의 두 입력을 받아, 같은 타입의 결과를 반환할 때 사용된다.
- 예)
Integer
두 개를 받아서 더한 값을 반환 - 예)
Integer
두 개를 받아서 둘 중에 더 큰 값을 반환
- 예)
BiFunction<T,T,T>
를 상속받는 방식으로 구현되어 있는데, 입력값 2개와 반환을 모두 같은 T로 고정한다. 따라서BinaryOperator
는 모든 입력값과 반환 타입이 반드시 같아야 한다.BiFunction
은 입력 매개변수가 2개인Function
이다.
Operator를 제공하는 이유
Function<T, R>
와BiFunction<T, U, R>
만으로도 사실상 거의 모든 함수형 연산을 구현할 수 있지만, 의도(목적)의 명시성, “가독성과 유지 보수성”을 위해UnaryOperator<T>
와BinaryOperator<T>
를 별도로 제공한다.UnaryOperator<T>
입력과 출력 타입이 같고 단항 연산을 하는구나~BinaryOperator<T>
입력과 출력 타입이 같고, 같은 타입 두 개를 입력받아 이항 연산을 수행하는 구나~Function<T, T>
요렇게 타입 파라미터를 두 번 안적고UnaryOperator<T>
요렇게 한 번만 적어도 된다.
기타 함수형 인터페이스
- 매개 변수가 2개 필요한 경우에는
BiXxx
시리즈를 사용하면 된다.Bi
는 Binary(이항, 둘)의 줄임말이다.- 예)
BiFunction
,BiConsumer
,BiPredicate
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
- 물론 이 경우에는
BinaryOperator
를 사용하는 것이 더 나은 선택이다.
- 물론 이 경우에는
BiPredicate<Integer, Integer> isGreater = (a, b) -> a > b;
Supplier
는 매개변수가 없으므로BiSupplier
는 존재하지 않는다.
- 예)
- /입력값이 3개라면?
- 입력값이 3개라면
TriXxx
가 있으면 좋겠지만, 이런 함수형 인터페이스는 기본으로 제공하지 않는다. - 만약 입력값이 3개일 경우라면 다음과 같이 직접 만들어서 사용하면 된다.
- 입력값이 3개라면
@FunctionalInterface
interface TriFunction<A, B, C, R> {
R apply(A a, B b, C c);
}
TriFunction<Integer, Integer, Integer, Integer> triFunction = (a, b, c) -> a + b + c;
- /기본 함수형 인터페이스: 다음과 같이 기본형(primitive type)을 지원하는 함수형 인터페이스.
package java.util.function;
@FunctionalInterface
public interface IntFunction<R> {
R apply(int value);
}
- 기본형 지원 함수형 인터페이스가 존재하는 이유
- 오토박싱/언박싱(auto-boxing/unboxing)으로 인한 성능 비용을 줄이기 위해
- 자바 제네릭의 한계(제네릭은 primitive 타입을 직접 다룰 수 없음)를 극복하기 위해
- 자바의 제네릭은 기본형(primitive) 타입을 직접 다룰 수 없어서,
Function<int, R>
같은 식으로는 선언할 수 없다.
- 자바의 제네릭은 기본형(primitive) 타입을 직접 다룰 수 없어서,
기본형 매개변수를 받는 인터페이스, 기본형을 반환하는 인터페이스, 기본형을 받고 반환하는 인터페이스 + 기본형 Operator 등이 존재한다.
// 기본형 매개변수, IntFunction, LongFunction, DoubleFunction
IntFunction<String> function = x -> "숫자: " + x;
System.out.println("function.apply(100) = " + function.apply(100));
// 기본형 반환, ToIntFunction, ToLongFunction, ToDoubleFunction
ToIntFunction<String> toIntFunction = s -> s.length();
System.out.println("toIntFunction = " +
toIntFunction.applyAsInt("hello"));
// 기본형 매개변수, 기본형 반환
IntToLongFunction intToLongFunction = x -> x * 100L;
System.out.println("intToLongFunction = " +
intToLongFunction.applyAsLong(10));
// IntUnaryOperator: int -> int
IntUnaryOperator intUnaryOperator = x -> x * 100;
System.out.println("intUnaryOperator = " +
intUnaryOperator.applyAsInt(10));
// 기타, IntConsumer, IntSupplier, IntPredicate
- 참고로
IntToIntFunction
은 없는데,IntUnaryOperator
를 사용하면 된다 IntConsumer
,IntSupplier
,IntPredicate
와 같은 인터페이스도 존재한다.
기본, 특화 2개 알아두고, 나머지는 To를 붙이든 Bi를 붙이든 해가지고 연결해서 쓰면 된다.
'Java > Modern Java(8~)' 카테고리의 다른 글
[Java] 62. 메서드 참조 (0) | 2025.07.04 |
---|---|
[Java] 61. 람다 vs 익명 클래스 (0) | 2025.07.04 |
[Java] 60. 람다 활용 (0) | 2025.07.04 |
[Java] 58. 람다 (0) | 2025.07.04 |
[Java] 57. 람다가 필요한 이유 (0) | 2025.07.04 |