관리 메뉴

개발공부기록

함수형 인터페이스, 함수형 인터페이스와 제네릭, 람다와 타겟 타입, 함수형 인터페이스(기본, 특화, 기타) 본문

자바 로드맵 강의/고급 3 - 람다, 스트림, 함수형 프로그래밍

함수형 인터페이스, 함수형 인터페이스와 제네릭, 람다와 타겟 타입, 함수형 인터페이스(기본, 특화, 기타)

소소한나구리 2025. 4. 4. 12:15
728x90

함수형 인터페이스와 제네릭

함수형 인터페이스에 제네릭이 필요한 이유

GenericMain1, 각각 다른 타입 사용

package lambda.lambda3;

public class GenericMain1 {
    public static void main(String[] args) {
        StringFunction upperCase = s -> s.toUpperCase();
        String result1 = upperCase.apply("hello");
        System.out.println("result1 = " + result1);

        NumberFunction square = n -> n * n;
        Integer result2 = square.apply(3);
        System.out.println("result2 = " + result2);
    }

    @FunctionalInterface
    interface StringFunction {
        String apply(String s);
    }

    @FunctionalInterface
    interface NumberFunction {
        Integer apply(Integer i);
    }
}
/* 실행 결과
result1 = HELLO
result2 = 9
*/

 

StringFunction이 제공하는 apply 메서드와 NumberFunction이 제공하는 apply 메서드는 둘다 하나의 인자를 입력받고 결과를 반환함

다만 입력받는 타입과 반환 타입이 다를 뿐인데, 매개변수나 반환 타입이 다를 때마다 계속 함수형 인터페이스를 만드는 것은 비효율 적일 수 있음

 

GenericMain2, Object 타입으로 합치기

package lambda.lambda3;

public class GenericMain2 {
    public static void main(String[] args) {
        ObjectFunction upperCase = s -> ((String) s).toUpperCase();
        Object result1 = upperCase.apply("hello");
        System.out.println("result1 = " + result1);

        ObjectFunction square = n -> (Integer) n * (Integer) n;
        Object result2 = square.apply(3);
        System.out.println("result2 = " + result2);
    }

    @FunctionalInterface
    interface ObjectFunction {
        Object apply(Object o);
    }
}
/* 실행 결과 동일 */

 

Object는 모든 타입의 부모이기 때문에 다형성(다형적 참조)를 사용해서 문제를 해결할 수 있음

메서드가 Object를 매개변수로 사용하고 Object를 반환하면 모든 타입을 입력 받고 모든 타입을 반환할 수 있으므로 이전과 같이 타입에 따라 각각 다른 함수형 인터페이스를 만들지 않고 ObjectFunction 함수형 인터페이스 하나로 재사용하도록 되었음

 

그러나, Object를 사용하기 때문에 복잡하고 안전하지 않은 다운 캐스팅 과정이 필요함

 

GenericMain3, 익명 클래스로 변경

package lambda.lambda3;

public class GenericMain3 {
    public static void main(String[] args) {
        ObjectFunction upperCase = new ObjectFunction() {
            @Override
            public Object apply(Object s) {
                return ((String) s).toUpperCase();
            }
        };
        Object result1 = upperCase.apply("hello");
        System.out.println("result1 = " + result1);

        ObjectFunction square = new ObjectFunction() {
            @Override
            public Object apply(Object n) {
                return (Integer) n * (Integer) n;
            }
        };
        Object result2 = square.apply(3);
        System.out.println("result2 = " + result2);
    }

    @FunctionalInterface
    interface ObjectFunction {
        Object apply(Object o);
    }

}

 

람다로 구현했던 것을 이해하기 쉽게 익명 클래스로 변경해보면 이런 구조임

 

정리

Object와 다형성을 활용한 덕분에 코드의 중복을 제거하고 재사용성이 늘어났음

그러나 Object를 사용하므로 다운 캐스팅을 해야하고 결과적으로 타입 안정성 문제가 발생하게됨

 

StringFunction, NumberFunction 각각의 타입별로 함수형 인터페이스를 모두 정의하면 타입 안정성은 좋지만 코드 재사용성이 떨어짐

ObjectFunction을 사용하여 Object의 다형성을 활용하면 하나의 인터페이스만 정의하면 되기 때문에 코드 재사용성은 좋지만 타입 안정성이 떨어짐

제네릭 도입

GenericMain4, 제네릭 도입

package lambda.lambda3;

public class GenericMain4 {
    public static void main(String[] args) {
        GenericFunction<String, String> upperCase = new GenericFunction<>() {
            @Override
            public String apply(String s) {
                return s.toUpperCase();
            }
        };
        Object result1 = upperCase.apply("hello");
        System.out.println("result1 = " + result1);

        GenericFunction<Integer, Integer> square = new GenericFunction<>() {
            @Override
            public Integer apply(Integer n) {
                return n * n;
            }
        };
        Object result2 = square.apply(3);
        System.out.println("result2 = " + result2);
    }

    @FunctionalInterface
    interface GenericFunction<T, R> {
        R apply(T t);
    }

}

 

함수형 인터페이스에 제네릭을 도입한 덕분에 apply() 메서드의 매개변수타입과 반환 타입을 유연하게 변경할 수 있게 되어 코드 재사용성도 늘어나고 타입 안정성도 확보할 수 있게 되었음

  • GenericFunction<T, R>: T는 매개변수 타입, R은 반환타입으로 지정함
  • 여기에서는 <String, String>, <Integer, Integer> 처럼 매개변수 타입과 반환타입이 같지만 구성하는 로직에 따라 <Integer, String> 처럼 매개변수 타입과 반환 타입을 다르게 적용할 수 있게 됨

GenericMain5, 람다로 변경

package lambda.lambda3;

public class GenericMain5 {
    public static void main(String[] args) {
        GenericFunction<String, String> upperCase = s -> s.toUpperCase();
        Object result1 = upperCase.apply("hello");
        System.out.println("result1 = " + result1);

        GenericFunction<Integer, Integer> square = n -> n * n;
        Object result2 = square.apply(3);
        System.out.println("result2 = " + result2);
    }

    @FunctionalInterface
    interface GenericFunction<T, R> {
        R apply(T t);
    }

}

 

위에서 익명클래스로 구현한 코드를 람다로 변환하여 실행 결과는 동일하지만 코드 가독성이 매우 향상되었음

 

GenericFunction은 매개변수가 1개이고 반환값이 있는 모든 람다에 사용할 수 있음

매개변수의 타입과 반환값은 사용시점에 제네릭을 활용하여 얼마든지 변경할 수 있기 때문에 제네릭이 도입된 함수형 인터페이스는 재사용성이 매우 높음

 

GenericMain6, 제네릭이 도입된 함수형 인터페이스의 활용

package lambda.lambda3;

public class GenericMain6 {
    public static void main(String[] args) {
        GenericFunction<String, String> upperCase = s -> s.toUpperCase();
        GenericFunction<String, Integer> stringLength = s -> s.length();
        GenericFunction<Integer, Integer> square = n -> n * n;
        GenericFunction<Integer, Boolean> isEven = n -> n % 2 == 0;

        System.out.println(upperCase.apply("hello"));
        System.out.println(stringLength.apply("hello"));
        System.out.println(square.apply(3));
        System.out.println(isEven.apply(3));

    }

    @FunctionalInterface
    interface GenericFunction<T, R> {
        R apply(T t);
    }

}
/* 실행 결과
HELLO
5
9
false
*/

 

문자열을 대문자로 변환, 문자열의 길이 구하기, 숫자의 제곱 구하기, 짝수 여부 확인 등 서로 다른 기능들을 하나의 함수형 인터페이스로 구현하는 엄청난 재사용성을 보여줌

 

정리

  • 제네릭을 사용하면 동일한 구조의 함수형 인터페이스를 다양한 타입에 재사용할 수 있음
  • T는 입력 타입을, R은 반환 타입을 나타내며 실제 사용할 때 구체적인 타입을 지정하면 됨
  • 이렇게 제네릭을 활용하면 타입 안정성을 보장하면서도 유연한 코드를 작성할 수 있으며 컴파일 시점에 타입 체크가 이루어지므로 런타입 에러를 방지할 수 있음
  • 제네릭을 사용하지 않았다면 각각의 경우에 대해 별도의 함수형 인터페이스를 만들어야 하는데, 제네릭을 사용한 함수형 인터페이스를 사용한 덕분에 코드의 중복도 줄이고 유지보수성을 높이는데 큰 도움이 되었음

람다와 타겟 타입

남은 문제

제네릭을 도입한 GenericFunction은 코드 중복을 줄이고 유지보수성을 높여주지만 아래 2가지 문제가 있음

 

문제1. 모든 개발자들이 비슷한 함수형 인터페이스를 개발해야 함

직접 만든 GenericFunction은 매개변수가 1개이고 반환값이 있는 모든 람다에 사용할 수 있지만 람다를 사용하려면 함수형 인터페이스가 필수이기 때문에 전 세계의 개발자들이 비슷한 GenericFunction을 각각 만들어서 사용해야함

즉, 비슷한 모양의 GenericFunction이 많이 만들어질 것임

 

문제2. 개발자 A가 만든 함수형 인터페이스와 개발자 B가 만든 함수형 인터페이스는 서로 호환되지 않음

package lambda.lambda3;

public class TargetType1 {
    public static void main(String[] args) {
        // 람다 직접 대입: 문제 없음
        FunctionA functionA = i -> "value = " + i;
        FunctionB functionB = i -> "value = " + i;
        
        // 이미 만들어진 FunctionA 인스턴스를 FunctionB에 대입 불가능함
//        FunctionB targetB = functionA;  // 컴파일 에러 발생
    }

    @FunctionalInterface
    interface FunctionA {
        String apply(Integer i);
    }

    @FunctionalInterface
    interface FunctionB {
        String apply(Integer i);
    }
}

 

람다를 함수형 인터페이스에 대입할 때는 FunctionA, FunctionB 모두 메서드 시그니처가 맞으므로 문제없이 잘 대입됨

그러나 FunctionB targetB = functionA 부분은 컴파일 오류가 발생하는데, 두 인터페이스 모두 Integer를 받아 String을 리턴하는 동일한 apply(...) 메서드를 가지고 있지만 자바 타입 시스템상 전혀 다른 인터페이스이므로 서로 호환되지 않음

 

람다는 그 자체만으로는 구체적인 타입이 정해져 있지 않고 타겟 타입(target type)이라고 불리는 맥락(대입되는 참조형)에 의해 타입이 결정됨

 

FunctionA functionA = i -> "value = " + i;
FunctionB functionB = i -> "value = " + i;
  • 첫 번째 코드의 람다는 FunctionA라는 타겟 타입을 만나서 FunctionA 타입으로 결정됨
  • 두 번째 코드도 첫 번째 코드와 동일한 람다이지만 FunctionB 타입으로 타겟팅 되어 타입이 결정됨

정리하면 람다는 그 자체만으로는 구체적인 타입이 정해져 있지 않고 대입되는 함수형 인터페이스(타겟 타입)에 의해 비로소 타입이 결정됨

이렇게 타입이 결정되고 나면 이후에는 다른 타입에 대입하는 것이 불가능함

 

이후 함수형 인터페이스를 다른 함수형 인터페이스에 대입하는 것은 타입이 서로 다르기 때문에 메서드의 시그니처가 같아도 대입이 되지 않음

FunctionB targetB = functionA;  // 컴파일 에러 발생

 

functionA는 FunctionA 타입의 변수가 이미 결정된 상태이므로 명시적인 인터페이스 타입을 가진 객체가 되었음

이러한 객체를 FunctionB 타입에 대입하려고하면 자바 컴파일러는 당연히 서로 다른 타입임을 명확하게 인식하여 오류가 발생함

 

쉽게 이야기하면 FunctionA와 FunctionB는 서로 타입이 다르기 때문에 당연히 대입이 불가능하며 마치 서로 타입이 다른 Integer에 String을 대입하는 것과 같은 행위임

 

두 인터페이스가 시그니처가 같고 똑같은 모양의 함수형 인터페이스라도 타입 자체는 별개이므로 상호 대입은 허용되지 않음

 

정리

  • 람다는 익명 함수로서 특정 타입을 가지지 않고 대입되는 참조 변수가 어떤 함수형 인터페이스를 가리키느냐에 따라 타입이 결정됨
  • 이미 대입된 변수는 엄연히 타입을 가지고 있는 객체가 되었으므로 다른 타입의 참조 변수에 그대로 대입할 수 없음(자바 컴파일러가 다른 타입으로 간주하여 오류 발생)
  • 따라서 시그니처가 똑같은 함수형 인터페이스라도 타입이 다르면 상호 대입이 되지 않는 것이 자바의 타입 시스템 규칙임

자바가 기본으로 제공하는 함수형 인터페이스

자바는 이런 문제들을 해결하기 위해서 필요한 함수형 인터페이스 대부분을 기본으로 제공함

 

Function - 자바 기본 제공

@FunctionalInterface
public interface Function<T, R> {

    R apply(T t);
    
    // ... 기타 코드들 생략
}
  • 자바는 java.util.function 패키지에 다양한 기본 함수형 인터페이스들을 제공함

TargetType2 - Function 맛보기

package lambda.lambda3;

public class TargetType2 {
    public static void main(String[] args) {
        Function<String, String> upperCase = s -> s.toUpperCase();
        String result1 = upperCase.apply("hello");
        System.out.println("result = " + result1);

        Function<Integer, Integer> square = s -> s * s;
        Integer result2 = square.apply(3);
        System.out.println("result2 = " + result2);
    }
}
/* 실행 결과
result = HELLO
result2 = 9
*/

 

직접 만들었던 GenericFunction을 사용했던 코드와 동일하므로 실행 결과는 동일함

 

TargetType3 - 대입 적용

package lambda.lambda3;

// 자바가 기본으로 제공하는 Function 대입
public class TargetType3 {
    public static void main(String[] args) {
        Function<Integer, String> functionA = i -> "value = " + i;
        System.out.println("functionA.apply(10): " + functionA.apply(10));
        
        Function<Integer, String> functionB = functionA;
        System.out.println("functionB.apply(20): " + functionB.apply(20));
    }
}
/* 실행 결과
functionA.apply(10): value = 10
functionB.apply(20): value = 20
*/

 

GenericFunction을 사용했을 때에는 타입이 일치하지 않아서 불가능했던 람다 대입이 같은 타입을 사용했기 때문에 문제없이 대입이 적용되었음

서로 다른 코드와의 호환성을 지키고 개발자들이 사용된 함수형 인터페이스가 무슨 기능을 하는지 바로 인지가 가능한 자바가 기본으로 제공하는 함수형 인터페이스를 사용하면 됨


기본 함수형 인터페이스

자바가 기본으로 제공하는 대표적인 함수형 인터페이스

  • Function: 입력 O, 반환 O
  • Consumer: 입력 O, 반환 X
  • Supplier: 입력 X, 반환 O
  • Runnable: 입력 X, 반환 X

함수형 인터페이스들은 대부분 제네릭을 활용하므로 종류가 많을 필요가 없음

대부분 java.util.function 패키지에 위치함(Runnable은 java.lang 패키지에 위치함)

Function

핵심 코드 구조

package java.util.function;

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);
}
  • 하나의 매개변수를 받고, 결과를 반환하는 함수형 인터페이스(둘 이상의 매개변수를 받는 함수형 인터페이스는 뒤에서 설명함)
  • 입력값(T)을 받아서 다른 타입의 출력값(R)을 반환하는 연산을 표현할 때 사용하며 같은 타입의 출력 값도 가능함
  • 일반적인 함수(Function)의 개념에 가장 가까움
    • 문자열을 받아서 정수로 변환
    • 객체를 받아서 특정 필드 추출 등
  • 용어 설명
    • Function은 수학적인 "함수" 개념을 그대로 반영한 이름임
    • apply는 적용하다라는 의미로 입력값에 함수를 적용해서 결과를 얻는다는 수학적 개념을 표현함
    • 예: f(x)처럼 입력 x에 함수 f를 적용(apply)하여 결과를 얻음

Function 예제

package lambda.lambda4;

public class FunctionMain {
    public static void main(String[] args) {

        // 익명 클래스
        Function<String, Integer> function1 = new Function<>() {
            @Override
            public Integer apply(String s) {
                return s.length();
            }
        };
        System.out.println("function1 = " + function1.apply("hello"));

        // 람다
        Function<String, Integer> function2 = s -> s.length();
        System.out.println("function2 = " + function2.apply("hello"));

    }
}
/* 실행 결과
function1 = 5
function2 = 5
*/

Consumer

핵심 코드 구조

package java.util.function;

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}
  • 입력 값(T)만 받고 결과를 반환하지 않는(void) 연산을 수행하는 함수형 인터페이스
  • 입력 값을 받아서 처리하지만 결과를 반환하지 않은 연산을 표현할 때 사용함
    • 컬렉션에 값 추가
    • 콘솔 출력
    • 로그 작성
    • DB 저장 등
  • 용어 설명
    • Consumer는 소비자라는 의미로 데이터를 받아서 소비(사용)만 하고 아무것도 돌려주지 않는다는 개념을 표현함
    • accept는 받아들이다라는 의미로 입력값을 받아들여서 처리한다는 동작을 설명하며 입력 값을 받아서(accept) 소비(consume)해 버린다고 생각하면 됨

Consumer 예제

package lambda.lambda4;

public class ConsumerMain {
    public static void main(String[] args) {
        // 익명 클래스
        Consumer<String> consumer1 = new Consumer<>() {

            @Override
            public void accept(String s) {
                System.out.println(s);
            }
        };
        consumer1.accept("hello consumer");

        // 람다
        Consumer<String> consumer2 = s -> System.out.println(s);
        consumer2.accept("hello consumer");
    }
}
/* 실행 결과
hello consumer
hello consumer
*/

 

Supplier

핵심 코드 구조

package java.util.function;

@FunctionalInterface
public interface Supplier<T> {
    T get();
}
  • 입력을 받지 않고 어떤 데이터를 공급(supply)해주는 함수형 인터페이스
  • 객체나 값을 생성하거나 지연 초기화 등에 주로 사용됨
  • 용어 설명
    • Supplier는 공급자라는 의미로 요청할 때마다 값을 공급해주는 역할을 함
    • get은 얻다라는 의미로 supplier로부터 값을 얻어온다는 개념을 표현함

Supplier 예제

package lambda.lambda4;

public class SupplierMain {
    public static void main(String[] args) {
        // 익명 클래스
        Supplier<Integer> supplier1 = new Supplier<>() {

            @Override
            public Integer get() {
                return new Random().nextInt(10);
            }
        };
        System.out.println("supplier1.get() = " + supplier1.get());
        
        // 람다
        Supplier<Integer> supplier2 = () -> new Random().nextInt(10);
        System.out.println("supplier2.get() = " + supplier2.get());
    }
}
/* 실행 결과
supplier1.get() = 4
supplier2.get() = 9
*/

Runnable

핵심 코드 구조

package java.lang;

@FunctionalInterface
public interface Runnable {
    void run();
}
  • 입력값도 없고 반환값도 없는 함수형 인터페이스
  • 자바에서는 원래부터 스레드 실행을 위한 인터페이스로 쓰였지만 자바 8 이후에는 람다식으로도 많이 표현되며 자바 8로 업데이트 되면서 @FunctionalInterface 애노테이션도 붙었음
  • java.lang 패키지에 있으며 자바는 원래부터 있던 인터페이스는 하위 호환을 위해 그대로 유지함
  • 주로 멀티스레딩에서 스레드의 작업을 정의할 때 사용하며 입력값도 없고 반환값도 없는 함수형 인터페이스가 필요할때도 사용함

Runnable 예제

package lambda.lambda4;

public class RunnableMain {
    public static void main(String[] args) {
        // 익명 클래스
        Runnable runnable1 = new Runnable() {

            @Override
            public void run() {
                System.out.println("Hello Runnable");
            }
        };
        runnable1.run();

        // 람다
        Runnable runnable2 = () -> System.out.println("Hello Runnable");
        runnable2.run();
    }
}
/* 실행 결과
Hello Runnable
Hello Runnable
*/

특화 함수형 인터페이스

특화 함수형 인터페이스는 의도를 명확하게 만든 조금 특별한 함수형 인터페이스임

  • Predicate: 입력 O, 반환 boolean
    • 조건 검사, 필터링 용도
  • Operator(UnaryOperator, BinaryOperator): 입력 O, 반환 O
    • 동일한 타입의 연산 수행, 입력과 같은 타입을 반환하는 연산 용도

Predicate

핵심 코드 구조

package java.util.function;

@FunctionalInterface
public interface Predicate<T> {
    boolean test(T t);
}
  • 입력 값(T)을 받아서 true또는 false로 구분(판단)하는 함수형 인터페이스
  • 조건 검사, 필터링 등의 용도로 많이 사용됨(스트림 API에서 필터 조건을 지정할 때 자주 등장함)
  • 용어 설명
    • Predicate는 수학/논리학에서 술어를 의미하며 참/거짓을 판별하는 명제를 표현함
    • test는 시험하다라는 의미로 주어진 입력값이 조건을 만족하는지 테스트한다는 의미임, 그래서 반환값이 boolean임
      • 술어: 어떤 대상의 성질이나 관계를 설명하면서, 그 설명이 참인지 거짓인지를 판단할 수 있게 해주는 표현
      • 예: 숫자가 짝수인지 테스트하는 predicate는 조건 충족 여부를 판단함

Predicate 예제

package lambda.lambda4;

public class PredicateMain {
    public static void main(String[] args) {
        // 익명 클래스
        Predicate<Integer> predicate1 = new Predicate<>() {

            @Override
            public boolean test(Integer value) {
                return value % 2 == 0;
            }
        };
        System.out.println("predicate1.test(10) = " + predicate1.test(10));

        // 람다
        Predicate<Integer> predicate2 = value -> value % 2 == 0;
        System.out.println("predicate2.test(10) = " + predicate2.test(10));
    }
}
/* 실행 결과
predicate1.test(10) = true
predicate2.test(10) = true
*/

 

Predicate가 필요한 이유

Predicate는 입력이 T, 반환이 boolean이기 때문에 결과적으로 Function<T, Boolean>으로 대체할 수 있지만 그럼에도 불구하고 Predicate를 별도로 만든 이유가 있음

Function<Integer, Boolean> f1 = value -> value % 2 == 0;
Predicate<Integer> f2 = value -> value % 2 == 0;

 

Predicate<T>는 입력 값을 받아 true/false로 결과를 판단한다는 의도를 명시적으로 드러내기 위해 정의된 함수형 인터페이스

코드를 보면 boolean을 반환하는 함수라는 측면으로 봤을 때 Function<T, Boolean>으로도 충분이 구현할 수 있지만 Predicate<T>를 별도로 둠으로써 아래와 같은 이점이 있음

  • 의미의 명확성
    • Predicate<T>를 사용하면 이 함수는 조건을 검사하거나 필터링 용도로 쓰인다는 
    • Function<T, Boolean>을 사용하면 이 함수를 무언가 계산하여 Boolean을 반환한다고 볼 수 있긴하지만 '조건 검사'라는 목적이 분명히 드러나지 않을 수 있음
  • 가독성 및 유지보수성
    • 여러 사람과 협업하는 프로젝트에서 조건을 판단하는 함수는 Predicate<T>라는 패턴을 사용함을써 의미 전달이 명확해짐
    • boolean 판단 로직이 들어가는 부분에서 Predicate<T>를 사용하면 이름도 명시적이고 제네릭에 Boolean을 적지 않아도 되서 코드 가독성과 유지보수성이 향상됨

정리하면 Function<T, Boolean>으로도 같은 기능을 구현할 수 있어도 목적(조건 검사)과 용도(필터링 등)에 대해 더 분명히 표현하고 가독성과 유지보수를 위해 Predicate<T>라는 별도의 함수형 인터페이스가 마련되었음

 

의도가 가장 중요한 핵심

자바가 제공하는 다양한 함수형 인터페이스들을 선택할 때는 단순히 입력값, 반환값만 보고 선택하는게 아니라 해당 함수형 인터페이스가 제공하는 의도가 중요함

예를 들어 조건 검사, 필터링 등을 사용한다면 Function이 아니라 Predicate를 선택해야 다른 개발자가 '이 코드는 조건 검사 등에 사용하는 의도가 있구나' 하고 코드를 더욱 쉽게 이해할 수 있음

Operator

Operator는 UnaryOperator, BinaryOperator 2가지 종류가 제공됨

 

용어 설명

  • Operator라는 이름은 수학적인 연산자(Operator)의 개념에서 왔음
  • 수학에서 연산자는 보통 같은 타입의 값들을 받아서 동일한 타입의 결과를 반환
    • 덧셈 연산자(+): 숫자 + 숫자 - > 숫자
    • 곱셈 연산자(*): 숫자 * 숫자 -> 숫자
    • 논리 연산자(AND): boolean AND boolean -> boolean
  • 자바에서는 수학처럼 숫자의 연산에만 사용된다기 보다는 입력과 반환이 동일한 타입의 연산에 사용할 수 있음
  • 예를 들어 문자를 입력해서 대문자로 바꾸어 반환하는 작업도 될 수 있음

UnaryOperator(단항 연산)

@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
    T apply(T t); // 실제로는 Function에 해당 코드가 있음
}
  • 단항 연산은 하나의 피연산자(operand)에 대해 연산을 수행하는 것을 말함
    • 숫자의 부호 연산, 논리 부정 연산 등
  • 입력(피연산자)과 결과(연산 결과)가 동일한 타입인 연산을 수행할 때 사용함
    • 숫자 5을 입력하고 그 수를 제곱한 결과를 반환
    • String을 입력받아 다시 String을 반환하면서 내부적으로 문자열을 대문자로 변경하거나 앞,뒤에 추가 문자열을 붙이는 등의 작업을 수행
  • Function<T, T>를 상속받는데 입력과 반환을 모두 같은 T로 고정하므로 UnaryOperator는 입력과 반환 타입이 반드시 같아야함

BinaryOperator(이항 연산)

@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T,T,T> {
    T apply(T t); // 실제로는 BiFunction에 해당 코드가 있음
}
  • 이항 연산은 두 개의 피연산자(operand)에 대해 연산을 수행하는 것을 말함
    • 두 수의 덧셈, 곱셈 등
  • 같은 타입의 입력값을 두 개를 받아서 같은 타입의 결과를 반환할 때 사용함
    • Integer 두 개를 받아서 더한 값을 반환
    • Integer 두 개를 받아서 둘 주에 더 큰 값을 반환
  • BiFunction<T, T, T>를 상속받는 방식으로 구현되어 있는데 입력값 2개와 반환값을 모두 같은 T로 고정하므로 BinaryOperator는 모든 입력값과 반환 타입이 반드시 같아야 함
  • BiFunction은 입력 매개변수가 2개인 Function임

Operator 예제

package lambda.lambda4;

public class OperatorMain {
    public static void main(String[] args) {
        // UnaryOperator
        Function<Integer, Integer> square1 = x -> x * x;
        UnaryOperator<Integer> square2 = x -> x * x;
        System.out.println("square1 = " + square1.apply(5));
        System.out.println("square2 = " + square2.apply(5));

        // BinaryOperator
        BiFunction<Integer, Integer, Integer> addition1 = (a, b) -> a + b;
        BinaryOperator<Integer> addition2 = (a, b) -> a + b;
        System.out.println("addition1 = " + addition1.apply(2, 3));
        System.out.println("addition2 = " + addition2.apply(2, 3));
    }
}
/* 실행 결과
square1 = 25
square2 = 25
addition1 = 5
addition2 = 5
*/

 

Operator를 제공하는 이유

Function<T, R>과 BiFunction<T, U, R> 만으로도 사실상 거의 모든 함수형 연산을 구현할 수 있지만 UnaryOperator<T>와 BinaryOperator<T>를 별도로 제공하는 이유도 Predicate와 비슷함

  • 의미의 명확성
    • UnaryOperator<T>는 입력과 출력 타입이 동일한 단항 연산을 수행한다는 것을 한눈에 보여줌
    • BinaryOperator<T>는 같은 타입을 두 개 입력받아 같은 타입을 결과로 반환하는 이항 연산을 수행한다는 것을 명확하게 드러냄
    • 만약 모두 Function<T, R>나 BiFunction<T, U, R> 만으로 처리한다면 타입이 같은 연산임을 코드만 보고 즉시 파악하기 조금 힘들수 있음
  • 가독성 및 유지보수성
    • 코드에 UnaryOperator<T>가 등장하면 단항 연산임을 바로 알 수 있고, BinaryOperator<T>의 경우도 같은 타입 두 개를 받아 같은 타입으로 결과를 내는 연산이라는 사실이 명확하게 전달되며 제네릭을 적는 코드의 양도 하나로 줄일 수 있음
    • 여러 사람이 협업하는 프로젝트에는 이런 명시성이 코드 가독성과 유지보수성에 큰 도움이 됨

정리

  • 단항 연산이고 타입이 동일하면 UnaryOperator<T> 사용, 이항 연산이고 타입이 동일하면 BinaryOperator<T>를 사용하여 개발자의 의도로직을 더 명확하게 표현하고 가독성을 높이는 코드를 작성할 수 있음

기타 함수형 인터페이스

입력 값이 2개 이상

매개변수가 2개 이상 필요한 경우에는 BiXxx 시리즈를 사용하면 되며 Bi는 Binary(이항, 둘)의 줄임말임

  • BiFunction, BiConsumer, BiPredicate

BiMain

package lambda.lambda4;

public class BiMain {
    public static void main(String[] args) {
        BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
        System.out.println("add.apply(1, 50) = " + add.apply(1, 50));

        BiConsumer<String, Integer> repeat = (c, n) -> {
            for (int i = 0; i < n; i++) {
                System.out.print(c);
            }
            System.out.println();
        };
        repeat.accept("*", 5);

        BiPredicate<Integer, Integer> isGreater = (a, b) -> a > b;
        System.out.println("isGreater.test(10, 5) = " + isGreater.test(10, 5));
    }
}
/* 실행 결과
add.apply(1, 50) = 51
*****
isGreater.test(10, 5) = true
*/
  • 예제의 경우 타입이 같기 때문에 BiFunction대신에 BinaryOperator를 사용하는 것이 더 나은 선택임
  • Supplier는 매개변수가 없으므로 BiSupplier는 존재하지 않음

입력 값이 3개?

입력값이 3개라면 TriXxx가 있으면 좋겠지만 보통 함수형 인터페이스를 사용할 때 3개 이상의 매개변수는 잘 사용하지 않기 때문에 이런 함수형 인터페이스는 기본으로 제공하지 않음

만약 입력값이 3개일 경우라는 직접 만들어서 사용하면 됨

 

TriMain

package lambda.lambda4;

public class TriMain {
    public static void main(String[] args) {
        TriFunction<Integer, Integer, Integer, Integer> triFunction =
                (a, b, c) -> a + b + c;
        System.out.println("triFunction.apply(1, 2, 3) = " + triFunction.apply(1, 2, 3));
    }

    @FunctionalInterface
    interface TriFunction<A, B, C, R> {
        R apply(A a, B b, C c);
    }
}
/* 실행결과
triFunction.apply(1, 2, 3) = 6
*/

기본형 지원 함수형 인터페이스

기본형(primitive type)을 지원하는 함수형 인터페이스들도 있음

package java.util.function;

@FunctionalInterface
public interface IntFunction<R> {
    R apply(int value);
}

 

기본형 지원 함수형 인터페이스가 존재하는 이유

  • 오토박싱/언박싱(auto-boxing/unboxing)으로 인한 성능 비용을 줄이기 위함
  • 자바 제네릭의 한계(제네릭은 primitive 타입을 직접 다룰 수 없음)를 극복하기 위함

PrimitiveFunction

package lambda.lambda4;

public class PrimitiveFunction {
    public static void main(String[] args) {
        // 기본형 매개변수, 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.applyAsInt(\"hello\") = " + toIntFunction.applyAsInt("hello"));
        
        // 기본형 매개변수, 기본형 반환
        IntToLongFunction intToLongFunction = x -> x * 100000000000000000L;
        System.out.println("intToLongFunction.applyAsLong(10) = " + intToLongFunction.applyAsLong(10));

        // IntUnaryOperator: int -> int
        IntUnaryOperator intUnaryOperator = x -> x * 100;
        System.out.println("intUnaryOperator.applyAsInt(10) = " + intUnaryOperator.applyAsInt(10));

        // 기타, IntConsumer, IntSupplier, IntPredicate

    }
}
/* 실행 결과
function.apply(100) = 숫자: 100
toIntFunction.applyAsInt("hello") = 5
intToLongFunction.applyAsLong(10) = 1000000000000000000
intUnaryOperator.applyAsInt(10) = 1000
*/

 

Function

  • IntFunction: 매개변수가 기본형 int
  • ToIntFunction: 반환 타입이 기본형 int
  • IntToLongFunction: 매개변수가 int, 반환 타입이 long
    • IntToIntFunction은 IntOperator를 사용하면 되므로 없음

기타

  • IntOperator: Operator는 매개변수와 반환 타입이 같으므로 int 입력, int 반환임
  • IntConsumer: 매개변수만 존재함, int 입력
  • IntSupplier: 반환값만 존재함, int 반환
  • IntPredicate: 반환값은 boolean으로 고정이며 int를 입력

정리 - 함수형 인터페이스의 종류

기본 함수형 인터페이스

인터페이스 메서드 시그니처 입력 출력 대표 사용 예시
Function<T, R> R apply(T t) 1개 (T) 1개 (R) 데이터 변환, 필드 추출 등
Consumer<T> void accept(T t) 1개 (T) 없음 로그 출력, DB 저장 등
Supplier<T> T get() 없음 1개 (T) 객체 생성, 값 반환 등
Runnable void run() 없음 없음 스레드 실행(멀티스레드)

 

특화 함수형 인터페이스

인터페이스 메서드 시그니처 입력 출력 대표 사용 예시
Predicate<T> boolean test(T t) 1개 (T) boolean 조건 검사, 필터링
UnaryOperator<T> T apply(T t) 1개 (T) 1개 (T; 입력과 동일) 단항 연산(ex: 문자열 변환, 단항 계산 등)
BinaryOperator<T> T apply(T t1, T t2) 2개 (T, T) 1개 (T; 입력과 동일) 이항 연산(ex: 두 수의 합, 최댓값 반환 등)

 

자바가 기본으로 지원하지 않는 다면 직접 만들어서 사용(매개변수가 3개 이상)

기본형(primitive type)을 지원해야 한다면 IntFunction등을 사용하면 됨


문제와 풀이

문제1

요구사항

  • 앞서 람다에서 만든 문제를 자바가 제공하는 함수형 인터페이스로 대체(문제2, 문제3도 동일함)
더보기

FilterExampleEx2

public class FilterExampleEx2 {
    
    // 고차 함수, 함수를 인자로 받아서 조건에 맞는 요소만 뽑아내는 filter
    public static List<Integer> filter(List<Integer> list, MyPredicate predicate) {
        List<Integer> result = new ArrayList<>();
        for (int val : list) {
            if (predicate.test(val)) {
                result.add(val);
            }
        }
        return result;
    }

    public static void main(String[] args) {
        List<Integer> numbers = List.of(-3, -2, -1, 1, 2, 3, 5);
        System.out.println("원본 리스트: " + numbers);

        // 1. 음수(negative)만 뽑아내기
        List<Integer> negatives = filter(numbers, value -> value < 0);
        System.out.println("음수만: " + negatives);

        // 2. 짝수(even)만 뽑아내기
        List<Integer> evens = filter(numbers, value -> value % 2 == 0);
        System.out.println("짝수만: " + evens);
    }
}

 

정답

더보기
package lambda.ex3;

public class FilterExampleEx2 {

    public static List<Integer> filter(List<Integer> list, Predicate<Integer> predicate) {
        // 구현 코드 동일
    }
    
    // main 메서드는 변경 없음
    // 음수만 뽑아내기, 짝수만 뽑아내기
}

 

Predicate<Integer>, IntPredicate(기본형 지원 함수형 인터페이스) 둘 다 모두 정답임

Function<Integer, Boolean>도 가능하지만 Predicate가 보다 조건을 검사한다는 의도를 명확하게 드러내므로 Predicate가 더 나은 선택임

문제2

더보기

MapExample

package lambda.ex3;

public class MapExample {

    // 고차 함수, 함수를 인자로 받아, 리스트의 각 요소를 변환
    public static List<String> map(List<String> list, StringFunction func) {
        List<String> result = new ArrayList<>();
        for (String str : list) {
            result.add(func.apply(str));
        }
        return result;
    }

    public static void main(String[] args) {
        List<String> words = List.of("hello", "java", "lambda");
        System.out.println("원본 리스트: " + words);

        // 1. 대문자 변환
        List<String> upperList = map(words, s -> s.toUpperCase());
        System.out.println("대문자 변환 결과: " + upperList);

        // 2. 앞뒤에 *** 붙이기 (람다로 작성)
        List<String> decoratedList = map(words, s -> "***" + s + "***");
        System.out.println("특수문자 데코 결과: " + decoratedList);
    }
}

 

정답

더보기
package lambda.ex3;

public class MapExample {

    public static List<String> map(List<String> list, UnaryOperator<String> func) {
        // 구현 코드 동일
    }

    // main 메서드 코드 동일
    // 대문자 변환, 입력값 앞 뒤에 *** 붙이기
}

 

UnaryOperator<String>, Function<String, String> 모두 가능하지만 입력과 출력 타입이 동일한 연산을 표현하려는 의도가 분명하다면 UnaryOperator<String>을 사용하는 편이 더 의도를 명확하게 드러낼 수 있음

문제3

더보기

ReduceExample

package lambda.ex3;

public class ReduceExample {

    // 함수를 인자로 받아, 리스트 요소를 하나로 축약(reduce)하는 고차 함수
    public static int reduce(List<Integer> list, int initial, MyReducer reducer) {
        int result = initial;
        for (int val : list) {
            result = reducer.reduce(result, val);
        }
        return result;
    }
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4);
        System.out.println("리스트: " + numbers);
        
        // 1. 합 구하기 (초기값 0, 덧셈 로직)
        int sum = reduce(numbers, 0, (a, b) -> a + b);
        System.out.println("합(누적 +): " + sum);
        
        // 2. 곱 구하기 (초기값 1, 곱셈 로직, 람다로 작성)
        int product = reduce(numbers, 1, (a, b) -> a * b);
    }
}

 

정답

더보기
package lambda.ex3;

public class ReduceExample {
    public static int reduce(List<Integer> list, int initial, BinaryOperator<Integer> reducer) {
        int result = initial;
        for (int val : list) {
            result = reducer.apply(result, val); // 호출 메서드 변경
        }
        return result;
    }
    
    // main 메서드 동일
}

 

BinaryOperator<Integer>, IntBinaryOperator 모두 정답, 호출 메서드를 변경해주어야 함

 

BiFunction으로도 풀 수 있지만 Operator가 입력과 출력이 같다는 의도를 명확하게 드러내기 때문에 Operator가 더 나은 선택임

특히 reduce라는 기능은 보통 같은 타입의 연산을 누적하고 같은 타입의 결과를 냄

정리

함수형 인터페이스와 제네릭

  • 함수형 인터페이스에 제네릭을 도입하면 코드 재사용성과 타입 안정성을 모두 확보할 수 있음
  • 직접 Object 타입을 사용하는 방식(ObjectFunction)은 다양한 타입을 다룰 수 있지만, 다운 캐스팅 과정이 필요하고 타입 안정성이 떨어짐
  • 제네릭 타입을 사용하면 컴파일 시점에 타입 체크가 이루어지므로 런타임 에러를 방지할 수 있고 유연한 코드를 작성할 수 있음

람다와 타겟 타입

  • 람다는 그 자체로 타입이 정해져 있지 않고 어떤 함수형 인터페이스에 대입되느냐(타겟 타입)에 따라 타입이 결정됨
  • 같은 람다라도 FunctionA에 대입하면 FunctionA 타입이 되고 FunctionB에 대입하면 FunctionB 타입이 됨
  • 이미 한 번 특정 함수형 인터페이스 타입으로 대입된 람다는 시그니처가 같더라도 다른 함수형 인터페이스 타입으로 대입할 수 없음

자바가 제공하는 기본 함수형 인터페이스

  • Function<T, R>: 하나의 매개변수를 받아 결과를 반환함
  • Consumer<T>: 하나의 매개변수를 받아서 소비(처리)만 하고, 반환값은 없음
  • Supplier<T>: 매개변수가 없고, 값을 공급(생성)하여 반환함
  • Runnable: 매개변수와 반환값이 모두 없는 실행 작업을 나타냄(주로 스레드 실행)

특화 함수형 인터페이스

  • Predicate<T>: 입력값을 받아 조건을 검사(필터링)하고 boolean을 반환함
  • UnaryOperator<T>: 하나의 입력을 받아 입력받은 타입과 같은 타입을 반환(단항 연산)함
  • BinaryOperator<T>: 두 개의 같은 타입 입력을 받아 같은 타입을 반환(이항 연산)함
  • 매개변수가 2개 이상인 경우: BiFunction<T, U, R>, BiConsumer<T, U>, BiPredicate<T, U>를 사용
  • 기본형(primitive) 전용 함수형 인터페이스: IntFunction, IntPredicate 등을 사용

람다함수형 인터페이스를 제대로 활용하면 코드가 간결해지고 가독성이 높아지며 제네릭까지 도입하면 재사용성과 타입 안정성까지 모두 확보할 수 있음

자바가 기본적으로 제공하는 다양한 함수형 인터페이스를 적극 활용하면 불필요하게 유사한 인터페이스르 여러개 만들 필요가 없고 호환성 문제도 해결됨

 

무엇보다 의도를 명확하게 드러내는 함수형 인터페이스를 적절히 선택하는 것이 중요함

조건 검사는 Predicate, 입력과 반환 타입이 같은 단항 연산은 UnaryOperator, 매개변수가 2개 이면서 입력과 반환 타입이 같은 연산은 BinaryOperator 처럼 상황에 맞는 인터페이스를 사용하면 코드의 목적이 분명해지고 유지보수성이 향상됨


출처 : 인프런 - 김영한의 실전 자바 - 고급3편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용함

728x90